diff --git a/src/agents/auth-profiles/effective-oauth.ts b/src/agents/auth-profiles/effective-oauth.ts index 3522dee878c..3b3958e23c3 100644 --- a/src/agents/auth-profiles/effective-oauth.ts +++ b/src/agents/auth-profiles/effective-oauth.ts @@ -1,51 +1,18 @@ -import { log } from "./constants.js"; -import { - hasUsableOAuthCredential, - isSafeToUseExternalCliCredential, - readExternalCliBootstrapCredential, - shouldBootstrapFromExternalCliCredential, -} from "./external-cli-sync.js"; +import { readExternalCliBootstrapCredential } from "./external-cli-sync.js"; +import { resolveEffectiveOAuthCredential as resolveManagedOAuthCredential } from "./oauth-manager.js"; import type { OAuthCredential } from "./types.js"; export function resolveEffectiveOAuthCredential(params: { profileId: string; credential: OAuthCredential; }): OAuthCredential { - const imported = readExternalCliBootstrapCredential({ + return resolveManagedOAuthCredential({ profileId: params.profileId, credential: params.credential, + readBootstrapCredential: ({ profileId, credential }) => + readExternalCliBootstrapCredential({ + profileId, + credential, + }), }); - if (!imported) { - return params.credential; - } - if (hasUsableOAuthCredential(params.credential)) { - log.debug("resolved oauth credential from canonical local store", { - profileId: params.profileId, - provider: params.credential.provider, - localExpires: params.credential.expires, - externalExpires: imported.expires, - }); - return params.credential; - } - if (!isSafeToUseExternalCliCredential(params.credential, imported)) { - log.warn("refused external cli oauth bootstrap: identity mismatch", { - profileId: params.profileId, - provider: params.credential.provider, - }); - return params.credential; - } - const shouldBootstrap = shouldBootstrapFromExternalCliCredential({ - existing: params.credential, - imported, - }); - if (shouldBootstrap) { - log.debug("resolved oauth credential from external cli bootstrap", { - profileId: params.profileId, - provider: imported.provider, - localExpires: params.credential.expires, - externalExpires: imported.expires, - }); - return imported; - } - return params.credential; } diff --git a/src/agents/auth-profiles/oauth-manager.ts b/src/agents/auth-profiles/oauth-manager.ts new file mode 100644 index 00000000000..f55977b6a70 --- /dev/null +++ b/src/agents/auth-profiles/oauth-manager.ts @@ -0,0 +1,575 @@ +import { formatErrorMessage } from "../../infra/errors.js"; +import { withFileLock } from "../../infra/file-lock.js"; +import { + AUTH_STORE_LOCK_OPTIONS, + OAUTH_REFRESH_CALL_TIMEOUT_MS, + OAUTH_REFRESH_LOCK_OPTIONS, + log, +} from "./constants.js"; +import { resolveTokenExpiryState } from "./credential-state.js"; +import { ensureAuthStoreFile, resolveAuthStorePath, resolveOAuthRefreshLockPath } from "./paths.js"; +import { + ensureAuthProfileStore, + loadAuthProfileStoreForSecretsRuntime, + saveAuthProfileStore, + updateAuthProfileStoreWithLock, +} from "./store.js"; +import type { AuthProfileStore, OAuthCredential, OAuthCredentials } from "./types.js"; + +export type OAuthManagerAdapter = { + buildApiKey: (provider: string, credentials: OAuthCredential) => Promise; + refreshCredential: (credential: OAuthCredential) => Promise; + readBootstrapCredential: (params: { + profileId: string; + credential: OAuthCredential; + }) => OAuthCredential | null; + isRefreshTokenReusedError: (error: unknown) => boolean; + isSafeToCopyOAuthIdentity: ( + existing: Pick, + incoming: Pick, + ) => boolean; +}; + +export type ResolvedOAuthAccess = { + apiKey: string; + credential: OAuthCredential; +}; + +export class OAuthManagerRefreshError extends Error { + readonly credential: OAuthCredential; + readonly profileId: string; + readonly provider: string; + readonly refreshedStore: AuthProfileStore; + + constructor(params: { + credential: OAuthCredential; + profileId: string; + refreshedStore: AuthProfileStore; + cause: unknown; + }) { + super( + `OAuth token refresh failed for ${params.credential.provider}: ${formatErrorMessage(params.cause)}`, + { cause: params.cause }, + ); + this.name = "OAuthManagerRefreshError"; + this.credential = params.credential; + this.profileId = params.profileId; + this.provider = params.credential.provider; + this.refreshedStore = params.refreshedStore; + } +} + +function areOAuthCredentialsEquivalent( + a: OAuthCredential | undefined, + b: OAuthCredential, +): boolean { + if (!a || a.type !== "oauth") { + return false; + } + return ( + a.provider === b.provider && + a.access === b.access && + a.refresh === b.refresh && + a.expires === b.expires && + a.email === b.email && + a.enterpriseUrl === b.enterpriseUrl && + a.projectId === b.projectId && + a.accountId === b.accountId + ); +} + +function hasNewerStoredOAuthCredential( + existing: OAuthCredential | undefined, + incoming: OAuthCredential, +): boolean { + return Boolean( + existing && + existing.provider === incoming.provider && + Number.isFinite(existing.expires) && + (!Number.isFinite(incoming.expires) || existing.expires > incoming.expires), + ); +} + +function shouldReplaceStoredOAuthCredential( + existing: OAuthCredential | undefined, + incoming: OAuthCredential, +): boolean { + if (!existing || existing.type !== "oauth") { + return true; + } + if (areOAuthCredentialsEquivalent(existing, incoming)) { + return false; + } + return !hasNewerStoredOAuthCredential(existing, incoming); +} + +function hasUsableOAuthCredential( + credential: OAuthCredential | undefined, + now = Date.now(), +): boolean { + if (!credential || credential.type !== "oauth") { + return false; + } + if (typeof credential.access !== "string" || credential.access.trim().length === 0) { + return false; + } + return resolveTokenExpiryState(credential.expires, now) === "valid"; +} + +function shouldBootstrapFromExternalCliCredential(params: { + existing: OAuthCredential | undefined; + imported: OAuthCredential; + now?: number; +}): boolean { + const now = params.now ?? Date.now(); + if (hasUsableOAuthCredential(params.existing, now)) { + return false; + } + return hasUsableOAuthCredential(params.imported, now); +} + +function hasOAuthCredentialChanged( + previous: Pick, + current: Pick, +): boolean { + return ( + previous.access !== current.access || + previous.refresh !== current.refresh || + previous.expires !== current.expires + ); +} + +async function loadFreshStoredOAuthCredential(params: { + profileId: string; + agentDir?: string; + provider: string; + previous?: Pick; + requireChange?: boolean; +}): Promise { + const reloadedStore = loadAuthProfileStoreForSecretsRuntime(params.agentDir); + const reloaded = reloadedStore.profiles[params.profileId]; + if ( + reloaded?.type !== "oauth" || + reloaded.provider !== params.provider || + !hasUsableOAuthCredential(reloaded) + ) { + return null; + } + if ( + params.requireChange && + params.previous && + !hasOAuthCredentialChanged(params.previous, reloaded) + ) { + return null; + } + return reloaded; +} + +export function resolveEffectiveOAuthCredential(params: { + profileId: string; + credential: OAuthCredential; + readBootstrapCredential: OAuthManagerAdapter["readBootstrapCredential"]; +}): OAuthCredential { + const imported = params.readBootstrapCredential({ + profileId: params.profileId, + credential: params.credential, + }); + if (!imported) { + return params.credential; + } + if (hasUsableOAuthCredential(params.credential)) { + log.debug("resolved oauth credential from canonical local store", { + profileId: params.profileId, + provider: params.credential.provider, + localExpires: params.credential.expires, + externalExpires: imported.expires, + }); + return params.credential; + } + const shouldBootstrap = shouldBootstrapFromExternalCliCredential({ + existing: params.credential, + imported, + }); + if (shouldBootstrap) { + log.debug("resolved oauth credential from external cli bootstrap", { + profileId: params.profileId, + provider: imported.provider, + localExpires: params.credential.expires, + externalExpires: imported.expires, + }); + return imported; + } + return params.credential; +} + +export function createOAuthManager(adapter: OAuthManagerAdapter) { + function adoptNewerMainOAuthCredential(params: { + store: AuthProfileStore; + profileId: string; + agentDir?: string; + credential: OAuthCredential; + }): OAuthCredential | null { + if (!params.agentDir) { + return null; + } + try { + const mainStore = ensureAuthProfileStore(undefined); + const mainCred = mainStore.profiles[params.profileId]; + if ( + mainCred?.type === "oauth" && + mainCred.provider === params.credential.provider && + Number.isFinite(mainCred.expires) && + (!Number.isFinite(params.credential.expires) || + mainCred.expires > params.credential.expires) && + adapter.isSafeToCopyOAuthIdentity(params.credential, mainCred) + ) { + params.store.profiles[params.profileId] = { ...mainCred }; + saveAuthProfileStore(params.store, params.agentDir); + log.info("adopted newer OAuth credentials from main agent", { + profileId: params.profileId, + agentDir: params.agentDir, + expires: new Date(mainCred.expires).toISOString(), + }); + return mainCred; + } + } catch (err) { + log.debug("adoptNewerMainOAuthCredential failed", { + profileId: params.profileId, + error: formatErrorMessage(err), + }); + } + return null; + } + + const refreshQueues = new Map>(); + + function refreshQueueKey(provider: string, profileId: string): string { + return `${provider}\u0000${profileId}`; + } + + async function withRefreshCallTimeout( + label: string, + timeoutMs: number, + fn: () => Promise, + ): Promise { + let timeoutHandle: NodeJS.Timeout | undefined; + try { + return await new Promise((resolve, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`OAuth refresh call "${label}" exceeded hard timeout (${timeoutMs}ms)`)); + }, timeoutMs); + fn().then(resolve, reject); + }); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } + } + + async function mirrorRefreshedCredentialIntoMainStore(params: { + profileId: string; + refreshed: OAuthCredential; + }): Promise { + try { + const mainPath = resolveAuthStorePath(undefined); + ensureAuthStoreFile(mainPath); + await updateAuthProfileStoreWithLock({ + agentDir: undefined, + updater: (store) => { + const existing = store.profiles[params.profileId]; + if (existing && existing.type !== "oauth") { + return false; + } + if (existing && existing.provider !== params.refreshed.provider) { + return false; + } + if (existing && !adapter.isSafeToCopyOAuthIdentity(existing, params.refreshed)) { + log.warn("refused to mirror OAuth credential: identity mismatch or regression", { + profileId: params.profileId, + }); + return false; + } + if ( + existing && + Number.isFinite(existing.expires) && + Number.isFinite(params.refreshed.expires) && + existing.expires >= params.refreshed.expires + ) { + return false; + } + store.profiles[params.profileId] = { ...params.refreshed }; + log.debug("mirrored refreshed OAuth credential to main agent store", { + profileId: params.profileId, + expires: Number.isFinite(params.refreshed.expires) + ? new Date(params.refreshed.expires).toISOString() + : undefined, + }); + return true; + }, + }); + } catch (err) { + log.debug("mirrorRefreshedCredentialIntoMainStore failed", { + profileId: params.profileId, + error: formatErrorMessage(err), + }); + } + } + + async function doRefreshOAuthTokenWithLock(params: { + profileId: string; + provider: string; + agentDir?: string; + }): Promise { + const authPath = resolveAuthStorePath(params.agentDir); + ensureAuthStoreFile(authPath); + const globalRefreshLockPath = resolveOAuthRefreshLockPath(params.provider, params.profileId); + + return await withFileLock(globalRefreshLockPath, OAUTH_REFRESH_LOCK_OPTIONS, async () => + withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => { + const store = loadAuthProfileStoreForSecretsRuntime(params.agentDir); + const cred = store.profiles[params.profileId]; + if (!cred || cred.type !== "oauth") { + return null; + } + + if (hasUsableOAuthCredential(cred)) { + return { + apiKey: await adapter.buildApiKey(cred.provider, cred), + credential: cred, + }; + } + + if (params.agentDir) { + try { + const mainStore = loadAuthProfileStoreForSecretsRuntime(undefined); + const mainCred = mainStore.profiles[params.profileId]; + if ( + mainCred?.type === "oauth" && + mainCred.provider === cred.provider && + hasUsableOAuthCredential(mainCred) && + adapter.isSafeToCopyOAuthIdentity(cred, mainCred) + ) { + store.profiles[params.profileId] = { ...mainCred }; + saveAuthProfileStore(store, params.agentDir); + log.info("adopted fresh OAuth credential from main store (under refresh lock)", { + profileId: params.profileId, + agentDir: params.agentDir, + expires: new Date(mainCred.expires).toISOString(), + }); + return { + apiKey: await adapter.buildApiKey(mainCred.provider, mainCred), + credential: mainCred, + }; + } else if ( + mainCred?.type === "oauth" && + mainCred.provider === cred.provider && + hasUsableOAuthCredential(mainCred) && + !adapter.isSafeToCopyOAuthIdentity(cred, mainCred) + ) { + log.warn("refused to adopt fresh main-store OAuth credential: identity mismatch", { + profileId: params.profileId, + agentDir: params.agentDir, + }); + } + } catch (err) { + log.debug("inside-lock main-store adoption failed; proceeding to refresh", { + profileId: params.profileId, + error: formatErrorMessage(err), + }); + } + } + + const externallyManaged = adapter.readBootstrapCredential({ + profileId: params.profileId, + credential: cred, + }); + if (externallyManaged) { + if ( + shouldReplaceStoredOAuthCredential(cred, externallyManaged) && + !areOAuthCredentialsEquivalent(cred, externallyManaged) + ) { + store.profiles[params.profileId] = externallyManaged; + saveAuthProfileStore(store, params.agentDir); + } + if (hasUsableOAuthCredential(externallyManaged)) { + return { + apiKey: await adapter.buildApiKey(externallyManaged.provider, externallyManaged), + credential: externallyManaged, + }; + } + } + + const refreshedCredentials = await withRefreshCallTimeout( + `refreshOAuthCredential(${cred.provider})`, + OAUTH_REFRESH_CALL_TIMEOUT_MS, + async () => { + const refreshed = await adapter.refreshCredential(cred); + return refreshed + ? ({ + ...cred, + ...refreshed, + type: "oauth", + } satisfies OAuthCredential) + : null; + }, + ); + if (!refreshedCredentials) { + return null; + } + store.profiles[params.profileId] = refreshedCredentials; + saveAuthProfileStore(store, params.agentDir); + if (params.agentDir) { + const mainPath = resolveAuthStorePath(undefined); + if (mainPath !== authPath) { + await mirrorRefreshedCredentialIntoMainStore({ + profileId: params.profileId, + refreshed: refreshedCredentials, + }); + } + } + return { + apiKey: await adapter.buildApiKey(cred.provider, refreshedCredentials), + credential: refreshedCredentials, + }; + }), + ); + } + + async function refreshOAuthTokenWithLock(params: { + profileId: string; + provider: string; + agentDir?: string; + }): Promise { + const key = refreshQueueKey(params.provider, params.profileId); + const prev = refreshQueues.get(key) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + refreshQueues.set(key, gate); + try { + await prev; + return await doRefreshOAuthTokenWithLock(params); + } finally { + release(); + if (refreshQueues.get(key) === gate) { + refreshQueues.delete(key); + } + } + } + + async function resolveOAuthAccess(params: { + store: AuthProfileStore; + profileId: string; + credential: OAuthCredential; + agentDir?: string; + }): Promise { + const adoptedCredential = + adoptNewerMainOAuthCredential({ + store: params.store, + profileId: params.profileId, + agentDir: params.agentDir, + credential: params.credential, + }) ?? params.credential; + const effectiveCredential = resolveEffectiveOAuthCredential({ + profileId: params.profileId, + credential: adoptedCredential, + readBootstrapCredential: adapter.readBootstrapCredential, + }); + + if (hasUsableOAuthCredential(effectiveCredential)) { + return { + apiKey: await adapter.buildApiKey(effectiveCredential.provider, effectiveCredential), + credential: effectiveCredential, + }; + } + + try { + const refreshed = await refreshOAuthTokenWithLock({ + profileId: params.profileId, + provider: params.credential.provider, + agentDir: params.agentDir, + }); + return refreshed; + } catch (error) { + const refreshedStore = loadAuthProfileStoreForSecretsRuntime(params.agentDir); + const refreshed = refreshedStore.profiles[params.profileId]; + if (refreshed?.type === "oauth" && hasUsableOAuthCredential(refreshed)) { + return { + apiKey: await adapter.buildApiKey(refreshed.provider, refreshed), + credential: refreshed, + }; + } + if ( + adapter.isRefreshTokenReusedError(error) && + refreshed?.type === "oauth" && + refreshed.provider === params.credential.provider && + hasOAuthCredentialChanged(params.credential, refreshed) + ) { + const recovered = await loadFreshStoredOAuthCredential({ + profileId: params.profileId, + agentDir: params.agentDir, + provider: params.credential.provider, + previous: params.credential, + requireChange: true, + }); + if (recovered) { + return { + apiKey: await adapter.buildApiKey(recovered.provider, recovered), + credential: recovered, + }; + } + const retried = await refreshOAuthTokenWithLock({ + profileId: params.profileId, + provider: params.credential.provider, + agentDir: params.agentDir, + }); + if (retried) { + return retried; + } + } + if (params.agentDir) { + try { + const mainStore = ensureAuthProfileStore(undefined); + const mainCred = mainStore.profiles[params.profileId]; + if ( + mainCred?.type === "oauth" && + mainCred.provider === params.credential.provider && + hasUsableOAuthCredential(mainCred) && + adapter.isSafeToCopyOAuthIdentity(params.credential, mainCred) + ) { + refreshedStore.profiles[params.profileId] = { ...mainCred }; + saveAuthProfileStore(refreshedStore, params.agentDir); + log.info("inherited fresh OAuth credentials from main agent", { + profileId: params.profileId, + agentDir: params.agentDir, + expires: new Date(mainCred.expires).toISOString(), + }); + return { + apiKey: await adapter.buildApiKey(mainCred.provider, mainCred), + credential: mainCred, + }; + } + } catch { + // keep the original refresh error below + } + } + throw new OAuthManagerRefreshError({ + credential: params.credential, + profileId: params.profileId, + refreshedStore, + cause: error, + }); + } + } + + function resetRefreshQueuesForTest(): void { + refreshQueues.clear(); + } + + return { + resolveOAuthAccess, + resetRefreshQueuesForTest, + }; +} diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index 3f516dbc85d..9bc170c84d6 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -8,11 +8,6 @@ import { loadConfig } from "../../config/config.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { coerceSecretRef } from "../../config/types.secrets.js"; import { formatErrorMessage } from "../../infra/errors.js"; -import { - FILE_LOCK_TIMEOUT_ERROR_CODE, - type FileLockTimeoutError, - withFileLock, -} from "../../infra/file-lock.js"; import { formatProviderAuthProfileApiKeyWithPlugin, refreshProviderOAuthCredentialWithPlugin, @@ -20,31 +15,14 @@ import { import { resolveSecretRefString, type SecretRefResolveCache } from "../../secrets/resolve.js"; import { normalizeLowercaseStringOrEmpty } from "../../shared/string-coerce.js"; import { refreshChutesTokens } from "../chutes-oauth.js"; -import { - AUTH_STORE_LOCK_OPTIONS, - OAUTH_REFRESH_CALL_TIMEOUT_MS, - OAUTH_REFRESH_LOCK_OPTIONS, - log, -} from "./constants.js"; +import { log } from "./constants.js"; import { resolveTokenExpiryState } from "./credential-state.js"; import { formatAuthDoctorHint } from "./doctor.js"; -import { resolveEffectiveOAuthCredential } from "./effective-oauth.js"; -import { - areOAuthCredentialsEquivalent, - hasUsableOAuthCredential, - isSafeToUseExternalCliCredential, - readExternalCliBootstrapCredential, - shouldReplaceStoredOAuthCredential, -} from "./external-cli-sync.js"; -import { ensureAuthStoreFile, resolveAuthStorePath, resolveOAuthRefreshLockPath } from "./paths.js"; +import { readManagedExternalCliCredential } from "./external-cli-sync.js"; +import { createOAuthManager, OAuthManagerRefreshError } from "./oauth-manager.js"; import { assertNoOAuthSecretRefPolicyViolations } from "./policy.js"; import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; -import { - ensureAuthProfileStore, - loadAuthProfileStoreForSecretsRuntime, - saveAuthProfileStore, - updateAuthProfileStoreWithLock, -} from "./store.js"; +import { loadAuthProfileStoreForSecretsRuntime } from "./store.js"; import type { AuthProfileStore, OAuthCredential } from "./types.js"; function listOAuthProviderIds(): string[] { @@ -122,18 +100,6 @@ function buildApiKeyProfileResult(params: { apiKey: string; provider: string; em }; } -async function buildOAuthProfileResult(params: { - provider: string; - credentials: OAuthCredential; - email?: string; -}) { - return buildApiKeyProfileResult({ - apiKey: await buildOAuthApiKey(params.provider, params.credentials), - provider: params.provider, - email: params.email, - }); -} - function extractErrorMessage(error: unknown): string { return formatErrorMessage(error); } @@ -147,43 +113,6 @@ export function isRefreshTokenReusedError(error: unknown): boolean { ); } -function hasOAuthCredentialChanged( - previous: Pick, - current: Pick, -): boolean { - return ( - previous.access !== current.access || - previous.refresh !== current.refresh || - previous.expires !== current.expires - ); -} - -async function loadFreshStoredOAuthCredential(params: { - profileId: string; - agentDir?: string; - provider: string; - previous?: Pick; - requireChange?: boolean; -}): Promise { - const reloadedStore = loadAuthProfileStoreForSecretsRuntime(params.agentDir); - const reloaded = reloadedStore.profiles[params.profileId]; - if ( - reloaded?.type !== "oauth" || - reloaded.provider !== params.provider || - !hasUsableOAuthCredential(reloaded) - ) { - return null; - } - if ( - params.requireChange && - params.previous && - !hasOAuthCredentialChanged(params.previous, reloaded) - ) { - return null; - } - return reloaded; -} - type ResolveApiKeyForProfileParams = { cfg?: OpenClawConfig; store: AuthProfileStore; @@ -192,182 +121,6 @@ type ResolveApiKeyForProfileParams = { }; type SecretDefaults = NonNullable["defaults"]; - -function adoptNewerMainOAuthCredential(params: { - store: AuthProfileStore; - profileId: string; - agentDir?: string; - cred: OAuthCredentials & { type: "oauth"; provider: string; email?: string }; -}): (OAuthCredentials & { type: "oauth"; provider: string; email?: string }) | null { - if (!params.agentDir) { - return null; - } - try { - const mainStore = ensureAuthProfileStore(undefined); - const mainCred = mainStore.profiles[params.profileId]; - if ( - mainCred?.type === "oauth" && - mainCred.provider === params.cred.provider && - Number.isFinite(mainCred.expires) && - (!Number.isFinite(params.cred.expires) || mainCred.expires > params.cred.expires) && - // Defense-in-depth against cross-account leaks: refuse on positive - // mismatch, identity regression, or non-overlapping-field - // credentials. Tolerates the pure upgrade case where the sub has - // no identity metadata yet and main does. - isSafeToCopyOAuthIdentity(params.cred, mainCred) - ) { - params.store.profiles[params.profileId] = { ...mainCred }; - saveAuthProfileStore(params.store, params.agentDir); - log.info("adopted newer OAuth credentials from main agent", { - profileId: params.profileId, - agentDir: params.agentDir, - expires: new Date(mainCred.expires).toISOString(), - }); - return mainCred; - } - } catch (err) { - // Best-effort: don't crash if main agent store is missing or unreadable. - log.debug("adoptNewerMainOAuthCredential failed", { - profileId: params.profileId, - error: formatErrorMessage(err), - }); - } - return null; -} - -// In-process serialization: callers for the same provider+profileId are -// chained so only one enters doRefreshOAuthTokenWithLock at a time. -// Necessary because withFileLock is re-entrant within the same PID -// (HELD_LOCKS short-circuits), which would otherwise let two concurrent -// same-PID callers both pass the file lock gate and race to refresh. -// -// The key is `${provider}\0${profileId}` (matching the cross-agent file -// lock key) so two profiles that happen to share a profileId across -// providers do not needlessly serialize against each other. -const refreshQueues = new Map>(); - -function refreshQueueKey(provider: string, profileId: string): string { - return `${provider}\u0000${profileId}`; -} - -/** - * Wrap an async call with a deadline after which the caller sees a - * timeout rejection and releases its locks. Used on the OAuth refresh - * critical section so the in-flight lock cannot outlive - * OAUTH_REFRESH_LOCK_OPTIONS.stale. - * - * LIMITATION: this does NOT cancel the underlying work. JavaScript - * promises are not cancellable and the pi-ai OAuth stack does not - * currently accept an AbortSignal. When the deadline fires the caller - * moves on and releases its file lock, but the original `fn()` promise - * keeps running in the background. That means a slow upstream refresh - * could still burn a refresh token well after we have given up on it, - * and a waiting peer that has now taken the lock may hit - * `refresh_token_reused`. - * - * The existing `isRefreshTokenReusedError` recovery path is the backstop - * for that residual case — it reloads from the main store and adopts if - * another agent's refresh has since landed. A fuller fix requires - * plumbing `AbortSignal` through the refresh stack into the HTTP - * client; tracked as a follow-up. - */ -async function withRefreshCallTimeout( - label: string, - timeoutMs: number, - fn: () => Promise, -): Promise { - let timeoutHandle: NodeJS.Timeout | undefined; - try { - return await new Promise((resolve, reject) => { - timeoutHandle = setTimeout(() => { - reject(new Error(`OAuth refresh call "${label}" exceeded hard timeout (${timeoutMs}ms)`)); - }, timeoutMs); - fn().then(resolve, reject); - }); - } finally { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } - } -} - -function createOAuthRefreshContentionError(params: { - profileId: string; - provider: string; - cause?: unknown; -}): Error & { code: "refresh_contention" } { - const error = new Error( - `OAuth refresh failed (refresh_contention): another process is already refreshing ${params.provider} for ${params.profileId}`, - { cause: params.cause }, - ); - return Object.assign(error, { code: "refresh_contention" as const }); -} - -function isGlobalOAuthRefreshLockTimeoutError( - error: unknown, - refreshLockPath: string, -): error is FileLockTimeoutError { - return ( - (error as { code?: string } | undefined)?.code === FILE_LOCK_TIMEOUT_ERROR_CODE && - (error as { lockPath?: string } | undefined)?.lockPath === `${refreshLockPath}.lock` - ); -} - -/** - * Drop any in-flight entries in the module-level refresh queue. Intended - * exclusively for tests that exercise the concurrent-refresh surface; a - * timed-out test can leave pending gates in the map and confuse subsequent - * tests that share the same Vitest worker. - */ -export function resetOAuthRefreshQueuesForTest(): void { - refreshQueues.clear(); -} - -async function refreshOAuthTokenWithLock(params: { - profileId: string; - provider: string; - agentDir?: string; -}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { - const key = refreshQueueKey(params.provider, params.profileId); - const prev = refreshQueues.get(key) ?? Promise.resolve(); - let release!: () => void; - const gate = new Promise((r) => { - release = r; - }); - refreshQueues.set(key, gate); - try { - await prev; - return await doRefreshOAuthTokenWithLock(params); - } finally { - release(); - if (refreshQueues.get(key) === gate) { - refreshQueues.delete(key); - } - } -} - -/** - * Mirror a refreshed OAuth credential back into the main-agent store so peer - * agents adopt it on their next `adoptNewerMainOAuthCredential` pass instead - * of racing to refresh the (now-single-used) refresh token. - * - * Identity binding (CWE-284): we require positive evidence the existing main - * credential and the refreshed credential belong to the same account before - * overwriting. If both sides expose `accountId` (strongest signal, Codex CLI) - * they must match; otherwise if both expose `email` they must match (case- - * insensitive, trimmed). Provider-only matches are not sufficient because - * nothing guarantees two agents with the same profileId are authenticated as - * the same user. This prevents a compromised sub-agent from poisoning the - * main store's credentials. - * - * Serialization: uses `updateAuthProfileStoreWithLock` so the read-modify- - * write takes the main-store lock and cannot race with other main-store - * writers (e.g. `updateAuthProfileStoreWithLock` in other flows, CLI-sync). - * - * Intentionally best-effort: a failure here must not fail the caller's - * refresh, since the credential has already been persisted to the agent's - * own store and returned to the requester. - */ export function normalizeAuthIdentityToken(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; @@ -501,282 +254,45 @@ export function isSafeToCopyOAuthIdentity( return true; } -async function mirrorRefreshedCredentialIntoMainStore(params: { - profileId: string; - refreshed: OAuthCredential; -}): Promise { - try { - const mainPath = resolveAuthStorePath(undefined); - ensureAuthStoreFile(mainPath); - await updateAuthProfileStoreWithLock({ - agentDir: undefined, - updater: (store) => { - const existing = store.profiles[params.profileId]; - if (existing && existing.type !== "oauth") { - return false; - } - if (existing && existing.provider !== params.refreshed.provider) { - return false; - } - // Identity binding for the mirror direction, using the unified - // copy-safety gate. Accepts upgrades (main has no accountId yet, - // incoming does) while refusing positive mismatches, identity - // regressions, and non-overlapping-field credentials. - if (existing && !isSafeToCopyOAuthIdentity(existing, params.refreshed)) { - log.warn("refused to mirror OAuth credential: identity mismatch or regression", { - profileId: params.profileId, - }); - return false; - } - // Only overwrite when the incoming credential is strictly fresher - // (or main has no usable expiry). Prevents clobbering a concurrent - // successful refresh performed by the main agent itself. - if ( - existing && - Number.isFinite(existing.expires) && - Number.isFinite(params.refreshed.expires) && - existing.expires >= params.refreshed.expires - ) { - return false; - } - store.profiles[params.profileId] = { ...params.refreshed }; - log.debug("mirrored refreshed OAuth credential to main agent store", { - profileId: params.profileId, - expires: Number.isFinite(params.refreshed.expires) - ? new Date(params.refreshed.expires).toISOString() - : undefined, - }); - return true; - }, - }); - } catch (err) { - log.debug("mirrorRefreshedCredentialIntoMainStore failed", { - profileId: params.profileId, - error: formatErrorMessage(err), - }); +async function refreshOAuthCredential( + credential: OAuthCredential, +): Promise { + const pluginRefreshed = await refreshProviderOAuthCredentialWithPlugin({ + provider: credential.provider, + context: credential, + }); + if (pluginRefreshed) { + return pluginRefreshed; } + + if (credential.provider === "chutes") { + return await refreshChutesTokens({ credential }); + } + + const oauthProvider = resolveOAuthProvider(credential.provider); + if (!oauthProvider || typeof getOAuthApiKey !== "function") { + return null; + } + const result = await getOAuthApiKey(oauthProvider, { + [credential.provider]: credential, + }); + return result?.newCredentials ?? null; } -async function doRefreshOAuthTokenWithLock(params: { - profileId: string; - provider: string; - agentDir?: string; -}): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { - const authPath = resolveAuthStorePath(params.agentDir); - ensureAuthStoreFile(authPath); - // Two-layer coordination: - // 1. Global refresh lock keyed on sha256(profileId): every agent trying - // to refresh the same profile acquires the same file lock, so only - // one HTTP refresh is in-flight at a time (#26322). - // 2. Per-store lock (AUTH_STORE_LOCK_OPTIONS) on this agent's - // auth-profiles.json: serializes the refresh's read-modify-writes - // with other writers of the same store (e.g. usage/profile updates - // via updateAuthProfileStoreWithLock, CLI sync). - // Lock acquisition order is always refresh -> per-store; non-refresh code - // paths only take the per-store lock, so no cycle is possible. - const globalRefreshLockPath = resolveOAuthRefreshLockPath(params.provider, params.profileId); +const oauthManager = createOAuthManager({ + buildApiKey: buildOAuthApiKey, + refreshCredential: refreshOAuthCredential, + readBootstrapCredential: ({ profileId, credential }) => + readManagedExternalCliCredential({ + profileId, + credential, + }), + isRefreshTokenReusedError, + isSafeToCopyOAuthIdentity, +}); - try { - return await withFileLock(globalRefreshLockPath, OAUTH_REFRESH_LOCK_OPTIONS, async () => - withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => { - // Locked refresh must bypass runtime snapshots so we can adopt fresher - // on-disk credentials written by another refresh attempt. - const store = loadAuthProfileStoreForSecretsRuntime(params.agentDir); - const cred = store.profiles[params.profileId]; - if (!cred || cred.type !== "oauth") { - return null; - } - - if (hasUsableOAuthCredential(cred)) { - return { - apiKey: await buildOAuthApiKey(cred.provider, cred), - newCredentials: cred, - }; - } - - // Inside-the-lock recheck: a prior agent that already held this lock may - // have completed a refresh and mirrored its fresh credential into the - // main store. If so, adopt into the local store and return without - // issuing another HTTP refresh. This is what turns N serialized - // refreshes into 1 refresh + (N-1) adoptions, preventing the - // `refresh_token_reused` storm reported in #26322. - if (params.agentDir) { - try { - const mainStore = loadAuthProfileStoreForSecretsRuntime(undefined); - const mainCred = mainStore.profiles[params.profileId]; - if ( - mainCred?.type === "oauth" && - mainCred.provider === cred.provider && - hasUsableOAuthCredential(mainCred) && - // Defense-in-depth identity gate. Tolerates the pure upgrade - // case (sub predates identity capture) but refuses positive - // mismatch, identity regression, and non-overlapping fields. - isSafeToCopyOAuthIdentity(cred, mainCred) - ) { - store.profiles[params.profileId] = { ...mainCred }; - saveAuthProfileStore(store, params.agentDir); - log.info("adopted fresh OAuth credential from main store (under refresh lock)", { - profileId: params.profileId, - agentDir: params.agentDir, - expires: new Date(mainCred.expires).toISOString(), - }); - return { - apiKey: await buildOAuthApiKey(mainCred.provider, mainCred), - newCredentials: mainCred, - }; - } else if ( - mainCred?.type === "oauth" && - mainCred.provider === cred.provider && - hasUsableOAuthCredential(mainCred) && - !isSafeToCopyOAuthIdentity(cred, mainCred) - ) { - // Main has fresh creds but they belong to a DIFFERENT account — - // record the refusal so operators can diagnose, then proceed to - // our own refresh rather than leaking credentials. - log.warn("refused to adopt fresh main-store OAuth credential: identity mismatch", { - profileId: params.profileId, - agentDir: params.agentDir, - }); - } - } catch (err) { - log.debug("inside-lock main-store adoption failed; proceeding to refresh", { - profileId: params.profileId, - error: formatErrorMessage(err), - }); - } - } - - const externallyManaged = readExternalCliBootstrapCredential({ - profileId: params.profileId, - credential: cred, - }); - let refreshCred = cred; - if (externallyManaged) { - if (isSafeToUseExternalCliCredential(cred, externallyManaged)) { - const hasUsableExternalCredential = hasUsableOAuthCredential(externallyManaged); - const shouldAdoptExternalCredential = - shouldReplaceStoredOAuthCredential(cred, externallyManaged) && - !areOAuthCredentialsEquivalent(cred, externallyManaged); - if (shouldAdoptExternalCredential) { - store.profiles[params.profileId] = externallyManaged; - saveAuthProfileStore(store, params.agentDir); - refreshCred = externallyManaged; - } - if (hasUsableExternalCredential) { - return { - apiKey: await buildOAuthApiKey(externallyManaged.provider, externallyManaged), - newCredentials: externallyManaged, - }; - } - } else { - log.warn("refused to adopt external cli OAuth credential: identity mismatch", { - profileId: params.profileId, - provider: params.provider, - agentDir: params.agentDir, - }); - } - } - - const pluginRefreshed = await withRefreshCallTimeout( - `refreshProviderOAuthCredentialWithPlugin(${refreshCred.provider})`, - OAUTH_REFRESH_CALL_TIMEOUT_MS, - () => - refreshProviderOAuthCredentialWithPlugin({ - provider: refreshCred.provider, - context: refreshCred, - }), - ); - if (pluginRefreshed) { - const refreshedCredentials: OAuthCredential = { - ...refreshCred, - ...pluginRefreshed, - type: "oauth", - }; - store.profiles[params.profileId] = refreshedCredentials; - saveAuthProfileStore(store, params.agentDir); - if (params.agentDir) { - const mainPath = resolveAuthStorePath(undefined); - if (mainPath !== authPath) { - await mirrorRefreshedCredentialIntoMainStore({ - profileId: params.profileId, - refreshed: refreshedCredentials, - }); - } - } - return { - apiKey: await buildOAuthApiKey(cred.provider, refreshedCredentials), - newCredentials: refreshedCredentials, - }; - } - - const oauthCreds: Record = { - [refreshCred.provider]: refreshCred, - }; - const result = - refreshCred.provider === "chutes" - ? await (async () => { - const newCredentials = await withRefreshCallTimeout( - `refreshChutesTokens(${refreshCred.provider})`, - OAUTH_REFRESH_CALL_TIMEOUT_MS, - () => refreshChutesTokens({ credential: refreshCred }), - ); - return { apiKey: newCredentials.access, newCredentials }; - })() - : await (async () => { - const oauthProvider = resolveOAuthProvider(refreshCred.provider); - if (!oauthProvider) { - return null; - } - if (typeof getOAuthApiKey !== "function") { - return null; - } - return await withRefreshCallTimeout( - `getOAuthApiKey(${oauthProvider})`, - OAUTH_REFRESH_CALL_TIMEOUT_MS, - () => getOAuthApiKey(oauthProvider, oauthCreds), - ); - })(); - if (!result) { - return null; - } - const mergedCred: OAuthCredential = { - ...refreshCred, - ...result.newCredentials, - type: "oauth", - }; - store.profiles[params.profileId] = mergedCred; - saveAuthProfileStore(store, params.agentDir); - - // Mirror the refreshed credential back into the main-agent store while - // both locks are still held (refresh lock + this agent's store lock) - // plus we'll take main-store lock inside the mirror. Doing this inside - // the refresh lock closes the cross-process race window where a second - // agent could acquire the refresh lock between our lock release and - // our main-store write, see only stale main creds, and redundantly - // refresh (reproducing refresh_token_reused). - if (params.agentDir) { - const mainPath = resolveAuthStorePath(undefined); - if (mainPath !== authPath) { - await mirrorRefreshedCredentialIntoMainStore({ - profileId: params.profileId, - refreshed: mergedCred, - }); - } - } - - return result; - }), - ); - } catch (error) { - if (isGlobalOAuthRefreshLockTimeoutError(error, globalRefreshLockPath)) { - throw createOAuthRefreshContentionError({ - profileId: params.profileId, - provider: params.provider, - cause: error, - }); - } - throw error; - } +export function resetOAuthRefreshQueuesForTest(): void { + oauthManager.resetRefreshQueuesForTest(); } async function tryResolveOAuthProfile( @@ -798,31 +314,19 @@ async function tryResolveOAuthProfile( return null; } - const effectiveCred = resolveEffectiveOAuthCredential({ + const resolved = await oauthManager.resolveOAuthAccess({ + store, profileId, credential: cred, - }); - - if (hasUsableOAuthCredential(effectiveCred)) { - return await buildOAuthProfileResult({ - provider: effectiveCred.provider, - credentials: effectiveCred, - email: effectiveCred.email ?? cred.email, - }); - } - - const refreshed = await refreshOAuthTokenWithLock({ - profileId, - provider: cred.provider, agentDir: params.agentDir, }); - if (!refreshed) { + if (!resolved) { return null; } return buildApiKeyProfileResult({ - apiKey: refreshed.apiKey, - provider: cred.provider, - email: cred.email, + apiKey: resolved.apiKey, + provider: resolved.credential.provider, + email: resolved.credential.email ?? cred.email, }); } @@ -947,83 +451,26 @@ export async function resolveApiKeyForProfile( return buildApiKeyProfileResult({ apiKey: token, provider: cred.provider, email: cred.email }); } - const oauthCred = - adoptNewerMainOAuthCredential({ - store, - profileId, - agentDir: params.agentDir, - cred, - }) ?? cred; - const effectiveOAuthCred = resolveEffectiveOAuthCredential({ - profileId, - credential: oauthCred, - }); - - if (hasUsableOAuthCredential(effectiveOAuthCred)) { - return await buildOAuthProfileResult({ - provider: effectiveOAuthCred.provider, - credentials: effectiveOAuthCred, - email: effectiveOAuthCred.email, - }); - } - try { - const result = await refreshOAuthTokenWithLock({ - profileId, - provider: cred.provider, + const resolved = await oauthManager.resolveOAuthAccess({ + store, agentDir: params.agentDir, + profileId, + credential: cred, }); - if (!result) { + if (!resolved) { return null; } return buildApiKeyProfileResult({ - apiKey: result.apiKey, - provider: cred.provider, - email: cred.email, + apiKey: resolved.apiKey, + provider: resolved.credential.provider, + email: resolved.credential.email ?? cred.email, }); } catch (error) { - const refreshedStore = loadAuthProfileStoreForSecretsRuntime(params.agentDir); - const refreshed = refreshedStore.profiles[profileId]; - if (refreshed?.type === "oauth" && hasUsableOAuthCredential(refreshed)) { - return await buildOAuthProfileResult({ - provider: refreshed.provider, - credentials: refreshed, - email: refreshed.email ?? cred.email, - }); - } - if ( - isRefreshTokenReusedError(error) && - refreshed?.type === "oauth" && - refreshed.provider === cred.provider && - hasOAuthCredentialChanged(cred, refreshed) - ) { - const recovered = await loadFreshStoredOAuthCredential({ - profileId, - agentDir: params.agentDir, - provider: cred.provider, - previous: cred, - requireChange: true, - }); - if (recovered) { - return await buildOAuthProfileResult({ - provider: recovered.provider, - credentials: recovered, - email: recovered.email ?? cred.email, - }); - } - const retried = await refreshOAuthTokenWithLock({ - profileId, - provider: cred.provider, - agentDir: params.agentDir, - }); - if (retried) { - return buildApiKeyProfileResult({ - apiKey: retried.apiKey, - provider: cred.provider, - email: cred.email, - }); - } - } + const refreshedStore = + error instanceof OAuthManagerRefreshError + ? error.refreshedStore + : loadAuthProfileStoreForSecretsRuntime(params.agentDir); const fallbackProfileId = suggestOAuthProfileIdForLegacyDefault({ cfg, store: refreshedStore, @@ -1046,41 +493,7 @@ export async function resolveApiKeyForProfile( } } - // Fallback: if this is a secondary agent, try using the main agent's credentials - if (params.agentDir) { - try { - const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir) - const mainCred = mainStore.profiles[profileId]; - if ( - mainCred?.type === "oauth" && - mainCred.provider === cred.provider && - hasUsableOAuthCredential(mainCred) && - // Defense-in-depth identity gate — refuse to inherit credentials - // from a different account even under refresh failure. Tolerates - // pre-capture credentials but refuses regression/non-overlap. - isSafeToCopyOAuthIdentity(cred, mainCred) - ) { - // Main agent has fresh credentials - copy them to this agent and use them - refreshedStore.profiles[profileId] = { ...mainCred }; - saveAuthProfileStore(refreshedStore, params.agentDir); - log.info("inherited fresh OAuth credentials from main agent", { - profileId, - agentDir: params.agentDir, - expires: new Date(mainCred.expires).toISOString(), - }); - return await buildOAuthProfileResult({ - provider: mainCred.provider, - credentials: mainCred, - email: mainCred.email, - }); - } - } catch { - // keep original error if main agent fallback also fails - } - } - const message = extractErrorMessage(error); - const errorCode = (error as { code?: string } | undefined)?.code; const hint = await formatAuthDoctorHint({ cfg, store: refreshedStore, @@ -1089,9 +502,7 @@ export async function resolveApiKeyForProfile( }); throw new Error( `OAuth token refresh failed for ${cred.provider}: ${message}. ` + - (errorCode === "refresh_contention" - ? "Please wait for the in-flight refresh to finish and retry." - : "Please try again or re-authenticate.") + + "Please try again or re-authenticate." + (hint ? `\n\n${hint}` : ""), { cause: error }, );