mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 17:38:27 +00:00
fix(auth): bidirectional mode/type compat + sync OAuth to all agents (#12692)
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: 2dee8e1174
Co-authored-by: mudrii <220262+mudrii@users.noreply.github.com>
Co-authored-by: obviyus <22031114+obviyus@users.noreply.github.com>
Reviewed-by: @obviyus
This commit is contained in:
@@ -114,6 +114,205 @@ describe("resolveApiKeyForProfile fallback to main agent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adopts newer OAuth token from main agent even when secondary token is still valid", async () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const now = Date.now();
|
||||
const secondaryExpiry = now + 30 * 60 * 1000;
|
||||
const mainExpiry = now + 2 * 60 * 60 * 1000;
|
||||
|
||||
const secondaryStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "secondary-access-token",
|
||||
refresh: "secondary-refresh-token",
|
||||
expires: secondaryExpiry,
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(secondaryAgentDir, "auth-profiles.json"),
|
||||
JSON.stringify(secondaryStore),
|
||||
);
|
||||
|
||||
const mainStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "main-newer-access-token",
|
||||
refresh: "main-newer-refresh-token",
|
||||
expires: mainExpiry,
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
|
||||
|
||||
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
|
||||
const result = await resolveApiKeyForProfile({
|
||||
store: loadedSecondaryStore,
|
||||
profileId,
|
||||
agentDir: secondaryAgentDir,
|
||||
});
|
||||
|
||||
expect(result?.apiKey).toBe("main-newer-access-token");
|
||||
|
||||
const updatedSecondaryStore = JSON.parse(
|
||||
await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
|
||||
) as AuthProfileStore;
|
||||
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
|
||||
access: "main-newer-access-token",
|
||||
expires: mainExpiry,
|
||||
});
|
||||
});
|
||||
|
||||
it("adopts main token when secondary expires is NaN/malformed", async () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const now = Date.now();
|
||||
const mainExpiry = now + 2 * 60 * 60 * 1000;
|
||||
|
||||
const secondaryStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "secondary-stale",
|
||||
refresh: "secondary-refresh",
|
||||
expires: NaN,
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(
|
||||
path.join(secondaryAgentDir, "auth-profiles.json"),
|
||||
JSON.stringify(secondaryStore),
|
||||
);
|
||||
|
||||
const mainStore: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "main-fresh-token",
|
||||
refresh: "main-refresh",
|
||||
expires: mainExpiry,
|
||||
},
|
||||
},
|
||||
};
|
||||
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
|
||||
|
||||
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
|
||||
const result = await resolveApiKeyForProfile({
|
||||
store: loadedSecondaryStore,
|
||||
profileId,
|
||||
agentDir: secondaryAgentDir,
|
||||
});
|
||||
|
||||
expect(result?.apiKey).toBe("main-fresh-token");
|
||||
});
|
||||
|
||||
it("accepts mode=token + type=oauth for legacy compatibility", async () => {
|
||||
const profileId = "anthropic:default";
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "oauth-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
provider: "anthropic",
|
||||
mode: "token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
store,
|
||||
profileId,
|
||||
});
|
||||
|
||||
expect(result?.apiKey).toBe("oauth-token");
|
||||
});
|
||||
|
||||
it("accepts mode=oauth + type=token (regression)", async () => {
|
||||
const profileId = "anthropic:default";
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "token",
|
||||
provider: "anthropic",
|
||||
token: "static-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
provider: "anthropic",
|
||||
mode: "oauth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
store,
|
||||
profileId,
|
||||
});
|
||||
|
||||
expect(result?.apiKey).toBe("static-token");
|
||||
});
|
||||
|
||||
it("rejects true mode/type mismatches", async () => {
|
||||
const profileId = "anthropic:default";
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
type: "oauth",
|
||||
provider: "anthropic",
|
||||
access: "oauth-token",
|
||||
refresh: "refresh-token",
|
||||
expires: Date.now() + 60_000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await resolveApiKeyForProfile({
|
||||
cfg: {
|
||||
auth: {
|
||||
profiles: {
|
||||
[profileId]: {
|
||||
provider: "anthropic",
|
||||
mode: "api_key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
store,
|
||||
profileId,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("throws error when both secondary and main agent credentials are expired", async () => {
|
||||
const profileId = "anthropic:claude-cli";
|
||||
const now = Date.now();
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("resolveApiKeyForProfile config compatibility", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects oauth credentials when config mode is token", async () => {
|
||||
it("accepts oauth credentials when config mode is token (bidirectional compat)", async () => {
|
||||
const profileId = "anthropic:oauth";
|
||||
const store: AuthProfileStore = {
|
||||
version: 1,
|
||||
@@ -80,7 +80,12 @@ describe("resolveApiKeyForProfile config compatibility", () => {
|
||||
store,
|
||||
profileId,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
// token ↔ oauth are bidirectionally compatible bearer-token auth paths.
|
||||
expect(result).toEqual({
|
||||
apiKey: "access-123",
|
||||
provider: "anthropic",
|
||||
email: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects credentials when provider does not match config", async () => {
|
||||
|
||||
@@ -23,6 +23,20 @@ const isOAuthProvider = (provider: string): provider is OAuthProvider =>
|
||||
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;
|
||||
@@ -34,16 +48,8 @@ function isProfileConfigCompatible(params: {
|
||||
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;
|
||||
}
|
||||
if (profileConfig && !isCompatibleModeType(profileConfig.mode, params.mode)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -91,6 +97,43 @@ type ResolveApiKeyForProfileParams = {
|
||||
agentDir?: string;
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -229,11 +272,20 @@ export async function resolveApiKeyForProfile(
|
||||
}
|
||||
return buildApiKeyProfileResult({ apiKey: token, provider: cred.provider, email: cred.email });
|
||||
}
|
||||
if (Date.now() < cred.expires) {
|
||||
|
||||
const oauthCred =
|
||||
adoptNewerMainOAuthCredential({
|
||||
store,
|
||||
profileId,
|
||||
agentDir: params.agentDir,
|
||||
cred,
|
||||
}) ?? cred;
|
||||
|
||||
if (Date.now() < oauthCred.expires) {
|
||||
return buildOAuthProfileResult({
|
||||
provider: cred.provider,
|
||||
credentials: cred,
|
||||
email: cred.email,
|
||||
provider: oauthCred.provider,
|
||||
credentials: oauthCred,
|
||||
email: oauthCred.email,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ export type AuthProfileFailureReason =
|
||||
| "rate_limit"
|
||||
| "billing"
|
||||
| "timeout"
|
||||
| "model_not_found"
|
||||
| "unknown";
|
||||
|
||||
/** Per-profile usage statistics for round-robin and cooldown tracking */
|
||||
|
||||
Reference in New Issue
Block a user