global update and global fix

This commit is contained in:
lendry
2026-06-25 23:48:57 +03:00
parent 3880c68d59
commit b0ea87e898
121 changed files with 13759 additions and 663 deletions

View File

@@ -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')