/** * OAuth Callback Handler * * Handles the OAuth 2.0 callback from the provider by: * 1. Validating the state parameter to prevent CSRF attacks * 2. Exchanging the authorization code for an access token * 3. Fetching the user profile from the provider * 4. Calling the onAuthSuccess callback to create/link user and generate JWT token * 5. Optionally storing the OAuth connection (if storeTokens enabled) * 6. Returning the JWT token to the client (via JSON or redirect) * * Route: GET /oauth/: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 { getProvider } from "../providers/ProviderRegistry"; import { formatTokenResponse } from "../utils/token-response-utils"; import { encryptToken } from "../utils/encryption-utils"; import { validateProvider, validateResponseType, createOAuthError, OAuthErrorCodes, handleProviderError } from "../utils/error-utils"; /** * Path parameters for the handler */ interface PathParams { provider: string; [key: string]: string; } /** * Route configuration * Note: This handler is registered programmatically by the plugin * with dynamic provider parameter support */ export const Route: RouteProps = { path: "/oauth/:provider/callback", method: HttpMethod.get, }; /** * OAuth Callback Handler * * Completes the OAuth flow by exchanging the authorization code for tokens, * fetching user profile, calling the app's onAuthSuccess callback to generate * JWT token, and returning the token to the client. */ const CallbackOAuth: GetHandler = async ({ ctx, req }) => { const { provider } = req.params; const { code, state, error: oauthError, response_type } = req.query; try { // Validate provider and response_type validateProvider(provider); validateResponseType(response_type); // Check for OAuth provider errors (e.g., user denied access) if (oauthError) { const error = handleProviderError({ error: oauthError }); // Call onAuthError callback if provided const { options } = ctx.plugins.oauth; if (options.onAuthError) { const errorResult = await options.onAuthError({ error, provider: provider as "github" | "google", }); if (errorResult.redirectUrl) { return { status: 302, headers: { Location: errorResult.redirectUrl }, data: {}, }; } } return badRequest(error.message); } // Validate required parameters if (!code || !state) { throw createOAuthError(OAuthErrorCodes.MISSING_CODE, "Missing authorization code or state parameter", { hasCode: !!code, hasState: !!state }); } // Find OAuth session by state const session = await ctx.repos.oauthSessionRepo.getOne({ state }); if (!session) { throw createOAuthError(OAuthErrorCodes.SESSION_EXPIRED, "OAuth session not found or expired. Please try logging in again.", { state }); } // Validate state parameter (CSRF protection) if (!validateState(state, session.state)) { throw createOAuthError(OAuthErrorCodes.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.oauthSessionRepo.deleteBySessionId(session.sessionId); // Get plugin options and provider config const { options } = ctx.plugins.oauth; const providerConfig = options.providers[provider as "github" | "google"]; if (!providerConfig) { throw createOAuthError(OAuthErrorCodes.INVALID_PROVIDER, `Provider "${provider}" is not configured`, { provider }); } // Exchange authorization code for access token const oauthProvider = getProvider(provider as "github" | "google", providerConfig); const tokens = await oauthProvider.exchangeCodeForToken({ code, redirectUri: providerConfig.callbackUrl, }); // Fetch user profile from provider const profile = await oauthProvider.getUserProfile(tokens.accessToken); // Call onAuthSuccess callback to create/link user and generate JWT token const authSuccessParams = { profile, provider: provider as "github" | "google", ...(options.storeTokens ? { tokens } : {}), }; let authResult; try { authResult = await options.onAuthSuccess(authSuccessParams, ctx); } catch (error: any) { // Handle JWT generation or user creation errors log.error("OAuth onAuthSuccess callback failed:", error); const oauthError = createOAuthError(OAuthErrorCodes.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: oauthError, provider: provider as "github" | "google", }); 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 createOAuthError(OAuthErrorCodes.JWT_GENERATION_FAILED, "No authentication token returned from callback", { hasUser: !!user }); } // Store OAuth connection if token storage is enabled if (options.storeTokens && user && user._id) { const encryptionSecret = providerConfig.clientSecret; // Encrypt tokens before storing const encryptedAccessToken = encryptToken(tokens.accessToken, encryptionSecret); const encryptedRefreshToken = tokens.refreshToken ? encryptToken(tokens.refreshToken, encryptionSecret) : undefined; // Calculate token expiration const expiresAt = tokens.expiresIn ? new Date(Date.now() + tokens.expiresIn * 1000) : undefined; // Create or update OAuth connection const existingConnection = await ctx.repos.oauthConnectionRepo.findByUserAndProvider(user._id, provider); if (existingConnection) { await ctx.repos.oauthConnectionRepo.updateOne(existingConnection._id!, { accessToken: encryptedAccessToken, refreshToken: encryptedRefreshToken, scope: tokens.scope || "", expiresAt, updatedAt: new Date(), }); } else { await ctx.repos.oauthConnectionRepo.create({ userId: user._id, provider: provider as "github" | "google", providerId: profile.id, accessToken: encryptedAccessToken, refreshToken: encryptedRefreshToken, scope: tokens.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("OAuth callback error:", error); // Handle OAuth-specific errors if (error.code && Object.values(OAuthErrorCodes).includes(error.code)) { // Call onAuthError callback if provided const { options } = ctx.plugins.oauth; if (options.onAuthError) { try { const errorResult = await options.onAuthError({ error, provider: provider as "github" | "google", }); 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 CallbackOAuth;