diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index adb942b1..4ae2e8c4 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -1,4 +1,5 @@ import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry, FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js"; +import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js"; import { proxyAwareFetch } from "../utils/proxyFetch.js"; import { dbg } from "../utils/debugLog.js"; @@ -87,9 +88,7 @@ export class BaseExecutor { } needsRefresh(credentials) { - if (!credentials.expiresAt) return false; - const expiresAtMs = new Date(credentials.expiresAt).getTime(); - return expiresAtMs - Date.now() < 5 * 60 * 1000; + return shouldRefreshCredentials(this.provider, credentials); } parseError(response, bodyText) { diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index eaac4bf6..fa818ae7 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -2,6 +2,10 @@ import { createHash } from "crypto"; import { BaseExecutor } from "./base.js"; import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js"; import { PROVIDERS } from "../config/providers.js"; +import { + refreshProviderCredentials, + shouldRefreshCredentials, +} from "../services/oauthCredentialManager.js"; import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js"; import { fetchImageAsBase64 } from "../translator/helpers/imageHelper.js"; import { getModelUpstreamId } from "../config/providerModels.js"; @@ -212,6 +216,15 @@ export class CodexExecutor extends BaseExecutor { return this._isCompact ? `${base}/compact` : base; } + async refreshCredentials(credentials, log) { + if (!credentials?.refreshToken) return null; + return refreshProviderCredentials("codex", credentials, log); + } + + needsRefresh(credentials) { + return shouldRefreshCredentials("codex", credentials); + } + /** * Prefetch remote image URLs and inline them as base64 data URIs. * Runs before execute() because Codex backend cannot fetch remote images. diff --git a/open-sse/index.js b/open-sse/index.js index dde009c5..4b2ac3a9 100644 --- a/open-sse/index.js +++ b/open-sse/index.js @@ -59,6 +59,14 @@ export { refreshTokenByProvider } from "./services/tokenRefresh.js"; +export { + CODEX_MAX_REFRESH_AGE_MS, + shouldRefreshCredentials, + refreshProviderCredentials, + mergeRefreshedCredentials, + mergeProviderSpecificData, +} from "./services/oauthCredentialManager.js"; + // Handlers export { handleChatCore, isTokenExpiringSoon } from "./handlers/chatCore.js"; export { createStreamController, pipeWithDisconnect, createDisconnectAwareStream } from "./utils/streamHandler.js"; diff --git a/open-sse/services/oauthCredentialManager.js b/open-sse/services/oauthCredentialManager.js new file mode 100644 index 00000000..466fcda9 --- /dev/null +++ b/open-sse/services/oauthCredentialManager.js @@ -0,0 +1,151 @@ +import { + getRefreshLeadMs, + isUnrecoverableRefreshError, + refreshTokenByProvider, +} from "./tokenRefresh.js"; + +export const CODEX_MAX_REFRESH_AGE_MS = 8 * 24 * 60 * 60 * 1000; + +const refreshLocks = new Map(); + +function parseTimeMs(value) { + if (value === undefined || value === null || value === "") return null; + if (typeof value === "number") { + return value < 1e12 ? value * 1000 : value; + } + + const parsed = new Date(value).getTime(); + return Number.isFinite(parsed) ? parsed : null; +} + +function toExpiresAt(expiresIn, nowMs = Date.now()) { + if (!expiresIn) return null; + return new Date(nowMs + expiresIn * 1000).toISOString(); +} + +export function getCredentialExpiryMs(credentials) { + return parseTimeMs(credentials?.expiresAt ?? credentials?.tokenExpiresAt); +} + +export function getCredentialLastRefreshMs(credentials) { + return parseTimeMs( + credentials?.lastRefreshAt ?? + credentials?.lastRefresh ?? + credentials?.providerSpecificData?.lastRefreshAt + ); +} + +export function isCodexRefreshStale(credentials, nowMs = Date.now()) { + const lastRefreshMs = getCredentialLastRefreshMs(credentials); + return !lastRefreshMs || nowMs - lastRefreshMs >= CODEX_MAX_REFRESH_AGE_MS; +} + +export function shouldRefreshCredentials(provider, credentials, nowMs = Date.now()) { + if (!credentials) return false; + + const expiresAtMs = getCredentialExpiryMs(credentials); + if (expiresAtMs !== null && expiresAtMs - nowMs < getRefreshLeadMs(provider)) { + return true; + } + + if (provider === "codex" && credentials.refreshToken && isCodexRefreshStale(credentials, nowMs)) { + return true; + } + + return false; +} + +export function mergeProviderSpecificData(existing, next) { + if (!next || typeof next !== "object") return existing; + return { + ...(existing || {}), + ...next, + }; +} + +export function mergeRefreshedCredentials(provider, currentCredentials, refreshedCredentials, nowMs = Date.now()) { + if (!refreshedCredentials) return null; + if (isUnrecoverableRefreshError(refreshedCredentials)) return refreshedCredentials; + + const next = {}; + const nowIso = new Date(nowMs).toISOString(); + + if (refreshedCredentials.accessToken) next.accessToken = refreshedCredentials.accessToken; + if (refreshedCredentials.apiKey) next.apiKey = refreshedCredentials.apiKey; + if (refreshedCredentials.token) next.token = refreshedCredentials.token; + + const refreshToken = refreshedCredentials.refreshToken ?? currentCredentials?.refreshToken; + if (refreshToken) next.refreshToken = refreshToken; + + const idToken = refreshedCredentials.idToken ?? currentCredentials?.idToken; + if (idToken) next.idToken = idToken; + + if (refreshedCredentials.expiresIn) { + next.expiresIn = refreshedCredentials.expiresIn; + next.expiresAt = toExpiresAt(refreshedCredentials.expiresIn, nowMs); + } else if (refreshedCredentials.expiresAt) { + next.expiresAt = refreshedCredentials.expiresAt; + } + + if (refreshedCredentials.projectId) next.projectId = refreshedCredentials.projectId; + + if (refreshedCredentials.providerSpecificData) { + next.providerSpecificData = mergeProviderSpecificData( + currentCredentials?.providerSpecificData, + refreshedCredentials.providerSpecificData + ); + } + + if (refreshedCredentials.copilotToken) next.copilotToken = refreshedCredentials.copilotToken; + if (refreshedCredentials.copilotTokenExpiresAt) { + next.copilotTokenExpiresAt = refreshedCredentials.copilotTokenExpiresAt; + } + + if ( + provider === "codex" || + next.accessToken || + next.apiKey || + next.token || + next.refreshToken || + next.copilotToken + ) { + next.lastRefreshAt = refreshedCredentials.lastRefreshAt || nowIso; + } + + return next; +} + +function getRefreshLockKey(provider, credentials) { + const stableId = + credentials?.connectionId || + credentials?.id || + credentials?.email || + credentials?.name || + credentials?.refreshToken?.slice?.(-16) || + "default"; + return `${provider}:${stableId}`; +} + +export async function withCredentialRefreshLock(provider, credentials, refreshFn) { + const key = getRefreshLockKey(provider, credentials); + const existing = refreshLocks.get(key); + if (existing) return existing; + + const pending = Promise.resolve() + .then(refreshFn) + .finally(() => { + refreshLocks.delete(key); + }); + + refreshLocks.set(key, pending); + return pending; +} + +export async function refreshProviderCredentials(provider, credentials, log) { + if (!credentials) return null; + + return withCredentialRefreshLock(provider, credentials, async () => { + const refreshed = await refreshTokenByProvider(provider, credentials, log); + return mergeRefreshedCredentials(provider, credentials, refreshed); + }); +} diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 63b0fdf9..0dce4121 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -277,6 +277,27 @@ export async function refreshQwenToken(refreshToken, log) { }, 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. @@ -286,68 +307,59 @@ export async function refreshQwenToken(refreshToken, log) { 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/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 offline_access", - }), - }); - - if (!response.ok) { - const errorText = await response.text(); - - // Detect unrecoverable errors (token reused/expired) — Auth0 revokes whole family on retry - let errorCode = null; try { - const parsed = JSON.parse(errorText); - errorCode = parsed?.error?.code || (typeof parsed?.error === "string" ? parsed.error : null); - } catch {} - - 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-auth required.", { - status: response.status, - errorCode, + 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, + }), }); - return { error: "unrecoverable_refresh_error", code: errorCode }; + + 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?.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; - } }, log); } diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index 6d657ecd..bbc7b020 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -5,10 +5,13 @@ import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/sha import { PROVIDER_ENDPOINTS } from "@/shared/constants/config"; import { getDefaultModel } from "open-sse/config/providerModels.js"; import { resolveOllamaLocalHost } from "open-sse/config/providers.js"; +import { + refreshProviderCredentials, + shouldRefreshCredentials, +} from "open-sse/services/oauthCredentialManager.js"; import { GEMINI_CONFIG, ANTIGRAVITY_CONFIG, - CODEX_CONFIG, KIRO_CONFIG, QWEN_CONFIG, CLAUDE_CONFIG, @@ -126,18 +129,7 @@ async function refreshOAuthToken(connection) { } if (provider === "codex") { - const response = await fetch(CODEX_CONFIG.tokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - client_id: CODEX_CONFIG.clientId, - refresh_token: refreshToken, - }), - }); - if (!response.ok) return null; - const data = await response.json(); - return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken }; + return await refreshProviderCredentials(provider, connection, console); } if (provider === "claude") { @@ -227,10 +219,7 @@ async function refreshOAuthToken(connection) { } function isTokenExpired(connection) { - if (!connection.expiresAt) return false; - const expiresAt = new Date(connection.expiresAt).getTime(); - const buffer = 5 * 60 * 1000; - return expiresAt <= Date.now() + buffer; + return shouldRefreshCredentials(connection.provider, connection); } async function testOAuthConnection(connection, effectiveProxy = null) { @@ -673,14 +662,25 @@ export async function testSingleConnection(id) { }; if (result.refreshed && result.newTokens) { - updateData.accessToken = result.newTokens.accessToken; + if (result.newTokens.accessToken) updateData.accessToken = result.newTokens.accessToken; if (result.newTokens.refreshToken) updateData.refreshToken = result.newTokens.refreshToken; + if (result.newTokens.idToken) updateData.idToken = result.newTokens.idToken; + if (result.newTokens.lastRefreshAt) updateData.lastRefreshAt = result.newTokens.lastRefreshAt; + if (result.newTokens.expiresIn) updateData.expiresIn = result.newTokens.expiresIn; if (result.newTokens.expiresIn) { updateData.expiresAt = new Date(Date.now() + result.newTokens.expiresIn * 1000).toISOString(); + } else if (result.newTokens.expiresAt) { + updateData.expiresAt = result.newTokens.expiresAt; + } + if (result.newTokens.providerSpecificData) { + updateData.providerSpecificData = { + ...(connection.providerSpecificData || {}), + ...result.newTokens.providerSpecificData, + }; } } await updateProviderConnection(id, updateData); - return { valid: result.valid, error: result.error, latencyMs, testedAt: new Date().toISOString() }; + return { valid: result.valid, error: result.error, refreshed: !!result.refreshed, latencyMs, testedAt: new Date().toISOString() }; } diff --git a/src/app/api/translator/send/route.js b/src/app/api/translator/send/route.js index d725610a..0e752ef0 100644 --- a/src/app/api/translator/send/route.js +++ b/src/app/api/translator/send/route.js @@ -1,5 +1,36 @@ -import { getProviderConnections } from "@/lib/localDb.js"; -import { getExecutor, refreshTokenByProvider } from "open-sse/index.js"; +import { getProviderConnections, updateProviderConnection } from "@/lib/localDb.js"; +import { getExecutor } from "open-sse/index.js"; + +async function persistRefreshedCredentials(connection, newCredentials) { + const updateData = {}; + + if (newCredentials.accessToken) updateData.accessToken = newCredentials.accessToken; + if (newCredentials.refreshToken) updateData.refreshToken = newCredentials.refreshToken; + if (newCredentials.idToken) updateData.idToken = newCredentials.idToken; + if (newCredentials.lastRefreshAt) updateData.lastRefreshAt = newCredentials.lastRefreshAt; + if (newCredentials.expiresIn) { + updateData.expiresIn = newCredentials.expiresIn; + updateData.expiresAt = new Date(Date.now() + newCredentials.expiresIn * 1000).toISOString(); + } else if (newCredentials.expiresAt) { + updateData.expiresAt = newCredentials.expiresAt; + } + + const providerSpecificUpdates = { + ...(newCredentials.providerSpecificData || {}), + ...(newCredentials.copilotToken ? { copilotToken: newCredentials.copilotToken } : {}), + ...(newCredentials.copilotTokenExpiresAt ? { copilotTokenExpiresAt: newCredentials.copilotTokenExpiresAt } : {}), + }; + if (Object.keys(providerSpecificUpdates).length > 0) { + updateData.providerSpecificData = { + ...(connection.providerSpecificData || {}), + ...providerSpecificUpdates, + }; + } + + if (Object.keys(updateData).length > 0) { + await updateProviderConnection(connection.id, updateData); + } +} export async function POST(request) { try { @@ -19,7 +50,11 @@ export async function POST(request) { apiKey: connection.apiKey, accessToken: connection.accessToken, refreshToken: connection.refreshToken, - copilotToken: connection.copilotToken, + idToken: connection.idToken, + lastRefreshAt: connection.lastRefreshAt, + connectionId: connection.id, + copilotToken: connection.providerSpecificData?.copilotToken, + copilotTokenExpiresAt: connection.providerSpecificData?.copilotTokenExpiresAt, projectId: connection.projectId, providerSpecificData: connection.providerSpecificData }; @@ -31,9 +66,10 @@ export async function POST(request) { // Auto-refresh token on 401/403 and retry (same as chatCore.js) if (response.status === 401 || response.status === 403) { - const newCredentials = await refreshTokenByProvider(provider, credentials); + const newCredentials = await executor.refreshCredentials(credentials, console); if (newCredentials?.accessToken || newCredentials?.copilotToken) { Object.assign(credentials, newCredentials); + await persistRefreshedCredentials(connection, newCredentials); ({ response } = await executor.execute({ model, body, stream, credentials })); } } diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index 238aaa05..99953dea 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -27,7 +27,10 @@ async function refreshAndUpdateCredentials(connection, force = false, proxyOptio const credentials = { accessToken: connection.accessToken, refreshToken: connection.refreshToken, + idToken: connection.idToken, expiresAt: connection.expiresAt || connection.tokenExpiresAt, + lastRefreshAt: connection.lastRefreshAt, + connectionId: connection.id, providerSpecificData: connection.providerSpecificData, // For GitHub copilotToken: connection.providerSpecificData?.copilotToken, @@ -68,19 +71,32 @@ async function refreshAndUpdateCredentials(connection, force = false, proxyOptio updateData.refreshToken = refreshResult.refreshToken; } + if (refreshResult.idToken) { + updateData.idToken = refreshResult.idToken; + } + + if (refreshResult.lastRefreshAt) { + updateData.lastRefreshAt = refreshResult.lastRefreshAt; + } + // Update token expiry if (refreshResult.expiresIn) { updateData.expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString(); + updateData.expiresIn = refreshResult.expiresIn; } else if (refreshResult.expiresAt) { updateData.expiresAt = refreshResult.expiresAt; } // Handle provider-specific data (copilotToken for GitHub, etc.) - if (refreshResult.copilotToken || refreshResult.copilotTokenExpiresAt) { + const providerSpecificUpdates = { + ...(refreshResult.providerSpecificData || {}), + ...(refreshResult.copilotToken ? { copilotToken: refreshResult.copilotToken } : {}), + ...(refreshResult.copilotTokenExpiresAt ? { copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt } : {}), + }; + if (Object.keys(providerSpecificUpdates).length > 0) { updateData.providerSpecificData = { - ...connection.providerSpecificData, - copilotToken: refreshResult.copilotToken, - copilotTokenExpiresAt: refreshResult.copilotTokenExpiresAt, + ...(connection.providerSpecificData || {}), + ...providerSpecificUpdates, }; } @@ -91,6 +107,7 @@ async function refreshAndUpdateCredentials(connection, force = false, proxyOptio const updatedConnection = { ...connection, ...updateData, + providerSpecificData: updateData.providerSpecificData || connection.providerSpecificData, }; return { diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 3866e862..3d23274b 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -225,9 +225,12 @@ const PROVIDERS = { const mapped = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, + idToken: tokens.id_token, expiresIn: tokens.expires_in, + lastRefreshAt: new Date().toISOString(), }; - if (info.email) mapped.email = info.email; + const email = info.email || extractEmailFromAccessToken(tokens.access_token); + if (email) mapped.email = email; if (info.chatgptAccountId || info.chatgptPlanType) { mapped.providerSpecificData = { chatgptAccountId: info.chatgptAccountId, diff --git a/src/lib/oauth/services/codex.js b/src/lib/oauth/services/codex.js index fdc0310e..72417aa6 100644 --- a/src/lib/oauth/services/codex.js +++ b/src/lib/oauth/services/codex.js @@ -54,6 +54,7 @@ export class CodexService extends OAuthService { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresIn: tokens.expires_in, + lastRefreshAt: new Date().toISOString(), }), }); @@ -141,4 +142,3 @@ export class CodexService extends OAuthService { } } } - diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 0d7e4a9f..9c4b1598 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -218,9 +218,8 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re sourceFormatOverride: request?.url ? detectFormatByEndpoint(new URL(request.url).pathname, body) : null, onCredentialsRefreshed: async (newCreds) => { await updateProviderCredentials(credentials.connectionId, { - accessToken: newCreds.accessToken, - refreshToken: newCreds.refreshToken, - providerSpecificData: newCreds.providerSpecificData, + ...newCreds, + existingProviderSpecificData: credentials.providerSpecificData, testStatus: "active" }); }, diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js index 49380948..cde0d41e 100644 --- a/src/sse/handlers/embeddings.js +++ b/src/sse/handlers/embeddings.js @@ -114,9 +114,8 @@ export async function handleEmbeddings(request) { log, onCredentialsRefreshed: async (newCreds) => { await updateProviderCredentials(credentials.connectionId, { - accessToken: newCreds.accessToken, - refreshToken: newCreds.refreshToken, - providerSpecificData: newCreds.providerSpecificData, + ...newCreds, + existingProviderSpecificData: credentials.providerSpecificData, testStatus: "active" }); }, diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index ba3146c3..241082d4 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -163,6 +163,10 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu apiKey: connection.apiKey, accessToken: connection.accessToken, refreshToken: connection.refreshToken, + idToken: connection.idToken, + expiresAt: connection.expiresAt, + expiresIn: connection.expiresIn, + lastRefreshAt: connection.lastRefreshAt, projectId: connection.projectId, connectionName: connection.displayName || connection.name || connection.email || connection.id, copilotToken: connection.providerSpecificData?.copilotToken, diff --git a/src/sse/services/tokenRefresh.js b/src/sse/services/tokenRefresh.js index 83bd7d7c..05914e84 100644 --- a/src/sse/services/tokenRefresh.js +++ b/src/sse/services/tokenRefresh.js @@ -23,6 +23,10 @@ import { refreshKiroToken as _refreshKiroToken, getRefreshLeadMs as _getRefreshLeadMs } from "open-sse/services/tokenRefresh.js"; +import { + refreshProviderCredentials as _refreshProviderCredentials, + shouldRefreshCredentials as _shouldRefreshCredentials, +} from "open-sse/services/oauthCredentialManager.js"; export const TOKEN_EXPIRY_BUFFER_MS = BUFFER_MS; @@ -67,6 +71,9 @@ export const formatProviderCredentials = (provider, credentials) => export const getAllAccessTokens = (userInfo) => _getAllAccessTokens(userInfo, log); +export const shouldRefreshCredentials = (provider, credentials) => + _shouldRefreshCredentials(provider, credentials); + // ─── Lifecycle hook ─────────────────────────────────────────────────────────── /** @@ -158,6 +165,9 @@ export async function updateProviderCredentials(connectionId, newCredentials) { if (newCredentials.accessToken) updates.accessToken = newCredentials.accessToken; if (newCredentials.refreshToken) updates.refreshToken = newCredentials.refreshToken; + if (newCredentials.idToken) updates.idToken = newCredentials.idToken; + if (newCredentials.lastRefreshAt) updates.lastRefreshAt = newCredentials.lastRefreshAt; + if (newCredentials.expiresAt) updates.expiresAt = newCredentials.expiresAt; if (newCredentials.expiresIn) { updates.expiresAt = toExpiresAt(newCredentials.expiresIn); updates.expiresIn = newCredentials.expiresIn; @@ -174,6 +184,13 @@ export async function updateProviderCredentials(connectionId, newCredentials) { ...newCredentials.providerSpecificData, }; } + if (newCredentials.copilotToken || newCredentials.copilotTokenExpiresAt) { + updates.providerSpecificData = { + ...(updates.providerSpecificData || newCredentials.existingProviderSpecificData || {}), + ...(newCredentials.copilotToken ? { copilotToken: newCredentials.copilotToken } : {}), + ...(newCredentials.copilotTokenExpiresAt ? { copilotTokenExpiresAt: newCredentials.copilotTokenExpiresAt } : {}), + }; + } if (newCredentials.projectId) updates.projectId = newCredentials.projectId; const result = await updateProviderConnection(connectionId, updates); @@ -205,44 +222,41 @@ export async function checkAndRefreshToken(provider, credentials) { let creds = { ...credentials }; // ── 1. Regular access-token expiry ──────────────────────────────────────── - if (creds.expiresAt) { - const expiresAt = new Date(creds.expiresAt).getTime(); - const now = Date.now(); - const remaining = expiresAt - now; - + if (_shouldRefreshCredentials(provider, creds)) { + const expiresAt = creds.expiresAt ? new Date(creds.expiresAt).getTime() : null; + const remaining = expiresAt ? expiresAt - Date.now() : null; const refreshLead = _getRefreshLeadMs(provider); - if (remaining < refreshLead) { - log.info("TOKEN_REFRESH", "Token expiring soon, refreshing proactively", { - provider, - expiresIn: Math.round(remaining / 1000), - refreshLeadMs: refreshLead, - }); - const newCreds = await getAccessToken(provider, creds); - if (newCreds?.accessToken) { - const mergedCreds = { - ...newCreds, - existingProviderSpecificData: creds.providerSpecificData, - }; + log.info("TOKEN_REFRESH", "Refreshing provider credentials proactively", { + provider, + expiresIn: remaining === null ? null : Math.round(remaining / 1000), + refreshLeadMs: refreshLead, + lastRefreshAt: creds.lastRefreshAt || null, + }); - // Persist to DB (non-blocking path continues below) - await updateProviderCredentials(creds.connectionId, mergedCreds); + const newCreds = await _refreshProviderCredentials(provider, creds, log); + if (newCreds?.accessToken || newCreds?.apiKey || newCreds?.copilotToken) { + const mergedCreds = { + ...newCreds, + existingProviderSpecificData: creds.providerSpecificData, + }; - 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) - : normalizeExpiresAt(newCreds.expiresAt) || creds.expiresAt, - }; + // Persist to DB (non-blocking path continues below) + await updateProviderCredentials(creds.connectionId, mergedCreds); - // Non-blocking: refresh projectId with the new access token - _refreshProjectId(provider, creds.connectionId, creds.accessToken); - } + creds = { + ...creds, + ...newCreds, + expiresAt: newCreds.expiresIn + ? toExpiresAt(newCreds.expiresIn) + : normalizeExpiresAt(newCreds.expiresAt) || newCreds.expiresAt || creds.expiresAt, + providerSpecificData: newCreds.providerSpecificData + ? { ...creds.providerSpecificData, ...newCreds.providerSpecificData } + : creds.providerSpecificData, + }; + + // Non-blocking: refresh projectId with the new access token + _refreshProjectId(provider, creds.connectionId, creds.accessToken); } } diff --git a/tests/unit/codex-refresh-token.test.js b/tests/unit/codex-refresh-token.test.js index 838c5fbf..3b702af8 100644 --- a/tests/unit/codex-refresh-token.test.js +++ b/tests/unit/codex-refresh-token.test.js @@ -14,21 +14,30 @@ const originalFetch = global.fetch; describe("Codex Refresh Token", () => { beforeEach(() => { vi.clearAllMocks(); + vi.resetModules(); + global.fetch = originalFetch; }); afterEach(() => { global.fetch = originalFetch; }); + function mockFetchWithJson(payload) { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(payload), + }); + global.fetch = fetchMock; + return fetchMock; + } + describe("refreshCodexToken", () => { it("should return new refresh_token when server provides one (token rotation)", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ + const fetchMock = mockFetchWithJson({ access_token: "new-access", refresh_token: "rotated-refresh-token", + id_token: "new-id-token", expires_in: 3600, - }), }); const { refreshCodexToken } = await import("../../open-sse/services/tokenRefresh.js"); @@ -36,21 +45,101 @@ describe("Codex Refresh Token", () => { expect(result.refreshToken).toBe("rotated-refresh-token"); expect(result.accessToken).toBe("new-access"); + expect(result.idToken).toBe("new-id-token"); + expect(fetchMock).toHaveBeenCalledWith( + "https://auth.openai.com/oauth/token", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + Accept: "application/json", + }), + body: JSON.stringify({ + client_id: "app_EMoamEEZ73f0CkXaXp7hrann", + grant_type: "refresh_token", + refresh_token: "old-refresh-token", + }), + }) + ); }); it("should keep old refresh_token when server does not return new one", async () => { - global.fetch = vi.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve({ + mockFetchWithJson({ access_token: "new-access", expires_in: 3600, - }), }); const { refreshCodexToken } = await import("../../open-sse/services/tokenRefresh.js"); - const result = await refreshCodexToken("old-refresh-token", null); + const result = await refreshCodexToken("old-refresh-token-without-rotation", null); - expect(result.refreshToken).toBe("old-refresh-token"); + expect(result.refreshToken).toBe("old-refresh-token-without-rotation"); + }); + }); + + describe("CodexExecutor credential lifecycle", () => { + it("should refresh Codex credentials and preserve omitted id_token", async () => { + mockFetchWithJson({ + access_token: "new-access", + refresh_token: "rotated-refresh-token", + expires_in: 3600, + }); + + const { CodexExecutor } = await import("../../open-sse/executors/codex.js"); + const executor = new CodexExecutor(); + const result = await executor.refreshCredentials({ + connectionId: "codex-1", + refreshToken: "old-refresh-token", + idToken: "old-id-token", + }, null); + + expect(result.accessToken).toBe("new-access"); + expect(result.refreshToken).toBe("rotated-refresh-token"); + expect(result.idToken).toBe("old-id-token"); + expect(result.lastRefreshAt).toBeTruthy(); + expect(result.expiresAt).toBeTruthy(); + }); + + it("should refresh Codex when lastRefreshAt is older than the upstream stale window", async () => { + const { CodexExecutor } = await import("../../open-sse/executors/codex.js"); + const executor = new CodexExecutor(); + const farFuture = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); + const staleRefresh = new Date(Date.now() - 9 * 24 * 60 * 60 * 1000).toISOString(); + const recentRefresh = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(); + + expect(executor.needsRefresh({ + refreshToken: "refresh-token", + expiresAt: farFuture, + lastRefreshAt: staleRefresh, + })).toBe(true); + + expect(executor.needsRefresh({ + refreshToken: "refresh-token", + expiresAt: farFuture, + lastRefreshAt: recentRefresh, + })).toBe(false); + }); + + it("should de-duplicate concurrent refreshes for the same Codex connection", async () => { + const fetchMock = mockFetchWithJson({ + access_token: "new-access", + refresh_token: "rotated-refresh-token", + expires_in: 3600, + }); + + const { refreshProviderCredentials } = await import("../../open-sse/services/oauthCredentialManager.js"); + const credentials = { + connectionId: "codex-single-flight", + refreshToken: "old-refresh-token", + }; + + const [first, second] = await Promise.all([ + refreshProviderCredentials("codex", credentials, null), + refreshProviderCredentials("codex", credentials, null), + ]); + + expect(first.accessToken).toBe("new-access"); + expect(second.accessToken).toBe("new-access"); + expect(fetchMock).toHaveBeenCalledTimes(1); }); });