import { BadRequestException, Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { randomBytes } from 'node:crypto'; 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( private readonly prisma: PrismaService, private readonly jwt: JwtService, private readonly config: ConfigService, private readonly oauthIssuer: OAuthIssuerService ) {} async authorize(command: { userId: string; clientId: string; redirectUri: string; scope: string; state?: string; nonce?: string; grantConsent?: boolean; }) { const user = await this.prisma.user.findFirst({ where: { id: command.userId, deletedAt: null, status: 'ACTIVE', ...HUMAN_USER_WHERE } }); if (!user) { throw new UnauthorizedException('Пользователь не найден или заблокирован'); } assertHumanAccount(user, { asAuth: true }); const client = await this.prisma.oAuthClient.findUnique({ where: { clientId: command.clientId } }); if (!client?.isActive || !client.redirectUris.includes(command.redirectUri)) { throw new BadRequestException('OAuth приложение не найдено или redirect_uri запрещен'); } if (command.grantConsent) { await this.persistConsent(command.userId, client.id, command.scope); } else { const consent = await this.checkConsent(command.userId, command.clientId, command.scope); if (!consent.granted) { throw new BadRequestException('Согласие пользователя на доступ не получено'); } } const code = randomBytes(32).toString('base64url'); await this.prisma.oAuthAuthorizationCode.create({ data: { code, userId: command.userId, clientId: client.id, redirectUri: command.redirectUri, scopes: command.scope.split(' ').filter(Boolean), nonce: command.nonce ?? null, expiresAt: new Date(Date.now() + 5 * 60_000) } }); const url = new URL(command.redirectUri); url.searchParams.set('code', code); if (command.state) url.searchParams.set('state', command.state); return { redirectUrl: url.toString(), code, state: command.state }; } 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 (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 ) { 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, authCode.nonce ?? undefined); } if (command.grantType === 'refresh_token') { if (!command.refreshToken) throw new BadRequestException('Передайте 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 ?? []); } throw new BadRequestException('Неподдерживаемый grant_type'); } async userInfo(accessToken: string) { 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 }); const scopes = this.parseScopes(payload.scopes ?? ['openid', 'profile']); return this.buildUserClaims(user, scopes); } 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 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 }; } async checkConsent(userId: string, clientId: string, scope: string) { const client = await this.prisma.oAuthClient.findUnique({ where: { clientId } }); if (!client?.isActive) { throw new BadRequestException('OAuth приложение не найдено'); } const requestedSlugs = this.parseScopeSlugs(scope); const requestedScopes = await this.resolveScopeInfos(requestedSlugs); const grantedSlugs = await this.loadGrantedScopeSlugs(userId, client.id); const granted = requestedSlugs.every((slug) => grantedSlugs.has(slug)); return { granted, client: { clientId: client.clientId, name: client.name }, requestedScopes }; } async grantConsent(userId: string, clientId: string, scope: string) { const client = await this.prisma.oAuthClient.findUnique({ where: { clientId } }); if (!client?.isActive) { throw new BadRequestException('OAuth приложение не найдено'); } await this.persistConsent(userId, client.id, scope); return { granted: true }; } async listUserConsents(userId: string) { const consents = await this.prisma.oAuthConsent.findMany({ where: { userId }, include: { client: true, scopes: { include: { scope: true } } }, orderBy: { updatedAt: 'desc' } }); return { consents: consents.map((consent) => ({ id: consent.id, clientId: consent.client.clientId, clientName: consent.client.name, scopes: consent.scopes.map((item) => ({ slug: item.scope.slug, name: item.scope.name, description: item.scope.description ?? undefined })), grantedAt: consent.createdAt.toISOString(), updatedAt: consent.updatedAt.toISOString() })) }; } async revokeConsent(userId: string, consentId: string) { const consent = await this.prisma.oAuthConsent.findFirst({ where: { id: consentId, userId } }); if (!consent) { throw new BadRequestException('Согласие не найдено'); } await this.prisma.oAuthConsent.delete({ where: { id: consent.id } }); return { count: 1 }; } private parseScopeSlugs(scope: string) { return [...new Set(scope.split(/\s+/).map((item) => item.trim()).filter(Boolean))]; } private async resolveScopeInfos(slugs: string[]) { if (slugs.length === 0) { return [{ slug: 'openid', name: 'OpenID', description: 'Базовая идентификация OpenID Connect' }]; } const records = await this.prisma.oAuthScope.findMany({ where: { slug: { in: slugs } } }); const bySlug = new Map(records.map((item) => [item.slug, item])); return slugs.map((slug) => { const record = bySlug.get(slug); return { slug, name: record?.name ?? slug, description: record?.description ?? undefined }; }); } private async loadGrantedScopeSlugs(userId: string, clientInternalId: string) { const consent = await this.prisma.oAuthConsent.findUnique({ where: { userId_clientId: { userId, clientId: clientInternalId } }, include: { scopes: { include: { scope: true } } } }); return new Set(consent?.scopes.map((item) => item.scope.slug) ?? []); } private async persistConsent(userId: string, clientInternalId: string, scope: string) { const slugs = this.parseScopeSlugs(scope); const scopeRecords = await this.prisma.oAuthScope.findMany({ where: { slug: { in: slugs } } }); const consent = await this.prisma.oAuthConsent.upsert({ where: { userId_clientId: { userId, clientId: clientInternalId } }, create: { userId, clientId: clientInternalId }, update: { updatedAt: new Date() } }); for (const scopeRecord of scopeRecords) { await this.prisma.oAuthConsentScope.upsert({ where: { consentId_scopeId: { consentId: consent.id, scopeId: scopeRecord.id } }, create: { consentId: consent.id, scopeId: scopeRecord.id }, update: {} }); } } }