mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-01 01:30:22 +00:00
492 lines
15 KiB
TypeScript
492 lines
15 KiB
TypeScript
import {
|
|
getOAuthApiKey,
|
|
getOAuthProviders,
|
|
type OAuthCredentials,
|
|
type OAuthProvider,
|
|
} from "@mariozechner/pi-ai";
|
|
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;
|
|
|
|
/** Bearer-token auth modes that are interchangeable (oauth tokens and raw tokens). */
|
|
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;
|
|
}
|
|
// Both token and oauth represent bearer-token auth paths — allow bidirectional compat.
|
|
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) {
|
|
// Best-effort: don't crash if main agent store is missing or unreadable.
|
|
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,
|
|
// Compatibility: treat "oauth" config as compatible with stored token profiles.
|
|
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 {
|
|
// keep original error
|
|
}
|
|
}
|
|
|
|
// 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" && Date.now() < mainCred.expires) {
|
|
// 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 buildOAuthProfileResult({
|
|
provider: mainCred.provider,
|
|
credentials: mainCred,
|
|
email: mainCred.email,
|
|
});
|
|
}
|
|
} catch {
|
|
// keep original error if main agent fallback also fails
|
|
}
|
|
}
|
|
|
|
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 },
|
|
);
|
|
}
|
|
}
|