/** * OAuth Callback Handler * * Handles OAuth callback redirects from external providers. * Registered as HTTP handlers at `/api/notification/oauth/{strategyId}/callback`. */ import type { NotificationStrategyRegistry, ConfigService, Logger, } from "@checkstack/backend-api"; import type { SafeDatabase } from "@checkstack/backend-api"; import { extractErrorMessage } from "@checkstack/common"; import { createStrategyService } from "./strategy-service"; import type * as schema from "./schema"; export interface OAuthCallbackDeps { db: SafeDatabase; configService: ConfigService; strategyRegistry: NotificationStrategyRegistry; baseUrl: string; logger: Logger; } /** * Create an OAuth callback handler that routes to the appropriate strategy. * * @param deps - Service dependencies * @returns HTTP handler function for OAuth callbacks */ export function createOAuthCallbackHandler( deps: OAuthCallbackDeps ): (req: Request) => Promise { const { db, configService, strategyRegistry, baseUrl, logger } = deps; const strategyService = createStrategyService({ db, configService, strategyRegistry, }); return async (req: Request): Promise => { const url = new URL(req.url); // Extract strategy ID from path: /oauth/{strategyId}/callback const pathParts = url.pathname.split("/"); const oauthIndex = pathParts.indexOf("oauth"); if (oauthIndex === -1 || pathParts.length <= oauthIndex + 2) { return new Response("Invalid OAuth path", { status: 400 }); } const strategyId = pathParts[oauthIndex + 1]; const action = pathParts[oauthIndex + 2]; // "callback" or other actions if (action !== "callback") { return new Response(`Unknown OAuth action: ${action}`, { status: 400 }); } // Find strategy const strategy = strategyRegistry.getStrategy(strategyId); if (!strategy) { return new Response(`Strategy not found: ${strategyId}`, { status: 404 }); } if (!strategy.oauth) { return new Response(`Strategy ${strategyId} does not support OAuth`, { status: 400, }); } const oauth = strategy.oauth; // Get code and state from query params const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); const error = url.searchParams.get("error"); // Handle error from provider if (error) { const errorDescription = url.searchParams.get("error_description") || error; return Response.redirect( `${baseUrl}/notification/settings?error=${encodeURIComponent( errorDescription )}`, 302 ); } if (!code || !state) { return Response.redirect( `${baseUrl}/notification/settings?error=${encodeURIComponent( "Missing code or state parameter" )}`, 302 ); } // Decode state let stateData: { userId: string; returnUrl: string }; try { const decoded = atob(state); stateData = JSON.parse(decoded); } catch { return Response.redirect( `${baseUrl}/notification/settings?error=${encodeURIComponent( "Invalid state parameter" )}`, 302 ); } const { userId, returnUrl } = stateData; const defaultReturnUrl = "/notification/settings"; const finalReturnUrl = returnUrl || defaultReturnUrl; try { // Get strategy config to pass to OAuth functions const strategyConfig = await strategyService.getStrategyConfig( strategyId ); if (!strategyConfig) { return Response.redirect( `${baseUrl}${finalReturnUrl}?error=${encodeURIComponent( "Strategy not configured" )}`, 302 ); } // Exchange code for tokens const callbackUrl = `${baseUrl}/api/notification/oauth/${strategyId}/callback`; // Call OAuth config functions with strategy config const clientId = oauth.clientId(strategyConfig); const clientSecret = oauth.clientSecret(strategyConfig); const tokenUrl = oauth.tokenUrl(strategyConfig); // Exchange authorization code for tokens const tokenResponse = await fetch(tokenUrl, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: callbackUrl, client_id: clientId, client_secret: clientSecret, }), }); if (!tokenResponse.ok) { const errorText = await tokenResponse.text(); logger.error(`OAuth token exchange failed: ${errorText}`); return Response.redirect( `${baseUrl}${finalReturnUrl}?error=${encodeURIComponent( "Token exchange failed" )}`, 302 ); } const tokens = (await tokenResponse.json()) as Record; // Extract tokens using strategy's extractors or defaults const accessToken = oauth.extractAccessToken?.(tokens) || (tokens.access_token as string); const refreshToken = oauth.extractRefreshToken?.(tokens) || (tokens.refresh_token as string | undefined); const expiresIn = oauth.extractExpiresIn?.(tokens) || (typeof tokens.expires_in === "number" ? tokens.expires_in : undefined); // Extract external ID using strategy's extractor const externalId = oauth.extractExternalId(tokens); // Calculate expiration date const expiresAt = expiresIn ? new Date(Date.now() + expiresIn * 1000) : undefined; // Store tokens via StrategyService await strategyService.storeOAuthTokens({ userId, strategyId, externalId, accessToken, refreshToken, expiresAt, }); // Redirect to return URL with success return Response.redirect( `${baseUrl}${finalReturnUrl}?linked=${strategyId}`, 302 ); } catch (error_) { logger.error(`OAuth callback error: ${extractErrorMessage(error_)}`); return Response.redirect( `${baseUrl}${finalReturnUrl}?error=${encodeURIComponent( "OAuth processing failed" )}`, 302 ); } }; }