import { BadRequestException, Injectable, ServiceUnavailableException, } from '@nestjs/common'; import { createHash, randomBytes } from 'crypto'; import type { JWTPayload } from 'jose'; type JoseModule = typeof import('jose'); type RemoteJwkSet = ReturnType; export interface OidcProfile { subject: string; email: string; name?: string; preferredUsername?: string; givenName?: string; familyName?: string; groups: string[]; idToken: string; } interface OidcDiscovery { authorization_endpoint: string; token_endpoint: string; introspection_endpoint?: string; userinfo_endpoint?: string; jwks_uri: string; issuer: string; end_session_endpoint?: string; } interface PendingOidcState { codeVerifier: string; nonce: string; expiresAt: number; } interface TokenResponse { id_token?: string; access_token?: string; error?: string; error_description?: string; } interface TokenIntrospectionResponse { active?: boolean; sub?: string; iss?: string; aud?: string | string[]; error?: string; error_description?: string; } @Injectable() export class OidcService { private readonly pendingStates = new Map(); private discovery?: OidcDiscovery; private jose?: Promise; private jwks?: RemoteJwkSet; async createAuthorizationUrl(): Promise { const config = this.getConfig(); const discovery = await this.getDiscovery(config); const state = this.createOpaqueToken(); const nonce = this.createOpaqueToken(); const codeVerifier = this.createOpaqueToken(); const codeChallenge = this.codeChallenge(codeVerifier); const authorizationUrl = new URL(discovery.authorization_endpoint); this.pendingStates.set(state, { codeVerifier, nonce, expiresAt: Date.now() + 10 * 60 * 1000, }); this.deleteExpiredStates(); authorizationUrl.searchParams.set('response_type', 'code'); authorizationUrl.searchParams.set('client_id', config.clientId); authorizationUrl.searchParams.set('redirect_uri', config.redirectUri); authorizationUrl.searchParams.set('scope', config.scopes); authorizationUrl.searchParams.set('state', state); authorizationUrl.searchParams.set('nonce', nonce); authorizationUrl.searchParams.set('code_challenge', codeChallenge); authorizationUrl.searchParams.set('code_challenge_method', 'S256'); return authorizationUrl.toString(); } async exchangeCallback(code?: string, state?: string): Promise { if (!code || !state) { throw new BadRequestException('OIDC code and state are required.'); } const pendingState = this.pendingStates.get(state); this.pendingStates.delete(state); if (!pendingState || pendingState.expiresAt <= Date.now()) { throw new BadRequestException('OIDC state is invalid or expired.'); } const config = this.getConfig(); const discovery = await this.getDiscovery(config); const tokenResponse = await this.requestTokens( discovery, config, code, pendingState.codeVerifier, ); if (!tokenResponse.id_token || !tokenResponse.access_token) { throw new ServiceUnavailableException( tokenResponse.error_description ?? tokenResponse.error ?? 'OIDC token response did not include the required tokens.', ); } const [{ jwtVerify }, jwks] = await Promise.all([ this.getJose(), this.getJwks(discovery.jwks_uri), ]); const { payload: idTokenPayload } = await jwtVerify( tokenResponse.id_token, jwks, { issuer: discovery.issuer, audience: config.clientId, }, ); if (idTokenPayload.nonce !== pendingState.nonce) { throw new BadRequestException('OIDC nonce is invalid.'); } if (!idTokenPayload.sub || typeof idTokenPayload.sub !== 'string') { throw new BadRequestException('OIDC subject is missing.'); } await this.introspectAccessToken( discovery, config, tokenResponse.access_token, idTokenPayload.sub, ); const userInfo = await this.requestUserInfo( discovery.userinfo_endpoint, tokenResponse.access_token, ); this.validateUserInfoSubject(userInfo, idTokenPayload.sub); const mergedClaims = { ...idTokenPayload, ...userInfo }; const email = this.stringClaim(mergedClaims, 'email'); if (!email) { throw new BadRequestException('OIDC email claim is missing.'); } const groups = this.extractGroups(mergedClaims, config.groupsClaim); const givenName = this.stringClaim(mergedClaims, 'given_name'); const familyName = this.stringClaim(mergedClaims, 'family_name'); const preferredUsername = this.stringClaim( mergedClaims, 'preferred_username', ); return { subject: idTokenPayload.sub, email, name: this.displayName(mergedClaims, preferredUsername), preferredUsername, givenName, familyName, groups, idToken: tokenResponse.id_token, }; } async createLogoutUrl(idTokenHint?: string): Promise { const config = this.getConfig(); const discovery = await this.getDiscovery(config); const state = this.createOpaqueToken(); const logoutUrl = new URL( discovery.end_session_endpoint ?? `${config.issuer.replace(/\/$/, '')}/oidc/session/end`, ); if (idTokenHint) { logoutUrl.searchParams.set('id_token_hint', idTokenHint); } if (config.postLogoutRedirectUri) { logoutUrl.searchParams.set( 'post_logout_redirect_uri', config.postLogoutRedirectUri, ); } logoutUrl.searchParams.set('state', state); return logoutUrl.toString(); } private async requestTokens( discovery: OidcDiscovery, config: ReturnType, code: string, codeVerifier: string, ): Promise { const body = new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: config.redirectUri, client_id: config.clientId, code_verifier: codeVerifier, }); this.addClientAuthentication(body, config); const response = await fetch(discovery.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); const payload = (await response.json().catch(() => ({}))) as TokenResponse; if (!response.ok) { throw new ServiceUnavailableException( payload.error_description ?? payload.error ?? 'OIDC token request failed.', ); } return payload; } private async introspectAccessToken( discovery: OidcDiscovery, config: ReturnType, accessToken: string, expectedSubject: string, ): Promise { const body = new URLSearchParams({ token: accessToken, token_type_hint: 'access_token', }); this.addClientAuthentication(body, config); const response = await fetch( discovery.introspection_endpoint ?? `${config.issuer}/oidc/token/introspection`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }, ); const payload = (await response .json() .catch(() => ({}))) as TokenIntrospectionResponse; if (!response.ok) { throw new ServiceUnavailableException( payload.error_description ?? payload.error ?? 'OIDC token introspection failed.', ); } if (payload.active !== true) { throw new BadRequestException('OIDC access token is inactive.'); } if (payload.iss && this.normalizeIssuer(payload.iss) !== config.issuer) { throw new BadRequestException('OIDC access token issuer is invalid.'); } if (payload.sub && payload.sub !== expectedSubject) { throw new BadRequestException('OIDC access token subject is invalid.'); } if ( payload.aud && !this.audienceIncludes(payload.aud, config.accessTokenAudience) ) { throw new BadRequestException('OIDC access token audience is invalid.'); } } private async getDiscovery( config: ReturnType, ): Promise { if (this.discovery) { return this.discovery; } const response = await fetch(config.discoveryUrl); if (!response.ok) { throw new ServiceUnavailableException('OIDC discovery failed.'); } const discovery = (await response.json()) as OidcDiscovery; if (this.normalizeIssuer(discovery.issuer) !== config.issuer) { throw new ServiceUnavailableException( 'OIDC discovery issuer does not match OIDC_ISSUER.', ); } this.discovery = discovery; return this.discovery; } private async getJwks(jwksUri: string): Promise { const { createRemoteJWKSet } = await this.getJose(); this.jwks ??= createRemoteJWKSet(new URL(jwksUri)); return this.jwks; } private getJose(): Promise { this.jose ??= import('jose'); return this.jose; } private getConfig() { const issuer = this.normalizeIssuer( process.env.OIDC_ISSUER ?? process.env.OIDC_ISSUER_URL, ); const clientId = process.env.OIDC_CLIENT_ID; const redirectUri = process.env.OIDC_REDIRECT_URI ?? process.env.OIDC_CALLBACK_URL; if (!issuer || !clientId || !redirectUri) { throw new ServiceUnavailableException( 'OIDC configuration is incomplete. Required: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_REDIRECT_URI.', ); } const postLogoutRedirectUri = process.env.OIDC_POST_LOGOUT_REDIRECT_URI?.trim() || new URL('/auth/sso/logout-callback', redirectUri).toString(); return { issuer, discoveryUrl: `${issuer}/.well-known/openid-configuration`, clientId, redirectUri, clientSecret: process.env.OIDC_CLIENT_SECRET, scopes: process.env.OIDC_SCOPES ?? 'openid profile email groups', postLogoutRedirectUri, accessTokenAudience: process.env.OIDC_ACCESS_TOKEN_AUDIENCE ?? clientId, groupsClaim: process.env.OIDC_GROUPS_CLAIM ?? 'groups', }; } private async requestUserInfo( userInfoEndpoint: string | undefined, accessToken: string | undefined, ): Promise> { if (!userInfoEndpoint || !accessToken) { return {}; } const response = await fetch(userInfoEndpoint, { headers: { Authorization: `Bearer ${accessToken}` }, }); if (!response.ok) { return {}; } return (await response.json().catch(() => ({}))) as Record; } private addClientAuthentication( body: URLSearchParams, config: ReturnType, ): void { body.set('client_id', config.clientId); if (config.clientSecret) { body.set('client_secret', config.clientSecret); } } private validateUserInfoSubject( userInfo: Record, expectedSubject: string, ): void { const subject = userInfo.sub; if (typeof subject === 'string' && subject !== expectedSubject) { throw new BadRequestException('OIDC UserInfo subject is invalid.'); } } private audienceIncludes( audience: string | string[], expectedAudience: string, ): boolean { return Array.isArray(audience) ? audience.includes(expectedAudience) : audience === expectedAudience; } private extractGroups( payload: Record, groupsClaim: string, ): string[] { const claimValue = payload[groupsClaim]; if (!Array.isArray(claimValue)) { return []; } return [...new Set(claimValue)] .filter((group): group is string => typeof group === 'string') .map((group) => group.trim()) .filter(Boolean) .sort((left, right) => left.localeCompare(right)); } private displayName( payload: JWTPayload | Record, preferredUsername?: string, ): string | undefined { const explicitName = this.stringClaim(payload, 'name'); const givenName = this.stringClaim(payload, 'given_name'); const familyName = this.stringClaim(payload, 'family_name'); const familyNameDisplay = [givenName, familyName].filter(Boolean).join(' '); return explicitName ?? (familyNameDisplay || preferredUsername); } private stringClaim( payload: JWTPayload | Record, claim: string, ): string | undefined { const value = payload[claim]; return typeof value === 'string' && value.trim() ? value.trim() : undefined; } private normalizeIssuer(issuer?: string): string { return issuer?.trim().replace(/\/$/, '') ?? ''; } private createOpaqueToken(): string { return randomBytes(32).toString('base64url'); } private codeChallenge(codeVerifier: string): string { return createHash('sha256').update(codeVerifier).digest('base64url'); } private deleteExpiredStates(): void { const now = Date.now(); for (const [state, pendingState] of this.pendingStates.entries()) { if (pendingState.expiresAt <= now) { this.pendingStates.delete(state); } } } }