import { Body, Controller, Delete, Get, Headers, Param, Post, Query, Res, UnauthorizedException, UsePipes, ValidationPipe } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { ApiBearerAuth, ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; import { firstValueFrom } from 'rxjs'; import { CoreGrpcService } from '../core-grpc.service'; import { OAuthAuthorizeIncomingDto, OAuthConsentActionDto, OAuthTokenIncomingDto } from '../dto/oauth.dto'; import { extractBearerToken } from '../auth-token'; import { appendQueryParams, mapOAuthClientPublicInfo, mapOAuthConsentCheckResponse, mapUserInfoToOidc, mapUserOAuthConsentsResponse, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeConsentQuery, normalizeTokenBody } from '../lib/oauth-params'; import { resolveFrontendUrl } from '../lib/oauth-issuer'; import { verifyAccessToken } from '../session-auth'; type HttpResponse = { redirect(status: number, url: string): void; }; const oauthValidationPipe = new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: false }); @ApiTags('OAuth 2.0') @Controller('oauth') export class OAuthController { constructor( private readonly core: CoreGrpcService, private readonly jwt: JwtService ) {} @Get('authorize') @UsePipes(oauthValidationPipe) @ApiOperation({ summary: 'OAuth / OIDC авторизация', description: 'Поддерживает стандартные query-параметры RFC 6749 / OIDC (client_id, redirect_uri, response_type, code_challenge) и legacy camelCase. ' + 'Без userId и без Bearer-токена перенаправляет на страницу подтверждения доступа.' }) @ApiQuery({ name: 'client_id', required: false, description: 'OAuth client_id (RFC 6749)' }) @ApiQuery({ name: 'clientId', required: false, description: 'OAuth client_id (legacy)' }) @ApiQuery({ name: 'redirect_uri', required: false }) @ApiQuery({ name: 'redirectUri', required: false }) @ApiQuery({ name: 'scope', required: false }) @ApiQuery({ name: 'state', required: false }) @ApiQuery({ name: 'response_type', required: false, example: 'code' }) @ApiQuery({ name: 'code_challenge', required: false }) @ApiQuery({ name: 'code_challenge_method', required: false }) @ApiQuery({ name: 'userId', required: false, description: 'Legacy: ID пользователя после входа' }) @ApiResponse({ status: 302, description: 'Redirect на redirect_uri с authorization code' }) async authorize( @Query() query: OAuthAuthorizeIncomingDto, @Headers('authorization') authorization: string | undefined, @Headers('accept') acceptHeader: string | undefined, @Res({ passthrough: true }) res: HttpResponse ) { const normalized = normalizeAuthorizeQuery(query as Record); let userId: string | undefined; if (authorization) { try { const payload = await verifyAccessToken(this.jwt, authorization); userId = payload.sub; } catch { // токен недействителен — не доверяем userId из query } } if (!userId) { const frontendUrl = await resolveFrontendUrl(this.core); const consentQuery = { ...(query as Record) }; delete consentQuery.userId; delete consentQuery.user_id; const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, consentQuery); res.redirect(302, consentUrl); return; } const consentCheck = (await firstValueFrom( this.core.oauth.CheckOAuthConsent({ userId, clientId: normalized.clientId, scope: normalized.scope }) )) as { granted?: boolean }; const accept = acceptHeader ?? ''; const wantsJson = authorization?.startsWith('Bearer') || accept.includes('application/json'); if (!consentCheck.granted) { if (wantsJson) { const pendingConsent = (await firstValueFrom( this.core.oauth.CheckOAuthConsent({ userId, clientId: normalized.clientId, scope: normalized.scope }) )) as Record; return mapOAuthConsentCheckResponse(pendingConsent); } const frontendUrl = await resolveFrontendUrl(this.core); const consentQuery = { ...(query as Record) }; delete consentQuery.userId; delete consentQuery.user_id; const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, consentQuery); res.redirect(302, consentUrl); return; } const result = (await firstValueFrom( this.core.oauth.Authorize({ userId, clientId: normalized.clientId, redirectUri: normalized.redirectUri, scope: normalized.scope, state: normalized.state, nonce: normalized.nonce }) )) as { redirectUrl?: string; code?: string; state?: string }; if (wantsJson) { return result; } if (!result.redirectUrl) { throw new UnauthorizedException('Не удалось создать authorization code'); } res.redirect(302, result.redirectUrl); } @Get('consent/check') @ApiBearerAuth() @UsePipes(oauthValidationPipe) @ApiOperation({ summary: 'Проверить сохранённое согласие OAuth', description: 'Возвращает статус согласия пользователя для указанного OAuth-приложения и scopes.' }) @ApiQuery({ name: 'client_id', required: false }) @ApiQuery({ name: 'clientId', required: false }) @ApiQuery({ name: 'scope', required: false }) async checkConsent( @Query() query: OAuthConsentActionDto, @Headers('authorization') authorization: string | undefined ) { const payload = await verifyAccessToken(this.jwt, authorization); const normalized = normalizeConsentQuery(query as Record); const result = (await firstValueFrom( this.core.oauth.CheckOAuthConsent({ userId: payload.sub, clientId: normalized.clientId, scope: normalized.scope }) )) as Record; return mapOAuthConsentCheckResponse(result); } @Get('clients/:clientId/public') @ApiOperation({ summary: 'Публичная информация об OAuth-приложении', description: 'Возвращает человекочитаемое название приложения для экрана согласия.' }) async getClientPublicInfo(@Param('clientId') clientId: string) { const result = (await firstValueFrom( this.core.oauth.GetOAuthClientPublicInfo({ clientId }) )) as Record; return mapOAuthClientPublicInfo(result); } @Post('consent/approve') @ApiBearerAuth() @UsePipes(oauthValidationPipe) @ApiOperation({ summary: 'Подтвердить OAuth-согласие', description: 'Сохраняет согласие пользователя и выдаёт authorization code для redirect_uri.' }) @ApiBody({ type: OAuthConsentActionDto }) async approveConsent( @Body() body: OAuthConsentActionDto, @Headers('authorization') authorization: string | undefined ) { const payload = await verifyAccessToken(this.jwt, authorization); const normalized = normalizeAuthorizeQuery(body as Record); return firstValueFrom( this.core.oauth.Authorize({ userId: payload.sub, clientId: normalized.clientId, redirectUri: normalized.redirectUri, scope: normalized.scope, state: normalized.state, nonce: normalized.nonce, grantConsent: true }) ); } @Get('consents/users/:userId') @ApiBearerAuth() @ApiOperation({ summary: 'Список OAuth-согласий пользователя', description: 'Возвращает все приложения, которым пользователь выдал доступ к данным.' }) async listUserConsents( @Param('userId') userId: string, @Headers('authorization') authorization: string | undefined ) { const payload = await verifyAccessToken(this.jwt, authorization); if (payload.sub !== userId) { throw new UnauthorizedException('Можно просматривать только свои согласия'); } const result = (await firstValueFrom(this.core.oauth.ListUserOAuthConsents({ userId }))) as Record; return mapUserOAuthConsentsResponse(result); } @Delete('consents/users/:userId/:consentId') @ApiBearerAuth() @ApiOperation({ summary: 'Отозвать OAuth-согласие', description: 'Удаляет сохранённое согласие. При следующем входе приложение снова запросит доступ.' }) async revokeConsent( @Param('userId') userId: string, @Param('consentId') consentId: string, @Headers('authorization') authorization: string | undefined ) { const payload = await verifyAccessToken(this.jwt, authorization); if (payload.sub !== userId) { throw new UnauthorizedException('Можно отзывать только свои согласия'); } return firstValueFrom(this.core.oauth.RevokeOAuthConsent({ userId, consentId })); } @Post('token') @UsePipes(oauthValidationPipe) @ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token. ' + 'Принимает application/x-www-form-urlencoded (RFC 6749) и JSON. ' + 'Ответ — snake_case: access_token, token_type, expires_in, refresh_token, id_token.' }) @ApiBody({ type: OAuthTokenIncomingDto }) @ApiResponse({ status: 201, description: 'OAuth токены выданы' }) async token(@Body() body: OAuthTokenIncomingDto, @Headers('authorization') authorization?: string) { const normalized = mergeTokenCredentials(normalizeTokenBody(body as Record), authorization); const result = (await firstValueFrom( this.core.oauth.Token({ grantType: normalized.grantType, code: normalized.code, refreshToken: normalized.refreshToken, clientId: normalized.clientId, clientSecret: normalized.clientSecret, redirectUri: normalized.redirectUri }) )) as { accessToken?: string; tokenType?: string; expiresIn?: number; refreshToken?: string; idToken?: string; }; return mapTokenResponseToStandard(result); } @Get('userinfo') @ApiBearerAuth() @ApiOperation({ summary: 'OAuth userinfo', description: 'Возвращает профиль пользователя по OAuth access token.' }) @ApiResponse({ status: 200, description: 'Профиль пользователя получен' }) 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); } }