mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
refactor(app): DRY pass — split large files, extract shared utils
S1: delete page.new.js (1724L abandoned) + remove dead getAntigravityProjectId
S2: split large files by natural seams
- usage.js → usage/{github,google,claude,codex,kiro,minimax,misc,shared}.js
- media-providers page → components/{Embedding,Tts,Generic,Stt}ExampleCard.js
- EndpointPageClient → endpointConstants.js + endpointPing.js + components/
- tokenRefresh.js → tokenRefresh/{dedup,providers}.js
- ProviderLimits/index.js: 16 pure fn + 9 constants → utils.js
- oauth/providers.js: 7 pure helpers → providerHelpers.js
S3: shared utils
- getModelKind(m, fallback) → shared/constants/models.js (replaces 20× m.kind||m.type)
- getStatusVariant → shared/utils/connectionStatus.js (dedup ConnectionRow/ConnectionsCard)
- sseChunk → open-sse/utils/sse.js (dedup grok-web/perplexity-web)
- fetchWithTimeout → usage/shared.js (replace 4× AbortController pattern in google.js)
fix: enableObservability2 field name in requestDetailsRepo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,73 +1,35 @@
|
||||
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS, GITHUB_COPILOT, REFRESH_LEAD_MS } from "../config/appConstants.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS, REFRESH_LEAD_MS } from "../config/appConstants.js";
|
||||
import {
|
||||
refreshXaiToken,
|
||||
refreshAccessToken,
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshQwenToken,
|
||||
refreshCodexToken,
|
||||
refreshKiroToken,
|
||||
refreshIflowToken,
|
||||
refreshGitHubToken,
|
||||
refreshCopilotToken,
|
||||
classifyOAuthRefreshError,
|
||||
} from "./tokenRefresh/providers.js";
|
||||
|
||||
// xAI refresh — wraps the class method from src/lib/oauth/services/xai.js so
|
||||
// the token-refresh switches below can stay flat (one function per provider).
|
||||
let _xaiServiceSingleton = null;
|
||||
async function refreshXaiToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("xai", refreshToken, async () => {
|
||||
try {
|
||||
if (!_xaiServiceSingleton) {
|
||||
const mod = await import("../../src/lib/oauth/services/xai.js");
|
||||
_xaiServiceSingleton = new mod.XaiService();
|
||||
}
|
||||
const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken);
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
idToken: tokens.id_token,
|
||||
};
|
||||
} catch (e) {
|
||||
log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`);
|
||||
const msg = String(e?.message || "");
|
||||
if (msg.includes("invalid_grant") || msg.includes("invalid_request")) {
|
||||
return { error: "invalid_grant" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
// Re-export all provider refresh functions (preserves public API for all consumers)
|
||||
export {
|
||||
refreshAccessToken,
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshQwenToken,
|
||||
refreshCodexToken,
|
||||
refreshKiroToken,
|
||||
refreshIflowToken,
|
||||
refreshGitHubToken,
|
||||
refreshCopilotToken,
|
||||
classifyOAuthRefreshError,
|
||||
};
|
||||
|
||||
// Default token expiry buffer (refresh if expires within 5 minutes)
|
||||
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
// Dedup: cache in-flight promise + recent result to prevent refresh_token_reused (Auth0 family revoke)
|
||||
const REFRESH_RESULT_TTL_MS = 10_000;
|
||||
const refreshDedupCache = new Map();
|
||||
|
||||
async function dedupRefresh(provider, oldToken, fn, log) {
|
||||
if (!oldToken) return fn();
|
||||
const key = `${provider}:${oldToken}`;
|
||||
const hit = refreshDedupCache.get(key);
|
||||
if (hit) {
|
||||
if (hit.promise) {
|
||||
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
|
||||
return hit.promise;
|
||||
}
|
||||
if (hit.expiresAt > Date.now()) {
|
||||
log?.info?.("TOKEN_REFRESH", `Reusing recent refresh result for ${provider}`);
|
||||
return hit.result;
|
||||
}
|
||||
refreshDedupCache.delete(key);
|
||||
}
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await fn();
|
||||
refreshDedupCache.set(key, { result, expiresAt: Date.now() + REFRESH_RESULT_TTL_MS });
|
||||
return result;
|
||||
} catch (err) {
|
||||
refreshDedupCache.delete(key);
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
refreshDedupCache.set(key, { promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Check if refresh result indicates unrecoverable error (caller should stop retry, force re-auth)
|
||||
export function isUnrecoverableRefreshError(result) {
|
||||
return (
|
||||
result &&
|
||||
@@ -79,543 +41,82 @@ export function isUnrecoverableRefreshError(result) {
|
||||
);
|
||||
}
|
||||
|
||||
// Get provider-specific refresh lead time, falls back to default buffer
|
||||
export function getRefreshLeadMs(provider) {
|
||||
return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OAuth access token using refresh token
|
||||
*/
|
||||
export async function refreshAccessToken(provider, refreshToken, credentials, log) {
|
||||
const config = PROVIDERS[provider];
|
||||
|
||||
if (!config || !config.refreshUrl) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return dedupRefresh(provider, refreshToken, async () => {
|
||||
export function parseVertexSaJson(apiKey) {
|
||||
if (typeof apiKey !== "string") return null;
|
||||
try {
|
||||
const response = await fetch(config.refreshUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
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 parsed = JSON.parse(apiKey);
|
||||
if (parsed.type === "service_account" && parsed.client_email && parsed.private_key && parsed.project_id) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Claude OAuth tokens
|
||||
*/
|
||||
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("claude", refreshToken, async () => {
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.claude.clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
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, 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;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Google providers (Gemini, Antigravity)
|
||||
*/
|
||||
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh(`google:${clientId}`, refreshToken, async () => {
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
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, 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 Google token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Qwen OAuth tokens
|
||||
*/
|
||||
export async function refreshQwenToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("qwen", refreshToken, async () => {
|
||||
const endpoint = OAUTH_ENDPOINTS.qwen.token;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
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(() => "");
|
||||
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;
|
||||
}, log);
|
||||
}
|
||||
|
||||
export function classifyOAuthRefreshError(errorText = "", status = 0) {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = errorText ? JSON.parse(errorText) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
const code = parsed?.error?.code || parsed?.error || parsed?.error_code || "";
|
||||
const description = parsed?.error_description || parsed?.message || errorText || "";
|
||||
const combined = `${code} ${description}`.toLowerCase();
|
||||
const permanent = [
|
||||
"refresh_token_expired",
|
||||
"refresh_token_reused",
|
||||
"refresh_token_invalidated",
|
||||
"invalid_grant",
|
||||
].some((marker) => combined.includes(marker));
|
||||
|
||||
return { status, code, description, permanent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Codex (OpenAI) OAuth tokens.
|
||||
* OpenAI uses rotating (one-time-use) refresh tokens.
|
||||
* Returns { error: 'unrecoverable_refresh_error' } when token already consumed/invalid,
|
||||
* so callers stop retrying and request re-authentication.
|
||||
*/
|
||||
export async function refreshCodexToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("codex", refreshToken, async () => {
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: PROVIDERS.codex.clientId,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
const failure = classifyOAuthRefreshError(errorText, response.status);
|
||||
if (failure.permanent) {
|
||||
log?.error?.("TOKEN_REFRESH", "Codex refresh token already used or invalid. Re-auth required.", {
|
||||
status: response.status,
|
||||
code: failure.code,
|
||||
});
|
||||
return { error: "unrecoverable_refresh_error", code: failure.code };
|
||||
}
|
||||
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
code: failure.code,
|
||||
permanent: failure.permanent,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
hasIdToken: !!tokens.id_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
idToken: tokens.id_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Kiro (AWS CodeWhisperer) tokens
|
||||
* Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub)
|
||||
*/
|
||||
// Backfill missing Kiro profileArn on refresh so existing IDC connections self-heal
|
||||
async function resolveKiroProfileArnPatch(providerSpecificData, accessToken, refreshedArn) {
|
||||
if (providerSpecificData?.profileArn) return {};
|
||||
let profileArn = refreshedArn?.trim?.() || null;
|
||||
if (!profileArn) {
|
||||
const { fetchKiroProfileArn } = await import("../../src/lib/oauth/providers.js");
|
||||
profileArn = await fetchKiroProfileArn(accessToken);
|
||||
}
|
||||
return profileArn ? { providerSpecificData: { profileArn } } : {};
|
||||
}
|
||||
|
||||
export async function refreshKiroToken(refreshToken, providerSpecificData, log, proxyOptions = null) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("kiro", refreshToken, async () => {
|
||||
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 proxyAwareFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
refreshToken: refreshToken,
|
||||
grantType: "refresh_token",
|
||||
}),
|
||||
}, proxyOptions);
|
||||
|
||||
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,
|
||||
...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)),
|
||||
};
|
||||
}
|
||||
|
||||
// Social Auth (Google/GitHub) - use Kiro's refresh endpoint
|
||||
const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "kiro-cli/1.0.0",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refreshToken: refreshToken,
|
||||
}),
|
||||
}, proxyOptions);
|
||||
|
||||
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,
|
||||
...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)),
|
||||
};
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for iFlow OAuth tokens
|
||||
*/
|
||||
export async function refreshIflowToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("iflow", refreshToken, async () => {
|
||||
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
|
||||
// Cache Vertex tokens keyed by service account email { token, expiresAt }
|
||||
const vertexTokenCache = new Map();
|
||||
|
||||
const response = await fetch(OAUTH_ENDPOINTS.iflow.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
Authorization: `Basic ${basicAuth}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.iflow.clientId,
|
||||
client_secret: PROVIDERS.iflow.clientSecret,
|
||||
}),
|
||||
});
|
||||
export async function refreshVertexToken(saJson, log) {
|
||||
const cacheKey = saJson.client_email;
|
||||
const cached = vertexTokenCache.get(cacheKey);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) {
|
||||
return { accessToken: cached.token, expiresAt: cached.expiresAt };
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow 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,
|
||||
};
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for GitHub Copilot OAuth tokens
|
||||
*/
|
||||
export async function refreshGitHubToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("github", refreshToken, async () => {
|
||||
const params = {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.github.clientId,
|
||||
};
|
||||
if (PROVIDERS.github.clientSecret) {
|
||||
params.client_secret = PROVIDERS.github.clientSecret;
|
||||
}
|
||||
|
||||
const response = await fetch(OAUTH_ENDPOINTS.github.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams(params),
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh GitHub Copilot token using GitHub access token
|
||||
*/
|
||||
export async function refreshCopilotToken(githubAccessToken, log) {
|
||||
if (!githubAccessToken) return null;
|
||||
return dedupRefresh("copilot", githubAccessToken, async () => {
|
||||
try {
|
||||
const response = await fetch(PROVIDER_OAUTH["github"]?.copilotTokenUrl, {
|
||||
headers: {
|
||||
"Authorization": `token ${githubAccessToken}`,
|
||||
"User-Agent": GITHUB_COPILOT.USER_AGENT,
|
||||
"Editor-Version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`,
|
||||
"Editor-Plugin-Version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`,
|
||||
"Accept": "application/json",
|
||||
"x-github-api-version": GITHUB_COPILOT.API_VERSION
|
||||
}
|
||||
const { SignJWT, importPKCS8 } = await import("jose");
|
||||
log?.debug?.("TOKEN_REFRESH", `Vertex minting token for ${saJson.client_email}`);
|
||||
const privateKey = await importPKCS8(saJson.private_key.replace(/\\n/g, "\n"), "RS256");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/cloud-platform" })
|
||||
.setProtectedHeader({ alg: "RS256" })
|
||||
.setIssuer(saJson.client_email)
|
||||
.setAudience(OAUTH_ENDPOINTS.google.token)
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 3600)
|
||||
.sign(privateKey);
|
||||
|
||||
const res = await fetch(OAUTH_ENDPOINTS.google.token, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion: jwt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
|
||||
status: response.status,
|
||||
error: errorText
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token mint failed: ${err}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const { access_token, expires_in } = await res.json();
|
||||
const expiresAt = Date.now() + (expires_in ?? 3600) * 1000;
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", {
|
||||
hasToken: !!data.token,
|
||||
expiresAt: data.expires_at
|
||||
});
|
||||
vertexTokenCache.set(cacheKey, { token: access_token, expiresAt });
|
||||
log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`);
|
||||
|
||||
return {
|
||||
token: data.token,
|
||||
expiresAt: data.expires_at
|
||||
};
|
||||
return { accessToken: access_token, expiresAt };
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
|
||||
error: error.message
|
||||
});
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
// Single source of per-provider refresh dispatch (logic stays in each refreshXxx fn).
|
||||
// Each handler: (credentials, log) => Promise<tokens|null>
|
||||
function vertexRefreshHandler(c, log) {
|
||||
const saJson = parseVertexSaJson(c.apiKey);
|
||||
if (!saJson) return null;
|
||||
return refreshVertexToken(saJson, log);
|
||||
}
|
||||
|
||||
const REFRESH_HANDLERS = {
|
||||
"gemini-cli": (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS["gemini-cli"].clientId, PROVIDERS["gemini-cli"].clientSecret, log),
|
||||
antigravity: (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS.antigravity.clientId, PROVIDERS.antigravity.clientSecret, log),
|
||||
@@ -630,28 +131,15 @@ const REFRESH_HANDLERS = {
|
||||
"vertex-partner": vertexRefreshHandler
|
||||
};
|
||||
|
||||
function vertexRefreshHandler(c, log) {
|
||||
const saJson = parseVertexSaJson(c.apiKey);
|
||||
if (!saJson) return null;
|
||||
return refreshVertexToken(saJson, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token for a specific provider (with in-flight dedup).
|
||||
* If a refresh is already in-flight for same provider+token, share the promise
|
||||
* to prevent parallel OAuth requests → Auth0 'refresh_token_reused' family revoke.
|
||||
*/
|
||||
export async function getAccessToken(provider, credentials, log) {
|
||||
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
|
||||
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
// Dedup is handled inside each refreshXxxToken function
|
||||
return _getAccessTokenInternal(provider, credentials, log);
|
||||
}
|
||||
|
||||
async function _getAccessTokenInternal(provider, credentials, log) {
|
||||
// "gemini" shares Google refresh here (unlike refreshTokenByProvider)
|
||||
if (provider === "gemini") {
|
||||
return refreshGoogleToken(credentials.refreshToken, PROVIDERS.gemini.clientId, PROVIDERS.gemini.clientSecret, log);
|
||||
}
|
||||
@@ -663,19 +151,12 @@ async function _getAccessTokenInternal(provider, credentials, log) {
|
||||
return handler(credentials, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token by provider type (helper for handlers)
|
||||
*/
|
||||
export async function refreshTokenByProvider(provider, credentials, log) {
|
||||
if (!credentials.refreshToken) return null;
|
||||
const handler = REFRESH_HANDLERS[provider];
|
||||
// default: generic refresh (note: "gemini" is NOT special-cased here, by design)
|
||||
return handler ? handler(credentials, log) : refreshAccessToken(provider, credentials.refreshToken, credentials, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format credentials for provider
|
||||
*/
|
||||
export function formatProviderCredentials(provider, credentials, log) {
|
||||
const config = PROVIDERS[provider];
|
||||
if (!config) {
|
||||
@@ -725,9 +206,6 @@ export function formatProviderCredentials(provider, credentials, log) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all access tokens for a user
|
||||
*/
|
||||
export async function getAllAccessTokens(userInfo, log) {
|
||||
const results = {};
|
||||
|
||||
@@ -748,89 +226,6 @@ export async function getAllAccessTokens(userInfo, log) {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Vertex AI Service Account JSON from apiKey string
|
||||
*/
|
||||
export function parseVertexSaJson(apiKey) {
|
||||
if (typeof apiKey !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(apiKey);
|
||||
if (parsed.type === "service_account" && parsed.client_email && parsed.private_key && parsed.project_id) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache Vertex tokens keyed by service account email { token, expiresAt }
|
||||
const vertexTokenCache = new Map();
|
||||
|
||||
/**
|
||||
* Mint a short-lived OAuth2 Bearer token for Google Cloud Vertex AI
|
||||
* using Service Account JSON + jose (RS256 JWT assertion flow).
|
||||
* Token is cached until 5 minutes before expiry.
|
||||
*/
|
||||
export async function refreshVertexToken(saJson, log) {
|
||||
const cacheKey = saJson.client_email;
|
||||
const cached = vertexTokenCache.get(cacheKey);
|
||||
|
||||
// Return cached token if still valid (5-min buffer)
|
||||
if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) {
|
||||
return { accessToken: cached.token, expiresAt: cached.expiresAt };
|
||||
}
|
||||
|
||||
try {
|
||||
const { SignJWT, importPKCS8 } = await import("jose");
|
||||
log?.debug?.("TOKEN_REFRESH", `Vertex minting token for ${saJson.client_email}`);
|
||||
const privateKey = await importPKCS8(saJson.private_key.replace(/\\n/g, "\n"), "RS256");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/cloud-platform" })
|
||||
.setProtectedHeader({ alg: "RS256" })
|
||||
.setIssuer(saJson.client_email)
|
||||
.setAudience(OAUTH_ENDPOINTS.google.token)
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 3600)
|
||||
.sign(privateKey);
|
||||
|
||||
const res = await fetch(OAUTH_ENDPOINTS.google.token, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion: jwt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token mint failed: ${err}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { access_token, expires_in } = await res.json();
|
||||
const expiresAt = Date.now() + (expires_in ?? 3600) * 1000;
|
||||
|
||||
vertexTokenCache.set(cacheKey, { token: access_token, expiresAt });
|
||||
log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`);
|
||||
|
||||
return { accessToken: access_token, expiresAt };
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token with retry and exponential backoff
|
||||
* Retries on failure with increasing delay: 1s, 2s, 3s...
|
||||
* @param {function} refreshFn - Async function that returns token or null
|
||||
* @param {number} maxRetries - Max retry attempts (default 3)
|
||||
* @param {object} log - Logger instance (optional)
|
||||
* @returns {Promise<object|null>} Token result or null if all retries fail
|
||||
*/
|
||||
export async function refreshWithRetry(refreshFn, maxRetries = 3, log = null) {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
|
||||
Reference in New Issue
Block a user