refactor(auth): centralize oauth lifecycle manager

This commit is contained in:
Vincent Koc
2026-04-17 14:39:02 -07:00
committed by Peter Steinberger
parent 554507b413
commit 5f2e77a6e1
3 changed files with 641 additions and 688 deletions

View File

@@ -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;
}

View File

@@ -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<string>;
refreshCredential: (credential: OAuthCredential) => Promise<OAuthCredentials | null>;
readBootstrapCredential: (params: {
profileId: string;
credential: OAuthCredential;
}) => OAuthCredential | null;
isRefreshTokenReusedError: (error: unknown) => boolean;
isSafeToCopyOAuthIdentity: (
existing: Pick<OAuthCredential, "accountId" | "email">,
incoming: Pick<OAuthCredential, "accountId" | "email">,
) => 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<OAuthCredential, "access" | "refresh" | "expires">,
current: Pick<OAuthCredential, "access" | "refresh" | "expires">,
): 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<OAuthCredential, "access" | "refresh" | "expires">;
requireChange?: boolean;
}): Promise<OAuthCredential | null> {
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<string, Promise<unknown>>();
function refreshQueueKey(provider: string, profileId: string): string {
return `${provider}\u0000${profileId}`;
}
async function withRefreshCallTimeout<T>(
label: string,
timeoutMs: number,
fn: () => Promise<T>,
): Promise<T> {
let timeoutHandle: NodeJS.Timeout | undefined;
try {
return await new Promise<T>((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<void> {
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<ResolvedOAuthAccess | null> {
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<ResolvedOAuthAccess | null> {
const key = refreshQueueKey(params.provider, params.profileId);
const prev = refreshQueues.get(key) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((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<ResolvedOAuthAccess | null> {
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,
};
}

View File

@@ -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<OAuthCredential, "access" | "refresh" | "expires">,
current: Pick<OAuthCredential, "access" | "refresh" | "expires">,
): 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<OAuthCredential, "access" | "refresh" | "expires">;
requireChange?: boolean;
}): Promise<OAuthCredential | null> {
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<OpenClawConfig["secrets"]>["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<string, Promise<unknown>>();
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<T>(
label: string,
timeoutMs: number,
fn: () => Promise<T>,
): Promise<T> {
let timeoutHandle: NodeJS.Timeout | undefined;
try {
return await new Promise<T>((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<void>((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<void> {
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<OAuthCredentials | null> {
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<string, OAuthCredentials> = {
[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 },
);