/** * OIDC Callback Handler * * Handles the OIDC callback from the provider by: * 1. Validating the state parameter to prevent CSRF attacks * 2. Exchanging the authorization code for tokens (with PKCE validation) * 3. Validating the ID token (signature, claims, nonce) * 4. Fetching user profile from ID token and UserInfo endpoint * 5. Calling the onAuthSuccess callback to create/link user and generate JWT token * 6. Optionally storing the OIDC connection (if storeTokens enabled) * 7. Returning the JWT token to the client (via JSON or redirect) * * Route: GET /oidc/:provider/callback?code=...&state=...&response_type=json */ import { GetHandler, HttpMethod, RouteProps, badRequest, internalServerError, log } from "@flink-app/flink"; import CallbackRequest from "../schemas/CallbackRequest"; import { validateState } from "../utils/state-utils"; import { formatTokenResponse } from "../utils/response-utils"; import { encryptToken } from "../utils/encryption-utils"; import { validateProvider, validateResponseType, createOidcError, OidcErrorCodes, handleProviderError } from "../utils/error-utils"; /** * Path parameters for the handler */ interface PathParams { provider: string; [key: string]: string; } /** * Route configuration * This handler is registered programmatically by the plugin */ export const Route: RouteProps = { path: "/oidc/:provider/callback", method: HttpMethod.get, }; /** * OIDC Callback Handler * * Completes the OIDC flow by exchanging the authorization code for tokens, * validating the ID token, building user profile, calling the app's onAuthSuccess * callback to generate JWT token, and returning the token to the client. */ const CallbackOidc: GetHandler = async ({ ctx, req }) => { const { provider } = req.params; const { code, state, error: oidcError, error_description, response_type } = req.query; try { // Validate provider and response_type validateProvider(provider); validateResponseType(response_type); // Check for OIDC provider errors (e.g., user denied access) if (oidcError) { const error = handleProviderError({ error: oidcError, error_description }); // Call onAuthError callback if provided const { options } = ctx.plugins.oidc; if (options.onAuthError) { const errorResult = await options.onAuthError({ error, provider, }); if (errorResult.redirectUrl) { return { status: 302, headers: { Location: errorResult.redirectUrl }, data: {}, }; } } return badRequest(error.message); } // Validate required parameters if (!code || !state) { throw createOidcError(OidcErrorCodes.MISSING_CODE, "Missing authorization code or state parameter", { hasCode: !!code, hasState: !!state, }); } // Find OIDC session by state const session = await ctx.repos.oidcSessionRepo.getByState(state); if (!session) { throw createOidcError(OidcErrorCodes.SESSION_EXPIRED, "OIDC session not found or expired. Please try logging in again.", { state }); } // Validate state parameter (CSRF protection) if (!validateState(state, session.state)) { throw createOidcError(OidcErrorCodes.INVALID_STATE, "Invalid state parameter. Possible CSRF attack detected.", { providedState: state.substring(0, 10) + "...", }); } // Delete session immediately after validation (one-time use) await ctx.repos.oidcSessionRepo.deleteBySessionId(session.sessionId); // Get plugin options const { options } = ctx.plugins.oidc; // Get provider instance const providerRegistry = (ctx as any).oidcProviderRegistry; if (!providerRegistry) { throw createOidcError(OidcErrorCodes.PROVIDER_NOT_CONFIGURED, "OIDC plugin not properly initialized"); } const oidcProvider = await providerRegistry.getProvider(provider); // Exchange authorization code for tokens with PKCE validation const tokenSet = await oidcProvider.exchangeCodeForToken({ code, codeVerifier: session.codeVerifier, state: session.state, nonce: session.nonce, }); // Build user profile from ID token and UserInfo const profile = await oidcProvider.buildProfile(tokenSet, true); // Call onAuthSuccess callback to create/link user and generate JWT token const authSuccessParams = { profile, claims: tokenSet.claims, provider, ...(options.storeTokens ? { tokens: tokenSet } : {}), }; let authResult; try { authResult = await options.onAuthSuccess(authSuccessParams, ctx); } catch (error: any) { // Handle JWT generation or user creation errors log.error("OIDC onAuthSuccess callback failed:", error); const oidcError = createOidcError(OidcErrorCodes.JWT_GENERATION_FAILED, "Failed to complete authentication. Please try again.", { originalError: error.message, }); // Call onAuthError callback if provided if (options.onAuthError) { const errorResult = await options.onAuthError({ error: oidcError, provider, }); if (errorResult.redirectUrl) { return { status: 302, headers: { Location: errorResult.redirectUrl }, data: {}, }; } } return internalServerError("Authentication failed. Please try again."); } // Extract user and JWT token from callback result const { user, token, redirectUrl } = authResult; if (!token) { throw createOidcError(OidcErrorCodes.JWT_GENERATION_FAILED, "No authentication token returned from callback", { hasUser: !!user }); } // Store OIDC connection if token storage is enabled if (options.storeTokens && user && user._id) { // Get encryption secret (use provider client secret or global encryption key) const staticProviderConfig = options.providers[provider]; const encryptionSecret = options.encryptionKey || staticProviderConfig?.clientSecret; if (!encryptionSecret) { log.warn("No encryption secret available, tokens will not be stored"); } else { // Encrypt tokens before storing const encryptedAccessToken = encryptToken(tokenSet.accessToken, encryptionSecret); const encryptedIdToken = encryptToken(tokenSet.idToken, encryptionSecret); const encryptedRefreshToken = tokenSet.refreshToken ? encryptToken(tokenSet.refreshToken, encryptionSecret) : undefined; // Calculate token expiration const expiresAt = tokenSet.expiresIn ? new Date(Date.now() + tokenSet.expiresIn * 1000) : undefined; // Create or update OIDC connection const existingConnection = await ctx.repos.oidcConnectionRepo.findByUserAndProvider(user._id, provider); if (existingConnection) { await ctx.repos.oidcConnectionRepo.updateOne(existingConnection._id!, { accessToken: encryptedAccessToken, idToken: encryptedIdToken, refreshToken: encryptedRefreshToken, scope: tokenSet.scope || "", expiresAt, updatedAt: new Date(), }); } else { await ctx.repos.oidcConnectionRepo.create({ userId: user._id, provider, subject: tokenSet.claims.sub, issuer: tokenSet.claims.iss, email: profile.email, accessToken: encryptedAccessToken, idToken: encryptedIdToken, refreshToken: encryptedRefreshToken, scope: tokenSet.scope || "", expiresAt, createdAt: new Date(), updatedAt: new Date(), }); } } } // Return JWT token in requested format return formatTokenResponse(token, user, redirectUrl || session.redirectUri, response_type); } catch (error: any) { log.error("OIDC callback error:", error); // Handle OIDC-specific errors if (error.code && Object.values(OidcErrorCodes).includes(error.code)) { // Call onAuthError callback if provided const { options } = ctx.plugins.oidc; if (options.onAuthError) { try { const errorResult = await options.onAuthError({ error, provider, }); if (errorResult.redirectUrl) { return { status: 302, headers: { Location: errorResult.redirectUrl }, data: {}, }; } } catch (callbackError) { log.error("onAuthError callback failed:", callbackError); } } return badRequest(error.message); } // Handle provider errors const mappedError = handleProviderError(error); return internalServerError(mappedError.message); } }; export default CallbackOidc;