add oauth app connected

This commit is contained in:
lendry
2026-06-26 10:49:34 +03:00
parent d5e6b58955
commit b81c0cedbb
18 changed files with 838 additions and 59 deletions

View File

@@ -1,11 +1,11 @@
import { Body, Controller, Get, Headers, Post, Query, Res, UnauthorizedException, UsePipes, ValidationPipe } from '@nestjs/common';
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, OAuthTokenIncomingDto } from '../dto/oauth.dto';
import { OAuthAuthorizeIncomingDto, OAuthConsentActionDto, OAuthTokenIncomingDto } from '../dto/oauth.dto';
import { extractBearerToken } from '../auth-token';
import { appendQueryParams, mapUserInfoToOidc, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeTokenBody } from '../lib/oauth-params';
import { appendQueryParams, mapUserInfoToOidc, mergeTokenCredentials, mapTokenResponseToStandard, normalizeAuthorizeQuery, normalizeConsentQuery, normalizeTokenBody } from '../lib/oauth-params';
import { resolveFrontendUrl } from '../lib/oauth-issuer';
import { verifyAccessToken } from '../session-auth';
@@ -74,6 +74,36 @@ export class OAuthController {
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) {
return (await firstValueFrom(
this.core.oauth.CheckOAuthConsent({
userId,
clientId: normalized.clientId,
scope: normalized.scope
})
)) as Record<string, unknown>;
}
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,
@@ -85,8 +115,6 @@ export class OAuthController {
})
)) as { redirectUrl?: string; code?: string; state?: string };
const accept = acceptHeader ?? '';
const wantsJson = authorization?.startsWith('Bearer') || accept.includes('application/json');
if (wantsJson) {
return result;
}
@@ -98,6 +126,84 @@ export class OAuthController {
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>);
return firstValueFrom(
this.core.oauth.CheckOAuthConsent({
userId: payload.sub,
clientId: normalized.clientId,
scope: normalized.scope
})
);
}
@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('Можно просматривать только свои согласия');
}
return firstValueFrom(this.core.oauth.ListUserOAuthConsents({ userId }));
}
@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({

View File

@@ -131,3 +131,40 @@ export class OAuthTokenIncomingDto {
@IsString()
code_verifier?: string;
}
export class OAuthConsentActionDto {
@ApiPropertyOptional({ description: 'OAuth client_id (RFC 6749)' })
@IsOptional()
@IsString()
clientId?: string;
@ApiPropertyOptional({ description: 'OAuth client_id (legacy camelCase)' })
@IsOptional()
@IsString()
client_id?: string;
@ApiPropertyOptional({ description: 'Scopes через пробел', example: 'openid profile email' })
@IsOptional()
@IsString()
scope?: string;
@ApiPropertyOptional({ description: 'redirect_uri (RFC 6749)' })
@IsOptional()
@IsString()
redirectUri?: string;
@ApiPropertyOptional({ description: 'redirect_uri (legacy camelCase)' })
@IsOptional()
@IsString()
redirect_uri?: string;
@ApiPropertyOptional({ description: 'OAuth state' })
@IsOptional()
@IsString()
state?: string;
@ApiPropertyOptional({ description: 'OpenID Connect nonce' })
@IsOptional()
@IsString()
nonce?: string;
}

View File

@@ -62,6 +62,20 @@ export function normalizeAuthorizeQuery(query: Record<string, unknown>): Normali
};
}
export function normalizeConsentQuery(query: Record<string, unknown>) {
const clientId = readString(query.clientId) ?? readString(query.client_id);
const scope = readString(query.scope) ?? 'openid profile';
const redirectUri = readString(query.redirectUri) ?? readString(query.redirect_uri);
const state = readString(query.state);
const nonce = readString(query.nonce);
if (!clientId) {
throw new BadRequestException('Укажите client_id');
}
return { clientId, scope, redirectUri, state, nonce };
}
export function normalizeTokenBody(body: Record<string, unknown>): NormalizedTokenBody {
const grantType = readString(body.grantType) ?? readString(body.grant_type);
const clientId = readString(body.clientId) ?? readString(body.client_id);