import { Issuer, Client, generators, TokenSet, UserinfoResponse } from "openid-client"; import { OidcProviderConfig } from "../OidcProviderConfig"; import OidcProfile from "../schemas/OidcProfile"; import OidcTokenSet from "../schemas/OidcTokenSet"; import { mapClaimsToProfile, extractCustomClaims } from "../utils/claims-mapper"; import { createOidcError, OidcErrorCodes } from "../utils/error-utils"; /** * Generic OIDC Provider implementation using openid-client * * Supports both OIDC discovery and manual configuration. * Handles the complete OIDC flow including: * - Authorization URL generation with PKCE * - Token exchange (code → tokens) * - ID token validation * - UserInfo endpoint fetching * - Claims mapping to profile */ export class OidcProvider { private config: OidcProviderConfig; private issuer: Issuer | null = null; private client: Client | null = null; private initialized: boolean = false; constructor(config: OidcProviderConfig) { this.config = config; } /** * Initialize the provider by discovering or creating the OIDC client * * Uses OIDC discovery if discoveryUrl is provided, otherwise uses * manual endpoint configuration. * * This is async and should be called before using the provider. */ async initialize(): Promise { if (this.initialized) { return; } try { // Option 1: OIDC Discovery if (this.config.discoveryUrl) { this.issuer = await Issuer.discover(this.config.discoveryUrl); } // Option 2: Manual configuration else { // Validate required endpoints for manual config if (!this.config.authorizationEndpoint || !this.config.tokenEndpoint || !this.config.jwksUri) { throw createOidcError( OidcErrorCodes.PROVIDER_NOT_CONFIGURED, "Provider must have either discoveryUrl or manual endpoints (authorizationEndpoint, tokenEndpoint, jwksUri)", { provider: this.config.issuer } ); } this.issuer = new Issuer({ issuer: this.config.issuer, authorization_endpoint: this.config.authorizationEndpoint, token_endpoint: this.config.tokenEndpoint, userinfo_endpoint: this.config.userinfoEndpoint, jwks_uri: this.config.jwksUri, }); } // Create OIDC client this.client = new this.issuer.Client({ client_id: this.config.clientId, client_secret: this.config.clientSecret, redirect_uris: [this.config.callbackUrl], response_types: ["code"], }); this.initialized = true; } catch (error: any) { throw createOidcError(OidcErrorCodes.DISCOVERY_FAILED, `Failed to initialize OIDC provider: ${error.message}`, { issuer: this.config.issuer, originalError: error.message, }); } } /** * Generate authorization URL with PKCE and nonce * * @param params - Authorization parameters * @returns Authorization URL to redirect user to */ async getAuthorizationUrl(params: { state: string; codeVerifier: string; nonce: string }): Promise { await this.ensureInitialized(); const codeChallenge = generators.codeChallenge(params.codeVerifier); const scope = this.config.scope?.join(" ") || "openid email profile"; const authUrl = this.client!.authorizationUrl({ scope, state: params.state, code_challenge: codeChallenge, code_challenge_method: "S256", nonce: params.nonce, }); return authUrl; } /** * Exchange authorization code for tokens * * Performs the OAuth 2.0 token exchange and validates the ID token. * * @param params - Token exchange parameters * @returns Token set with access token, ID token, and claims */ async exchangeCodeForToken(params: { code: string; codeVerifier: string; state: string; nonce: string }): Promise { await this.ensureInitialized(); try { const tokenSet: TokenSet = await this.client!.callback( this.config.callbackUrl, { code: params.code, state: params.state, }, { code_verifier: params.codeVerifier, state: params.state, nonce: params.nonce, } ); // Extract claims from ID token (already validated by openid-client) const claims = tokenSet.claims(); return { accessToken: tokenSet.access_token!, idToken: tokenSet.id_token!, refreshToken: tokenSet.refresh_token, tokenType: tokenSet.token_type || "Bearer", expiresIn: tokenSet.expires_in, scope: tokenSet.scope, claims, }; } catch (error: any) { throw createOidcError(OidcErrorCodes.TOKEN_EXCHANGE_FAILED, `Token exchange failed: ${error.message}`, { originalError: error.message, errorCode: error.error, }); } } /** * Get user profile from UserInfo endpoint * * Fetches additional user claims from the UserInfo endpoint. * Merges with claims from ID token. * * @param accessToken - Access token from token exchange * @returns UserInfo response */ async getUserInfo(accessToken: string): Promise { await this.ensureInitialized(); try { const userinfo = await this.client!.userinfo(accessToken); return userinfo; } catch (error: any) { throw createOidcError(OidcErrorCodes.USERINFO_FAILED, `UserInfo request failed: ${error.message}`, { originalError: error.message, }); } } /** * Build complete user profile from tokens * * Combines claims from ID token and UserInfo endpoint, * applies custom claim mapping, and returns normalized profile. * * @param tokenSet - Token set from exchange * @param includeUserInfo - Whether to fetch UserInfo endpoint * @returns Normalized user profile */ async buildProfile(tokenSet: OidcTokenSet, includeUserInfo: boolean = true): Promise { let claims = { ...tokenSet.claims }; // Optionally fetch additional claims from UserInfo endpoint if (includeUserInfo && this.config.userinfoEndpoint) { try { const userinfo = await this.getUserInfo(tokenSet.accessToken); // Merge UserInfo claims with ID token claims claims = { ...claims, ...userinfo }; } catch (error) { // UserInfo is optional - continue with ID token claims only console.warn("Failed to fetch UserInfo, using ID token claims only:", error); } } // Apply custom claim mapping if configured if (this.config.claimMapping) { const customClaims = extractCustomClaims(claims, this.config.claimMapping); claims = { ...claims, ...customClaims }; } // Map to normalized profile const profile = mapClaimsToProfile(claims); return profile; } /** * Ensure provider is initialized before use * * @throws Error if not initialized */ private async ensureInitialized(): Promise { if (!this.initialized) { await this.initialize(); } if (!this.client) { throw createOidcError(OidcErrorCodes.PROVIDER_NOT_CONFIGURED, "OIDC client not initialized"); } } /** * Get issuer metadata (after initialization) * * @returns Issuer metadata */ getIssuerMetadata(): any { if (!this.issuer) { throw createOidcError(OidcErrorCodes.PROVIDER_NOT_CONFIGURED, "Provider not initialized"); } return this.issuer.metadata; } }