| import { |
| getOAuthApiKey, |
| getOAuthProviders, |
| type OAuthCredentials, |
| type OAuthProvider, |
| } from "@mariozechner/pi-ai/oauth"; |
| import { loadConfig, type OpenClawConfig } from "../../config/config.js"; |
| import { coerceSecretRef } from "../../config/types.secrets.js"; |
| import { withFileLock } from "../../infra/file-lock.js"; |
| import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js"; |
| import { resolveSecretRefString, type SecretRefResolveCache } from "../../secrets/resolve.js"; |
| import { refreshChutesTokens } from "../chutes-oauth.js"; |
| import { normalizeProviderId } from "../model-selection.js"; |
| import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js"; |
| import { resolveTokenExpiryState } from "./credential-state.js"; |
| import { formatAuthDoctorHint } from "./doctor.js"; |
| import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js"; |
| import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js"; |
| import { ensureAuthProfileStore, saveAuthProfileStore } from "./store.js"; |
| import type { AuthProfileStore } from "./types.js"; |
|
|
| const OAUTH_PROVIDER_IDS = new Set<string>(getOAuthProviders().map((provider) => provider.id)); |
|
|
| const isOAuthProvider = (provider: string): provider is OAuthProvider => |
| OAUTH_PROVIDER_IDS.has(provider); |
|
|
| const resolveOAuthProvider = (provider: string): OAuthProvider | null => |
| isOAuthProvider(provider) ? provider : null; |
|
|
| |
| const BEARER_AUTH_MODES = new Set(["oauth", "token"]); |
|
|
| const isCompatibleModeType = (mode: string | undefined, type: string | undefined): boolean => { |
| if (!mode || !type) { |
| return false; |
| } |
| if (mode === type) { |
| return true; |
| } |
| |
| return BEARER_AUTH_MODES.has(mode) && BEARER_AUTH_MODES.has(type); |
| }; |
|
|
| function isProfileConfigCompatible(params: { |
| cfg?: OpenClawConfig; |
| profileId: string; |
| provider: string; |
| mode: "api_key" | "token" | "oauth"; |
| allowOAuthTokenCompatibility?: boolean; |
| }): boolean { |
| const profileConfig = params.cfg?.auth?.profiles?.[params.profileId]; |
| if (profileConfig && profileConfig.provider !== params.provider) { |
| return false; |
| } |
| if (profileConfig && !isCompatibleModeType(profileConfig.mode, params.mode)) { |
| return false; |
| } |
| return true; |
| } |
|
|
| function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): string { |
| const needsProjectId = provider === "google-gemini-cli"; |
| return needsProjectId |
| ? JSON.stringify({ |
| token: credentials.access, |
| projectId: credentials.projectId, |
| }) |
| : credentials.access; |
| } |
|
|
| function buildApiKeyProfileResult(params: { apiKey: string; provider: string; email?: string }) { |
| return { |
| apiKey: params.apiKey, |
| provider: params.provider, |
| email: params.email, |
| }; |
| } |
|
|
| function buildOAuthProfileResult(params: { |
| provider: string; |
| credentials: OAuthCredentials; |
| email?: string; |
| }) { |
| return buildApiKeyProfileResult({ |
| apiKey: buildOAuthApiKey(params.provider, params.credentials), |
| provider: params.provider, |
| email: params.email, |
| }); |
| } |
|
|
| function extractErrorMessage(error: unknown): string { |
| return error instanceof Error ? error.message : String(error); |
| } |
|
|
| function shouldUseOpenaiCodexRefreshFallback(params: { |
| provider: string; |
| credentials: OAuthCredentials; |
| error: unknown; |
| }): boolean { |
| if (normalizeProviderId(params.provider) !== "openai-codex") { |
| return false; |
| } |
| const message = extractErrorMessage(params.error); |
| if (!/extract\s+accountid\s+from\s+token/i.test(message)) { |
| return false; |
| } |
| return ( |
| typeof params.credentials.access === "string" && params.credentials.access.trim().length > 0 |
| ); |
| } |
|
|
| type ResolveApiKeyForProfileParams = { |
| cfg?: OpenClawConfig; |
| store: AuthProfileStore; |
| profileId: string; |
| agentDir?: string; |
| }; |
|
|
| 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) |
| ) { |
| 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: err instanceof Error ? err.message : String(err), |
| }); |
| } |
| return null; |
| } |
|
|
| async function refreshOAuthTokenWithLock(params: { |
| profileId: string; |
| agentDir?: string; |
| }): Promise<{ apiKey: string; newCredentials: OAuthCredentials } | null> { |
| const authPath = resolveAuthStorePath(params.agentDir); |
| ensureAuthStoreFile(authPath); |
|
|
| return await withFileLock(authPath, AUTH_STORE_LOCK_OPTIONS, async () => { |
| const store = ensureAuthProfileStore(params.agentDir); |
| const cred = store.profiles[params.profileId]; |
| if (!cred || cred.type !== "oauth") { |
| return null; |
| } |
|
|
| if (Date.now() < cred.expires) { |
| return { |
| apiKey: buildOAuthApiKey(cred.provider, cred), |
| newCredentials: cred, |
| }; |
| } |
|
|
| const oauthCreds: Record<string, OAuthCredentials> = { |
| [cred.provider]: cred, |
| }; |
|
|
| const result = |
| String(cred.provider) === "chutes" |
| ? await (async () => { |
| const newCredentials = await refreshChutesTokens({ |
| credential: cred, |
| }); |
| return { apiKey: newCredentials.access, newCredentials }; |
| })() |
| : String(cred.provider) === "qwen-portal" |
| ? await (async () => { |
| const newCredentials = await refreshQwenPortalCredentials(cred); |
| return { apiKey: newCredentials.access, newCredentials }; |
| })() |
| : await (async () => { |
| const oauthProvider = resolveOAuthProvider(cred.provider); |
| if (!oauthProvider) { |
| return null; |
| } |
| return await getOAuthApiKey(oauthProvider, oauthCreds); |
| })(); |
| if (!result) { |
| return null; |
| } |
| store.profiles[params.profileId] = { |
| ...cred, |
| ...result.newCredentials, |
| type: "oauth", |
| }; |
| saveAuthProfileStore(store, params.agentDir); |
|
|
| return result; |
| }); |
| } |
|
|
| async function tryResolveOAuthProfile( |
| params: ResolveApiKeyForProfileParams, |
| ): Promise<{ apiKey: string; provider: string; email?: string } | null> { |
| const { cfg, store, profileId } = params; |
| const cred = store.profiles[profileId]; |
| if (!cred || cred.type !== "oauth") { |
| return null; |
| } |
| if ( |
| !isProfileConfigCompatible({ |
| cfg, |
| profileId, |
| provider: cred.provider, |
| mode: cred.type, |
| }) |
| ) { |
| return null; |
| } |
|
|
| if (Date.now() < cred.expires) { |
| return buildOAuthProfileResult({ |
| provider: cred.provider, |
| credentials: cred, |
| email: cred.email, |
| }); |
| } |
|
|
| const refreshed = await refreshOAuthTokenWithLock({ |
| profileId, |
| agentDir: params.agentDir, |
| }); |
| if (!refreshed) { |
| return null; |
| } |
| return buildApiKeyProfileResult({ |
| apiKey: refreshed.apiKey, |
| provider: cred.provider, |
| email: cred.email, |
| }); |
| } |
|
|
| async function resolveProfileSecretString(params: { |
| profileId: string; |
| provider: string; |
| value: string | undefined; |
| valueRef: unknown; |
| refDefaults: SecretDefaults | undefined; |
| configForRefResolution: OpenClawConfig; |
| cache: SecretRefResolveCache; |
| inlineFailureMessage: string; |
| refFailureMessage: string; |
| }): Promise<string | undefined> { |
| let resolvedValue = params.value?.trim(); |
| if (resolvedValue) { |
| const inlineRef = coerceSecretRef(resolvedValue, params.refDefaults); |
| if (inlineRef) { |
| try { |
| resolvedValue = await resolveSecretRefString(inlineRef, { |
| config: params.configForRefResolution, |
| env: process.env, |
| cache: params.cache, |
| }); |
| } catch (err) { |
| log.debug(params.inlineFailureMessage, { |
| profileId: params.profileId, |
| provider: params.provider, |
| error: err instanceof Error ? err.message : String(err), |
| }); |
| } |
| } |
| } |
|
|
| const explicitRef = coerceSecretRef(params.valueRef, params.refDefaults); |
| if (!resolvedValue && explicitRef) { |
| try { |
| resolvedValue = await resolveSecretRefString(explicitRef, { |
| config: params.configForRefResolution, |
| env: process.env, |
| cache: params.cache, |
| }); |
| } catch (err) { |
| log.debug(params.refFailureMessage, { |
| profileId: params.profileId, |
| provider: params.provider, |
| error: err instanceof Error ? err.message : String(err), |
| }); |
| } |
| } |
|
|
| return resolvedValue; |
| } |
|
|
| export async function resolveApiKeyForProfile( |
| params: ResolveApiKeyForProfileParams, |
| ): Promise<{ apiKey: string; provider: string; email?: string } | null> { |
| const { cfg, store, profileId } = params; |
| const cred = store.profiles[profileId]; |
| if (!cred) { |
| return null; |
| } |
| if ( |
| !isProfileConfigCompatible({ |
| cfg, |
| profileId, |
| provider: cred.provider, |
| mode: cred.type, |
| |
| allowOAuthTokenCompatibility: true, |
| }) |
| ) { |
| return null; |
| } |
|
|
| const refResolveCache: SecretRefResolveCache = {}; |
| const configForRefResolution = cfg ?? loadConfig(); |
| const refDefaults = configForRefResolution.secrets?.defaults; |
|
|
| if (cred.type === "api_key") { |
| const key = await resolveProfileSecretString({ |
| profileId, |
| provider: cred.provider, |
| value: cred.key, |
| valueRef: cred.keyRef, |
| refDefaults, |
| configForRefResolution, |
| cache: refResolveCache, |
| inlineFailureMessage: "failed to resolve inline auth profile api_key ref", |
| refFailureMessage: "failed to resolve auth profile api_key ref", |
| }); |
| if (!key) { |
| return null; |
| } |
| return buildApiKeyProfileResult({ apiKey: key, provider: cred.provider, email: cred.email }); |
| } |
| if (cred.type === "token") { |
| const expiryState = resolveTokenExpiryState(cred.expires); |
| if (expiryState === "expired" || expiryState === "invalid_expires") { |
| return null; |
| } |
| const token = await resolveProfileSecretString({ |
| profileId, |
| provider: cred.provider, |
| value: cred.token, |
| valueRef: cred.tokenRef, |
| refDefaults, |
| configForRefResolution, |
| cache: refResolveCache, |
| inlineFailureMessage: "failed to resolve inline auth profile token ref", |
| refFailureMessage: "failed to resolve auth profile token ref", |
| }); |
| if (!token) { |
| return null; |
| } |
| return buildApiKeyProfileResult({ apiKey: token, provider: cred.provider, email: cred.email }); |
| } |
|
|
| const oauthCred = |
| adoptNewerMainOAuthCredential({ |
| store, |
| profileId, |
| agentDir: params.agentDir, |
| cred, |
| }) ?? cred; |
|
|
| if (Date.now() < oauthCred.expires) { |
| return buildOAuthProfileResult({ |
| provider: oauthCred.provider, |
| credentials: oauthCred, |
| email: oauthCred.email, |
| }); |
| } |
|
|
| try { |
| const result = await refreshOAuthTokenWithLock({ |
| profileId, |
| agentDir: params.agentDir, |
| }); |
| if (!result) { |
| return null; |
| } |
| return buildApiKeyProfileResult({ |
| apiKey: result.apiKey, |
| provider: cred.provider, |
| email: cred.email, |
| }); |
| } catch (error) { |
| const refreshedStore = ensureAuthProfileStore(params.agentDir); |
| const refreshed = refreshedStore.profiles[profileId]; |
| if (refreshed?.type === "oauth" && Date.now() < refreshed.expires) { |
| return buildOAuthProfileResult({ |
| provider: refreshed.provider, |
| credentials: refreshed, |
| email: refreshed.email ?? cred.email, |
| }); |
| } |
| const fallbackProfileId = suggestOAuthProfileIdForLegacyDefault({ |
| cfg, |
| store: refreshedStore, |
| provider: cred.provider, |
| legacyProfileId: profileId, |
| }); |
| if (fallbackProfileId && fallbackProfileId !== profileId) { |
| try { |
| const fallbackResolved = await tryResolveOAuthProfile({ |
| cfg, |
| store: refreshedStore, |
| profileId: fallbackProfileId, |
| agentDir: params.agentDir, |
| }); |
| if (fallbackResolved) { |
| return fallbackResolved; |
| } |
| } catch { |
| |
| } |
| } |
|
|
| |
| if (params.agentDir) { |
| try { |
| const mainStore = ensureAuthProfileStore(undefined); |
| const mainCred = mainStore.profiles[profileId]; |
| if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) { |
| |
| 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 buildOAuthProfileResult({ |
| provider: mainCred.provider, |
| credentials: mainCred, |
| email: mainCred.email, |
| }); |
| } |
| } catch { |
| |
| } |
| } |
|
|
| if ( |
| shouldUseOpenaiCodexRefreshFallback({ |
| provider: cred.provider, |
| credentials: cred, |
| error, |
| }) |
| ) { |
| log.warn("openai-codex oauth refresh failed; using cached access token fallback", { |
| profileId, |
| provider: cred.provider, |
| }); |
| return buildApiKeyProfileResult({ |
| apiKey: cred.access, |
| provider: cred.provider, |
| email: cred.email, |
| }); |
| } |
|
|
| const message = extractErrorMessage(error); |
| const hint = formatAuthDoctorHint({ |
| cfg, |
| store: refreshedStore, |
| provider: cred.provider, |
| profileId, |
| }); |
| throw new Error( |
| `OAuth token refresh failed for ${cred.provider}: ${message}. ` + |
| "Please try again or re-authenticate." + |
| (hint ? `\n\n${hint}` : ""), |
| { cause: error }, |
| ); |
| } |
| } |
|
|