diff --git a/apps/api-gateway/src/controllers/oauth.controller.ts b/apps/api-gateway/src/controllers/oauth.controller.ts index ca7e3ae..6e4f1e5 100644 --- a/apps/api-gateway/src/controllers/oauth.controller.ts +++ b/apps/api-gateway/src/controllers/oauth.controller.ts @@ -5,7 +5,7 @@ import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { OAuthAuthorizeIncomingDto, OAuthTokenIncomingDto } from '../dto/oauth.dto'; import { extractBearerToken } from '../auth-token'; -import { appendQueryParams, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeTokenBody } from '../lib/oauth-params'; +import { appendQueryParams, mapUserInfoToOidc, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeTokenBody } from '../lib/oauth-params'; import { resolveFrontendUrl } from '../lib/oauth-issuer'; import { verifyAccessToken } from '../session-auth'; @@ -80,7 +80,8 @@ export class OAuthController { clientId: normalized.clientId, redirectUri: normalized.redirectUri, scope: normalized.scope, - state: normalized.state + state: normalized.state, + nonce: normalized.nonce }) )) as { redirectUrl?: string; code?: string; state?: string }; @@ -133,7 +134,20 @@ export class OAuthController { @ApiBearerAuth() @ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' }) @ApiResponse({ status: 200, description: 'Профиль пользователя получен' }) - userInfo(@Headers('authorization') authorization?: string) { - return this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) }); + async userInfo(@Headers('authorization') authorization?: string) { + const result = (await firstValueFrom( + this.core.oauth.UserInfo({ accessToken: extractBearerToken(authorization) }) + )) as { + sub?: string; + email?: string; + phone?: string; + name?: string; + picture?: string; + emailVerified?: boolean; + preferredUsername?: string; + phoneNumber?: string; + phoneNumberVerified?: boolean; + }; + return mapUserInfoToOidc(result); } } diff --git a/apps/api-gateway/src/lib/oauth-issuer.ts b/apps/api-gateway/src/lib/oauth-issuer.ts index 8c23f73..f68f2a8 100644 --- a/apps/api-gateway/src/lib/oauth-issuer.ts +++ b/apps/api-gateway/src/lib/oauth-issuer.ts @@ -84,6 +84,21 @@ export function buildOpenIdConfiguration(issuer: string) { token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'], grant_types_supported: ['authorization_code', 'refresh_token'], code_challenge_methods_supported: ['S256', 'plain'], - claims_supported: ['sub', 'email', 'phone', 'name', 'picture'] + claims_supported: [ + 'sub', + 'iss', + 'aud', + 'iat', + 'exp', + 'auth_time', + 'nonce', + 'email', + 'email_verified', + 'phone_number', + 'phone_number_verified', + 'name', + 'preferred_username', + 'picture' + ] }; } diff --git a/apps/api-gateway/src/lib/oauth-params.ts b/apps/api-gateway/src/lib/oauth-params.ts index 9e18c5d..2415eb0 100644 --- a/apps/api-gateway/src/lib/oauth-params.ts +++ b/apps/api-gateway/src/lib/oauth-params.ts @@ -133,6 +133,33 @@ export function mapTokenResponseToStandard(result: { }; } +export function mapUserInfoToOidc(result: { + sub?: string; + email?: string; + phone?: string; + name?: string; + picture?: string; + emailVerified?: boolean; + preferredUsername?: string; + phoneNumber?: string; + phoneNumberVerified?: boolean; +}) { + const claims: Record = { sub: result.sub }; + if (result.name) claims.name = result.name; + if (result.picture) claims.picture = result.picture; + if (result.preferredUsername) claims.preferred_username = result.preferredUsername; + if (result.email) { + claims.email = result.email; + if (result.emailVerified !== undefined) claims.email_verified = result.emailVerified; + } + const phoneNumber = result.phoneNumber ?? result.phone; + if (phoneNumber) { + claims.phone_number = phoneNumber; + if (result.phoneNumberVerified !== undefined) claims.phone_number_verified = result.phoneNumberVerified; + } + return claims; +} + export function mergeTokenCredentials( body: NormalizedTokenBody, authorization?: string diff --git a/apps/sso-core/prisma/schema.prisma b/apps/sso-core/prisma/schema.prisma index e7e2102..98d77b3 100644 --- a/apps/sso-core/prisma/schema.prisma +++ b/apps/sso-core/prisma/schema.prisma @@ -266,6 +266,7 @@ model OAuthAuthorizationCode { clientId String redirectUri String scopes String[] + nonce String? expiresAt DateTime consumedAt DateTime? createdAt DateTime @default(now()) diff --git a/apps/sso-core/src/domain/auth-grpc.controller.ts b/apps/sso-core/src/domain/auth-grpc.controller.ts index 9686d8f..6fa6b4c 100644 --- a/apps/sso-core/src/domain/auth-grpc.controller.ts +++ b/apps/sso-core/src/domain/auth-grpc.controller.ts @@ -773,7 +773,14 @@ export class AuthGrpcController { } @GrpcMethod('OAuthCoreService', 'Authorize') - authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) { + authorize(command: { + userId: string; + clientId: string; + redirectUri: string; + scope: string; + state?: string; + nonce?: string; + }) { return this.oauthCore.authorize(command); } diff --git a/apps/sso-core/src/domain/oauth-core.service.ts b/apps/sso-core/src/domain/oauth-core.service.ts index 5497b27..e846a35 100644 --- a/apps/sso-core/src/domain/oauth-core.service.ts +++ b/apps/sso-core/src/domain/oauth-core.service.ts @@ -6,6 +6,28 @@ import { PrismaService } from '../infra/prisma.service'; import { OAuthIssuerService } from './oauth-issuer.service'; import { assertHumanAccount, HUMAN_USER_WHERE } from './system-account.util'; +type OAuthUser = { + id: string; + email: string | null; + phone: string | null; + displayName: string; + username: string | null; + avatarUrl: string | null; + isVerified: boolean; +}; + +type OidcScopeClaims = { + sub: string; + name?: string; + picture?: string; + preferredUsername?: string; + email?: string; + emailVerified?: boolean; + phone?: string; + phoneNumber?: string; + phoneNumberVerified?: boolean; +}; + @Injectable() export class OAuthCoreService { constructor( @@ -15,7 +37,14 @@ export class OAuthCoreService { private readonly oauthIssuer: OAuthIssuerService ) {} - async authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string }) { + async authorize(command: { + userId: string; + clientId: string; + redirectUri: string; + scope: string; + state?: string; + nonce?: string; + }) { const user = await this.prisma.user.findFirst({ where: { id: command.userId, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE } }); @@ -37,6 +66,7 @@ export class OAuthCoreService { clientId: client.id, redirectUri: command.redirectUri, scopes: command.scope.split(' ').filter(Boolean), + nonce: command.nonce ?? null, expiresAt: new Date(Date.now() + 5 * 60_000) } }); @@ -47,26 +77,50 @@ export class OAuthCoreService { return { redirectUrl: url.toString(), code, state: command.state }; } - async token(command: { grantType: string; code?: string; refreshToken?: string; clientId: string; clientSecret?: string; redirectUri?: string }) { + async token(command: { + grantType: string; + code?: string; + refreshToken?: string; + clientId: string; + clientSecret?: string; + redirectUri?: string; + }) { const client = await this.prisma.oAuthClient.findUnique({ where: { clientId: command.clientId } }); if (!client?.isActive) throw new UnauthorizedException('OAuth приложение не найдено'); - if (client.clientSecret && client.clientSecret !== command.clientSecret) throw new UnauthorizedException('Неверный client_secret'); + if (client.clientSecret && client.clientSecret !== command.clientSecret) { + throw new UnauthorizedException('Неверный client_secret'); + } if (command.grantType === 'authorization_code') { if (!command.code || !command.redirectUri) throw new BadRequestException('Передайте code и redirect_uri'); - const authCode = await this.prisma.oAuthAuthorizationCode.findUnique({ where: { code: command.code }, include: { user: true } }); - if (!authCode || authCode.consumedAt || authCode.expiresAt < new Date() || authCode.redirectUri !== command.redirectUri || authCode.clientId !== client.id) { + const authCode = await this.prisma.oAuthAuthorizationCode.findUnique({ + where: { code: command.code }, + include: { user: true } + }); + if ( + !authCode || + authCode.consumedAt || + authCode.expiresAt < new Date() || + authCode.redirectUri !== command.redirectUri || + authCode.clientId !== client.id + ) { throw new UnauthorizedException('Код авторизации недействителен'); } await this.prisma.oAuthAuthorizationCode.update({ where: { id: authCode.id }, data: { consumedAt: new Date() } }); assertHumanAccount(authCode.user, { asAuth: true }); - return this.issueOAuthTokens(authCode.user.id, client.clientId, authCode.scopes); + return this.issueOAuthTokens(authCode.user.id, client.clientId, authCode.scopes, authCode.nonce ?? undefined); } if (command.grantType === 'refresh_token') { if (!command.refreshToken) throw new BadRequestException('Передайте refresh_token'); - const payload = await this.jwt.verifyAsync<{ sub: string; aud: string; scopes: string[]; typ: string }>(command.refreshToken, { secret: this.config.getOrThrow('JWT_REFRESH_SECRET') }); - if (payload.typ !== 'refresh_token' || payload.aud !== client.clientId) throw new UnauthorizedException('Refresh token недействителен'); + const issuer = await this.oauthIssuer.resolveIssuer(); + const payload = await this.jwt.verifyAsync<{ sub: string; aud: string; scopes: string[]; typ: string }>( + command.refreshToken, + { secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), issuer } + ); + if (payload.typ !== 'refresh_token' || payload.aud !== client.clientId) { + throw new UnauthorizedException('Refresh token недействителен'); + } return this.issueOAuthTokens(payload.sub, client.clientId, payload.scopes ?? []); } @@ -74,21 +128,106 @@ export class OAuthCoreService { } async userInfo(accessToken: string) { - const payload = await this.jwt.verifyAsync<{ sub: string }>(accessToken, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET') }); - const user = await this.prisma.user.findUnique({ where: { id: payload.sub } }); + const issuer = await this.oauthIssuer.resolveIssuer(); + let payload: { sub: string; scopes?: string[]; typ?: string }; + try { + payload = await this.jwt.verifyAsync(accessToken, { + secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), + issuer + }); + } catch { + throw new UnauthorizedException('Токен доступа недействителен'); + } + + if (payload.typ && payload.typ !== 'access_token') { + throw new UnauthorizedException('Неверный тип токена'); + } + + const user = await this.prisma.user.findFirst({ + where: { id: payload.sub, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE } + }); if (!user) throw new UnauthorizedException('Пользователь не найден'); assertHumanAccount(user, { asAuth: true }); - return { sub: user.id, email: user.email, phone: user.phone, name: user.displayName, picture: user.avatarUrl }; + + const scopes = this.parseScopes(payload.scopes ?? ['openid', 'profile']); + return this.buildUserClaims(user, scopes); } - private async issueOAuthTokens(userId: string, clientId: string, scopes: string[]) { + private parseScopes(scopes: string[] | undefined) { + return new Set((scopes ?? []).filter(Boolean)); + } + + private buildUserClaims(user: OAuthUser, scopes: Set): OidcScopeClaims { + const claims: OidcScopeClaims = { sub: user.id }; + + if (scopes.has('profile')) { + claims.name = user.displayName; + claims.preferredUsername = user.username ?? user.displayName; + if (user.avatarUrl) { + claims.picture = user.avatarUrl; + } + } + + if (scopes.has('email') && user.email) { + claims.email = user.email; + claims.emailVerified = user.isVerified; + } + + if (scopes.has('phone') && user.phone) { + claims.phone = user.phone; + claims.phoneNumber = user.phone; + claims.phoneNumberVerified = user.isVerified; + } + + return claims; + } + + private async issueOAuthTokens(userId: string, clientId: string, scopes: string[], nonce?: string) { const user = await this.prisma.user.findUnique({ where: { id: userId } }); if (!user) throw new UnauthorizedException('Пользователь не найден'); assertHumanAccount(user, { asAuth: true }); + const issuer = await this.oauthIssuer.resolveIssuer(); - const accessToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'access_token' }, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer }); - const refreshToken = await this.jwt.signAsync({ sub: userId, aud: clientId, scopes, typ: 'refresh_token' }, { secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), expiresIn: '30d', issuer }); - const idToken = await this.jwt.signAsync({ sub: userId, aud: clientId }, { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer }); + const scopeSet = this.parseScopes(scopes); + const now = Math.floor(Date.now() / 1000); + const profileClaims = this.buildUserClaims(user, scopeSet); + + const accessToken = await this.jwt.signAsync( + { sub: userId, aud: clientId, scopes, typ: 'access_token' }, + { secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), expiresIn: '15m', issuer } + ); + const refreshToken = await this.jwt.signAsync( + { sub: userId, aud: clientId, scopes, typ: 'refresh_token' }, + { secret: this.config.getOrThrow('JWT_REFRESH_SECRET'), expiresIn: '30d', issuer } + ); + + const idTokenClaims: Record = { + sub: userId, + aud: clientId, + iat: now, + auth_time: now + }; + if (nonce) { + idTokenClaims.nonce = nonce; + } + if (profileClaims.name) idTokenClaims.name = profileClaims.name; + if (profileClaims.preferredUsername) idTokenClaims.preferred_username = profileClaims.preferredUsername; + if (profileClaims.picture) idTokenClaims.picture = profileClaims.picture; + if (profileClaims.email) { + idTokenClaims.email = profileClaims.email; + idTokenClaims.email_verified = profileClaims.emailVerified ?? false; + } + if (profileClaims.phoneNumber) { + idTokenClaims.phone_number = profileClaims.phoneNumber; + idTokenClaims.phone_number_verified = profileClaims.phoneNumberVerified ?? false; + } + + const idToken = await this.jwt.signAsync(idTokenClaims, { + secret: this.config.getOrThrow('JWT_ACCESS_SECRET'), + expiresIn: '15m', + issuer + }); + return { accessToken, tokenType: 'Bearer', expiresIn: 900, refreshToken, idToken }; } } diff --git a/shared/proto/identity.proto b/shared/proto/identity.proto index c4e2cb2..73b38f6 100644 --- a/shared/proto/identity.proto +++ b/shared/proto/identity.proto @@ -44,6 +44,7 @@ message AuthorizeRequest { string redirectUri = 3; string scope = 4; optional string state = 5; + optional string nonce = 6; } message AuthorizeResponse { @@ -79,6 +80,10 @@ message UserInfoResponse { optional string phone = 3; string name = 4; optional string picture = 5; + optional bool email_verified = 6; + optional string preferred_username = 7; + optional string phone_number = 8; + optional bool phone_number_verified = 9; } message SendOtpRequest {