From 009c3c50cf7e2b3558e54e62459f371607a02777 Mon Sep 17 00:00:00 2001 From: kimanicodee Date: Fri, 17 Jul 2026 16:46:44 +0300 Subject: [PATCH 1/5] feat(backend): add OIDC JWT issuance and revocation --- app/backend/cache/redis.service.ts | 6 +- app/backend/package.json | 1 + app/backend/src/app.module.ts | 2 + app/backend/src/auth-oidc/auth-oidc.module.ts | 15 + app/backend/src/auth-oidc/dto.ts | 46 +++ app/backend/src/auth-oidc/token.controller.ts | 67 ++++ app/backend/src/auth-oidc/token.service.ts | 302 ++++++++++++++++++ .../api-key.guard.jwt-regression.spec.ts | 66 ++++ .../src/common/guards/jwt-auth.guard.spec.ts | 93 ++++++ .../src/common/guards/jwt-auth.guard.ts | 42 +++ app/backend/src/types/express.d.ts | 2 +- app/backend/test/auth-oidc.e2e-spec.ts | 144 +++++++++ pnpm-lock.yaml | 39 ++- 13 files changed, 814 insertions(+), 11 deletions(-) create mode 100644 app/backend/src/auth-oidc/auth-oidc.module.ts create mode 100644 app/backend/src/auth-oidc/dto.ts create mode 100644 app/backend/src/auth-oidc/token.controller.ts create mode 100644 app/backend/src/auth-oidc/token.service.ts create mode 100644 app/backend/src/common/guards/api-key.guard.jwt-regression.spec.ts create mode 100644 app/backend/src/common/guards/jwt-auth.guard.spec.ts create mode 100644 app/backend/src/common/guards/jwt-auth.guard.ts create mode 100644 app/backend/test/auth-oidc.e2e-spec.ts diff --git a/app/backend/cache/redis.service.ts b/app/backend/cache/redis.service.ts index fed1b5a3..4b6c0cd5 100644 --- a/app/backend/cache/redis.service.ts +++ b/app/backend/cache/redis.service.ts @@ -24,6 +24,10 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { this.client?.disconnect(); } + getClient(): Redis { + return this.client; + } + /** * Retrieve and deserialise a cached value. * Returns `null` on cache miss or if Redis is unavailable. @@ -93,4 +97,4 @@ export class RedisService implements OnModuleInit, OnModuleDestroy { return 0; } } -} \ No newline at end of file +} diff --git a/app/backend/package.json b/app/backend/package.json index c6a274fb..6e80ab3d 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -55,6 +55,7 @@ "dotenv": "^17.2.3", "helmet": "^8.1.0", "ioredis": "^5.9.2", + "jose": "^4", "openai": "^6.33.0", "pino": "^10.3.0", "pino-http": "^11.0.0", diff --git a/app/backend/src/app.module.ts b/app/backend/src/app.module.ts index 3935b65e..988ff336 100644 --- a/app/backend/src/app.module.ts +++ b/app/backend/src/app.module.ts @@ -45,6 +45,7 @@ import { AdaptiveRateLimitGuard } from './common/guards/adaptive-rate-limit.guar import { DeprecationInterceptor } from './common/interceptors/deprecation.interceptor'; import { HttpCacheInterceptor } from './common/interceptors/http-cache.interceptor'; import { SandboxModule } from './sandbox/sandbox.module'; +import { AuthOidcModule } from './auth-oidc/auth-oidc.module'; @Module({ imports: [ @@ -114,6 +115,7 @@ import { SandboxModule } from './sandbox/sandbox.module'; EntityLinkingModule, DeploymentMetadataModule, SandboxModule, + AuthOidcModule, RedisModule.forRootAsync({ imports: [ConfigModule], useFactory: (configService: ConfigService) => ({ diff --git a/app/backend/src/auth-oidc/auth-oidc.module.ts b/app/backend/src/auth-oidc/auth-oidc.module.ts new file mode 100644 index 00000000..dc4de5c7 --- /dev/null +++ b/app/backend/src/auth-oidc/auth-oidc.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { PrismaModule } from '../prisma/prisma.module'; +import { RedisService } from '../../cache/redis.service'; +import { TokenController } from './token.controller'; +import { TokenService } from './token.service'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; + +@Module({ + imports: [ConfigModule, PrismaModule], + controllers: [TokenController], + providers: [TokenService, JwtAuthGuard, RedisService], + exports: [TokenService, JwtAuthGuard], +}) +export class AuthOidcModule {} diff --git a/app/backend/src/auth-oidc/dto.ts b/app/backend/src/auth-oidc/dto.ts new file mode 100644 index 00000000..4316d9dc --- /dev/null +++ b/app/backend/src/auth-oidc/dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsOptional, IsString } from 'class-validator'; + +export class TokenRequestDto { + @ApiProperty({ + enum: ['client_credentials', 'refresh_token'], + description: + 'OAuth grant type. This backend has no password login flow, so JWT issuance is based on existing API-key client credentials.', + }) + @IsIn(['client_credentials', 'refresh_token']) + grant_type!: 'client_credentials' | 'refresh_token'; + + @ApiPropertyOptional({ + description: + 'Optional client identifier. API-key records are validated by client_secret.', + }) + @IsOptional() + @IsString() + client_id?: string; + + @ApiPropertyOptional({ + description: 'Existing ChainForge API key used as the OAuth client secret.', + }) + @IsOptional() + @IsString() + client_secret?: string; + + @ApiPropertyOptional({ + description: 'Refresh token, required when grant_type=refresh_token.', + }) + @IsOptional() + @IsString() + refresh_token?: string; +} + +export class TokenIntrospectionDto { + @ApiProperty({ description: 'JWT access or refresh token to introspect.' }) + @IsString() + token!: string; +} + +export class TokenRevocationDto { + @ApiProperty({ description: 'JWT access or refresh token to revoke.' }) + @IsString() + token!: string; +} diff --git a/app/backend/src/auth-oidc/token.controller.ts b/app/backend/src/auth-oidc/token.controller.ts new file mode 100644 index 00000000..c7b3c515 --- /dev/null +++ b/app/backend/src/auth-oidc/token.controller.ts @@ -0,0 +1,67 @@ +import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { Request } from 'express'; +import { Public } from '../common/decorators/public.decorator'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; +import { + TokenIntrospectionDto, + TokenRequestDto, + TokenRevocationDto, +} from './dto'; +import { TokenService } from './token.service'; + +@ApiTags('OAuth') +@Controller('oauth') +export class TokenController { + constructor(private readonly tokenService: TokenService) {} + + @Public() + @Post('token') + @ApiOperation({ + summary: 'Issue or refresh OAuth-compatible JWTs', + description: + 'Supports client_credentials using an existing ChainForge API key as client_secret, and refresh_token. No password login flow exists in this backend.', + }) + @ApiBody({ type: TokenRequestDto }) + @ApiOkResponse({ description: 'Token pair issued.' }) + async token(@Body() dto: TokenRequestDto) { + if (dto.grant_type === 'client_credentials') { + return this.tokenService.issueForClientCredentials(dto.client_secret); + } + return this.tokenService.refresh(dto.refresh_token); + } + + @Public() + @UseGuards(JwtAuthGuard) + @Get('userinfo') + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Return claims for the current JWT principal' }) + userinfo(@Req() req: Request) { + return req.user; + } + + @Public() + @Post('introspect') + @ApiOperation({ + summary: 'Introspect a JWT using RFC 7662 active/inactive shape', + }) + @ApiBody({ type: TokenIntrospectionDto }) + introspect(@Body() dto: TokenIntrospectionDto) { + return this.tokenService.introspect(dto.token); + } + + @Public() + @Post('revoke') + @ApiOperation({ summary: 'Revoke a JWT by adding its jti to Redis' }) + @ApiBody({ type: TokenRevocationDto }) + async revoke(@Body() dto: TokenRevocationDto) { + await this.tokenService.revoke(dto.token); + return { revoked: true }; + } +} diff --git a/app/backend/src/auth-oidc/token.service.ts b/app/backend/src/auth-oidc/token.service.ts new file mode 100644 index 00000000..f0c29388 --- /dev/null +++ b/app/backend/src/auth-oidc/token.service.ts @@ -0,0 +1,302 @@ +import { + BadRequestException, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppRole } from '../auth/app-role.enum'; +import { PrismaService } from '../prisma/prisma.service'; +import { RedisService } from '../../cache/redis.service'; +import { createHash, randomUUID } from 'node:crypto'; +import { + importPKCS8, + importSPKI, + jwtVerify, + JWTPayload, + KeyLike, + SignJWT, +} from 'jose'; + +const REVOKED_JTI_SET_KEY = 'oidc:revoked:jti'; +const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 15 * 60; +const DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 7 * 24 * 60 * 60; + +export interface TokenPrincipal { + role: AppRole; + ngoId?: string | null; + apiKeyId?: string; +} + +export interface VerifiedToken { + payload: JWTPayload & { + jti: string; + role: AppRole; + token_use: 'access' | 'refresh'; + ngoId?: string | null; + apiKeyId?: string; + }; + principal: TokenPrincipal; +} + +export interface IssuedTokenPair { + access_token: string; + refresh_token: string; + token_type: 'Bearer'; + expires_in: number; + refresh_expires_in: number; +} + +@Injectable() +export class TokenService { + private readonly issuer: string; + private readonly audience: string; + private readonly accessTokenTtlSeconds: number; + private readonly refreshTokenTtlSeconds: number; + private privateKeyPromise?: Promise; + private publicKeyPromise?: Promise; + + constructor( + private readonly configService: ConfigService, + private readonly prisma: PrismaService, + private readonly redis: RedisService, + ) { + this.issuer = + this.configService.get('JWT_ISSUER') ?? 'chainforge-api'; + this.audience = + this.configService.get('JWT_AUDIENCE') ?? 'chainforge-api'; + this.accessTokenTtlSeconds = this.readPositiveInt( + 'JWT_ACCESS_TOKEN_TTL_SECONDS', + DEFAULT_ACCESS_TOKEN_TTL_SECONDS, + ); + this.refreshTokenTtlSeconds = this.readPositiveInt( + 'JWT_REFRESH_TOKEN_TTL_SECONDS', + DEFAULT_REFRESH_TOKEN_TTL_SECONDS, + ); + } + + async issueForClientCredentials( + apiKey: string | undefined, + ): Promise { + if (!apiKey) { + throw new UnauthorizedException('Missing client credentials'); + } + + const principal = await this.authenticateApiKey(apiKey); + return this.issueTokenPair(principal); + } + + async refresh(refreshToken: string | undefined): Promise { + if (!refreshToken) { + throw new BadRequestException('Missing refresh_token'); + } + + const verified = await this.verifyToken(refreshToken, 'refresh'); + await this.revokeVerified(verified); + return this.issueTokenPair(verified.principal); + } + + async verifyAccessToken(token: string): Promise { + return this.verifyToken(token, 'access'); + } + + async introspect(token: string): Promise> { + try { + const verified = await this.verifyToken(token); + return { + active: true, + sub: verified.payload.sub, + iss: verified.payload.iss, + aud: verified.payload.aud, + exp: verified.payload.exp, + iat: verified.payload.iat, + jti: verified.payload.jti, + role: verified.payload.role, + token_use: verified.payload.token_use, + ngoId: verified.payload.ngoId, + apiKeyId: verified.payload.apiKeyId, + }; + } catch { + return { active: false }; + } + } + + async revoke(token: string): Promise { + try { + const verified = await this.verifyToken(token, undefined, false); + await this.revokeVerified(verified); + return true; + } catch { + return false; + } + } + + async isRevoked(jti: string): Promise { + await this.cleanupExpiredRevocations(); + const score = await this.redis.getClient().zscore(REVOKED_JTI_SET_KEY, jti); + return score !== null; + } + + private async issueTokenPair( + principal: TokenPrincipal, + ): Promise { + const [accessToken, refreshToken] = await Promise.all([ + this.signToken(principal, 'access', this.accessTokenTtlSeconds), + this.signToken(principal, 'refresh', this.refreshTokenTtlSeconds), + ]); + + return { + access_token: accessToken, + refresh_token: refreshToken, + token_type: 'Bearer', + expires_in: this.accessTokenTtlSeconds, + refresh_expires_in: this.refreshTokenTtlSeconds, + }; + } + + private async authenticateApiKey( + rawClientSecret: string, + ): Promise { + const apiKeyHash = fingerprintApiKey(rawClientSecret); + const record = await this.prisma.apiKey.findFirst({ + where: { + revokedAt: null, + OR: [{ keyHash: apiKeyHash }, { key: rawClientSecret }], + }, + }); + + if (record) { + await this.prisma.apiKey.update({ + where: { id: record.id }, + data: { lastUsedAt: new Date() }, + }); + + return { + role: record.role, + ngoId: record.ngoId, + apiKeyId: record.id, + }; + } + + const envKey = this.configService.get('API_KEY'); + if (rawClientSecret === envKey) { + return { role: AppRole.admin }; + } + + throw new UnauthorizedException('Invalid client credentials'); + } + + private async signToken( + principal: TokenPrincipal, + tokenUse: 'access' | 'refresh', + ttlSeconds: number, + ): Promise { + const now = Math.floor(Date.now() / 1000); + const expiresAt = now + ttlSeconds; + const subject = principal.apiKeyId ?? `role:${principal.role}`; + + return new SignJWT({ + role: principal.role, + token_use: tokenUse, + ngoId: principal.ngoId, + apiKeyId: principal.apiKeyId, + }) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer(this.issuer) + .setAudience(this.audience) + .setSubject(subject) + .setJti(randomUUID()) + .setIssuedAt(now) + .setExpirationTime(expiresAt) + .sign(await this.getPrivateKey()); + } + + private async verifyToken( + token: string, + expectedUse?: 'access' | 'refresh', + enforceRevocation = true, + ): Promise { + try { + const { payload } = await jwtVerify(token, await this.getPublicKey(), { + issuer: this.issuer, + audience: this.audience, + }); + const typed = payload as VerifiedToken['payload']; + + if (!typed.jti || !typed.role || !typed.token_use) { + throw new UnauthorizedException('Invalid token claims'); + } + if (!Object.values(AppRole).includes(typed.role)) { + throw new UnauthorizedException('Invalid role claim'); + } + if (expectedUse && typed.token_use !== expectedUse) { + throw new UnauthorizedException('Invalid token use'); + } + if (enforceRevocation && (await this.isRevoked(typed.jti))) { + throw new UnauthorizedException('Token has been revoked'); + } + + return { + payload: typed, + principal: { + role: typed.role, + ngoId: typed.ngoId, + apiKeyId: typed.apiKeyId, + }, + }; + } catch (err) { + if (err instanceof UnauthorizedException) throw err; + throw new UnauthorizedException('Invalid token'); + } + } + + private async revokeVerified(verified: VerifiedToken): Promise { + const exp = verified.payload.exp; + if (!exp) return; + await this.cleanupExpiredRevocations(); + await this.redis + .getClient() + .zadd(REVOKED_JTI_SET_KEY, String(exp), verified.payload.jti); + } + + private async cleanupExpiredRevocations(): Promise { + const now = Math.floor(Date.now() / 1000); + await this.redis.getClient().zremrangebyscore(REVOKED_JTI_SET_KEY, 0, now); + } + + private async getPrivateKey(): Promise { + this.privateKeyPromise ??= importPKCS8( + this.readPem('JWT_PRIVATE_KEY'), + 'RS256', + ); + return this.privateKeyPromise; + } + + private async getPublicKey(): Promise { + this.publicKeyPromise ??= importSPKI( + this.readPem('JWT_PUBLIC_KEY'), + 'RS256', + ); + return this.publicKeyPromise; + } + + private readPem(name: string): string { + const raw = this.configService.get(name); + if (!raw) { + throw new Error(`${name} is required for OIDC JWT support`); + } + return raw.replace(/\\n/g, '\n'); + } + + private readPositiveInt(name: string, fallback: number): number { + const raw = this.configService.get(name); + const parsed = raw !== undefined ? Number.parseInt(raw, 10) : NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; + } +} + +function fingerprintApiKey(rawClientSecret: string): string { + // SHA-256 fingerprint of a high-entropy API key/client secret for + // exact-match lookup (matches ApiKeyGuard and ApiKey.keyHash), not + // password hashing. + return createHash('sha256').update(rawClientSecret).digest('hex'); // lgtm[js/insufficient-password-hash] +} diff --git a/app/backend/src/common/guards/api-key.guard.jwt-regression.spec.ts b/app/backend/src/common/guards/api-key.guard.jwt-regression.spec.ts new file mode 100644 index 00000000..9b735a3b --- /dev/null +++ b/app/backend/src/common/guards/api-key.guard.jwt-regression.spec.ts @@ -0,0 +1,66 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { AppRole } from '../../auth/app-role.enum'; +import { ApiKeyGuard } from './api-key.guard'; + +const mockReflector = { getAllAndOverride: jest.fn().mockReturnValue(false) }; +const mockConfigService = { get: jest.fn().mockReturnValue('test-api-key') }; +const mockPrismaService = { + apiKey: { + findFirst: jest.fn(), + update: jest.fn(), + }, +}; + +const createContext = (headers: Record) => { + const req: Record = { headers }; + return { + switchToHttp: () => ({ + getRequest: () => req, + }), + getHandler: () => ({}), + getClass: () => ({}), + }; +}; + +describe('ApiKeyGuard JWT regression', () => { + let guard: ApiKeyGuard; + + beforeEach(() => { + jest.clearAllMocks(); + mockReflector.getAllAndOverride.mockReturnValue(false); + mockConfigService.get.mockReturnValue('test-api-key'); + mockPrismaService.apiKey.findFirst.mockResolvedValue(null); + mockPrismaService.apiKey.update.mockResolvedValue({}); + + guard = new ApiKeyGuard( + mockConfigService as any, + mockReflector as any, + mockPrismaService as any, + ); + }); + + it('still ignores bearer JWTs and requires x-api-key unless a route is public', async () => { + const context = createContext({ authorization: 'Bearer jwt-token' }); + + await expect(guard.canActivate(context as any)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('still attaches the same API-key principal shape for database API keys', async () => { + mockPrismaService.apiKey.findFirst.mockResolvedValue({ + id: 'api-key-1', + role: AppRole.admin, + ngoId: null, + }); + + const context = createContext({ 'x-api-key': 'test-api-key' }); + await expect(guard.canActivate(context as any)).resolves.toBe(true); + + expect(context.switchToHttp().getRequest().user).toMatchObject({ + role: AppRole.admin, + apiKeyId: 'api-key-1', + authType: 'apiKey', + }); + }); +}); diff --git a/app/backend/src/common/guards/jwt-auth.guard.spec.ts b/app/backend/src/common/guards/jwt-auth.guard.spec.ts new file mode 100644 index 00000000..6d01ed45 --- /dev/null +++ b/app/backend/src/common/guards/jwt-auth.guard.spec.ts @@ -0,0 +1,93 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { generateKeyPairSync } from 'node:crypto'; +import { decodeJwt } from 'jose'; +import { AppRole } from '../../auth/app-role.enum'; +import { TokenService } from '../../auth-oidc/token.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, +}); + +function makeContext(authorization?: string) { + const req: { headers: Record; user?: unknown } = { + headers: authorization ? { authorization } : {}, + }; + + return { + req, + context: { + switchToHttp: () => ({ + getRequest: () => req, + }), + }, + }; +} + +describe('JwtAuthGuard', () => { + const redisClient = { + zscore: jest.fn(), + zadd: jest.fn(), + zremrangebyscore: jest.fn(), + }; + const redisService = { + getClient: () => redisClient, + }; + const prisma = { + apiKey: { + findFirst: jest.fn(), + update: jest.fn(), + }, + }; + + let tokenService: TokenService; + let guard: JwtAuthGuard; + + beforeEach(() => { + jest.clearAllMocks(); + redisClient.zscore.mockResolvedValue(null); + redisClient.zadd.mockResolvedValue(1); + redisClient.zremrangebyscore.mockResolvedValue(0); + prisma.apiKey.findFirst.mockResolvedValue({ + id: 'api-key-1', + role: AppRole.operator, + ngoId: 'ngo-1', + }); + prisma.apiKey.update.mockResolvedValue({}); + + const config = { + get: jest.fn((key: string) => { + const values: Record = { + JWT_PRIVATE_KEY: privateKey, + JWT_PUBLIC_KEY: publicKey, + API_KEY: 'env-api-key', + }; + return values[key]; + }), + }; + + tokenService = new TokenService( + config as unknown as ConfigService, + prisma as any, + redisService as any, + ); + guard = new JwtAuthGuard(tokenService); + }); + + it('rejects a JWT whose jti is present in the Redis revocation set', async () => { + const pair = await tokenService.issueForClientCredentials('api-key-secret'); + const decoded = decodeJwt(pair.access_token); + redisClient.zscore.mockImplementation((_key: string, jti: string) => + Promise.resolve(jti === decoded.jti ? String(decoded.exp) : null), + ); + + const { context } = makeContext(`Bearer ${pair.access_token}`); + + await expect(guard.canActivate(context as any)).rejects.toThrow( + UnauthorizedException, + ); + }); +}); diff --git a/app/backend/src/common/guards/jwt-auth.guard.ts b/app/backend/src/common/guards/jwt-auth.guard.ts new file mode 100644 index 00000000..d5fe4179 --- /dev/null +++ b/app/backend/src/common/guards/jwt-auth.guard.ts @@ -0,0 +1,42 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { TokenService } from '../../auth-oidc/token.service'; + +@Injectable() +export class JwtAuthGuard implements CanActivate { + constructor(private readonly tokenService: TokenService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = this.extractBearerToken(request); + const verified = await this.tokenService.verifyAccessToken(token); + + request.user = { + role: verified.principal.role, + ngoId: verified.principal.ngoId, + apiKeyId: verified.principal.apiKeyId, + authType: 'jwt', + }; + + return true; + } + + private extractBearerToken(request: Request): string { + const header = request.headers.authorization; + if (!header) { + throw new UnauthorizedException('Missing bearer token'); + } + + const [scheme, token] = header.split(' '); + if (scheme !== 'Bearer' || !token) { + throw new UnauthorizedException('Invalid bearer token'); + } + + return token; + } +} diff --git a/app/backend/src/types/express.d.ts b/app/backend/src/types/express.d.ts index fc2314e3..663621f3 100644 --- a/app/backend/src/types/express.d.ts +++ b/app/backend/src/types/express.d.ts @@ -7,7 +7,7 @@ declare global { role: AppRole; ngoId?: string | null; apiKeyId?: string; - authType?: 'apiKey' | 'envApiKey'; + authType?: 'apiKey' | 'envApiKey' | 'jwt'; }; } } diff --git a/app/backend/test/auth-oidc.e2e-spec.ts b/app/backend/test/auth-oidc.e2e-spec.ts new file mode 100644 index 00000000..b4bd4be7 --- /dev/null +++ b/app/backend/test/auth-oidc.e2e-spec.ts @@ -0,0 +1,144 @@ +import { INestApplication } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { generateKeyPairSync } from 'node:crypto'; +import { ConfigService } from '@nestjs/config'; +import { AuthOidcModule } from '../src/auth-oidc/auth-oidc.module'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { RedisService } from '../cache/redis.service'; +import { AppRole } from '../src/auth/app-role.enum'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, +}); + +describe('OIDC token flow (e2e)', () => { + let app: INestApplication; + const revoked = new Map(); + const redisClient = { + zscore: jest.fn((_key: string, jti: string) => { + const score = revoked.get(jti); + return Promise.resolve(score === undefined ? null : String(score)); + }), + zadd: jest.fn((_key: string, score: string, jti: string) => { + revoked.set(jti, Number(score)); + return Promise.resolve(1); + }), + zremrangebyscore: jest.fn((_key: string, min: number, max: number) => { + let deleted = 0; + for (const [jti, score] of revoked.entries()) { + if (score >= Number(min) && score <= Number(max)) { + revoked.delete(jti); + deleted += 1; + } + } + return Promise.resolve(deleted); + }), + }; + + beforeAll(async () => { + const prisma = { + apiKey: { + findFirst: jest.fn().mockResolvedValue({ + id: 'api-key-1', + role: AppRole.operator, + ngoId: 'ngo-1', + }), + update: jest.fn().mockResolvedValue({}), + }, + }; + + const config = { + get: jest.fn((key: string) => { + const values: Record = { + JWT_PRIVATE_KEY: privateKey, + JWT_PUBLIC_KEY: publicKey, + API_KEY: 'env-api-key', + JWT_ACCESS_TOKEN_TTL_SECONDS: '900', + JWT_REFRESH_TOKEN_TTL_SECONDS: '604800', + }; + return values[key]; + }), + }; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AuthOidcModule], + }) + .overrideProvider(PrismaService) + .useValue(prisma) + .overrideProvider(ConfigService) + .useValue(config) + .overrideProvider(RedisService) + .useValue({ getClient: () => redisClient }) + .compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + revoked.clear(); + jest.clearAllMocks(); + }); + + it('issues, uses, refreshes, revokes, and introspects a JWT token', async () => { + const issueResponse = await request(app.getHttpServer()) + .post('/oauth/token') + .send({ + grant_type: 'client_credentials', + client_secret: 'api-key-secret', + }) + .expect(201); + + expect(issueResponse.body).toMatchObject({ + token_type: 'Bearer', + expires_in: 900, + refresh_expires_in: 604800, + }); + expect(typeof issueResponse.body.access_token).toBe('string'); + expect(typeof issueResponse.body.refresh_token).toBe('string'); + + const userinfoResponse = await request(app.getHttpServer()) + .get('/oauth/userinfo') + .set('Authorization', `Bearer ${issueResponse.body.access_token}`) + .expect(200); + + expect(userinfoResponse.body).toMatchObject({ + role: AppRole.operator, + ngoId: 'ngo-1', + apiKeyId: 'api-key-1', + authType: 'jwt', + }); + + const refreshResponse = await request(app.getHttpServer()) + .post('/oauth/token') + .send({ + grant_type: 'refresh_token', + refresh_token: issueResponse.body.refresh_token, + }) + .expect(201); + + expect(typeof refreshResponse.body.access_token).toBe('string'); + expect(refreshResponse.body.access_token).not.toEqual( + issueResponse.body.access_token, + ); + + await request(app.getHttpServer()) + .post('/oauth/revoke') + .send({ token: refreshResponse.body.access_token }) + .expect(201); + + const introspectResponse = await request(app.getHttpServer()) + .post('/oauth/introspect') + .send({ token: refreshResponse.body.access_token }) + .expect(201); + + expect(introspectResponse.body).toEqual({ active: false }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdd7d856..8749afdd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -136,6 +136,9 @@ importers: class-validator: specifier: ^0.14.3 version: 0.14.4 + compression: + specifier: 1.7.5 + version: 1.7.5 dotenv: specifier: ^17.2.3 version: 17.4.2 @@ -145,6 +148,9 @@ importers: ioredis: specifier: ^5.9.2 version: 5.10.1 + jose: + specifier: ^4 + version: 4.15.9 openai: specifier: ^6.33.0 version: 6.34.0(ws@8.19.0)(zod@4.3.6) @@ -185,6 +191,9 @@ importers: '@nestjs/testing': specifier: ^11.0.1 version: 11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.17(@nestjs/common@11.1.27(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)) + '@types/compression': + specifier: 1.7.5 + version: 1.7.5 '@types/express': specifier: ^5.0.0 version: 5.0.6 @@ -3242,6 +3251,9 @@ packages: '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/compression@1.7.5': + resolution: {integrity: sha512-AAQvK5pxMpaT+nDvhHrsBhLSYG5yQdtkaJE1WYieSNY2mVFKAgmU4ks65rkZD5oqnGCFLyQpUr1CqI4DmUMyDg==} + '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} @@ -4370,8 +4382,8 @@ packages: resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} engines: {node: '>= 0.6'} - compression@1.8.1: - resolution: {integrity: sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==} + compression@1.7.5: + resolution: {integrity: sha512-bQJ0YRck5ak3LgtnpKkiabX5pNF7tMUh1BSy2ZBOTh0Dim0BUu6aPPwByIns6/A5Prh8PufSPerMDUklpzes2Q==} engines: {node: '>= 0.8.0'} concat-map@0.0.1: @@ -6110,6 +6122,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + joycon@3.1.1: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} @@ -6973,8 +6988,8 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - on-headers@1.1.0: - resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} engines: {node: '>= 0.8'} once@1.4.0: @@ -9792,7 +9807,7 @@ snapshots: bplist-parser: 0.3.2 chalk: 4.1.2 ci-info: 3.9.0 - compression: 1.8.1 + compression: 1.7.5 connect: 3.7.0 debug: 4.4.3(supports-color@10.2.2) env-editor: 0.4.2 @@ -12192,6 +12207,10 @@ snapshots: '@types/connect': 3.4.38 '@types/node': 25.9.1 + '@types/compression@1.7.5': + dependencies: + '@types/express': 5.0.6 + '@types/connect@3.4.38': dependencies: '@types/node': 25.9.1 @@ -13661,13 +13680,13 @@ snapshots: dependencies: mime-db: 1.54.0 - compression@1.8.1: + compression@1.7.5: dependencies: bytes: 3.1.2 compressible: 2.0.18 debug: 2.6.9 negotiator: 0.6.4 - on-headers: 1.1.0 + on-headers: 1.0.2 safe-buffer: 5.2.1 vary: 1.1.2 transitivePeerDependencies: @@ -14157,7 +14176,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -16182,6 +16201,8 @@ snapshots: jiti@2.6.1: {} + jose@4.15.9: {} + joycon@3.1.1: {} js-levenshtein@1.1.6: {} @@ -17178,7 +17199,7 @@ snapshots: dependencies: ee-first: 1.1.1 - on-headers@1.1.0: {} + on-headers@1.0.2: {} once@1.4.0: dependencies: From 50ae67323e48500a03960c3d805be469012f359d Mon Sep 17 00:00:00 2001 From: kimanicodee Date: Sun, 19 Jul 2026 21:29:34 +0300 Subject: [PATCH 2/5] Suppress CodeQL false positive for API key fingerprint --- app/backend/src/auth-oidc/token.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/backend/src/auth-oidc/token.service.ts b/app/backend/src/auth-oidc/token.service.ts index f0c29388..63067d8d 100644 --- a/app/backend/src/auth-oidc/token.service.ts +++ b/app/backend/src/auth-oidc/token.service.ts @@ -298,5 +298,7 @@ function fingerprintApiKey(rawClientSecret: string): string { // SHA-256 fingerprint of a high-entropy API key/client secret for // exact-match lookup (matches ApiKeyGuard and ApiKey.keyHash), not // password hashing. - return createHash('sha256').update(rawClientSecret).digest('hex'); // lgtm[js/insufficient-password-hash] + + // codeql[js/insufficient-password-hash] + return createHash('sha256').update(rawClientSecret).digest('hex'); } From f5202431d38df0b6a05af584924dd999375b7a80 Mon Sep 17 00:00:00 2001 From: Kimani <107630309+kimanicode@users.noreply.github.com> Date: Sun, 19 Jul 2026 22:58:01 +0300 Subject: [PATCH 3/5] Fix OIDC CI checks --- app/backend/src/auth-oidc/token.service.ts | 4 +- app/frontend/openapi.json | 148 +++++++++++++++++++++ 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/app/backend/src/auth-oidc/token.service.ts b/app/backend/src/auth-oidc/token.service.ts index 63067d8d..e0f351e7 100644 --- a/app/backend/src/auth-oidc/token.service.ts +++ b/app/backend/src/auth-oidc/token.service.ts @@ -298,7 +298,5 @@ function fingerprintApiKey(rawClientSecret: string): string { // SHA-256 fingerprint of a high-entropy API key/client secret for // exact-match lookup (matches ApiKeyGuard and ApiKey.keyHash), not // password hashing. - - // codeql[js/insufficient-password-hash] - return createHash('sha256').update(rawClientSecret).digest('hex'); + return createHash('sha256').update(rawClientSecret).digest('hex'); // codeql[js/insufficient-password-hash] } diff --git a/app/frontend/openapi.json b/app/frontend/openapi.json index c7f11262..f2f0529f 100644 --- a/app/frontend/openapi.json +++ b/app/frontend/openapi.json @@ -5314,6 +5314,102 @@ "Sandbox" ] } + }, + "/api/v1/oauth/token": { + "post": { + "description": "Supports client_credentials using an existing ChainForge API key as client_secret, and refresh_token. No password login flow exists in this backend.", + "operationId": "TokenController_token_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRequestDto" + } + } + } + }, + "responses": { + "200": { + "description": "Token pair issued." + } + }, + "summary": "Issue or refresh OAuth-compatible JWTs", + "tags": [ + "OAuth" + ] + } + }, + "/api/v1/oauth/userinfo": { + "get": { + "operationId": "TokenController_userinfo_v1", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "JWT-auth": [] + } + ], + "summary": "Return claims for the current JWT principal", + "tags": [ + "OAuth" + ] + } + }, + "/api/v1/oauth/introspect": { + "post": { + "operationId": "TokenController_introspect_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenIntrospectionDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "summary": "Introspect a JWT using RFC 7662 active/inactive shape", + "tags": [ + "OAuth" + ] + } + }, + "/api/v1/oauth/revoke": { + "post": { + "operationId": "TokenController_revoke_v1", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TokenRevocationDto" + } + } + } + }, + "responses": { + "201": { + "description": "" + } + }, + "summary": "Revoke a JWT by adding its jti to Redis", + "tags": [ + "OAuth" + ] + } } }, "info": { @@ -6553,6 +6649,58 @@ "UpdateDeploymentMetadataDto": { "type": "object", "properties": {} + }, + "TokenRequestDto": { + "type": "object", + "properties": { + "grant_type": { + "type": "string", + "enum": [ + "client_credentials", + "refresh_token" + ], + "description": "OAuth grant type. This backend has no password login flow, so JWT issuance is based on existing API-key client credentials." + }, + "client_id": { + "type": "string", + "description": "Optional client identifier. API-key records are validated by client_secret." + }, + "client_secret": { + "type": "string", + "description": "Existing ChainForge API key used as the OAuth client secret." + }, + "refresh_token": { + "type": "string", + "description": "Refresh token, required when grant_type=refresh_token." + } + }, + "required": [ + "grant_type" + ] + }, + "TokenIntrospectionDto": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "JWT access or refresh token to introspect." + } + }, + "required": [ + "token" + ] + }, + "TokenRevocationDto": { + "type": "object", + "properties": { + "token": { + "type": "string", + "description": "JWT access or refresh token to revoke." + } + }, + "required": [ + "token" + ] } } } From a0b2732ac8c65df33a60d7848a86032926f3a5df Mon Sep 17 00:00:00 2001 From: kimanicodee Date: Mon, 20 Jul 2026 14:45:38 +0300 Subject: [PATCH 4/5] Add OIDC token coverage --- app/backend/test/auth-oidc.unit.spec.ts | 284 ++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 app/backend/test/auth-oidc.unit.spec.ts diff --git a/app/backend/test/auth-oidc.unit.spec.ts b/app/backend/test/auth-oidc.unit.spec.ts new file mode 100644 index 00000000..cc07f397 --- /dev/null +++ b/app/backend/test/auth-oidc.unit.spec.ts @@ -0,0 +1,284 @@ +import { BadRequestException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { generateKeyPairSync } from 'node:crypto'; +import { decodeJwt, importPKCS8, SignJWT } from 'jose'; +import { AppRole } from '../src/auth/app-role.enum'; +import { TokenController } from '../src/auth-oidc/token.controller'; +import { TokenService } from '../src/auth-oidc/token.service'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + publicKeyEncoding: { type: 'spki', format: 'pem' }, +}); + +function makeTokenService(overrides: Record = {}) { + const revoked = new Map(); + const redisClient = { + zscore: jest.fn((_key: string, jti: string) => { + const score = revoked.get(jti); + return Promise.resolve(score === undefined ? null : String(score)); + }), + zadd: jest.fn((_key: string, score: string, jti: string) => { + revoked.set(jti, Number(score)); + return Promise.resolve(1); + }), + zremrangebyscore: jest.fn((_key: string, min: number, max: number) => { + for (const [jti, score] of revoked.entries()) { + if (score >= Number(min) && score <= Number(max)) { + revoked.delete(jti); + } + } + return Promise.resolve(0); + }), + }; + const prisma = { + apiKey: { + findFirst: jest.fn(), + update: jest.fn().mockResolvedValue({}), + }, + }; + const configValues: Record = { + JWT_PRIVATE_KEY: privateKey, + JWT_PUBLIC_KEY: publicKey, + JWT_ISSUER: 'test-issuer', + JWT_AUDIENCE: 'test-audience', + JWT_ACCESS_TOKEN_TTL_SECONDS: '60', + JWT_REFRESH_TOKEN_TTL_SECONDS: '120', + API_KEY: 'env-api-key', + ...overrides, + }; + const config = { + get: jest.fn((key: string) => configValues[key]), + }; + + return { + config, + prisma, + redisClient, + revoked, + service: new TokenService( + config as unknown as ConfigService, + prisma as any, + { getClient: () => redisClient } as any, + ), + }; +} + +describe('TokenService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('rejects missing and invalid client credentials', async () => { + const { service, prisma } = makeTokenService(); + prisma.apiKey.findFirst.mockResolvedValue(null); + + await expect(service.issueForClientCredentials(undefined)).rejects.toThrow( + UnauthorizedException, + ); + await expect(service.issueForClientCredentials('bad-key')).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('issues tokens for stored API keys and exposes active introspection data', async () => { + const { service, prisma } = makeTokenService(); + prisma.apiKey.findFirst.mockResolvedValue({ + id: 'api-key-1', + role: AppRole.operator, + ngoId: 'ngo-1', + }); + + const pair = await service.issueForClientCredentials('api-key-secret'); + const verified = await service.verifyAccessToken(pair.access_token); + const introspection = await service.introspect(pair.access_token); + + expect(pair).toMatchObject({ + token_type: 'Bearer', + expires_in: 60, + refresh_expires_in: 120, + }); + expect(verified.principal).toEqual({ + role: AppRole.operator, + ngoId: 'ngo-1', + apiKeyId: 'api-key-1', + }); + expect(introspection).toMatchObject({ + active: true, + role: AppRole.operator, + token_use: 'access', + ngoId: 'ngo-1', + apiKeyId: 'api-key-1', + }); + expect(prisma.apiKey.update).toHaveBeenCalledWith({ + where: { id: 'api-key-1' }, + data: { lastUsedAt: expect.any(Date) }, + }); + }); + + it('falls back to the environment API key and default TTLs', async () => { + const { service, prisma } = makeTokenService({ + JWT_ACCESS_TOKEN_TTL_SECONDS: '0', + JWT_REFRESH_TOKEN_TTL_SECONDS: 'not-a-number', + }); + prisma.apiKey.findFirst.mockResolvedValue(null); + + const pair = await service.issueForClientCredentials('env-api-key'); + const verified = await service.verifyAccessToken(pair.access_token); + + expect(pair.expires_in).toBe(900); + expect(pair.refresh_expires_in).toBe(604800); + expect(verified.principal).toEqual({ role: AppRole.admin }); + }); + + it('refreshes tokens and revokes the old refresh token', async () => { + const { service, prisma, redisClient } = makeTokenService(); + prisma.apiKey.findFirst.mockResolvedValue({ + id: 'api-key-1', + role: AppRole.operator, + ngoId: null, + }); + const pair = await service.issueForClientCredentials('api-key-secret'); + + const refreshed = await service.refresh(pair.refresh_token); + const oldRefresh = decodeJwt(pair.refresh_token); + + expect(refreshed.access_token).not.toBe(pair.access_token); + expect(redisClient.zadd).toHaveBeenCalledWith( + 'oidc:revoked:jti', + String(oldRefresh.exp), + oldRefresh.jti, + ); + await expect(service.verifyAccessToken(pair.refresh_token)).rejects.toThrow( + UnauthorizedException, + ); + }); + + it('rejects missing refresh tokens and reports inactive invalid tokens', async () => { + const { service } = makeTokenService(); + + await expect(service.refresh(undefined)).rejects.toThrow( + BadRequestException, + ); + await expect(service.verifyAccessToken('not-a-jwt')).rejects.toThrow( + UnauthorizedException, + ); + await expect(service.introspect('not-a-jwt')).resolves.toEqual({ + active: false, + }); + await expect(service.revoke('not-a-jwt')).resolves.toBe(false); + }); + + it('rejects tokens with invalid claims, roles, use, and revocation state', async () => { + const { service, revoked } = makeTokenService(); + const now = Math.floor(Date.now() / 1000); + const signingKey = await importPKCS8(privateKey, 'RS256'); + const makeJwt = (claims: Record & { jti?: string }) => + new SignJWT(claims) + .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) + .setIssuer('test-issuer') + .setAudience('test-audience') + .setSubject('subject') + .setJti(String(claims.jti ?? 'jti-1')) + .setIssuedAt(now) + .setExpirationTime(now + 60) + .sign(signingKey); + + await expect( + service.verifyAccessToken( + await makeJwt({ jti: 'missing-role', token_use: 'access' }), + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + service.verifyAccessToken( + await makeJwt({ + jti: 'bad-role', + role: 'superuser', + token_use: 'access', + }), + ), + ).rejects.toThrow(UnauthorizedException); + await expect( + service.verifyAccessToken( + await makeJwt({ + jti: 'wrong-use', + role: AppRole.operator, + token_use: 'refresh', + }), + ), + ).rejects.toThrow(UnauthorizedException); + + revoked.set('revoked-jti', now + 60); + await expect( + service.verifyAccessToken( + await makeJwt({ + jti: 'revoked-jti', + role: AppRole.operator, + token_use: 'access', + }), + ), + ).rejects.toThrow(UnauthorizedException); + }); + + it('requires configured PEM keys', async () => { + const { service, prisma } = makeTokenService({ + JWT_PRIVATE_KEY: undefined, + }); + prisma.apiKey.findFirst.mockResolvedValue(null); + + await expect( + service.issueForClientCredentials('env-api-key'), + ).rejects.toThrow('JWT_PRIVATE_KEY is required for OIDC JWT support'); + }); +}); + +describe('TokenController', () => { + it('routes token grants to the matching service method', async () => { + const tokenService = { + issueForClientCredentials: jest.fn().mockResolvedValue({ issued: true }), + refresh: jest.fn().mockResolvedValue({ refreshed: true }), + introspect: jest.fn(), + revoke: jest.fn(), + }; + const controller = new TokenController(tokenService as any); + + await expect( + controller.token({ + grant_type: 'client_credentials', + client_secret: 'api-key-secret', + }), + ).resolves.toEqual({ issued: true }); + await expect( + controller.token({ + grant_type: 'refresh_token', + refresh_token: 'refresh-token', + }), + ).resolves.toEqual({ refreshed: true }); + expect(tokenService.issueForClientCredentials).toHaveBeenCalledWith( + 'api-key-secret', + ); + expect(tokenService.refresh).toHaveBeenCalledWith('refresh-token'); + }); + + it('returns request userinfo and token introspection responses', async () => { + const tokenService = { + issueForClientCredentials: jest.fn(), + refresh: jest.fn(), + introspect: jest.fn().mockResolvedValue({ active: true }), + revoke: jest.fn().mockResolvedValue(true), + }; + const controller = new TokenController(tokenService as any); + const user = { role: AppRole.operator, authType: 'jwt' }; + + expect(controller.userinfo({ user } as any)).toBe(user); + await expect( + controller.introspect({ token: 'access-token' }), + ).resolves.toEqual({ active: true }); + await expect(controller.revoke({ token: 'access-token' })).resolves.toEqual( + { + revoked: true, + }, + ); + }); +}); From f9e56eda1495bdf961180be53408691ccf3d2c57 Mon Sep 17 00:00:00 2001 From: kimanicodee Date: Mon, 20 Jul 2026 19:45:06 +0300 Subject: [PATCH 5/5] Move CodeQL suppression before fingerprint hash --- app/backend/src/auth-oidc/token.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/backend/src/auth-oidc/token.service.ts b/app/backend/src/auth-oidc/token.service.ts index e0f351e7..48e08467 100644 --- a/app/backend/src/auth-oidc/token.service.ts +++ b/app/backend/src/auth-oidc/token.service.ts @@ -298,5 +298,6 @@ function fingerprintApiKey(rawClientSecret: string): string { // SHA-256 fingerprint of a high-entropy API key/client secret for // exact-match lookup (matches ApiKeyGuard and ApiKey.keyHash), not // password hashing. - return createHash('sha256').update(rawClientSecret).digest('hex'); // codeql[js/insufficient-password-hash] + // codeql[js/insufficient-password-hash] + return createHash('sha256').update(rawClientSecret).digest('hex'); }