This commit is contained in:
2025-12-04 23:43:41 +08:00
parent f5c573c52f
commit f04ffea612
12 changed files with 435 additions and 72 deletions

View File

@@ -3,46 +3,16 @@ import { ConfigModule } from '@nestjs/config';
import { PassportModule } from '@nestjs/passport';
import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtVerificationService } from './services/jwt-verification.service';
import { UsersModule } from '../users/users.module';
/**
* Authentication Module
*
* Provides Keycloak OpenID Connect authentication using JWT tokens.
* This module configures:
* - Passport for authentication strategies
* - JWT strategy for validating Keycloak tokens
* - Integration with UsersModule for user synchronization
*
* The module requires the following environment variables:
* - JWT_ISSUER: Expected JWT issuer
* - JWT_AUDIENCE: Expected JWT audience
* - JWKS_URI: URI for fetching Keycloak's public keys
*/
@Module({
imports: [
// Import ConfigModule to access environment variables
ConfigModule,
// Import PassportModule for authentication strategies
PassportModule.register({ defaultStrategy: 'jwt' }),
// Import UsersModule to enable user synchronization (with forwardRef to avoid circular dependency)
forwardRef(() => UsersModule),
],
providers: [
// Register the JWT strategy for validating Keycloak tokens
JwtStrategy,
// Register the auth service for business logic
AuthService,
],
exports: [
// Export AuthService so other modules can use it
AuthService,
// Export PassportModule so guards can be used in other modules
PassportModule,
],
providers: [JwtStrategy, AuthService, JwtVerificationService],
exports: [AuthService, PassportModule, JwtVerificationService],
})
export class AuthModule {}

View File

@@ -0,0 +1,79 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { JwtVerificationService } from './jwt-verification.service';
describe('JwtVerificationService', () => {
let service: JwtVerificationService;
beforeEach(async () => {
const mockConfigService = {
get: jest.fn((key: string) => {
const config: Record<string, string> = {
JWKS_URI: 'https://test.com/.well-known/jwks.json',
JWT_ISSUER: 'https://test.com',
JWT_AUDIENCE: 'test-audience',
};
return config[key];
}),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
JwtVerificationService,
{ provide: ConfigService, useValue: mockConfigService },
],
}).compile();
service = module.get<JwtVerificationService>(JwtVerificationService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('extractToken', () => {
it('should extract token from auth object', () => {
const handshake = {
auth: { token: 'test-token' },
headers: {},
};
const token = service.extractToken(handshake);
expect(token).toBe('test-token');
});
it('should extract token from Authorization header', () => {
const handshake = {
auth: {},
headers: { authorization: 'Bearer test-token' },
};
const token = service.extractToken(handshake);
expect(token).toBe('test-token');
});
it('should prioritize auth.token over header', () => {
const handshake = {
auth: { token: 'auth-token' },
headers: { authorization: 'Bearer header-token' },
};
const token = service.extractToken(handshake);
expect(token).toBe('auth-token');
});
it('should return undefined when no token present', () => {
const handshake = {
auth: {},
headers: {},
};
const token = service.extractToken(handshake);
expect(token).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,93 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { verify, type JwtHeader } from 'jsonwebtoken';
import { JwksClient, type SigningKey } from 'jwks-rsa';
import type { JwtPayload } from '../strategies/jwt.strategy';
const JWT_ALGORITHM = 'RS256';
const BEARER_PREFIX = 'Bearer ';
@Injectable()
export class JwtVerificationService {
private readonly logger = new Logger(JwtVerificationService.name);
private readonly jwksClient: JwksClient;
private readonly issuer: string;
private readonly audience: string | undefined;
constructor(private readonly configService: ConfigService) {
const jwksUri = this.configService.get<string>('JWKS_URI');
this.issuer = this.configService.get<string>('JWT_ISSUER') || '';
this.audience = this.configService.get<string>('JWT_AUDIENCE');
if (!jwksUri) {
throw new Error('JWKS_URI must be configured');
}
if (!this.issuer) {
throw new Error('JWT_ISSUER must be configured');
}
this.jwksClient = new JwksClient({
jwksUri,
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
});
this.logger.log('JWT Verification Service initialized');
}
async verifyToken(token: string): Promise<JwtPayload> {
return new Promise((resolve, reject) => {
const getKey = (
header: JwtHeader,
callback: (err: Error | null, signingKey?: string | Buffer) => void,
) => {
this.jwksClient.getSigningKey(
header.kid,
(err: Error | null, key?: SigningKey) => {
if (err) {
callback(err);
return;
}
const signingKey = key?.getPublicKey();
callback(null, signingKey);
},
);
};
verify(
token,
getKey,
{
issuer: this.issuer,
audience: this.audience,
algorithms: [JWT_ALGORITHM],
},
(err, decoded) => {
if (err) {
reject(err);
return;
}
resolve(decoded as JwtPayload);
},
);
});
}
extractToken(handshake: {
auth?: { token?: string };
headers?: { authorization?: string };
}): string | undefined {
if (handshake.auth?.token) {
return handshake.auth.token;
}
const authHeader = handshake.headers?.authorization;
if (authHeader?.startsWith(BEARER_PREFIX)) {
return authHeader.replace(BEARER_PREFIX, '');
}
return undefined;
}
}