add oauth app connected
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user