9router/src/sse/services/tokenRefresh.js

285 lines
10 KiB
JavaScript
Raw Normal View History

2026-01-04 21:58:59 -05:00
// Re-export from open-sse with local logger
import * as log from "../utils/logger.js";
import { updateProviderConnection } from "../../lib/localDb.js";
import {
getProjectIdForConnection,
invalidateProjectId,
removeConnection,
} from "open-sse/services/projectId.js";
2026-01-04 21:58:59 -05:00
import {
TOKEN_EXPIRY_BUFFER_MS as BUFFER_MS,
refreshAccessToken as _refreshAccessToken,
refreshClaudeOAuthToken as _refreshClaudeOAuthToken,
refreshGoogleToken as _refreshGoogleToken,
refreshQwenToken as _refreshQwenToken,
refreshCodexToken as _refreshCodexToken,
refreshIflowToken as _refreshIflowToken,
refreshGitHubToken as _refreshGitHubToken,
refreshCopilotToken as _refreshCopilotToken,
getAccessToken as _getAccessToken,
refreshTokenByProvider as _refreshTokenByProvider,
formatProviderCredentials as _formatProviderCredentials,
getAllAccessTokens as _getAllAccessTokens
} from "open-sse/services/tokenRefresh.js";
export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS;
// ─── Re-exports wrapped with local logger ─────────────────────────────────────
export const refreshAccessToken = (provider, refreshToken, credentials) =>
2026-01-04 21:58:59 -05:00
_refreshAccessToken(provider, refreshToken, credentials, log);
export const refreshClaudeOAuthToken = (refreshToken) =>
2026-01-04 21:58:59 -05:00
_refreshClaudeOAuthToken(refreshToken, log);
export const refreshGoogleToken = (refreshToken, clientId, clientSecret) =>
2026-01-04 21:58:59 -05:00
_refreshGoogleToken(refreshToken, clientId, clientSecret, log);
export const refreshQwenToken = (refreshToken) =>
2026-01-04 21:58:59 -05:00
_refreshQwenToken(refreshToken, log);
export const refreshCodexToken = (refreshToken) =>
2026-01-04 21:58:59 -05:00
_refreshCodexToken(refreshToken, log);
export const refreshIflowToken = (refreshToken) =>
2026-01-04 21:58:59 -05:00
_refreshIflowToken(refreshToken, log);
export const refreshGitHubToken = (refreshToken) =>
2026-01-04 21:58:59 -05:00
_refreshGitHubToken(refreshToken, log);
export const refreshCopilotToken = (githubAccessToken) =>
2026-01-04 21:58:59 -05:00
_refreshCopilotToken(githubAccessToken, log);
export const getAccessToken = (provider, credentials) =>
2026-01-04 21:58:59 -05:00
_getAccessToken(provider, credentials, log);
export const refreshTokenByProvider = (provider, credentials) =>
2026-01-04 21:58:59 -05:00
_refreshTokenByProvider(provider, credentials, log);
export const formatProviderCredentials = (provider, credentials) =>
2026-01-04 21:58:59 -05:00
_formatProviderCredentials(provider, credentials, log);
export const getAllAccessTokens = (userInfo) =>
2026-01-04 21:58:59 -05:00
_getAllAccessTokens(userInfo, log);
// ─── Lifecycle hook ───────────────────────────────────────────────────────────
/**
* Call this when a connection is fully closed / removed.
* Aborts any in-flight projectId fetch and evicts its cache entry,
* preventing the module-level Maps from accumulating stale entries.
*
* @param {string} connectionId
*/
export function releaseConnection(connectionId) {
if (!connectionId) return;
removeConnection(connectionId);
log.debug("TOKEN_REFRESH", "Released connection resources", { connectionId });
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
/**
* Compute an ISO expiry timestamp from a relative expiresIn (seconds).
* @param {number} expiresIn
* @returns {string}
*/
function toExpiresAt(expiresIn) {
return new Date(Date.now() + expiresIn * 1000).toISOString();
}
/**
* Providers that carry a real Google project ID.
* @param {string} provider
* @returns {boolean}
*/
function needsProjectId(provider) {
return provider === "antigravity" || provider === "gemini-cli";
}
/**
* Non-blocking: fetch the project ID for a connection after a token refresh and
* persist it to localDb. Invalidates the stale cached value first so the fetch
* always retrieves a fresh one.
*
* @param {string} provider
* @param {string} connectionId
* @param {string} accessToken
*/
function _refreshProjectId(provider, connectionId, accessToken) {
if (!needsProjectId(provider) || !connectionId || !accessToken) return;
// Evict the stale cached entry so getProjectIdForConnection does a real fetch
invalidateProjectId(connectionId);
getProjectIdForConnection(connectionId, accessToken)
.then((projectId) => {
if (!projectId) return;
updateProviderCredentials(connectionId, { projectId }).catch((err) => {
log.debug("TOKEN_REFRESH", "Failed to persist refreshed projectId", {
connectionId,
error: err?.message ?? err,
});
});
})
.catch((err) => {
log.debug("TOKEN_REFRESH", "Failed to fetch projectId after token refresh", {
connectionId,
error: err?.message ?? err,
});
});
}
// ─── Local-specific: persist credentials to localDb ──────────────────────────
/**
* Persist updated credentials for a connection to localDb.
* Only fields that are present in `newCredentials` are written.
*
* @param {string} connectionId
* @param {object} newCredentials
* @returns {Promise<boolean>}
*/
2026-01-04 21:58:59 -05:00
export async function updateProviderCredentials(connectionId, newCredentials) {
try {
const updates = {};
if (newCredentials.accessToken) updates.accessToken = newCredentials.accessToken;
if (newCredentials.refreshToken) updates.refreshToken = newCredentials.refreshToken;
2026-01-04 21:58:59 -05:00
if (newCredentials.expiresIn) {
updates.expiresAt = toExpiresAt(newCredentials.expiresIn);
2026-01-04 21:58:59 -05:00
updates.expiresIn = newCredentials.expiresIn;
}
if (newCredentials.providerSpecificData) {
updates.providerSpecificData = {
...(newCredentials.existingProviderSpecificData || {}),
...newCredentials.providerSpecificData,
};
}
if (newCredentials.projectId) updates.projectId = newCredentials.projectId;
2026-01-04 21:58:59 -05:00
const result = await updateProviderConnection(connectionId, updates);
log.info("TOKEN_REFRESH", "Credentials updated in localDb", {
connectionId,
success: !!result
2026-01-04 21:58:59 -05:00
});
return !!result;
} catch (error) {
log.error("TOKEN_REFRESH", "Error updating credentials in localDb", {
connectionId,
error: error.message,
});
return false;
}
}
// ─── Local-specific: proactive token refresh ─────────────────────────────────
/**
* Check whether the provider token (and, for GitHub, the Copilot token) is
* about to expire and refresh it proactively.
*
* @param {string} provider
* @param {object} credentials
* @returns {Promise<object>} updated credentials object
*/
2026-01-04 21:58:59 -05:00
export async function checkAndRefreshToken(provider, credentials) {
let creds = { ...credentials };
2026-01-04 21:58:59 -05:00
// ── 1. Regular access-token expiry ────────────────────────────────────────
if (creds.expiresAt) {
const expiresAt = new Date(creds.expiresAt).getTime();
const now = Date.now();
const remaining = expiresAt - now;
2026-01-04 21:58:59 -05:00
if (remaining < TOKEN_EXPIRY_BUFFER_MS) {
2026-01-04 21:58:59 -05:00
log.info("TOKEN_REFRESH", "Token expiring soon, refreshing proactively", {
provider,
expiresIn: Math.round(remaining / 1000),
2026-01-04 21:58:59 -05:00
});
const newCreds = await getAccessToken(provider, creds);
if (newCreds?.accessToken) {
const mergedCreds = {
...newCreds,
existingProviderSpecificData: creds.providerSpecificData,
};
// Persist to DB (non-blocking path continues below)
await updateProviderCredentials(creds.connectionId, mergedCreds);
creds = {
...creds,
accessToken: newCreds.accessToken,
refreshToken: newCreds.refreshToken ?? creds.refreshToken,
providerSpecificData: newCreds.providerSpecificData
? { ...creds.providerSpecificData, ...newCreds.providerSpecificData }
: creds.providerSpecificData,
expiresAt: newCreds.expiresIn
? toExpiresAt(newCreds.expiresIn)
: creds.expiresAt,
2026-01-04 21:58:59 -05:00
};
// Non-blocking: refresh projectId with the new access token
_refreshProjectId(provider, creds.connectionId, creds.accessToken);
2026-01-04 21:58:59 -05:00
}
}
}
// ── 2. GitHub Copilot token expiry ────────────────────────────────────────
if (provider === "github" && creds.providerSpecificData?.copilotTokenExpiresAt) {
const copilotExpiresAt = creds.providerSpecificData.copilotTokenExpiresAt * 1000;
const now = Date.now();
const remaining = copilotExpiresAt - now;
2026-01-04 21:58:59 -05:00
if (remaining < TOKEN_EXPIRY_BUFFER_MS) {
2026-01-04 21:58:59 -05:00
log.info("TOKEN_REFRESH", "Copilot token expiring soon, refreshing proactively", {
provider,
expiresIn: Math.round(remaining / 1000),
2026-01-04 21:58:59 -05:00
});
const copilotToken = await refreshCopilotToken(creds.accessToken);
2026-01-04 21:58:59 -05:00
if (copilotToken) {
const updatedSpecific = {
...creds.providerSpecificData,
copilotToken: copilotToken.token,
copilotTokenExpiresAt: copilotToken.expiresAt,
2026-01-04 21:58:59 -05:00
};
await updateProviderCredentials(creds.connectionId, {
providerSpecificData: updatedSpecific,
});
creds.providerSpecificData = updatedSpecific;
2026-01-04 21:58:59 -05:00
}
}
}
return creds;
2026-01-04 21:58:59 -05:00
}
// ─── Local-specific: combined GitHub + Copilot refresh ───────────────────────
/**
* Refresh the GitHub OAuth token and immediately exchange it for a fresh
* Copilot token.
*
* @param {object} credentials – must contain `refreshToken`
* @returns {Promise<object|null>} merged credentials or the raw GitHub credentials on Copilot failure
*/
2026-01-04 21:58:59 -05:00
export async function refreshGitHubAndCopilotTokens(credentials) {
const newGitHubCreds = await refreshGitHubToken(credentials.refreshToken);
if (!newGitHubCreds?.accessToken) return newGitHubCreds;
const copilotToken = await refreshCopilotToken(newGitHubCreds.accessToken);
if (!copilotToken) return newGitHubCreds;
return {
...newGitHubCreds,
providerSpecificData: {
copilotToken: copilotToken.token,
copilotTokenExpiresAt: copilotToken.expiresAt,
},
};
2026-01-04 21:58:59 -05:00
}