third commit
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user