import type { GatewayConfig } from "../../config.js"; import { getLogger } from "../../logger.js"; import { forwardOAuthCallback } from "../../runtime/client.js"; const log = getLogger("oauth-callback"); // Minimum length for the state parameter. The runtime generates 32 hex chars // (16 random bytes); reject anything shorter to block trivially guessable values. const MIN_STATE_LENGTH = 32; // Track consumed state tokens to prevent replay attacks. Each entry has a TTL // so the set doesn't grow unboundedly. const CONSUMED_STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes /** Exported for testing. */ export const MAX_CONSUMED_STATES = 1000; const consumedStates = new Map>(); function evictOldestConsumedState(): void { // Map iteration order is insertion order — first key is the oldest entry. const oldest = consumedStates.keys().next().value; if (oldest !== undefined) { const timer = consumedStates.get(oldest); if (timer !== undefined) clearTimeout(timer); consumedStates.delete(oldest); } } function markStateConsumed(state: string): void { // LRU eviction: if the map is at capacity, drop the oldest entry rather than // rejecting the request. Consumed states only exist to prevent replay within // their TTL window — losing the oldest is acceptable since it's closest to // expiry anyway, and hard-rejecting creates a DoS vector. if (consumedStates.size >= MAX_CONSUMED_STATES) { evictOldestConsumedState(); } const timer = setTimeout(() => { consumedStates.delete(state); }, CONSUMED_STATE_TTL_MS); // Unref so the timer doesn't prevent process exit if (typeof timer === "object" && "unref" in timer) timer.unref(); consumedStates.set(state, timer); } function rollBackConsumedState(state: string): void { const timer = consumedStates.get(state); if (timer !== undefined) { clearTimeout(timer); consumedStates.delete(state); } } /** Exported for testing — clears all consumed state entries. */ export function _resetConsumedStates(): void { for (const timer of consumedStates.values()) clearTimeout(timer); consumedStates.clear(); } export function createOAuthCallbackHandler(config: GatewayConfig) { return async (req: Request): Promise => { const url = new URL(req.url); const state = url.searchParams.get("state"); const code = url.searchParams.get("code"); const error = url.searchParams.get("error"); if (!state) { return new Response(renderErrorPage("Missing state parameter"), { status: 400, headers: { "Content-Type": "text/html" }, }); } if (state.length < MIN_STATE_LENGTH) { log.warn({ stateLength: state.length }, "OAuth state too short"); return new Response(renderErrorPage("Invalid state parameter"), { status: 400, headers: { "Content-Type": "text/html" }, }); } if (consumedStates.has(state)) { log.warn("OAuth state replay attempt blocked"); return new Response(renderErrorPage("State parameter already used"), { status: 400, headers: { "Content-Type": "text/html" }, }); } // Optimistically mark consumed so concurrent duplicate callbacks are // blocked while the runtime processes this one. Rolled back on transient // failures (5xx, transport errors, 401/403 auth failures) — deterministic // rejections (other 4xx) keep state consumed to prevent replay attacks. markStateConsumed(state); try { const response = await forwardOAuthCallback( config, state, code || undefined, error || undefined, ); if (response.status >= 200 && response.status < 300) { return new Response(renderSuccessPage(), { status: 200, headers: { "Content-Type": "text/html" }, }); } // Roll back consumed state for transient failures so the user can retry. // 401/403 are transient auth failures (token/secret mismatch) where the // runtime rejected the request before processing the callback state. // 5xx are server errors. Both are safe to retry. // Other 4xx (400/404/422) are deterministic rejections — keep state // consumed to prevent replay/spam. if ( response.status >= 500 || response.status === 401 || response.status === 403 ) { rollBackConsumedState(state); } return new Response( renderErrorPage("Authorization failed. Please try again."), { status: 400, headers: { "Content-Type": "text/html" } }, ); } catch (err) { // Transport errors are transient — roll back so the callback can be retried. rollBackConsumedState(state); log.error({ err }, "Failed to forward OAuth callback to runtime"); return new Response( renderErrorPage("Authorization failed. Please try again."), { status: 502, headers: { "Content-Type": "text/html" } }, ); } }; } function renderSuccessPage(): string { return `Authorization Successful

Authorization Successful

You can close this tab and return to the app.

`; } function escapeHtml(s: string): string { return s .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function renderErrorPage(message: string): string { return `Authorization Failed

Authorization Failed

${escapeHtml(message)}

`; }