first commit
This commit is contained in:
28
src/oidc/dto/authorize.dto.ts
Normal file
28
src/oidc/dto/authorize.dto.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class AuthorizeDto {
|
||||
@IsString()
|
||||
response_type!: string;
|
||||
|
||||
@IsString()
|
||||
client_id!: string;
|
||||
|
||||
@IsString()
|
||||
redirect_uri!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
scope?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
state?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_challenge?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_challenge_method?: string;
|
||||
}
|
||||
30
src/oidc/dto/token.dto.ts
Normal file
30
src/oidc/dto/token.dto.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class TokenDto {
|
||||
@IsString()
|
||||
grant_type!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
refresh_token?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
client_id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
client_secret?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redirect_uri?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
code_verifier?: string;
|
||||
}
|
||||
84
src/oidc/oidc.controller.ts
Normal file
84
src/oidc/oidc.controller.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Headers,
|
||||
Req,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Public } from '../common/decorators/public.decorator';
|
||||
import { OidcService } from './oidc.service';
|
||||
|
||||
@Controller()
|
||||
export class OidcController {
|
||||
constructor(private readonly oidcService: OidcService) {}
|
||||
|
||||
@Public()
|
||||
@Get('authorize')
|
||||
async authorize(
|
||||
@Query('response_type') responseType: string,
|
||||
@Query('client_id') clientId: string,
|
||||
@Query('redirect_uri') redirectUri: string,
|
||||
@Query('scope') scope: string,
|
||||
@Query('state') state: string,
|
||||
@Query('code_challenge') codeChallenge: string,
|
||||
@Query('code_challenge_method') codeChallengeMethod: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
if (responseType !== 'code') {
|
||||
return res.status(400).json({ error: 'unsupported_response_type' });
|
||||
}
|
||||
|
||||
const result = await this.oidcService.authorize(
|
||||
clientId,
|
||||
redirectUri,
|
||||
scope || 'openid profile',
|
||||
state,
|
||||
codeChallenge,
|
||||
codeChallengeMethod,
|
||||
);
|
||||
|
||||
return res.redirect(result.redirectUrl);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('token')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async token(@Body() body: Record<string, string>) {
|
||||
return this.oidcService.token(
|
||||
body.grant_type,
|
||||
body.code,
|
||||
body.refresh_token,
|
||||
body.client_id,
|
||||
body.client_secret,
|
||||
body.redirect_uri,
|
||||
body.code_verifier,
|
||||
);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('userinfo')
|
||||
async userinfo(@Req() req: Request) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new (await import('@nestjs/common')).UnauthorizedException(
|
||||
'Missing token',
|
||||
);
|
||||
}
|
||||
const token = authHeader.slice(7);
|
||||
return this.oidcService.userinfo(token);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('revoke')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async revoke(@Body('token') token: string) {
|
||||
await this.oidcService.revoke(token);
|
||||
return { revoked: true };
|
||||
}
|
||||
}
|
||||
26
src/oidc/oidc.module.ts
Normal file
26
src/oidc/oidc.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { OidcController } from './oidc.controller';
|
||||
import { OidcService } from './oidc.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
secret: config.get<string>('app.jwt.accessSecret'),
|
||||
signOptions: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
expiresIn: config.get<any>('app.jwt.accessExpiresIn'),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [OidcController],
|
||||
providers: [OidcService],
|
||||
})
|
||||
export class OidcModule {}
|
||||
206
src/oidc/oidc.service.ts
Normal file
206
src/oidc/oidc.service.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
UnauthorizedException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { JwtService, JwtSignOptions } from '@nestjs/jwt';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
|
||||
@Injectable()
|
||||
export class OidcService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly authService: AuthService,
|
||||
private readonly logger: PinoLogger,
|
||||
) {
|
||||
this.logger.setContext(OidcService.name);
|
||||
}
|
||||
|
||||
async authorize(
|
||||
clientId: string,
|
||||
redirectUri: string,
|
||||
scope: string,
|
||||
state?: string,
|
||||
codeChallenge?: string,
|
||||
codeChallengeMethod?: string,
|
||||
) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new BadRequestException('Invalid client_id');
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
throw new BadRequestException('Invalid redirect_uri');
|
||||
}
|
||||
|
||||
const authorizationCode = randomBytes(32).toString('hex');
|
||||
const codeHash = createHash('sha256')
|
||||
.update(authorizationCode)
|
||||
.digest('hex');
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
return { redirectUrl: redirectUrl.toString(), codeHash };
|
||||
}
|
||||
|
||||
async token(
|
||||
grantType: string,
|
||||
code?: string,
|
||||
refreshToken?: string,
|
||||
clientId?: string,
|
||||
clientSecret?: string,
|
||||
redirectUri?: string,
|
||||
codeVerifier?: string,
|
||||
) {
|
||||
switch (grantType) {
|
||||
case 'authorization_code':
|
||||
return this.tokenByAuthCode(
|
||||
code!,
|
||||
clientId!,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
codeVerifier,
|
||||
);
|
||||
case 'refresh_token':
|
||||
return this.authService.refresh(refreshToken!);
|
||||
case 'client_credentials':
|
||||
return this.tokenByClientCredentials(clientId!, clientSecret!);
|
||||
default:
|
||||
throw new BadRequestException('Unsupported grant_type');
|
||||
}
|
||||
}
|
||||
|
||||
async userinfo(accessToken: string) {
|
||||
try {
|
||||
const payload = await this.jwtService.verifyAsync<{ sub: string }>(
|
||||
accessToken,
|
||||
{
|
||||
secret: this.configService.get<string>('app.jwt.accessSecret'),
|
||||
},
|
||||
);
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
displayName: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
return {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
};
|
||||
} catch {
|
||||
throw new UnauthorizedException('Invalid or expired access token');
|
||||
}
|
||||
}
|
||||
|
||||
async revoke(token: string) {
|
||||
await this.authService.logout(token);
|
||||
}
|
||||
|
||||
private async tokenByAuthCode(
|
||||
code: string,
|
||||
clientId: string,
|
||||
clientSecret?: string,
|
||||
redirectUri?: string,
|
||||
codeVerifier?: string,
|
||||
) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new BadRequestException('Invalid client_id');
|
||||
}
|
||||
|
||||
if (client.isConfidential) {
|
||||
if (!clientSecret || client.clientSecret !== clientSecret) {
|
||||
throw new UnauthorizedException('Invalid client_secret');
|
||||
}
|
||||
}
|
||||
|
||||
if (redirectUri && !client.redirectUris.includes(redirectUri)) {
|
||||
throw new BadRequestException('Invalid redirect_uri');
|
||||
}
|
||||
|
||||
if (codeVerifier) {
|
||||
void createHash('sha256').update(codeVerifier).digest('base64url');
|
||||
}
|
||||
|
||||
const refreshToken = randomBytes(64).toString('hex');
|
||||
|
||||
const options = {
|
||||
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;
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ sub: clientId, client_id: clientId },
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 900,
|
||||
refresh_token: refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
private async tokenByClientCredentials(
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
) {
|
||||
const client = await this.prisma.client.findUnique({
|
||||
where: { clientId },
|
||||
});
|
||||
|
||||
if (!client || client.clientSecret !== clientSecret) {
|
||||
throw new UnauthorizedException('Invalid client credentials');
|
||||
}
|
||||
|
||||
const options = {
|
||||
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;
|
||||
const accessToken = await this.jwtService.signAsync(
|
||||
{ sub: clientId, client_id: clientId },
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
token_type: 'Bearer',
|
||||
expires_in: 900,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user