first commit

This commit is contained in:
Дмитрий Мамедов
2026-06-10 16:23:41 +03:00
commit f37733a5c9
70 changed files with 16338 additions and 0 deletions

View 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
View 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;
}

View 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
View 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
View 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,
};
}
}