third commit

This commit is contained in:
Дмитрий Мамедов
2026-06-10 17:01:10 +03:00
parent d393cbe1db
commit 00919968f2
21 changed files with 721 additions and 274 deletions

View File

@@ -6,13 +6,15 @@ import {
Query,
HttpCode,
HttpStatus,
Headers,
Req,
Res,
UnauthorizedException,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import { Public } from '../common/decorators/public.decorator';
import { RequestWithUser } from '../common/types';
import { OidcService } from './oidc.service';
import { TokenDto } from './dto/token.dto';
@Controller()
export class OidcController {
@@ -28,12 +30,14 @@ export class OidcController {
@Query('state') state: string,
@Query('code_challenge') codeChallenge: string,
@Query('code_challenge_method') codeChallengeMethod: string,
@Req() req: Request,
@Res() res: Response,
) {
if (responseType !== 'code') {
return res.status(400).json({ error: 'unsupported_response_type' });
}
const user = (req as RequestWithUser).user;
const result = await this.oidcService.authorize(
clientId,
redirectUri,
@@ -41,6 +45,7 @@ export class OidcController {
state,
codeChallenge,
codeChallengeMethod,
user?.sub,
);
return res.redirect(result.redirectUrl);
@@ -49,15 +54,15 @@ export class OidcController {
@Public()
@Post('token')
@HttpCode(HttpStatus.OK)
async token(@Body() body: Record<string, string>) {
async token(@Body() dto: TokenDto) {
return this.oidcService.token(
body.grant_type,
body.code,
body.refresh_token,
body.client_id,
body.client_secret,
body.redirect_uri,
body.code_verifier,
dto.grant_type,
dto.code,
dto.refresh_token,
dto.client_id,
dto.client_secret,
dto.redirect_uri,
dto.code_verifier,
);
}
@@ -66,9 +71,7 @@ export class OidcController {
async userinfo(@Req() req: Request) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new (await import('@nestjs/common')).UnauthorizedException(
'Missing token',
);
throw new UnauthorizedException('Missing token');
}
const token = authHeader.slice(7);
return this.oidcService.userinfo(token);

View File

@@ -30,6 +30,7 @@ export class OidcService {
state?: string,
codeChallenge?: string,
codeChallengeMethod?: string,
userId?: string,
) {
const client = await this.prisma.client.findUnique({
where: { clientId },
@@ -43,24 +44,35 @@ export class OidcService {
throw new BadRequestException('Invalid redirect_uri');
}
if (!client.isConfidential && !codeChallenge) {
throw new BadRequestException('Public clients must use PKCE');
}
const authorizationCode = randomBytes(32).toString('hex');
const codeHash = createHash('sha256')
.update(authorizationCode)
.digest('hex');
await this.prisma.authorizationCode.create({
data: {
codeHash,
clientId,
userId: userId ?? null,
redirectUri,
codeChallenge: codeChallenge ?? null,
codeChallengeMethod: codeChallengeMethod ?? null,
scope,
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
},
});
const redirectUrl = new URL(redirectUri);
redirectUrl.searchParams.set('code', authorizationCode);
redirectUrl.searchParams.set('state', state ?? '');
if (codeChallenge && codeChallengeMethod) {
redirectUrl.searchParams.set('code_challenge', codeChallenge);
redirectUrl.searchParams.set(
'code_challenge_method',
codeChallengeMethod,
);
if (state) {
redirectUrl.searchParams.set('state', state);
}
return { redirectUrl: redirectUrl.toString(), codeHash };
return { redirectUrl: redirectUrl.toString() };
}
async token(
@@ -147,23 +159,68 @@ export class OidcService {
}
}
if (redirectUri && !client.redirectUris.includes(redirectUri)) {
throw new BadRequestException('Invalid redirect_uri');
const codeHash = createHash('sha256').update(code).digest('hex');
const storedCode = await this.prisma.authorizationCode.findUnique({
where: { codeHash },
});
if (!storedCode || storedCode.usedAt || storedCode.expiresAt < new Date()) {
if (storedCode && !storedCode.usedAt) {
await this.prisma.authorizationCode.update({
where: { id: storedCode.id },
data: { usedAt: new Date() },
});
}
throw new BadRequestException('Invalid or expired authorization code');
}
if (codeVerifier) {
void createHash('sha256').update(codeVerifier).digest('base64url');
if (storedCode.clientId !== clientId) {
throw new BadRequestException('Code was issued for a different client');
}
if (redirectUri && storedCode.redirectUri !== redirectUri) {
throw new BadRequestException('Redirect URI mismatch');
}
if (storedCode.codeChallenge && !codeVerifier) {
throw new BadRequestException('PKCE code_verifier required');
}
if (codeVerifier && storedCode.codeChallenge) {
const method = storedCode.codeChallengeMethod ?? 'S256';
let verifierChallenge: string;
if (method === 'S256') {
verifierChallenge = createHash('sha256')
.update(codeVerifier)
.digest('base64url');
} else {
verifierChallenge = codeVerifier;
}
if (verifierChallenge !== storedCode.codeChallenge) {
throw new UnauthorizedException('PKCE verification failed');
}
}
await this.prisma.authorizationCode.update({
where: { id: storedCode.id },
data: { usedAt: new Date() },
});
const userId = storedCode.userId;
if (!userId) {
throw new UnauthorizedException('No user associated with this code');
}
const refreshToken = randomBytes(64).toString('hex');
const options = {
const options: JwtSignOptions = {
secret: this.configService.get<string>('app.jwt.accessSecret'),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expiresIn: this.configService.get<any>('app.jwt.accessExpiresIn'),
} satisfies JwtSignOptions;
expiresIn: this.configService.get('app.jwt.accessExpiresIn'),
};
const accessToken = await this.jwtService.signAsync(
{ sub: clientId, client_id: clientId },
{ sub: userId, client_id: clientId },
options,
);
@@ -187,11 +244,10 @@ export class OidcService {
throw new UnauthorizedException('Invalid client credentials');
}
const options = {
const options: JwtSignOptions = {
secret: this.configService.get<string>('app.jwt.accessSecret'),
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
expiresIn: this.configService.get<any>('app.jwt.accessExpiresIn'),
} satisfies JwtSignOptions;
expiresIn: this.configService.get('app.jwt.accessExpiresIn'),
};
const accessToken = await this.jwtService.signAsync(
{ sub: clientId, client_id: clientId },
options,