From 63403d47d9039e89b5cc37670f7b436cfadb444e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Feb 2026 18:17:18 +0000 Subject: [PATCH] refactor(auth): share oauth profile config checks --- src/agents/auth-profiles/oauth.test.ts | 106 +++++++++++++++++++++++++ src/agents/auth-profiles/oauth.ts | 92 ++++++++++++++------- 2 files changed, 171 insertions(+), 27 deletions(-) create mode 100644 src/agents/auth-profiles/oauth.test.ts diff --git a/src/agents/auth-profiles/oauth.test.ts b/src/agents/auth-profiles/oauth.test.ts new file mode 100644 index 0000000000..630fbc974a --- /dev/null +++ b/src/agents/auth-profiles/oauth.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; +import { resolveApiKeyForProfile } from "./oauth.js"; +import type { AuthProfileStore } from "./types.js"; + +function cfgFor(profileId: string, provider: string, mode: "api_key" | "token" | "oauth") { + return { + auth: { + profiles: { + [profileId]: { provider, mode }, + }, + }, + } satisfies OpenClawConfig; +} + +describe("resolveApiKeyForProfile config compatibility", () => { + it("accepts token credentials when config mode is oauth", async () => { + const profileId = "anthropic:token"; + const store: AuthProfileStore = { + version: 1, + profiles: { + [profileId]: { + type: "token", + provider: "anthropic", + token: "tok-123", + }, + }, + }; + + const result = await resolveApiKeyForProfile({ + cfg: cfgFor(profileId, "anthropic", "oauth"), + store, + profileId, + }); + expect(result).toEqual({ + apiKey: "tok-123", + provider: "anthropic", + email: undefined, + }); + }); + + it("rejects token credentials when config mode is api_key", async () => { + const profileId = "anthropic:token"; + const store: AuthProfileStore = { + version: 1, + profiles: { + [profileId]: { + type: "token", + provider: "anthropic", + token: "tok-123", + }, + }, + }; + + const result = await resolveApiKeyForProfile({ + cfg: cfgFor(profileId, "anthropic", "api_key"), + store, + profileId, + }); + expect(result).toBeNull(); + }); + + it("rejects oauth credentials when config mode is token", async () => { + const profileId = "anthropic:oauth"; + const store: AuthProfileStore = { + version: 1, + profiles: { + [profileId]: { + type: "oauth", + provider: "anthropic", + access: "access-123", + refresh: "refresh-123", + expires: Date.now() + 60_000, + }, + }, + }; + + const result = await resolveApiKeyForProfile({ + cfg: cfgFor(profileId, "anthropic", "token"), + store, + profileId, + }); + expect(result).toBeNull(); + }); + + it("rejects credentials when provider does not match config", async () => { + const profileId = "anthropic:token"; + const store: AuthProfileStore = { + version: 1, + profiles: { + [profileId]: { + type: "token", + provider: "anthropic", + token: "tok-123", + }, + }, + }; + + const result = await resolveApiKeyForProfile({ + cfg: cfgFor(profileId, "openai", "token"), + store, + profileId, + }); + expect(result).toBeNull(); + }); +}); diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts index b757925379..d0d5dec24c 100644 --- a/src/agents/auth-profiles/oauth.ts +++ b/src/agents/auth-profiles/oauth.ts @@ -23,6 +23,31 @@ const isOAuthProvider = (provider: string): provider is OAuthProvider => const resolveOAuthProvider = (provider: string): OAuthProvider | null => isOAuthProvider(provider) ? provider : null; +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 && profileConfig.mode !== params.mode) { + if ( + !( + params.allowOAuthTokenCompatibility && + profileConfig.mode === "oauth" && + params.mode === "token" + ) + ) { + return false; + } + } + return true; +} + function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): string { const needsProjectId = provider === "google-gemini-cli" || provider === "google-antigravity"; return needsProjectId @@ -33,6 +58,14 @@ function buildOAuthApiKey(provider: string, credentials: OAuthCredentials): stri : credentials.access; } +function buildApiKeyProfileResult(params: { apiKey: string; provider: string; email?: string }) { + return { + apiKey: params.apiKey, + provider: params.provider, + email: params.email, + }; +} + async function refreshOAuthTokenWithLock(params: { profileId: string; agentDir?: string; @@ -103,20 +136,23 @@ async function tryResolveOAuthProfile(params: { if (!cred || cred.type !== "oauth") { return null; } - const profileConfig = cfg?.auth?.profiles?.[profileId]; - if (profileConfig && profileConfig.provider !== cred.provider) { - return null; - } - if (profileConfig && profileConfig.mode !== cred.type) { + if ( + !isProfileConfigCompatible({ + cfg, + profileId, + provider: cred.provider, + mode: cred.type, + }) + ) { return null; } if (Date.now() < cred.expires) { - return { + return buildApiKeyProfileResult({ apiKey: buildOAuthApiKey(cred.provider, cred), provider: cred.provider, email: cred.email, - }; + }); } const refreshed = await refreshOAuthTokenWithLock({ @@ -126,11 +162,11 @@ async function tryResolveOAuthProfile(params: { if (!refreshed) { return null; } - return { + return buildApiKeyProfileResult({ apiKey: refreshed.apiKey, provider: cred.provider, email: cred.email, - }; + }); } export async function resolveApiKeyForProfile(params: { @@ -144,23 +180,25 @@ export async function resolveApiKeyForProfile(params: { if (!cred) { return null; } - const profileConfig = cfg?.auth?.profiles?.[profileId]; - if (profileConfig && profileConfig.provider !== cred.provider) { + if ( + !isProfileConfigCompatible({ + cfg, + profileId, + provider: cred.provider, + mode: cred.type, + // Compatibility: treat "oauth" config as compatible with stored token profiles. + allowOAuthTokenCompatibility: true, + }) + ) { return null; } - if (profileConfig && profileConfig.mode !== cred.type) { - // Compatibility: treat "oauth" config as compatible with stored token profiles. - if (!(profileConfig.mode === "oauth" && cred.type === "token")) { - return null; - } - } if (cred.type === "api_key") { const key = cred.key?.trim(); if (!key) { return null; } - return { apiKey: key, provider: cred.provider, email: cred.email }; + return buildApiKeyProfileResult({ apiKey: key, provider: cred.provider, email: cred.email }); } if (cred.type === "token") { const token = cred.token?.trim(); @@ -175,14 +213,14 @@ export async function resolveApiKeyForProfile(params: { ) { return null; } - return { apiKey: token, provider: cred.provider, email: cred.email }; + return buildApiKeyProfileResult({ apiKey: token, provider: cred.provider, email: cred.email }); } if (Date.now() < cred.expires) { - return { + return buildApiKeyProfileResult({ apiKey: buildOAuthApiKey(cred.provider, cred), provider: cred.provider, email: cred.email, - }; + }); } try { @@ -193,20 +231,20 @@ export async function resolveApiKeyForProfile(params: { if (!result) { return null; } - return { + 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 { + return buildApiKeyProfileResult({ apiKey: buildOAuthApiKey(refreshed.provider, refreshed), provider: refreshed.provider, email: refreshed.email ?? cred.email, - }; + }); } const fallbackProfileId = suggestOAuthProfileIdForLegacyDefault({ cfg, @@ -244,11 +282,11 @@ export async function resolveApiKeyForProfile(params: { agentDir: params.agentDir, expires: new Date(mainCred.expires).toISOString(), }); - return { + return buildApiKeyProfileResult({ apiKey: buildOAuthApiKey(mainCred.provider, mainCred), provider: mainCred.provider, email: mainCred.email, - }; + }); } } catch { // keep original error if main agent fallback also fails