mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
refactor(auth): centralize oauth lifecycle manager
This commit is contained in:
committed by
Peter Steinberger
parent
554507b413
commit
5f2e77a6e1
@@ -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;
|
||||
}
|
||||
|
||||
575
src/agents/auth-profiles/oauth-manager.ts
Normal file
575
src/agents/auth-profiles/oauth-manager.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user