Files
IdP/apps/api-gateway/src/controllers/oauth.controller.ts
2026-06-26 11:00:48 +03:00

275 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>);
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<string, unknown>) };
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<string, unknown>;
return mapOAuthConsentCheckResponse(pendingConsent);
}
const frontendUrl = await resolveFrontendUrl(this.core);
const consentQuery = { ...(query as Record<string, unknown>) };
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<string, unknown>);
const result = (await firstValueFrom(
this.core.oauth.CheckOAuthConsent({
userId: payload.sub,
clientId: normalized.clientId,
scope: normalized.scope
})
)) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>);
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<string, unknown>;
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<string, unknown>), 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);
}
}