275 lines
11 KiB
TypeScript
275 lines
11 KiB
TypeScript
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);
|
||
}
|
||
}
|