c8828b8a42
Add targeted TypeScript annotations and module declarations to reduce type errors in open-sse services, executors, and shared utilities while temporarily disabling checking in legacy files that still need migration. Reset stale `.next/standalone` output before isolated builds so release artifacts are generated from a clean state. Update the dashboard proxy settings UI to bypass cached settings reads and immediately roll back debug mode when the PATCH request fails, which prevents stale data and inconsistent toggle state.
1089 lines
32 KiB
TypeScript
Executable File
1089 lines
32 KiB
TypeScript
Executable File
// @ts-nocheck
|
|
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
|
|
import { getGitHubCopilotRefreshHeaders } from "../config/providerHeaderProfiles.ts";
|
|
import { pbkdf2Sync } from "node:crypto";
|
|
import { runWithProxyContext } from "../utils/proxyFetch.ts";
|
|
|
|
// Token expiry buffer (refresh if expires within 5 minutes)
|
|
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
|
|
const CACHE_SECRET = "omniroute-token-cache";
|
|
|
|
// In-flight refresh promise cache to prevent race conditions
|
|
// Key: "provider:sha256(refreshToken)" → Value: Promise<result>
|
|
const refreshPromiseCache = new Map();
|
|
|
|
type RefreshLogger = {
|
|
info?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
|
warn?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
|
error?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
|
debug?: (tag: string, message: string, data?: Record<string, unknown>) => void;
|
|
} | null;
|
|
|
|
function buildFormParams(entries: Record<string, unknown>): URLSearchParams {
|
|
const params = new URLSearchParams();
|
|
for (const [key, value] of Object.entries(entries)) {
|
|
if (typeof value === "string" && value.length > 0) {
|
|
params.set(key, value);
|
|
}
|
|
}
|
|
return params;
|
|
}
|
|
|
|
function getRefreshCacheKey(provider, refreshToken) {
|
|
const tokenHash = pbkdf2Sync(refreshToken, CACHE_SECRET, 1000, 32, "sha256").toString("hex");
|
|
return `${provider}:${tokenHash}`;
|
|
}
|
|
|
|
/**
|
|
* Refresh OAuth access token using refresh token
|
|
*/
|
|
export async function refreshAccessToken(
|
|
provider,
|
|
refreshToken,
|
|
credentials,
|
|
log,
|
|
proxyConfig: unknown = null
|
|
) {
|
|
const config = PROVIDERS[provider];
|
|
|
|
const refreshEndpoint = config?.refreshUrl || config?.tokenUrl;
|
|
if (!config || !refreshEndpoint) {
|
|
log?.warn?.("TOKEN_REFRESH", `No refresh endpoint configured for provider: ${provider}`);
|
|
return null;
|
|
}
|
|
|
|
if (!refreshToken) {
|
|
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
});
|
|
if (config.clientId) params.set("client_id", config.clientId);
|
|
if (config.clientSecret) params.set("client_secret", config.clientSecret);
|
|
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(refreshEndpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: params,
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for ${provider}`, {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
|
|
error: error.message,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Cline OAuth tokens.
|
|
* Cline refresh endpoint expects JSON body and returns camelCase fields.
|
|
*/
|
|
export async function refreshClineToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
const endpoint = PROVIDERS.cline?.refreshUrl;
|
|
if (!endpoint) {
|
|
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Cline");
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
refreshToken,
|
|
grantType: "refresh_token",
|
|
clientType: "extension",
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Cline token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const payload = await response.json();
|
|
const data = payload?.data || payload;
|
|
const expiresAtIso = data?.expiresAt;
|
|
const expiresIn = expiresAtIso
|
|
? Math.max(1, Math.floor((new Date(expiresAtIso).getTime() - Date.now()) / 1000))
|
|
: undefined;
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Cline token", {
|
|
hasNewAccessToken: !!data?.accessToken,
|
|
hasNewRefreshToken: !!data?.refreshToken,
|
|
expiresIn,
|
|
});
|
|
|
|
return {
|
|
accessToken: data?.accessToken,
|
|
refreshToken: data?.refreshToken || refreshToken,
|
|
expiresIn,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Cline token: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Kimi Coding OAuth tokens.
|
|
* Uses custom X-Msh-* headers required by Kimi OAuth API.
|
|
*/
|
|
export async function refreshKimiCodingToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
const endpoint = PROVIDERS["kimi-coding"]?.refreshUrl || PROVIDERS["kimi-coding"]?.tokenUrl;
|
|
if (!endpoint) {
|
|
log?.warn?.("TOKEN_REFRESH", "No refresh URL configured for Kimi Coding");
|
|
return null;
|
|
}
|
|
|
|
// Generate device info for headers (same as OAuth flow)
|
|
const deviceId = "kimi-refresh-" + Date.now();
|
|
const platform = "omniroute";
|
|
const version = "2.1.2";
|
|
const deviceModel =
|
|
typeof process !== "undefined" ? `${process.platform} ${process.arch}` : "unknown";
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS["kimi-coding"]?.clientId || "",
|
|
});
|
|
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
"X-Msh-Platform": platform,
|
|
"X-Msh-Version": version,
|
|
"X-Msh-Device-Model": deviceModel,
|
|
"X-Msh-Device-Id": deviceId,
|
|
},
|
|
body: params,
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kimi Coding token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kimi Coding token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
tokenType: tokens.token_type,
|
|
scope: tokens.scope,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Kimi Coding token: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Claude OAuth tokens
|
|
*/
|
|
export async function refreshClaudeOAuthToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
try {
|
|
// Standard OAuth2 token refresh uses form-urlencoded (not JSON)
|
|
const params = buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS.claude.clientId,
|
|
});
|
|
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
"anthropic-beta": "oauth-2025-04-20",
|
|
},
|
|
body: params.toString(),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Google providers (Gemini, Antigravity)
|
|
*/
|
|
export async function refreshGoogleToken(
|
|
refreshToken,
|
|
clientId,
|
|
clientSecret,
|
|
log,
|
|
proxyConfig: unknown = null
|
|
) {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(OAUTH_ENDPOINTS.google.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
}
|
|
|
|
export async function refreshQwenToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
const endpoint = OAUTH_ENDPOINTS.qwen.token;
|
|
|
|
try {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS.qwen.clientId,
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (response.status === 200) {
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
providerSpecificData: tokens.resource_url
|
|
? { resourceUrl: tokens.resource_url }
|
|
: undefined,
|
|
};
|
|
} else {
|
|
const errorText = await response.text().catch(() => "");
|
|
|
|
// Detect unrecoverable invalid_request (expired/revoked refresh token or bad client_id)
|
|
let errorCode = null;
|
|
try {
|
|
const parsed = JSON.parse(errorText);
|
|
errorCode = parsed?.error;
|
|
} catch {
|
|
// not JSON, ignore
|
|
}
|
|
|
|
if (errorCode === "invalid_request") {
|
|
log?.error?.(
|
|
"TOKEN_REFRESH",
|
|
"Qwen refresh token is invalid or expired. Re-authentication required.",
|
|
{
|
|
status: response.status,
|
|
}
|
|
);
|
|
return { error: "invalid_request" };
|
|
}
|
|
|
|
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
|
|
error: error.message,
|
|
});
|
|
}
|
|
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Codex (OpenAI) OAuth tokens.
|
|
* OpenAI uses rotating (one-time-use) refresh tokens.
|
|
* Returns { error: 'refresh_token_reused' } when the token has already been consumed,
|
|
* so callers can stop retrying and request re-authentication.
|
|
*/
|
|
export async function refreshCodexToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
try {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(OAUTH_ENDPOINTS.openai.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS.codex.clientId,
|
|
scope: "openid profile email offline_access",
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
|
|
// Detect unrecoverable "refresh_token_reused" error from OpenAI
|
|
// This means the token was already consumed and a new one was issued.
|
|
// Retrying with the same token will never succeed.
|
|
let errorCode = null;
|
|
try {
|
|
const parsed = JSON.parse(errorText);
|
|
errorCode = parsed?.error?.code;
|
|
} catch {
|
|
// not JSON, ignore
|
|
}
|
|
|
|
if (errorCode === "refresh_token_reused") {
|
|
log?.error?.(
|
|
"TOKEN_REFRESH",
|
|
"Codex refresh token already used (rotating token consumed). Re-authentication required.",
|
|
{
|
|
status: response.status,
|
|
}
|
|
);
|
|
return { error: "refresh_token_reused" };
|
|
}
|
|
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Kiro (AWS CodeWhisperer) tokens
|
|
* Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub)
|
|
*/
|
|
export async function refreshKiroToken(
|
|
refreshToken,
|
|
providerSpecificData,
|
|
log,
|
|
proxyConfig: unknown = null
|
|
) {
|
|
try {
|
|
const authMethod = providerSpecificData?.authMethod;
|
|
const clientId = providerSpecificData?.clientId;
|
|
const clientSecret = providerSpecificData?.clientSecret;
|
|
const region = providerSpecificData?.region;
|
|
|
|
// AWS SSO OIDC (Builder ID or IDC)
|
|
// If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified)
|
|
if (clientId && clientSecret) {
|
|
const isIDC = authMethod === "idc";
|
|
const endpoint =
|
|
isIDC && region
|
|
? `https://oidc.${region}.amazonaws.com/token`
|
|
: "https://oidc.us-east-1.amazonaws.com/token";
|
|
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(endpoint, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
clientId: clientId,
|
|
clientSecret: clientSecret,
|
|
refreshToken: refreshToken,
|
|
grantType: "refresh_token",
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", {
|
|
hasNewAccessToken: !!tokens.accessToken,
|
|
expiresIn: tokens.expiresIn,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken || refreshToken,
|
|
expiresIn: tokens.expiresIn,
|
|
};
|
|
}
|
|
|
|
// Social Auth (Google/GitHub) - use Kiro's refresh endpoint
|
|
const tokenUrl = PROVIDERS.kiro.tokenUrl;
|
|
if (!tokenUrl) {
|
|
log?.error?.("TOKEN_REFRESH", "Missing Kiro token endpoint");
|
|
return null;
|
|
}
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(tokenUrl, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
refreshToken: refreshToken,
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", {
|
|
hasNewAccessToken: !!tokens.accessToken,
|
|
expiresIn: tokens.expiresIn,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.accessToken,
|
|
refreshToken: tokens.refreshToken || refreshToken,
|
|
expiresIn: tokens.expiresIn,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", `Network error refreshing Kiro token: ${error.message}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Qoder OAuth tokens
|
|
*/
|
|
export async function refreshQoderToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
if (!OAUTH_ENDPOINTS.qoder.token || !PROVIDERS.qoder.clientId || !PROVIDERS.qoder.clientSecret) {
|
|
log?.warn?.(
|
|
"TOKEN_REFRESH",
|
|
"Qoder OAuth refresh skipped: browser OAuth is not configured in this environment"
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const basicAuth = btoa(`${PROVIDERS.qoder.clientId}:${PROVIDERS.qoder.clientSecret}`);
|
|
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(OAUTH_ENDPOINTS.qoder.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
Authorization: `Basic ${basicAuth}`,
|
|
},
|
|
body: buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS.qoder.clientId,
|
|
client_secret: PROVIDERS.qoder.clientSecret,
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qoder token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qoder token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for GitHub Copilot OAuth tokens
|
|
*/
|
|
export async function refreshGitHubToken(refreshToken, log, proxyConfig: unknown = null) {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(OAUTH_ENDPOINTS.github.token, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
Accept: "application/json",
|
|
},
|
|
body: buildFormParams({
|
|
grant_type: "refresh_token",
|
|
refresh_token: refreshToken,
|
|
client_id: PROVIDERS.github.clientId,
|
|
client_secret: PROVIDERS.github.clientSecret,
|
|
}),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const tokens = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
|
|
hasNewAccessToken: !!tokens.access_token,
|
|
hasNewRefreshToken: !!tokens.refresh_token,
|
|
expiresIn: tokens.expires_in,
|
|
});
|
|
|
|
return {
|
|
accessToken: tokens.access_token,
|
|
refreshToken: tokens.refresh_token || refreshToken,
|
|
expiresIn: tokens.expires_in,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Refresh GitHub Copilot token using GitHub access token
|
|
*/
|
|
export async function refreshCopilotToken(githubAccessToken, log, proxyConfig: unknown = null) {
|
|
try {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch("https://api.github.com/copilot_internal/v2/token", {
|
|
headers: getGitHubCopilotRefreshHeaders(`token ${githubAccessToken}`),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", {
|
|
hasToken: !!data.token,
|
|
expiresAt: data.expires_at,
|
|
});
|
|
|
|
return {
|
|
token: data.token,
|
|
expiresAt: data.expires_at,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
|
|
error: error.message,
|
|
});
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get access token for a specific provider (internal, does the actual work)
|
|
*/
|
|
async function _getAccessTokenInternal(provider, credentials, log, proxyConfig: unknown = null) {
|
|
switch (provider) {
|
|
case "gemini":
|
|
case "gemini-cli":
|
|
case "antigravity":
|
|
return await refreshGoogleToken(
|
|
credentials.refreshToken,
|
|
PROVIDERS[provider].clientId,
|
|
PROVIDERS[provider].clientSecret,
|
|
log,
|
|
proxyConfig
|
|
);
|
|
|
|
case "claude":
|
|
return await refreshClaudeOAuthToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "codex":
|
|
return await refreshCodexToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "qwen":
|
|
return await refreshQwenToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "qoder":
|
|
return await refreshQoderToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "github":
|
|
return await refreshGitHubToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "kiro":
|
|
return await refreshKiroToken(
|
|
credentials.refreshToken,
|
|
credentials.providerSpecificData,
|
|
log,
|
|
proxyConfig
|
|
);
|
|
|
|
case "cline":
|
|
return await refreshClineToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
case "kimi-coding":
|
|
return await refreshKimiCodingToken(credentials.refreshToken, log, proxyConfig);
|
|
|
|
default:
|
|
// Fallback to generic OAuth refresh for unknown providers
|
|
return refreshAccessToken(provider, credentials.refreshToken, credentials, log, proxyConfig);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a provider has a supported refresh path in this service.
|
|
*/
|
|
export function supportsTokenRefresh(provider) {
|
|
const explicitlySupported = new Set([
|
|
"gemini",
|
|
"gemini-cli",
|
|
"antigravity",
|
|
"claude",
|
|
"codex",
|
|
"qwen",
|
|
"qoder",
|
|
"github",
|
|
"kiro",
|
|
"cline",
|
|
"kimi-coding",
|
|
]);
|
|
if (explicitlySupported.has(provider)) return true;
|
|
const config = PROVIDERS[provider];
|
|
return !!(config?.refreshUrl || config?.tokenUrl);
|
|
}
|
|
|
|
/**
|
|
* Check if a refresh result indicates an unrecoverable error
|
|
* (e.g. the refresh token was already consumed and cannot be reused).
|
|
* Callers should stop retrying and request re-authentication.
|
|
*/
|
|
export function isUnrecoverableRefreshError(result) {
|
|
return (
|
|
result &&
|
|
typeof result === "object" &&
|
|
(result.error === "refresh_token_reused" || result.error === "invalid_request")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get access token for a specific provider (with deduplication).
|
|
* If a refresh is already in-flight for the same provider+token,
|
|
* subsequent calls share the existing promise instead of making
|
|
* parallel OAuth requests.
|
|
*/
|
|
export async function getAccessToken(provider, credentials, log, proxyConfig: unknown = null) {
|
|
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
|
|
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
|
|
return null;
|
|
}
|
|
|
|
const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken);
|
|
|
|
// If a refresh is already in-flight, reuse it
|
|
if (refreshPromiseCache.has(cacheKey)) {
|
|
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
|
|
return refreshPromiseCache.get(cacheKey);
|
|
}
|
|
|
|
// Start a new refresh and cache the promise
|
|
const refreshPromise = _getAccessTokenInternal(provider, credentials, log, proxyConfig).finally(
|
|
() => {
|
|
refreshPromiseCache.delete(cacheKey);
|
|
}
|
|
);
|
|
|
|
refreshPromiseCache.set(cacheKey, refreshPromise);
|
|
return refreshPromise;
|
|
}
|
|
|
|
/**
|
|
* Refresh token by provider type (alias for getAccessToken)
|
|
* @deprecated Since v0.2.70 — use getAccessToken() directly.
|
|
* Still exported because open-sse/index.js and src/sse wrapper use it.
|
|
* Will be removed in a future major version.
|
|
*/
|
|
export const refreshTokenByProvider = getAccessToken;
|
|
|
|
/**
|
|
* Format credentials for provider
|
|
*/
|
|
export function formatProviderCredentials(provider, credentials, log) {
|
|
const config = PROVIDERS[provider];
|
|
if (!config) {
|
|
log?.warn?.("TOKEN_REFRESH", `No configuration found for provider: ${provider}`);
|
|
return null;
|
|
}
|
|
|
|
switch (provider) {
|
|
case "gemini":
|
|
return {
|
|
apiKey: credentials.apiKey,
|
|
accessToken: credentials.accessToken,
|
|
projectId: credentials.projectId,
|
|
};
|
|
|
|
case "claude":
|
|
return {
|
|
apiKey: credentials.apiKey,
|
|
accessToken: credentials.accessToken,
|
|
};
|
|
|
|
case "codex":
|
|
case "qwen":
|
|
case "qoder":
|
|
case "openai":
|
|
case "openrouter":
|
|
return {
|
|
apiKey: credentials.apiKey,
|
|
accessToken: credentials.accessToken,
|
|
};
|
|
|
|
case "antigravity":
|
|
case "gemini-cli":
|
|
return {
|
|
accessToken: credentials.accessToken,
|
|
refreshToken: credentials.refreshToken,
|
|
};
|
|
|
|
default:
|
|
return {
|
|
apiKey: credentials.apiKey,
|
|
accessToken: credentials.accessToken,
|
|
refreshToken: credentials.refreshToken,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all access tokens for a user
|
|
*/
|
|
export async function getAllAccessTokens(userInfo, log) {
|
|
const results = {};
|
|
|
|
if (userInfo.connections && Array.isArray(userInfo.connections)) {
|
|
for (const connection of userInfo.connections) {
|
|
if (connection.isActive && connection.provider) {
|
|
const token = await getAccessToken(
|
|
connection.provider,
|
|
{
|
|
refreshToken: connection.refreshToken,
|
|
},
|
|
log
|
|
);
|
|
|
|
if (token) {
|
|
results[connection.provider] = token;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Refresh token with retry and exponential backoff
|
|
* Retries on failure with increasing delay: 1s, 2s, 3s...
|
|
*
|
|
* Includes:
|
|
* - Per-provider circuit breaker (5 consecutive failures → 30min pause)
|
|
* - 30s timeout per refresh attempt to prevent hanging connections
|
|
*
|
|
* @param {function} refreshFn - Async function that returns token or null
|
|
* @param {number} maxRetries - Max retry attempts (default 3)
|
|
* @param {object} log - Logger instance (optional)
|
|
* @param {string} provider - Provider ID for circuit breaker tracking (optional)
|
|
* @returns {Promise<object|null>} Token result or null if all retries fail
|
|
*/
|
|
|
|
// ─── Circuit Breaker State ──────────────────────────────────────────────────
|
|
const _circuitBreaker: Record<string, { failures: number; blockedUntil: number }> = {};
|
|
const CIRCUIT_BREAKER_THRESHOLD = 5; // consecutive failures before tripping
|
|
const CIRCUIT_BREAKER_COOLDOWN = 30 * 60 * 1000; // 30 minutes
|
|
const REFRESH_TIMEOUT_MS = 30_000; // 30s max per refresh attempt
|
|
|
|
interface CircuitBreakerStatusEntry {
|
|
failures: number;
|
|
blocked: boolean;
|
|
blockedUntil: string | null;
|
|
remainingMs: number;
|
|
}
|
|
|
|
interface RefreshLoggerLike {
|
|
error?: (scope: string, message: string) => void;
|
|
warn?: (scope: string, message: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Check if a provider is circuit-breaker blocked.
|
|
*/
|
|
export function isProviderBlocked(provider: string): boolean {
|
|
const state = _circuitBreaker[provider];
|
|
if (!state) return false;
|
|
if (!state.blockedUntil) return false;
|
|
if (state.blockedUntil > Date.now()) return true;
|
|
// Cooldown expired — reset
|
|
delete _circuitBreaker[provider];
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Get circuit breaker status for all providers (for diagnostics).
|
|
*/
|
|
export function getCircuitBreakerStatus(): Record<string, CircuitBreakerStatusEntry> {
|
|
const result: Record<string, CircuitBreakerStatusEntry> = {};
|
|
for (const [provider, state] of Object.entries(_circuitBreaker)) {
|
|
result[provider] = {
|
|
failures: state.failures,
|
|
blocked: state.blockedUntil > Date.now(),
|
|
blockedUntil:
|
|
state.blockedUntil > Date.now() ? new Date(state.blockedUntil).toISOString() : null,
|
|
remainingMs: Math.max(0, state.blockedUntil - Date.now()),
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Record a successful refresh — resets circuit breaker for provider.
|
|
*/
|
|
function recordSuccess(provider: string) {
|
|
if (_circuitBreaker[provider]) {
|
|
delete _circuitBreaker[provider];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record a failed refresh — increments circuit breaker counter.
|
|
*/
|
|
function recordFailure(provider: string, log: RefreshLoggerLike | null = null) {
|
|
if (!_circuitBreaker[provider]) {
|
|
_circuitBreaker[provider] = { failures: 0, blockedUntil: 0 };
|
|
}
|
|
_circuitBreaker[provider].failures++;
|
|
|
|
if (_circuitBreaker[provider].failures >= CIRCUIT_BREAKER_THRESHOLD) {
|
|
_circuitBreaker[provider].blockedUntil = Date.now() + CIRCUIT_BREAKER_COOLDOWN;
|
|
log?.error?.(
|
|
"TOKEN_REFRESH",
|
|
`🔴 Circuit breaker tripped for ${provider}: ${CIRCUIT_BREAKER_THRESHOLD} consecutive failures. ` +
|
|
`Blocked for ${CIRCUIT_BREAKER_COOLDOWN / 60000}min. Provider needs re-authentication.`
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Execute a function with a timeout.
|
|
*/
|
|
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T | null> {
|
|
return await new Promise<T | null>((resolve, reject) => {
|
|
const timer = setTimeout(() => resolve(null), timeoutMs);
|
|
if (typeof timer === "object" && "unref" in timer) {
|
|
(timer as { unref?: () => void }).unref?.();
|
|
}
|
|
|
|
fn().then(
|
|
(result) => {
|
|
clearTimeout(timer);
|
|
resolve(result);
|
|
},
|
|
(error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
}
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function refreshWithRetry(
|
|
refreshFn,
|
|
maxRetries = 3,
|
|
log: RefreshLogger = null,
|
|
provider = "unknown"
|
|
) {
|
|
// Circuit breaker check
|
|
if (isProviderBlocked(provider)) {
|
|
log?.warn?.("TOKEN_REFRESH", `⚡ Circuit breaker active for ${provider}, skipping refresh`);
|
|
return null;
|
|
}
|
|
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
if (attempt > 0) {
|
|
const delay = attempt * 1000;
|
|
log?.debug?.("TOKEN_REFRESH", `Retry ${attempt}/${maxRetries} after ${delay}ms`);
|
|
await new Promise((r) => setTimeout(r, delay));
|
|
}
|
|
|
|
try {
|
|
const result = await withTimeout(refreshFn, REFRESH_TIMEOUT_MS);
|
|
if (result) {
|
|
recordSuccess(provider);
|
|
return result;
|
|
}
|
|
} catch (error) {
|
|
log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// All retries exhausted — record failure for circuit breaker
|
|
recordFailure(provider, log);
|
|
log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed for ${provider}`);
|
|
return null;
|
|
}
|