global update and global fix
This commit is contained in:
@@ -1,31 +1,129 @@
|
||||
import { Body, Controller, Get, Headers, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Headers, 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 { OAuthAuthorizeQueryDto, OAuthTokenDto } from '../dto/identity.dto';
|
||||
import { OAuthAuthorizeIncomingDto, OAuthTokenIncomingDto } from '../dto/oauth.dto';
|
||||
import { extractBearerToken } from '../auth-token';
|
||||
import { appendQueryParams, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, 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) {}
|
||||
constructor(
|
||||
private readonly core: CoreGrpcService,
|
||||
private readonly jwt: JwtService
|
||||
) {}
|
||||
|
||||
@Get('authorize')
|
||||
@ApiOperation({ summary: 'OAuth авторизация', description: 'Создает authorization_code и возвращает redirectUrl для OAuth клиента. Consent считается подтвержденным для переданного userId.' })
|
||||
@ApiQuery({ name: 'userId', description: 'ID пользователя' })
|
||||
@ApiQuery({ name: 'clientId', description: 'OAuth client_id' })
|
||||
@ApiQuery({ name: 'redirectUri', description: 'redirect_uri' })
|
||||
@ApiQuery({ name: 'scope', description: 'Scopes через пробел' })
|
||||
@ApiResponse({ status: 200, description: 'Authorization code создан' })
|
||||
authorize(@Query() query: OAuthAuthorizeQueryDto) {
|
||||
return this.core.oauth.Authorize(query);
|
||||
@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 = normalized.userId;
|
||||
|
||||
if (!userId && authorization) {
|
||||
try {
|
||||
const payload = await verifyAccessToken(this.jwt, authorization);
|
||||
userId = payload.sub;
|
||||
} catch {
|
||||
// handled below
|
||||
}
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
const frontendUrl = await resolveFrontendUrl(this.core);
|
||||
const consentUrl = appendQueryParams(`${frontendUrl}/auth/oauth/authorize`, query as Record<string, unknown>);
|
||||
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
|
||||
})
|
||||
)) as { redirectUrl?: string; code?: string; state?: string };
|
||||
|
||||
const accept = acceptHeader ?? '';
|
||||
const wantsJson = authorization?.startsWith('Bearer') || accept.includes('application/json');
|
||||
if (wantsJson) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (!result.redirectUrl) {
|
||||
throw new UnauthorizedException('Не удалось создать authorization code');
|
||||
}
|
||||
|
||||
res.redirect(302, result.redirectUrl);
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
@ApiOperation({ summary: 'Выдать OAuth токены', description: 'Поддерживает grant_type=authorization_code и grant_type=refresh_token.' })
|
||||
@ApiBody({ type: OAuthTokenDto })
|
||||
@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 токены выданы' })
|
||||
token(@Body() dto: OAuthTokenDto) {
|
||||
return this.core.oauth.Token(dto);
|
||||
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')
|
||||
|
||||
Reference in New Issue
Block a user