/** * OAuth Provider Configurations and Handlers * * This file re-exports from the modular providers/ directory. * Each provider is now in its own file for maintainability. * * @see ./providers/index.js for the registry */ import { generatePKCE, generateState } from "./utils/pkce"; import { PROVIDERS } from "./providers/index"; import { resolvePublicCred } from "@omniroute/open-sse/utils/publicCreds.ts"; const GOOGLE_BROWSER_PROVIDERS = new Set(["antigravity", "agy", "gemini-cli"]); type OAuthRedirectEnv = Record; function hasValue(value: string | undefined): boolean { return typeof value === "string" && value.trim().length > 0; } function firstValue(...values: Array): string | undefined { return values.find(hasValue); } function normalizeBaseUrl(value: unknown): string { const trimmed = typeof value === "string" ? value.trim() : ""; if (!trimmed) return ""; return trimmed.replace(/\/+$/, ""); } function hasCustomGoogleOAuthCredentials( providerName: string, env: OAuthRedirectEnv | null | undefined = process.env ): boolean { if (providerName === "antigravity" || providerName === "agy") { // `agy` reuses the antigravity OAuth client + env overrides. const clientId = env?.ANTIGRAVITY_OAUTH_CLIENT_ID; const clientSecret = env?.ANTIGRAVITY_OAUTH_CLIENT_SECRET; return ( hasValue(clientId) && hasValue(clientSecret) && clientId !== resolvePublicCred("antigravity_id") ); } if (providerName === "gemini-cli") { const clientId = firstValue(env?.GEMINI_CLI_OAUTH_CLIENT_ID, env?.GEMINI_OAUTH_CLIENT_ID); const clientSecret = firstValue( env?.GEMINI_CLI_OAUTH_CLIENT_SECRET, env?.GEMINI_OAUTH_CLIENT_SECRET ); return ( hasValue(clientId) && hasValue(clientSecret) && clientId !== resolvePublicCred("gemini_id") ); } return false; } function isLoopbackHostname(hostname: string): boolean { return /^(localhost|127\.0\.0\.1|\[::1\]|::1)$/i.test(hostname); } /** * Google providers default to loopback redirects so the embedded public * credentials keep working on out-of-the-box local installs. When operators * provide their own Google OAuth client IDs for a remote deployment, prefer the * public callback URL documented in .env.example / docs/README so the popup can * navigate back to OmniRoute instead of stalling on localhost. */ export function resolveBrowserOAuthRedirectUri( providerName: string, redirectUri: string, env: OAuthRedirectEnv | null | undefined = process.env ): string { if (!GOOGLE_BROWSER_PROVIDERS.has(providerName)) { return redirectUri; } if (!hasCustomGoogleOAuthCredentials(providerName, env)) { return redirectUri; } const publicBaseUrl = normalizeBaseUrl(env.NEXT_PUBLIC_BASE_URL) || normalizeBaseUrl(env.OMNIROUTE_PUBLIC_BASE_URL); if (!publicBaseUrl) { return redirectUri; } try { const requested = new URL(redirectUri); if (!isLoopbackHostname(requested.hostname)) { return redirectUri; } const callbackPath = requested.pathname && requested.pathname !== "/" ? requested.pathname : "/callback"; return `${publicBaseUrl}${callbackPath}${requested.search}`; } catch { return redirectUri; } } /** * Get provider handler */ export function getProvider(name) { const provider = PROVIDERS[name]; if (!provider) { throw new Error(`Unknown provider: ${name}`); } return provider; } /** * Get all provider names */ export function getProviderNames() { return Object.keys(PROVIDERS); } /** * Generate auth data for a provider. * * Returns `{ supported: false, error }` (no `authUrl`) for providers whose * browser-OAuth flow is currently disabled — e.g. windsurf / devin-cli post * 2026-05 rebrand, where the legacy PKCE endpoint at app.devin.ai returns 404. * Callers (UI / API route) should surface the `error` string and route the * user to the import-token flow instead. */ export function generateAuthData(providerName, redirectUri) { const provider = getProvider(providerName); const { codeVerifier, codeChallenge, state } = generatePKCE(); if (provider.flowType === "import_token") { const error = providerName === "windsurf" || providerName === "devin-cli" ? "Browser login disabled — paste token from https://windsurf.com/show-auth-token instead. Phase 2 will restore Firebase OAuth via app.devin.ai successor." : `Browser login is disabled for ${providerName}. Use the import-token flow instead.`; return { authUrl: undefined, state: undefined, codeVerifier: undefined, codeChallenge: undefined, redirectUri, flowType: provider.flowType, fixedPort: provider.fixedPort, callbackPath: provider.callbackPath || "/callback", supported: false, error, }; } let authUrl; if (provider.flowType === "device_code") { authUrl = null; } else if (provider.flowType === "authorization_code_pkce") { authUrl = provider.buildAuthUrl(provider.config, redirectUri, state, codeChallenge); } else { authUrl = provider.buildAuthUrl(provider.config, redirectUri, state); } return { authUrl, state, codeVerifier, codeChallenge, redirectUri, flowType: provider.flowType, fixedPort: provider.fixedPort, callbackPath: provider.callbackPath || "/callback", }; } /** * Exchange code for tokens */ export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state) { const provider = getProvider(providerName); const tokens = await provider.exchangeToken( provider.config, code, redirectUri, codeVerifier, state ); let extra = null; if (provider.postExchange) { extra = await provider.postExchange(tokens); } return provider.mapTokens(tokens, extra); } /** * Finalize tokens obtained out-of-band (e.g. the browser-driven Codex device * flow, where the browser performs the auth.openai.com exchange because the * server's datacenter IP is blocked). Runs the provider's postExchange + * mapTokens — the same tail as exchangeTokens — without an HTTP token exchange. */ export async function finalizeTokens(providerName, tokens) { const provider = getProvider(providerName); let extra = null; if (provider.postExchange) { extra = await provider.postExchange(tokens); } return provider.mapTokens(tokens, extra); } /** * Request device code (for device_code flow) */ export async function requestDeviceCode(providerName, codeChallenge, configOverride = null) { const provider = getProvider(providerName); if (provider.flowType !== "device_code") { throw new Error(`Provider ${providerName} does not support device code flow`); } return await provider.requestDeviceCode(configOverride || provider.config, codeChallenge); } /** * Poll for token (for device_code flow) * @param {string} providerName - Provider name * @param {string} deviceCode - Device code from requestDeviceCode * @param {string} codeVerifier - PKCE code verifier (optional for some providers) * @param {object} extraData - Extra data from device code response (e.g. clientId/clientSecret for Kiro) */ export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) { const provider = getProvider(providerName); if (provider.flowType !== "device_code") { throw new Error(`Provider ${providerName} does not support device code flow`); } const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData); if (result.ok) { if (result.data.access_token) { let extra = null; if (provider.postExchange) { extra = await provider.postExchange(result.data); } return { success: true, tokens: provider.mapTokens(result.data, extra) }; } else { if (result.data.error === "authorization_pending" || result.data.error === "slow_down") { return { success: false, error: result.data.error, errorDescription: result.data.error_description || result.data.message, pending: result.data.error === "authorization_pending", }; } else { return { success: false, error: result.data.error || "no_access_token", errorDescription: result.data.error_description || result.data.message || "No access token received", }; } } } return { success: false, error: result.data.error, errorDescription: result.data.error_description, }; }