ai_api / src /lib /oauth /providers.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
8.62 kB
/**
* 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<string, string | undefined>;
function hasValue(value: string | undefined): boolean {
return typeof value === "string" && value.trim().length > 0;
}
function firstValue(...values: Array<string | undefined>): 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,
};
}