mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
741 lines
22 KiB
JavaScript
741 lines
22 KiB
JavaScript
import { PROVIDERS } from "../config/providers.js";
|
|
import { OAUTH_ENDPOINTS, GITHUB_COPILOT, REFRESH_LEAD_MS } from "../config/appConstants.js";
|
|
|
|
// Default token expiry buffer (refresh if expires within 5 minutes)
|
|
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
|
|
// 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;
|
|
}
|
|
|
|
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 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 Claude OAuth tokens
|
|
*/
|
|
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Google providers (Gemini, Antigravity)
|
|
*/
|
|
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Qwen OAuth tokens
|
|
*/
|
|
export async function refreshQwenToken(refreshToken, log) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for Codex (OpenAI) OAuth tokens
|
|
*/
|
|
export async function refreshCodexToken(refreshToken, log) {
|
|
try {
|
|
const response = await fetch(OAUTH_ENDPOINTS.openai.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: PROVIDERS.codex.clientId,
|
|
scope: "openid profile email",
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
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) {
|
|
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 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 response = await fetch(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,
|
|
}),
|
|
});
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for iFlow OAuth tokens
|
|
*/
|
|
export async function refreshIflowToken(refreshToken, log) {
|
|
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
|
|
|
|
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,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", {
|
|
status: response.status,
|
|
error: errorText,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Specialized refresh for GitHub Copilot OAuth tokens
|
|
*/
|
|
export async function refreshGitHubToken(refreshToken, log) {
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Refresh GitHub Copilot token using GitHub access token
|
|
*/
|
|
export async function refreshCopilotToken(githubAccessToken, log) {
|
|
try {
|
|
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
|
|
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
|
|
}
|
|
});
|
|
|
|
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
|
|
*/
|
|
export async function getAccessToken(provider, credentials, log) {
|
|
if (!credentials || !credentials.refreshToken) {
|
|
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
|
return null;
|
|
}
|
|
|
|
switch (provider) {
|
|
case "gemini":
|
|
case "gemini-cli":
|
|
case "antigravity":
|
|
return await refreshGoogleToken(
|
|
credentials.refreshToken,
|
|
PROVIDERS[provider].clientId,
|
|
PROVIDERS[provider].clientSecret,
|
|
log
|
|
);
|
|
|
|
case "claude":
|
|
return await refreshClaudeOAuthToken(credentials.refreshToken, log);
|
|
|
|
case "codex":
|
|
return await refreshCodexToken(credentials.refreshToken, log);
|
|
|
|
case "qwen":
|
|
return await refreshQwenToken(credentials.refreshToken, log);
|
|
|
|
case "iflow":
|
|
return await refreshIflowToken(credentials.refreshToken, log);
|
|
|
|
case "github":
|
|
return await refreshGitHubToken(credentials.refreshToken, log);
|
|
|
|
case "kiro":
|
|
return await refreshKiroToken(
|
|
credentials.refreshToken,
|
|
credentials.providerSpecificData,
|
|
log
|
|
);
|
|
|
|
case "vertex":
|
|
case "vertex-partner": {
|
|
const saJson = parseVertexSaJson(credentials.apiKey);
|
|
if (!saJson) return null;
|
|
return await refreshVertexToken(saJson, log);
|
|
}
|
|
|
|
default:
|
|
log?.warn?.("TOKEN_REFRESH", `Unsupported provider for token refresh: ${provider}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refresh token by provider type (helper for handlers)
|
|
*/
|
|
export async function refreshTokenByProvider(provider, credentials, log) {
|
|
if (!credentials.refreshToken) return null;
|
|
|
|
switch (provider) {
|
|
case "gemini-cli":
|
|
case "antigravity":
|
|
return refreshGoogleToken(
|
|
credentials.refreshToken,
|
|
PROVIDERS[provider].clientId,
|
|
PROVIDERS[provider].clientSecret,
|
|
log
|
|
);
|
|
case "claude":
|
|
return refreshClaudeOAuthToken(credentials.refreshToken, log);
|
|
case "codex":
|
|
return refreshCodexToken(credentials.refreshToken, log);
|
|
case "qwen":
|
|
return refreshQwenToken(credentials.refreshToken, log);
|
|
case "iflow":
|
|
return refreshIflowToken(credentials.refreshToken, log);
|
|
case "github":
|
|
return refreshGitHubToken(credentials.refreshToken, log);
|
|
case "kiro":
|
|
return refreshKiroToken(
|
|
credentials.refreshToken,
|
|
credentials.providerSpecificData,
|
|
log
|
|
);
|
|
case "vertex":
|
|
case "vertex-partner": {
|
|
const saJson = parseVertexSaJson(credentials.apiKey);
|
|
if (!saJson) return null;
|
|
return refreshVertexToken(saJson, log);
|
|
}
|
|
default:
|
|
return refreshAccessToken(provider, credentials.refreshToken, credentials, log);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 "iflow":
|
|
case "openai":
|
|
case "openrouter":
|
|
return {
|
|
apiKey: credentials.apiKey,
|
|
accessToken: credentials.accessToken
|
|
};
|
|
|
|
case "antigravity":
|
|
case "gemini-cli":
|
|
return {
|
|
accessToken: credentials.accessToken,
|
|
refreshToken: credentials.refreshToken,
|
|
projectId: credentials.projectId
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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("https://oauth2.googleapis.com/token")
|
|
.setIssuedAt(now)
|
|
.setExpirationTime(now + 3600)
|
|
.sign(privateKey);
|
|
|
|
const res = await fetch("https://oauth2.googleapis.com/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) {
|
|
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 refreshFn();
|
|
if (result) return result;
|
|
} catch (error) {
|
|
log?.warn?.("TOKEN_REFRESH", `Attempt ${attempt + 1}/${maxRetries} failed: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
log?.error?.("TOKEN_REFRESH", `All ${maxRetries} retry attempts failed`);
|
|
return null;
|
|
}
|
|
|