mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
- plugin.mjs: replace execSync string-template with spawnSync arg-array (command injection) - electron/main.js: gate unsafe-eval on !app.isPackaged instead of URL substring; merge duplicate connect-src - budget/bulk, resilience/reset: add requireManagementAuth guard - gemini-web, claude-web, copilot-web executors: route catch-block messages through sanitizeErrorMessage - oauth route, agents/tasks routes: route catch-block errors through sanitizeErrorMessage - codex.ts: return null (not error-object) from refreshCredentials on failure - tokenRefresh.ts: safe unknown-error access in catch block - combo.ts: reset exhaustedProviders per set-retry iteration so providers get a second chance - circuitBreaker.ts: persist and restore lastFailureKind via options JSON column
1316 lines
40 KiB
TypeScript
Executable File
1316 lines
40 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";
|
|
import { WINDSURF_CONFIG } from "@/lib/oauth/constants/oauth";
|
|
|
|
// 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();
|
|
|
|
// Per-connection mutex: prevents parallel OAuth refresh for rotating tokens.
|
|
// Key: connectionId → Value: { promise, waiters }
|
|
// Primary dedup when credentials.connectionId is present; refreshPromiseCache is fallback.
|
|
const connectionRefreshMutex = 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.
|
|
*/
|
|
/**
|
|
* Refresh Windsurf (Devin CLI / Codeium) tokens.
|
|
*
|
|
* Windsurf uses Firebase Secure Token Service (STS) for token refresh.
|
|
* If the token is a long-lived Codeium API key (import flow), it never
|
|
* expires and refresh is a no-op returning the same token.
|
|
* If the token is a Firebase ID token (device-code flow), it expires after
|
|
* ~1 hour and can be refreshed with the stored Firebase refresh token.
|
|
*/
|
|
export async function refreshWindsurfToken(
|
|
refreshToken: string,
|
|
providerSpecificData: Record<string, unknown> | null | undefined,
|
|
log: RefreshLogger,
|
|
proxyConfig: unknown = null
|
|
) {
|
|
if (!refreshToken) {
|
|
log?.warn?.(
|
|
"TOKEN_REFRESH",
|
|
"No refresh token stored for Windsurf — token may be a long-lived API key"
|
|
);
|
|
return null;
|
|
}
|
|
|
|
const authMethod = (providerSpecificData?.authMethod as string) || "import";
|
|
|
|
// Long-lived Codeium API keys (import flow) have no expiry — nothing to refresh.
|
|
if (authMethod === "import") {
|
|
log?.debug?.("TOKEN_REFRESH", "Windsurf import token is long-lived — no refresh needed");
|
|
return null;
|
|
}
|
|
|
|
// Firebase STS refresh for browser-flow tokens.
|
|
// Resolves via WINDSURF_CONFIG.firebaseApiKey, which honors the
|
|
// WINDSURF_FIREBASE_API_KEY env override and falls back to the embedded
|
|
// public default in publicCreds.ts. See docs/security/PUBLIC_CREDS.md.
|
|
const firebaseApiKey = WINDSURF_CONFIG.firebaseApiKey || "";
|
|
if (!firebaseApiKey) {
|
|
log?.warn?.(
|
|
"TOKEN_REFRESH",
|
|
"Windsurf Firebase API key unavailable — skipping Firebase token refresh"
|
|
);
|
|
return null;
|
|
}
|
|
const tokenUrl = `https://securetoken.googleapis.com/v1/token?key=${firebaseApiKey}`;
|
|
|
|
try {
|
|
const response = await runWithProxyContext(proxyConfig, () =>
|
|
fetch(tokenUrl, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: buildFormParams({ grant_type: "refresh_token", refresh_token: refreshToken }),
|
|
})
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Windsurf Firebase token", {
|
|
status: response.status,
|
|
error: errorText.slice(0, 200),
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const data = await response.json();
|
|
const expiresIn = parseInt(data.expires_in ?? "3600", 10);
|
|
|
|
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Windsurf Firebase token", {
|
|
expiresIn,
|
|
hasNewIdToken: !!data.id_token,
|
|
});
|
|
|
|
return {
|
|
accessToken: data.id_token,
|
|
refreshToken: data.refresh_token || refreshToken,
|
|
expiresIn,
|
|
};
|
|
} catch (error) {
|
|
log?.error?.(
|
|
"TOKEN_REFRESH",
|
|
`Network error refreshing Windsurf token: ${error instanceof Error ? error.message : String(error)}`
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
let errorBody: { error?: string; error_description?: string } = {};
|
|
try {
|
|
errorBody = await response.json();
|
|
} catch {
|
|
const text = await response.text().catch(() => "unknown");
|
|
errorBody = { error: text };
|
|
}
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", {
|
|
status: response.status,
|
|
error: errorBody,
|
|
});
|
|
if (errorBody.error === "invalid_grant" || errorBody.error === "invalid_request") {
|
|
return { error: errorBody.error, code: `http_${response.status}` };
|
|
}
|
|
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: 'unrecoverable_refresh_error', code } when the token has already been
|
|
* consumed or is invalid, 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" or "invalid_grant" error from OpenAI
|
|
// This means the token was already consumed or has expired.
|
|
// Retrying with the same token will never succeed.
|
|
let errorCode = null;
|
|
try {
|
|
const parsed = JSON.parse(errorText);
|
|
errorCode =
|
|
parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null);
|
|
} catch {
|
|
// not JSON, ignore
|
|
}
|
|
|
|
if (
|
|
errorCode === "refresh_token_reused" ||
|
|
errorCode === "invalid_grant" ||
|
|
errorCode === "token_expired" ||
|
|
errorCode === "invalid_token"
|
|
) {
|
|
log?.error?.(
|
|
"TOKEN_REFRESH",
|
|
"Codex refresh token already used or invalid. Re-authentication required.",
|
|
{
|
|
status: response.status,
|
|
errorCode,
|
|
}
|
|
);
|
|
return { error: "unrecoverable_refresh_error", code: errorCode };
|
|
}
|
|
|
|
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 endpoint = `https://oidc.${region || "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":
|
|
case "amazon-q":
|
|
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);
|
|
|
|
case "windsurf":
|
|
case "devin-cli":
|
|
return await refreshWindsurfToken(
|
|
credentials.refreshToken,
|
|
credentials.providerSpecificData,
|
|
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",
|
|
"amazon-q",
|
|
"cline",
|
|
"kimi-coding",
|
|
"windsurf",
|
|
"devin-cli",
|
|
]);
|
|
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 === "unrecoverable_refresh_error" ||
|
|
result.error === "refresh_token_reused" ||
|
|
result.error === "invalid_request" ||
|
|
result.error === "invalid_grant")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get access token for a specific provider (with deduplication).
|
|
*
|
|
* Deduplication strategy (two layers):
|
|
* 1. Per-connection mutex (primary): if credentials.connectionId is present, all concurrent
|
|
* callers for that connection share one in-flight promise regardless of which token they
|
|
* loaded. This prevents refresh_token_reused errors with rotating (one-time-use) tokens,
|
|
* e.g. Codex/OpenAI, where callers that loaded credentials at different times may hold
|
|
* different token strings but refer to the same connection.
|
|
* 2. Token-hash fallback: if no connectionId, dedup by provider+sha256(refreshToken) as before.
|
|
*
|
|
* Additionally, when connectionId is present, the stale-token check reads the DB to detect
|
|
* whether another process already refreshed the token. If the DB token is still valid it is
|
|
* returned immediately without a new upstream call.
|
|
*/
|
|
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 connectionId = credentials.connectionId;
|
|
|
|
// ── Layer 1: per-connection mutex ──────────────────────────────────────────
|
|
if (connectionId && typeof connectionId === "string") {
|
|
const existing = connectionRefreshMutex.get(connectionId);
|
|
if (existing) {
|
|
existing.waiters++;
|
|
log?.info?.("TOKEN_REFRESH", "Concurrent refresh detected — sharing in-flight refresh", {
|
|
provider,
|
|
connectionId,
|
|
waiters: existing.waiters,
|
|
});
|
|
return existing.promise;
|
|
}
|
|
|
|
const entry = { promise: null, waiters: 0 };
|
|
entry.promise = _getAccessTokenWithStalenessCheck(
|
|
provider,
|
|
credentials,
|
|
log,
|
|
proxyConfig
|
|
).finally(() => {
|
|
connectionRefreshMutex.delete(connectionId);
|
|
});
|
|
connectionRefreshMutex.set(connectionId, entry);
|
|
return entry.promise;
|
|
}
|
|
|
|
// ── Layer 2: token-hash fallback (no connectionId) ─────────────────────────
|
|
const cacheKey = getRefreshCacheKey(provider, credentials.refreshToken);
|
|
|
|
if (refreshPromiseCache.has(cacheKey)) {
|
|
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
|
|
return refreshPromiseCache.get(cacheKey);
|
|
}
|
|
|
|
const refreshPromise = _getAccessTokenInternal(provider, credentials, log, proxyConfig).finally(
|
|
() => {
|
|
refreshPromiseCache.delete(cacheKey);
|
|
}
|
|
);
|
|
|
|
refreshPromiseCache.set(cacheKey, refreshPromise);
|
|
return refreshPromise;
|
|
}
|
|
|
|
/**
|
|
* Internal helper: performs the DB staleness check then calls the actual refresh.
|
|
* Only called from the per-connection mutex path (Layer 1 above).
|
|
*/
|
|
async function _getAccessTokenWithStalenessCheck(provider, credentials, log, proxyConfig) {
|
|
// RACE CONDITION PREVENTION:
|
|
// If the credentials object in memory is stale (e.g. it waited in a semaphore while another
|
|
// request refreshed the token), using its OLD refreshToken will cause the provider (e.g. OpenAI)
|
|
// to reject it with 'refresh_token_reused' and revoke the new token family.
|
|
// We MUST check if the DB has a newer token before proceeding with a network refresh.
|
|
if (credentials.connectionId) {
|
|
try {
|
|
const { getProviderConnectionById } = await import("../../src/lib/db/providers");
|
|
const dbConnection = await getProviderConnectionById(credentials.connectionId);
|
|
if (
|
|
dbConnection &&
|
|
dbConnection.refreshToken &&
|
|
dbConnection.refreshToken !== credentials.refreshToken
|
|
) {
|
|
log?.info?.(
|
|
"TOKEN_REFRESH",
|
|
`Stale token detected in memory for ${provider}. Using refreshed token from DB.`
|
|
);
|
|
|
|
// If the DB token is not expired, we can just return it!
|
|
const now = Date.now();
|
|
const dbExpiresAt = dbConnection.expiresAt ? new Date(dbConnection.expiresAt).getTime() : 0;
|
|
|
|
if (dbExpiresAt > now + 60000) {
|
|
// 60 seconds buffer
|
|
log?.info?.("TOKEN_REFRESH", `DB token is still valid. Skipping OAuth refresh.`);
|
|
return {
|
|
accessToken: dbConnection.accessToken,
|
|
refreshToken: dbConnection.refreshToken,
|
|
expiresIn: dbConnection.expiresIn,
|
|
};
|
|
} else {
|
|
// DB token is also expired, but it's the NEWEST one. We must use it to refresh.
|
|
credentials.refreshToken = dbConnection.refreshToken;
|
|
credentials.accessToken = dbConnection.accessToken;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
log?.warn?.(
|
|
"TOKEN_REFRESH",
|
|
`Failed to check DB for stale token: ${e instanceof Error ? e.message : String(e)}`
|
|
);
|
|
}
|
|
}
|
|
|
|
return _getAccessTokenInternal(provider, credentials, log, proxyConfig);
|
|
}
|
|
|
|
/**
|
|
* 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 active per-connection mutex entries (for diagnostics/metrics).
|
|
* Returns a snapshot of connections that have an in-flight refresh and their waiter count.
|
|
*/
|
|
export function getConnectionRefreshMutexStatus(): Record<string, { waiters: number }> {
|
|
const result: Record<string, { waiters: number }> = {};
|
|
for (const [connectionId, entry] of connectionRefreshMutex.entries()) {
|
|
result[connectionId] = { waiters: entry.waiters };
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 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 (isUnrecoverableRefreshError(result)) {
|
|
log?.warn?.(
|
|
"TOKEN_REFRESH",
|
|
`Unrecoverable refresh error for ${provider}: ${result.error} — skipping retries`
|
|
);
|
|
return result;
|
|
}
|
|
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;
|
|
}
|