import { Injectable, UnauthorizedException } from '@nestjs/common'; import { createRemoteJWKSet, jwtVerify, } from 'jose'; export interface AuthenticatedUser { sub: string; username?: string; email?: string; name?: string; roles: string[]; } type RemoteJwks = ReturnType; @Injectable() export class AuthService { private jwksPromise: Promise | null = null; async verifyAccessToken(token: string): Promise { const issuer = process.env.KEYCLOAK_ISSUER_URL; const audience = process.env.KEYCLOAK_AUDIENCE; if (!issuer || !audience) { throw new UnauthorizedException('Keycloak issuer or audience is not configured'); } try { const jwks = await this.getJwks(); const result = await jwtVerify(token, jwks, { issuer, audience, }); return this.mapPayloadToUser(result); } catch (error) { throw new UnauthorizedException('Token validation failed'); } } private async getJwks(): Promise { if (!this.jwksPromise) { this.jwksPromise = this.resolveJwks().catch((error) => { this.jwksPromise = null; throw error; }); } return this.jwksPromise; } private async resolveJwks(): Promise { const issuer = process.env.KEYCLOAK_ISSUER_URL; if (!issuer) { throw new UnauthorizedException('KEYCLOAK_ISSUER_URL is not configured'); } const explicitJwksUrl = process.env.KEYCLOAK_JWKS_URL; if (explicitJwksUrl) { return createRemoteJWKSet(new URL(explicitJwksUrl)); } try { const discoveryUrl = new URL('.well-known/openid-configuration', `${issuer.replace(/\/$/, '')}/`); const response = await fetch(discoveryUrl); if (response.ok) { const discovery = (await response.json()) as { jwks_uri?: string }; if (discovery.jwks_uri) { return createRemoteJWKSet(new URL(discovery.jwks_uri)); } } } catch { // Fall through to the Keycloak certs endpoint. } return createRemoteJWKSet(new URL(`${issuer.replace(/\/$/, '')}/protocol/openid-connect/certs`)); } private mapPayloadToUser(result: Awaited>): AuthenticatedUser { const payload = result.payload; const realmAccess = payload.realm_access as { roles?: string[] } | undefined; const roles = Array.isArray(realmAccess?.roles) ? realmAccess.roles : []; return { sub: String(payload.sub ?? ''), username: typeof payload.preferred_username === 'string' ? payload.preferred_username : undefined, email: typeof payload.email === 'string' ? payload.email : undefined, name: typeof payload.name === 'string' ? payload.name : undefined, roles, }; } }