9router/src/lib/oauth/providers.js

1467 lines
50 KiB
JavaScript
Raw Normal View History

2026-01-04 21:58:59 -05:00
/**
* OAuth Provider Configurations and Handlers
* Centralized DRY approach for all OAuth providers
*/
// Ensure outbound fetch respects HTTP(S)_PROXY/ALL_PROXY in Node runtime
import "open-sse/index.js";
import crypto from "crypto";
2026-01-04 21:58:59 -05:00
import { generatePKCE, generateState } from "./utils/pkce";
import {
CLAUDE_CONFIG,
CODEX_CONFIG,
GEMINI_CONFIG,
QWEN_CONFIG,
2026-03-31 04:41:52 -04:00
QODER_CONFIG,
2026-01-04 21:58:59 -05:00
IFLOW_CONFIG,
ANTIGRAVITY_CONFIG,
GITHUB_CONFIG,
2026-01-15 06:29:47 -05:00
KIRO_CONFIG,
CURSOR_CONFIG,
2026-02-20 05:05:46 -05:00
KIMI_CODING_CONFIG,
KILOCODE_CONFIG,
CLINE_CONFIG,
GITLAB_CONFIG,
CODEBUDDY_CONFIG,
getOAuthClientMetadata,
2026-01-04 21:58:59 -05:00
} from "./constants/oauth";
import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai";
2026-05-21 03:30:59 -04:00
// Inlined from services/xai.js to keep web route bundle free of `open` (CLI-only) package
let cachedXaiDiscovery = null;
function validateXaiOAuthEndpoint(rawUrl, field) {
const value = String(rawUrl || "").trim();
if (!value) throw new Error(`xai discovery ${field} is empty`);
let parsed;
try { parsed = new URL(value); } catch (err) {
throw new Error(`xai discovery ${field} is invalid: ${err.message}`);
}
if (parsed.protocol !== "https:") throw new Error(`xai discovery ${field} must use https: ${value}`);
const host = parsed.hostname.toLowerCase().trim();
if (host !== "x.ai" && !host.endsWith(".x.ai")) {
throw new Error(`xai discovery ${field} host ${host} is not on x.ai`);
}
return value;
}
async function discoverXaiEndpoints() {
if (cachedXaiDiscovery) return cachedXaiDiscovery;
try {
const res = await fetch(XAI_CONFIG.discoveryUrl, { headers: { Accept: "application/json" } });
if (res.ok) {
const data = await res.json();
cachedXaiDiscovery = {
authorizeUrl: validateXaiOAuthEndpoint(data.authorization_endpoint, "authorization_endpoint"),
tokenUrl: validateXaiOAuthEndpoint(data.token_endpoint, "token_endpoint"),
};
return cachedXaiDiscovery;
}
} catch { /* fall through to static fallback */ }
cachedXaiDiscovery = { authorizeUrl: XAI_CONFIG.authorizeUrl, tokenUrl: XAI_CONFIG.tokenUrl };
return cachedXaiDiscovery;
}
function decodeXaiIdTokenEmail(idToken) {
if (!idToken || typeof idToken !== "string") return undefined;
const parts = idToken.split(".");
if (parts.length !== 3) return undefined;
try {
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const padding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE;
const json = Buffer.from(base64 + "=".repeat(padding), "base64").toString("utf8");
const payload = JSON.parse(json);
return payload.email || payload.preferred_username || payload.sub || undefined;
} catch {
return undefined;
}
}
2026-01-04 21:58:59 -05:00
const BASE64_BLOCK_SIZE = 4;
/**
* Decode JWT access token and extract a stable account identifier for display/upsert.
* @param {string} accessToken
* @returns {string|undefined}
*/
function decodeJwtPayload(jwt) {
try {
if (!jwt || typeof jwt !== "string") return null;
const parts = jwt.split(".");
if (parts.length !== 3) return null;
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
const missingPadding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE;
const padded = base64 + "=".repeat(missingPadding);
return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
} catch {
return null;
}
}
function extractEmailFromAccessToken(accessToken) {
const payload = decodeJwtPayload(accessToken);
if (!payload) return undefined;
return payload.email || payload.preferred_username || payload.sub || undefined;
}
Squashed commit of the following: commit 6561679f5c396bb07f5f7ba5bc5ec75e81c803a4 Author: OpenClaw Patch <patch@openclaw.local> Date: Tue May 19 16:26:01 2026 -0700 fix: never dedup access_token connections Access tokens should always create new entries. User decides which to keep (refresh-based OAuth vs no-expiry website token) and removes the other manually. commit d773451657999a2965ca4a094a7f0b7a54066693 Author: OpenClaw Patch <patch@openclaw.local> Date: Tue May 19 16:24:30 2026 -0700 fix: support ChatGPT website token format (account_id, plan_type) ChatGPT website access tokens use top-level 'account_id' and 'plan_type' fields, while OAuth id_tokens use nested claims under 'https://api.openai.com/auth'. Now both formats are handled, so workspace dedup works for website tokens too. commit cb895a5f6be59c51267874f11567646fa1f43016 Author: OpenClaw Patch <patch@openclaw.local> Date: Tue May 19 16:12:56 2026 -0700 fix: detect JWT in manual callback URL field When user pastes a JWT access token (starts with eyJ) in the 'paste callback URL' input field, skip URL parsing and send it directly to the exchange endpoint as the code. Fixes 'Failed to construct URL: Invalid URL' error. commit 29650d4a6732e3cf0958c9963b53209e41c8281e Author: OpenClaw Patch <patch@openclaw.local> Date: Tue May 19 15:37:02 2026 -0700 feat: auto-detect access token in OAuth exchange When the exchange endpoint receives a JWT (starts with eyJ) instead of an OAuth authorization code, it detects this and creates an access_token connection directly — skipping the OAuth token exchange flow. This lets users paste a ChatGPT access token where the OAuth code would normally go, and have it work automatically. commit e8e7c5709a783abd0c45246a44de1cc6abdba100 Author: OpenClaw Patch <patch@openclaw.local> Date: Tue May 19 15:14:48 2026 -0700 feat: workspace-aware dedup + ChatGPT access token import 1. Dedup now checks email AND workspace (chatgptAccountId) - Same email in different workspaces = separate connections - Backward compatible: non-workspace providers still dedup by email 2. New authType 'access_token' for ChatGPT website tokens - POST /api/oauth/codex/import-token accepts raw access tokens - Extracts email, workspace, plan from JWT claims - Deduplicates by email+workspace like OAuth - No refresh token needed (avoids OAuth relogin issues)
2026-05-20 03:38:47 -04:00
// Extract codex account info from id_token or access token
export function extractCodexAccountInfo(idToken) {
const payload = decodeJwtPayload(idToken);
if (!payload) return {};
const chatgpt = payload["https://api.openai.com/auth"] || {};
return {
email: payload.email,
Squashed commit of the following: commit 6561679f5c396bb07f5f7ba5bc5ec75e81c803a4 Author: OpenClaw Patch <patch@openclaw.local> Date: Tue May 19 16:26:01 2026 -0700 fix: never dedup access_token connections Access tokens should always create new entries. User decides which to keep (refresh-based OAuth vs no-expiry website token) and removes the other manually. commit d773451657999a2965ca4a094a7f0b7a54066693 Author: OpenClaw Patch <patch@openclaw.local> Date: Tue May 19 16:24:30 2026 -0700 fix: support ChatGPT website token format (account_id, plan_type) ChatGPT website access tokens use top-level 'account_id' and 'plan_type' fields, while OAuth id_tokens use nested claims under 'https://api.openai.com/auth'. Now both formats are handled, so workspace dedup works for website tokens too. commit cb895a5f6be59c51267874f11567646fa1f43016 Author: OpenClaw Patch <patch@openclaw.local> Date: Tue May 19 16:12:56 2026 -0700 fix: detect JWT in manual callback URL field When user pastes a JWT access token (starts with eyJ) in the 'paste callback URL' input field, skip URL parsing and send it directly to the exchange endpoint as the code. Fixes 'Failed to construct URL: Invalid URL' error. commit 29650d4a6732e3cf0958c9963b53209e41c8281e Author: OpenClaw Patch <patch@openclaw.local> Date: Tue May 19 15:37:02 2026 -0700 feat: auto-detect access token in OAuth exchange When the exchange endpoint receives a JWT (starts with eyJ) instead of an OAuth authorization code, it detects this and creates an access_token connection directly — skipping the OAuth token exchange flow. This lets users paste a ChatGPT access token where the OAuth code would normally go, and have it work automatically. commit e8e7c5709a783abd0c45246a44de1cc6abdba100 Author: OpenClaw Patch <patch@openclaw.local> Date: Tue May 19 15:14:48 2026 -0700 feat: workspace-aware dedup + ChatGPT access token import 1. Dedup now checks email AND workspace (chatgptAccountId) - Same email in different workspaces = separate connections - Backward compatible: non-workspace providers still dedup by email 2. New authType 'access_token' for ChatGPT website tokens - POST /api/oauth/codex/import-token accepts raw access tokens - Extracts email, workspace, plan from JWT claims - Deduplicates by email+workspace like OAuth - No refresh token needed (avoids OAuth relogin issues)
2026-05-20 03:38:47 -04:00
chatgptAccountId: chatgpt.chatgpt_account_id || payload.account_id,
chatgptPlanType: chatgpt.chatgpt_plan_type || payload.plan_type,
};
}
2026-01-04 21:58:59 -05:00
// Provider configurations
const PROVIDERS = {
claude: {
config: CLAUDE_CONFIG,
flowType: "authorization_code_pkce",
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = new URLSearchParams({
code: "true",
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
state: state,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier, state) => {
// Parse code - may contain state after #
let authCode = code;
let codeState = "";
if (authCode.includes("#")) {
const parts = authCode.split("#");
authCode = parts[0];
codeState = parts[1] || "";
}
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
code: authCode,
state: codeState || state,
grant_type: "authorization_code",
client_id: config.clientId,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
}),
},
codex: {
config: CODEX_CONFIG,
flowType: "authorization_code_pkce",
fixedPort: 1455,
callbackPath: "/auth/callback",
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
const params = {
response_type: "code",
client_id: config.clientId,
redirect_uri: redirectUri,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
...config.extraParams,
state: state,
};
const queryString = Object.entries(params)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join("&");
return `${config.authorizeUrl}?${queryString}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
code: code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
mapTokens: (tokens) => {
const info = extractCodexAccountInfo(tokens.id_token);
const mapped = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
};
if (info.email) mapped.email = info.email;
if (info.chatgptAccountId || info.chatgptPlanType) {
mapped.providerSpecificData = {
chatgptAccountId: info.chatgptAccountId,
chatgptPlanType: info.chatgptPlanType,
};
}
return mapped;
},
2026-01-04 21:58:59 -05:00
},
xai: {
config: XAI_CONFIG,
flowType: "authorization_code_pkce",
fixedPort: XAI_CONFIG.loopbackPort,
callbackPath: XAI_CONFIG.callbackPath,
pkceVerifierBytes: XAI_PKCE_VERIFIER_BYTES,
prepareConfig: async (config) => {
const endpoints = await discoverXaiEndpoints();
return {
...config,
authorizeUrl: endpoints.authorizeUrl,
tokenUrl: endpoints.tokenUrl,
};
},
buildAuthUrl: (config, redirectUri, state, codeChallenge) => {
// Mirror CLIProxyAPI BuildAuthorizeURL: includes nonce, plan, referrer
const nonce = crypto.randomBytes(16).toString("hex");
const params = {
response_type: "code",
client_id: config.clientId,
redirect_uri: redirectUri,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
state,
nonce,
plan: "generic",
referrer: "cli-proxy-api",
};
const qs = Object.entries(params)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join("&");
return `${config.authorizeUrl}?${qs}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`xAI token exchange failed: ${error}`);
}
return await response.json();
},
mapTokens: (tokens) => {
const mapped = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
};
const email = decodeXaiIdTokenEmail(tokens.id_token);
if (email) mapped.email = email;
if (tokens.id_token) {
mapped.providerSpecificData = { idToken: tokens.id_token };
}
return mapped;
},
},
2026-01-04 21:58:59 -05:00
"gemini-cli": {
config: GEMINI_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
state: state,
access_type: "offline",
prompt: "consent",
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
client_secret: config.clientSecret,
code: code,
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
// Fetch user info
const userInfoRes = await fetch(`${GEMINI_CONFIG.userInfoUrl}?alt=json`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
// Fetch project ID
let projectId = "";
try {
const projectRes = await fetch(
"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
{
method: "POST",
headers: {
Authorization: `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
metadata: getOAuthClientMetadata(),
mode: 1,
2026-01-04 21:58:59 -05:00
}),
}
);
if (projectRes.ok) {
const data = await projectRes.json();
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
}
} catch (e) {
console.log("Failed to fetch project ID:", e);
}
return { userInfo, projectId };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: extra?.userInfo?.email,
projectId: extra?.projectId,
}),
},
antigravity: {
config: ANTIGRAVITY_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
client_id: config.clientId,
response_type: "code",
redirect_uri: redirectUri,
scope: config.scopes.join(" "),
state: state,
access_type: "offline",
prompt: "consent",
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: config.clientId,
client_secret: config.clientSecret,
code: code,
redirect_uri: redirectUri,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
// Numeric enums matching Antigravity binary ClientMetadata
2026-03-03 04:01:10 -05:00
const loadHeaders = {
"Authorization": `Bearer ${tokens.access_token}`,
"Content-Type": "application/json",
"User-Agent": ANTIGRAVITY_CONFIG.loadCodeAssistUserAgent,
"X-Goog-Api-Client": ANTIGRAVITY_CONFIG.loadCodeAssistApiClient,
"Client-Metadata": ANTIGRAVITY_CONFIG.loadCodeAssistClientMetadata,
2026-03-03 04:01:10 -05:00
"x-request-source": "local",
};
const metadata = getOAuthClientMetadata();
2026-01-04 21:58:59 -05:00
// Fetch user info
const userInfoRes = await fetch(`${ANTIGRAVITY_CONFIG.userInfoUrl}?alt=json`, {
2026-03-03 04:01:10 -05:00
headers: {
Authorization: `Bearer ${tokens.access_token}`,
2026-03-03 04:01:10 -05:00
"x-request-source": "local",
},
2026-01-04 21:58:59 -05:00
});
const userInfo = userInfoRes.ok ? await userInfoRes.json() : {};
// Load Code Assist to get project ID and tier
2026-01-04 21:58:59 -05:00
let projectId = "";
let tierId = "legacy-tier";
2026-01-04 21:58:59 -05:00
try {
const loadRes = await fetch(ANTIGRAVITY_CONFIG.loadCodeAssistEndpoint, {
2026-01-04 21:58:59 -05:00
method: "POST",
2026-03-03 04:01:10 -05:00
headers: loadHeaders,
body: JSON.stringify({ metadata }),
2026-01-04 21:58:59 -05:00
});
if (loadRes.ok) {
const data = await loadRes.json();
2026-01-04 21:58:59 -05:00
projectId = data.cloudaicompanionProject?.id || data.cloudaicompanionProject || "";
if (Array.isArray(data.allowedTiers)) {
for (const tier of data.allowedTiers) {
if (tier.isDefault && tier.id) {
tierId = tier.id.trim();
break;
}
}
}
2026-01-04 21:58:59 -05:00
}
} catch (e) {
console.log("Failed to load code assist:", e);
}
2026-02-26 21:17:49 -05:00
// Fire-and-forget onboarding — does not block DB save
if (projectId) {
2026-02-26 21:17:49 -05:00
const doOnboard = async () => {
for (let i = 0; i < 10; i++) {
2026-02-26 21:17:49 -05:00
try {
const onboardRes = await fetch(ANTIGRAVITY_CONFIG.onboardUserEndpoint, {
method: "POST",
2026-03-03 04:01:10 -05:00
headers: loadHeaders,
body: JSON.stringify({ tierId, metadata }),
2026-02-26 21:17:49 -05:00
});
if (onboardRes.ok) {
const result = await onboardRes.json();
if (result.done === true) break;
}
2026-02-26 21:17:49 -05:00
} catch (e) {
break;
}
await new Promise(resolve => setTimeout(resolve, 5000));
}
2026-02-26 21:17:49 -05:00
};
doOnboard().catch(() => {});
2026-01-04 21:58:59 -05:00
}
return { userInfo, projectId };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
email: extra?.userInfo?.email,
projectId: extra?.projectId,
}),
},
iflow: {
config: IFLOW_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri, state) => {
const params = new URLSearchParams({
loginMethod: config.extraParams.loginMethod,
type: config.extraParams.type,
redirect: redirectUri,
state: state,
client_id: config.clientId,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
// Create Basic Auth header
const basicAuth = Buffer.from(
`${config.clientId}:${config.clientSecret}`
).toString("base64");
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
Authorization: `Basic ${basicAuth}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: code,
redirect_uri: redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Token exchange failed: ${error}`);
}
return await response.json();
},
postExchange: async (tokens) => {
// Fetch user info (MUST succeed to get API key)
2026-01-04 21:58:59 -05:00
const userInfoRes = await fetch(
`${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`,
{
headers: {
Accept: "application/json",
},
}
);
if (!userInfoRes.ok) {
const errorText = await userInfoRes.text();
throw new Error(`Failed to fetch user info: ${errorText}`);
}
const result = await userInfoRes.json();
if (!result.success) {
throw new Error(`User info request failed: ${result.message || 'Unknown error'}`);
}
const userInfo = result.data || {};
// Validate API key (critical for iFlow)
if (!userInfo.apiKey || userInfo.apiKey.trim() === "") {
throw new Error("Empty API key returned from iFlow");
}
// Validate email/phone
const email = userInfo.email?.trim() || userInfo.phone?.trim();
if (!email) {
throw new Error("Missing account email/phone in user info");
}
2026-01-04 21:58:59 -05:00
return { userInfo };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
apiKey: extra?.userInfo?.apiKey,
email: extra?.userInfo?.email || extra?.userInfo?.phone,
displayName: extra?.userInfo?.nickname || extra?.userInfo?.name,
}),
},
2026-03-31 04:41:52 -04:00
qoder: {
config: QODER_CONFIG,
flowType: "device_code",
// Qoder uses a custom device flow: PKCE + nonce + machine_id are generated
// locally, the user lands on qoder.com/device/selectAccounts in the
// browser, and we poll openapi.qoder.sh until a `dt-...` token appears.
requestDeviceCode: async (config) => {
const { initiateDeviceFlow } = await import("@/lib/qoder/auth");
const flow = initiateDeviceFlow();
// Match the device_code shape the rest of the OAuthModal expects
// (device_code, user_code, verification_uri[_complete], interval).
// The poll endpoint identifies us by nonce+verifier, not by a
// server-issued device_code, so we plumb our own values through:
// device_code = nonce (modal forwards as deviceCode on poll)
// codeVerifier = our PKCE verifier (route forwards as codeVerifier)
return {
device_code: flow.nonce,
user_code: flow.nonce.slice(0, 8).toUpperCase(),
verification_uri: config.loginUrl,
verification_uri_complete: flow.verificationUriComplete,
expires_in: 300,
interval: 2,
codeVerifier: flow.codeVerifier,
_qoderNonce: flow.nonce,
_qoderMachineId: flow.machineId,
};
2026-03-31 04:41:52 -04:00
},
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
const { pollDeviceToken, fetchUserInfo } = await import("@/lib/qoder/auth");
const nonce = deviceCode || extraData?._qoderNonce;
const verifier = codeVerifier || extraData?._qoderVerifier;
if (!nonce || !verifier) {
return {
ok: false,
data: { error: "invalid_request", error_description: "Missing nonce/verifier" },
};
2026-03-31 04:41:52 -04:00
}
let result;
try {
result = await pollDeviceToken({ nonce, codeVerifier: verifier });
} catch (err) {
return {
ok: false,
data: { error: "poll_failed", error_description: err.message },
};
2026-03-31 04:41:52 -04:00
}
if (result.status === "pending") {
return { ok: false, data: { error: "authorization_pending" } };
2026-03-31 04:41:52 -04:00
}
// Best-effort profile lookup so we have a name/email to display.
const userInfo = await fetchUserInfo(result.accessToken);
// expireTime is a Unix-ms timestamp from parseExpiry, which already
// falls back to "now + 30 days" when the upstream omits expiry. Floor
// to a sane minimum (1 day) so a stale or skewed upstream timestamp
// doesn't truncate the stored token below something useful.
const minSeconds = 24 * 60 * 60;
const remainingSeconds = Math.floor((result.expireTime - Date.now()) / 1000);
const expiresIn = Math.max(minSeconds, remainingSeconds);
return {
ok: true,
data: {
access_token: result.accessToken,
refresh_token: result.refreshToken,
expires_in: expiresIn,
_qoderUserId: result.userId,
_qoderMachineId: extraData?._qoderMachineId || "",
_qoderName: userInfo.name,
_qoderEmail: userInfo.email,
_qoderOrganizationId: userInfo.organizationId,
},
};
},
mapTokens: (tokens) => {
const email = (tokens._qoderEmail || "").trim() || null;
const displayName = (tokens._qoderName || "").trim() || null;
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token || null,
expiresIn: tokens.expires_in,
email,
displayName,
providerSpecificData: {
authMethod: "device",
userId: tokens._qoderUserId || "",
machineId: tokens._qoderMachineId || "",
organizationId: tokens._qoderOrganizationId || "",
},
};
2026-03-31 04:41:52 -04:00
},
},
2026-01-04 21:58:59 -05:00
qwen: {
config: QWEN_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config, codeChallenge) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode, codeVerifier) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: config.clientId,
device_code: deviceCode,
code_verifier: codeVerifier,
}),
});
return {
ok: response.ok,
data: await response.json(),
};
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: { resourceUrl: tokens.resource_url },
}),
},
github: {
config: GITHUB_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
scope: config.scopes,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
return await response.json();
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: new URLSearchParams({
client_id: config.clientId,
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
// Handle response properly - if not ok, try to get error as text first
let data;
try {
data = await response.json();
} catch (e) {
// If response is not JSON, get as text
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
return {
ok: response.ok,
data: data,
};
},
postExchange: async (tokens) => {
// Get Copilot token using GitHub access token
const copilotRes = await fetch(GITHUB_CONFIG.copilotTokenUrl, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
},
});
const copilotToken = copilotRes.ok ? await copilotRes.json() : {};
// Get user info from GitHub
const userRes = await fetch(GITHUB_CONFIG.userInfoUrl, {
headers: {
Authorization: `Bearer ${tokens.access_token}`,
Accept: "application/json",
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
"User-Agent": GITHUB_CONFIG.userAgent,
},
});
const userInfo = userRes.ok ? await userRes.json() : {};
return { copilotToken, userInfo };
},
mapTokens: (tokens, extra) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
providerSpecificData: {
copilotToken: extra?.copilotToken?.token,
copilotTokenExpiresAt: extra?.copilotToken?.expires_at,
githubUserId: extra?.userInfo?.id,
githubLogin: extra?.userInfo?.login,
githubName: extra?.userInfo?.name,
githubEmail: extra?.userInfo?.email,
},
}),
},
2026-01-15 06:29:47 -05:00
kiro: {
config: KIRO_CONFIG,
flowType: "device_code",
// Kiro uses AWS SSO OIDC - requires client registration first
requestDeviceCode: async (config, codeChallenge, options = {}) => {
const trimmedRegion = typeof options.region === "string" ? options.region.trim() : "";
const region = trimmedRegion || "us-east-1";
const trimmedStartUrl = typeof options.startUrl === "string" ? options.startUrl.trim() : "";
const startUrl = trimmedStartUrl || config.startUrl;
const authMethod = options.authMethod === "idc" ? "idc" : "builder-id";
const registerClientUrl = `https://oidc.${region}.amazonaws.com/client/register`;
const deviceAuthUrl = `https://oidc.${region}.amazonaws.com/device_authorization`;
2026-01-15 06:29:47 -05:00
// Step 1: Register client with AWS SSO OIDC
const registerRes = await fetch(registerClientUrl, {
2026-01-15 06:29:47 -05:00
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientName: config.clientName,
clientType: config.clientType,
scopes: config.scopes,
grantTypes: config.grantTypes,
issuerUrl: config.issuerUrl,
}),
});
if (!registerRes.ok) {
const error = await registerRes.text();
throw new Error(`Client registration failed: ${error}`);
}
const clientInfo = await registerRes.json();
// Step 2: Request device authorization
const deviceRes = await fetch(deviceAuthUrl, {
2026-01-15 06:29:47 -05:00
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: clientInfo.clientId,
clientSecret: clientInfo.clientSecret,
startUrl,
2026-01-15 06:29:47 -05:00
}),
});
if (!deviceRes.ok) {
const error = await deviceRes.text();
throw new Error(`Device authorization failed: ${error}`);
}
const deviceData = await deviceRes.json();
// Return combined data for polling
return {
device_code: deviceData.deviceCode,
user_code: deviceData.userCode,
verification_uri: deviceData.verificationUri,
verification_uri_complete: deviceData.verificationUriComplete,
expires_in: deviceData.expiresIn,
interval: deviceData.interval || 5,
// Store client credentials for token exchange
_clientId: clientInfo.clientId,
_clientSecret: clientInfo.clientSecret,
_region: region,
_authMethod: authMethod,
_startUrl: startUrl,
2026-01-15 06:29:47 -05:00
};
},
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
const region = extraData?._region || "us-east-1";
const tokenUrl = `https://oidc.${region}.amazonaws.com/token`;
const response = await fetch(tokenUrl, {
2026-01-15 06:29:47 -05:00
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientId: extraData?._clientId,
clientSecret: extraData?._clientSecret,
deviceCode: deviceCode,
grantType: "urn:ietf:params:oauth:grant-type:device_code",
}),
});
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
// AWS SSO OIDC returns camelCase
if (data.accessToken) {
return {
ok: true,
data: {
access_token: data.accessToken,
refresh_token: data.refreshToken,
expires_in: data.expiresIn,
profile_arn: data?.profileArn || null,
2026-01-15 06:29:47 -05:00
// Store client credentials for refresh
_clientId: extraData?._clientId,
_clientSecret: extraData?._clientSecret,
_region: extraData?._region,
_authMethod: extraData?._authMethod,
_startUrl: extraData?._startUrl,
2026-01-15 06:29:47 -05:00
},
};
}
return {
ok: false,
data: {
error: data.error || "authorization_pending",
error_description: data.error_description || data.message,
},
};
},
mapTokens: (tokens) => {
const email = extractEmailFromAccessToken(tokens.access_token);
const mapped = {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
email,
providerSpecificData: {
profileArn: tokens?.profile_arn || null,
clientId: tokens._clientId,
clientSecret: tokens._clientSecret,
region: tokens._region || "us-east-1",
authMethod: tokens._authMethod || "builder-id",
startUrl: tokens._startUrl || KIRO_CONFIG.startUrl,
},
};
return mapped;
},
2026-01-15 06:29:47 -05:00
},
cursor: {
config: CURSOR_CONFIG,
flowType: "import_token",
// Cursor uses import token flow - tokens are extracted from local SQLite database
// No OAuth flow needed, handled by /api/oauth/cursor/import route
mapTokens: (tokens) => ({
accessToken: tokens.accessToken,
refreshToken: null, // Cursor doesn't have public refresh endpoint
expiresIn: tokens.expiresIn || 86400,
providerSpecificData: {
machineId: tokens.machineId,
authMethod: "imported",
},
}),
},
2026-02-20 05:05:46 -05:00
"kimi-coding": {
config: KIMI_CODING_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.deviceCodeUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: new URLSearchParams({ client_id: config.clientId }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Device code request failed: ${error}`);
}
const data = await response.json();
return {
device_code: data.device_code,
user_code: data.user_code,
verification_uri: data.verification_uri || "https://www.kimi.com/code/authorize_device",
verification_uri_complete:
data.verification_uri_complete ||
`https://www.kimi.com/code/authorize_device?user_code=${data.user_code}`,
expires_in: data.expires_in,
interval: data.interval || 5,
};
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
client_id: config.clientId,
device_code: deviceCode,
}),
});
let data;
try {
data = await response.json();
} catch (e) {
const text = await response.text();
data = { error: "invalid_response", error_description: text };
}
return { ok: response.ok, data };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
}),
},
kilocode: {
config: KILOCODE_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(config.initiateUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!response.ok) {
if (response.status === 429) {
throw new Error("Too many pending authorization requests. Please try again later.");
}
const error = await response.text();
throw new Error(`Device auth initiation failed: ${error}`);
}
const data = await response.json();
return {
device_code: data.code,
user_code: data.code,
verification_uri: data.verificationUrl,
verification_uri_complete: data.verificationUrl,
expires_in: data.expiresIn || 300,
interval: 3,
};
},
pollToken: async (config, deviceCode) => {
const response = await fetch(`${config.pollUrlBase}/${deviceCode}`);
if (response.status === 202) return { ok: false, data: { error: "authorization_pending" } };
if (response.status === 403) return { ok: false, data: { error: "access_denied", error_description: "Authorization denied by user" } };
if (response.status === 410) return { ok: false, data: { error: "expired_token", error_description: "Authorization code expired" } };
if (!response.ok) return { ok: false, data: { error: "poll_failed", error_description: `Poll failed: ${response.status}` } };
const data = await response.json();
if (data.status === "approved" && data.token) {
// Fetch profile to get orgId for X-Kilocode-OrganizationID header
let orgId = null;
try {
const profileRes = await fetch(`${config.apiBaseUrl}/api/profile`, {
headers: { "Authorization": `Bearer ${data.token}` }
});
if (profileRes.ok) {
const profile = await profileRes.json();
orgId = profile.organizations?.[0]?.id || null;
}
} catch {}
return { ok: true, data: { access_token: data.token, _userEmail: data.userEmail, _orgId: orgId } };
2026-02-20 05:05:46 -05:00
}
return { ok: false, data: { error: "authorization_pending" } };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: null,
expiresIn: null,
email: tokens._userEmail,
...(tokens._orgId ? { providerSpecificData: { orgId: tokens._orgId } } : {}),
2026-02-20 05:05:46 -05:00
}),
},
cline: {
config: CLINE_CONFIG,
flowType: "authorization_code",
buildAuthUrl: (config, redirectUri) => {
const params = new URLSearchParams({
client_type: "extension",
callback_url: redirectUri,
redirect_uri: redirectUri,
});
return `${config.authorizeUrl}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri) => {
try {
// Cline encodes token data as base64 in the code param
let base64 = code;
const padding = 4 - (base64.length % 4);
if (padding !== 4) base64 += "=".repeat(padding);
const decoded = Buffer.from(base64, "base64").toString("utf-8");
const lastBrace = decoded.lastIndexOf("}");
if (lastBrace === -1) throw new Error("No JSON found in decoded code");
const tokenData = JSON.parse(decoded.substring(0, lastBrace + 1));
return {
access_token: tokenData.accessToken,
refresh_token: tokenData.refreshToken,
email: tokenData.email,
firstName: tokenData.firstName,
lastName: tokenData.lastName,
expires_at: tokenData.expiresAt,
};
} catch (e) {
const response = await fetch(config.tokenExchangeUrl, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ grant_type: "authorization_code", code, client_type: "extension", redirect_uri: redirectUri }),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`Cline token exchange failed: ${error}`);
}
const data = await response.json();
return {
access_token: data.data?.accessToken || data.accessToken,
refresh_token: data.data?.refreshToken || data.refreshToken,
email: data.data?.userInfo?.email || "",
expires_at: data.data?.expiresAt || data.expiresAt,
};
}
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_at
? Math.floor((new Date(tokens.expires_at).getTime() - Date.now()) / 1000)
: 3600,
email: tokens.email,
providerSpecificData: { firstName: tokens.firstName, lastName: tokens.lastName },
}),
},
// GitLab Duo - Authorization Code Flow with PKCE
// Supports two login modes via loginMode metadata: "oauth" (default) or "pat"
gitlab: {
config: GITLAB_CONFIG,
flowType: "authorization_code_pkce",
buildAuthUrl: (config, redirectUri, state, codeChallenge, meta = {}) => {
const baseUrl = meta.baseUrl || config.defaultBaseUrl;
const clientId = meta.clientId || "";
const params = new URLSearchParams({
client_id: clientId,
redirect_uri: redirectUri,
response_type: "code",
state,
scope: config.scope,
code_challenge: codeChallenge,
code_challenge_method: config.codeChallengeMethod,
});
return `${baseUrl}${config.authorizeUrlPath}?${params.toString()}`;
},
exchangeToken: async (config, code, redirectUri, codeVerifier, state, meta = {}) => {
const baseUrl = meta.baseUrl || config.defaultBaseUrl;
const clientId = meta.clientId || "";
const clientSecret = meta.clientSecret || "";
const body = new URLSearchParams({
client_id: clientId,
grant_type: "authorization_code",
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
if (clientSecret) body.set("client_secret", clientSecret);
const response = await fetch(`${baseUrl}${config.tokenUrlPath}`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
body: body.toString(),
});
if (!response.ok) throw new Error(`GitLab token exchange failed: ${await response.text()}`);
const tokens = await response.json();
// Fetch user info
const userRes = await fetch(`${baseUrl}${config.userInfoUrlPath}`, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const user = userRes.ok ? await userRes.json() : {};
return { ...tokens, _user: user, _baseUrl: baseUrl, _clientId: clientId };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: tokens.expires_in,
scope: tokens.scope,
providerSpecificData: {
username: tokens._user?.username || "",
email: tokens._user?.email || tokens._user?.public_email || "",
name: tokens._user?.name || "",
baseUrl: tokens._baseUrl,
clientId: tokens._clientId,
authKind: "oauth",
},
}),
},
// CodeBuddy (Tencent) - Browser OAuth Polling Flow
// 1. POST stateUrl → get { state, authUrl }
// 2. Open authUrl in browser
// 3. Poll tokenUrl with state until success (code 0) or timeout
codebuddy: {
config: CODEBUDDY_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const response = await fetch(`${config.stateUrl}?platform=${config.platform}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": config.userAgent,
"X-Requested-With": "XMLHttpRequest",
"X-Domain": "copilot.tencent.com",
"X-No-Authorization": "true",
"X-No-User-Id": "true",
"X-Product": "SaaS",
},
body: "{}",
});
if (!response.ok) throw new Error(`CodeBuddy state request failed: ${await response.text()}`);
const data = await response.json();
if (data.code !== 0 || !data.data?.state || !data.data?.authUrl) {
throw new Error(`CodeBuddy state error: ${data.msg || "missing state/authUrl"}`);
}
return {
device_code: data.data.state,
verification_uri: data.data.authUrl,
user_code: "",
interval: config.pollInterval / 1000,
_isCodeBuddy: true,
};
},
pollToken: async (config, deviceCode) => {
const response = await fetch(config.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": config.userAgent,
"X-Requested-With": "XMLHttpRequest",
"X-Domain": "copilot.tencent.com",
"X-No-Authorization": "true",
"X-No-User-Id": "true",
"X-Product": "SaaS",
},
body: JSON.stringify({ state: deviceCode }),
});
if (!response.ok) return { ok: false, data: { error: "request_failed" } };
const data = await response.json();
// code 11217 = pending, code 0 = success
if (data.code === 0 && data.data?.accessToken) {
return {
ok: true,
data: {
access_token: data.data.accessToken,
refresh_token: data.data.refreshToken || "",
token_type: data.data.tokenType || "Bearer",
},
};
}
if (data.code === 11217) return { ok: true, data: { error: "authorization_pending" } };
return { ok: false, data: { error: data.msg || "unknown_error" } };
},
mapTokens: (tokens) => ({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresIn: 86400,
providerSpecificData: {},
}),
},
2026-01-04 21:58:59 -05:00
};
/**
* 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
* @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl)
2026-01-04 21:58:59 -05:00
*/
export async function generateAuthData(providerName, redirectUri, meta) {
2026-01-04 21:58:59 -05:00
const provider = getProvider(providerName);
const config = provider.prepareConfig
? await provider.prepareConfig(provider.config, meta || {})
: provider.config;
const { codeVerifier, codeChallenge, state } = generatePKCE(provider.pkceVerifierBytes);
2026-01-04 21:58:59 -05:00
let authUrl;
if (provider.flowType === "device_code") {
// Device code flow doesn't have auth URL upfront
authUrl = null;
} else if (provider.flowType === "authorization_code_pkce") {
authUrl = provider.buildAuthUrl(config, redirectUri, state, codeChallenge, meta || {});
2026-01-04 21:58:59 -05:00
} else {
authUrl = provider.buildAuthUrl(config, redirectUri, state, undefined, meta || {});
2026-01-04 21:58:59 -05:00
}
return {
authUrl,
state,
codeVerifier,
codeChallenge,
redirectUri,
flowType: provider.flowType,
fixedPort: provider.fixedPort,
callbackPath: provider.callbackPath || "/callback",
};
}
/**
* Exchange code for tokens
* @param {object} [meta] - Provider-specific metadata (e.g. gitlab clientId/baseUrl)
2026-01-04 21:58:59 -05:00
*/
export async function exchangeTokens(providerName, code, redirectUri, codeVerifier, state, meta) {
2026-01-04 21:58:59 -05:00
const provider = getProvider(providerName);
const config = provider.prepareConfig
? await provider.prepareConfig(provider.config, meta || {})
: provider.config;
const tokens = await provider.exchangeToken(config, code, redirectUri, codeVerifier, state, meta || {});
2026-01-04 21:58:59 -05:00
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, options) {
2026-01-04 21:58:59 -05:00
const provider = getProvider(providerName);
if (provider.flowType !== "device_code") {
throw new Error(`Provider ${providerName} does not support device code flow`);
}
return await provider.requestDeviceCode(provider.config, codeChallenge, options || {});
2026-01-04 21:58:59 -05:00
}
/**
* Poll for token (for device_code flow)
2026-01-15 06:29:47 -05:00
* @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)
2026-01-04 21:58:59 -05:00
*/
2026-01-15 06:29:47 -05:00
export async function pollForToken(providerName, deviceCode, codeVerifier, extraData) {
2026-01-04 21:58:59 -05:00
const provider = getProvider(providerName);
if (provider.flowType !== "device_code") {
throw new Error(`Provider ${providerName} does not support device code flow`);
}
2026-01-15 06:29:47 -05:00
const result = await provider.pollToken(provider.config, deviceCode, codeVerifier, extraData);
2026-01-04 21:58:59 -05:00
if (result.ok) {
// For device code flows, success is only when we have an access token
if (result.data.access_token) {
// Call postExchange to get additional data (copilotToken, userInfo, etc.)
let extra = null;
if (provider.postExchange) {
extra = await provider.postExchange(result.data);
}
return { success: true, tokens: provider.mapTokens(result.data, extra) };
} else {
// Check if it's still pending authorization
if (result.data.error === 'authorization_pending' || result.data.error === 'slow_down') {
// This is not a failure, just still waiting
return {
success: false,
error: result.data.error,
2026-01-04 21:58:59 -05:00
errorDescription: result.data.error_description || result.data.message,
pending: result.data.error === 'authorization_pending'
};
} else {
// Actual error
return {
success: false,
error: result.data.error || 'no_access_token',
errorDescription: result.data.error_description || result.data.message || 'No access token received'
2026-01-04 21:58:59 -05:00
};
}
}
}
2026-01-04 21:58:59 -05:00
return { success: false, error: result.data.error, errorDescription: result.data.error_description };
}
// Run-once guard across the process lifetime
let codexBackfillDone = false;
// Backfill email + chatgpt account info for existing codex OAuth connections missing them
export async function backfillCodexEmails() {
if (codexBackfillDone) return;
codexBackfillDone = true;
try {
const { getProviderConnections, updateProviderConnection } = await import("@/lib/localDb");
const connections = await getProviderConnections();
const targets = connections.filter((c) => {
if (c.provider !== "codex" || c.authType !== "oauth" || !c.idToken) return false;
const hasEmail = !!c.email;
const hasAccountInfo = !!c.providerSpecificData?.chatgptAccountId;
return !hasEmail || !hasAccountInfo;
});
for (const conn of targets) {
const info = extractCodexAccountInfo(conn.idToken);
if (!info.email && !info.chatgptAccountId) continue;
const patch = {};
if (!conn.email && info.email) patch.email = info.email;
if (info.chatgptAccountId || info.chatgptPlanType) {
patch.providerSpecificData = {
...(conn.providerSpecificData || {}),
chatgptAccountId: info.chatgptAccountId,
chatgptPlanType: info.chatgptPlanType,
};
}
if (Object.keys(patch).length) {
await updateProviderConnection(conn.id, patch);
}
}
} catch (err) {
codexBackfillDone = false;
console.log("backfillCodexEmails failed:", err?.message || err);
}
}