fix: preserve Claude OAuth scopes on token refresh so inference keeps working

The Anthropic OAuth refresh request sent `scope: user:profile`, which under
RFC 6749 §6 re-issues the access token with exactly that scope — stripping
`user:inference` and 403-ing every model call while the account still read
as "logged in via OAuth". Stop sending `scope` on refresh (Anthropic then
preserves the originally-granted scopes, matching pi-ai), and widen
ANTHROPIC_DEFAULT_SCOPES to mirror pi-ai's full granted Claude Code scope
set so any fallback describes a usable token.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-05 18:47:40 -07:00
parent 2025f9d56d
commit ed823c794c
4 changed files with 39 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Claude subscription login so model calls stop 403-ing after an OAuth token refresh.
category: fix
dev: `refreshAnthropicOAuthCredential` no longer sends `scope` on the refresh request (RFC 6749 §6 re-issues the token with exactly that scope, which stripped `user:inference` and narrowed refreshed tokens to `user:profile`). `ANTHROPIC_DEFAULT_SCOPES` now mirrors pi-ai's full granted Claude Code scope set so any fallback describes a usable token.

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix Anthropic subscription showing "logged in" while all model calls fail.
category: fix
dev: OAuth token refresh in `packages/engine/src/auth-storage.ts` sent a `scope` param (defaulting to `user:profile`), which per RFC 6749 §6 re-issued the access token narrowed to that scope and stripped `user:inference` — so refreshed tokens 403'd on every model call. Refresh now omits `scope` (preserving the originally-granted scopes, matching pi-ai's own refresh), and `ANTHROPIC_DEFAULT_SCOPES` mirrors the full Claude Code scope set. Existing narrowed tokens need one re-login to obtain a fresh broad grant.

View File

@@ -415,11 +415,16 @@ describe("createFusionAuthStorage", () => {
// subscription id only — the raw `anthropic` slot stays empty. // subscription id only — the raw `anthropic` slot stays empty.
expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-subscription-access-token"); expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-subscription-access-token");
expect(await authStorage.getApiKey("anthropic-subscription")).toBe("refreshed-subscription-access-token"); expect(await authStorage.getApiKey("anthropic-subscription")).toBe("refreshed-subscription-access-token");
// FNXC:ClaudeOAuth 2026-07-05-18:52: the refresh request MUST NOT send `scope`.
// Per RFC 6749 §6 an included scope re-issues the token with exactly that scope
// (never broader), which previously narrowed refreshed tokens to profile-only and
// stripped `user:inference` — leaving the account "logged in" yet 403ing on every
// model call. Omitting scope makes Anthropic preserve the originally-granted scopes.
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
"https://platform.claude.com/v1/oauth/token", "https://platform.claude.com/v1/oauth/token",
expect.objectContaining({ expect.objectContaining({
method: "POST", method: "POST",
body: expect.stringContaining("\"scope\":\"user:profile org:create_api_key\""), body: expect.not.stringContaining("\"scope\""),
}), }),
); );
expect(authStorage.get("anthropic-subscription")).toEqual({ expect(authStorage.get("anthropic-subscription")).toEqual({
@@ -739,11 +744,13 @@ describe("createFusionAuthStorage", () => {
const authStorage = createFusionAuthStorage(); const authStorage = createFusionAuthStorage();
expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-claude-access-token"); expect(await authStorage.getApiKey("anthropic")).toBe("refreshed-claude-access-token");
// FNXC:ClaudeOAuth 2026-07-05-18:52: refresh must omit `scope` so Anthropic preserves
// the original grant (RFC 6749 §6); sending it previously stripped `user:inference`.
expect(fetchMock).toHaveBeenCalledWith( expect(fetchMock).toHaveBeenCalledWith(
"https://platform.claude.com/v1/oauth/token", "https://platform.claude.com/v1/oauth/token",
expect.objectContaining({ expect.objectContaining({
method: "POST", method: "POST",
body: expect.stringContaining("\"scope\":\"user:profile org:create_api_key\""), body: expect.not.stringContaining("\"scope\""),
}), }),
); );
expect(authStorage.get("anthropic")).toEqual({ expect(authStorage.get("anthropic")).toEqual({

View File

@@ -33,7 +33,18 @@ const ANTHROPIC_PROVIDER_ID = "anthropic";
const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription";
const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token"; const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token";
const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; const ANTHROPIC_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const ANTHROPIC_DEFAULT_SCOPES = ["user:profile"]; /*
FNXC:ClaudeOAuth 2026-07-05-18:52:
Anthropic subscription login (delegated to pi-ai) grants the full Claude Code scope set — `user:inference` is what authorizes model calls. Earlier this constant was `["user:profile"]`, which was WRONG twice over: (1) it under-describes the token pi-ai actually obtains, and (2) it was fed into the refresh request's `scope` param, which under RFC 6749 §6 NARROWS the refreshed access token to profile-only and strips `user:inference`. The symptom: the account reads "logged in via OAuth" (token present + unexpired) yet every model call 403s with "OAuth token does not meet scope requirement any_of(user:inference, ...)". The default must mirror pi-ai's granted scopes so any fallback describes a usable token, and the refresh path (below) must NOT send it as a narrowing scope.
*/
const ANTHROPIC_DEFAULT_SCOPES = [
"org:create_api_key",
"user:profile",
"user:inference",
"user:sessions:claude_code",
"user:mcp_servers",
"user:file_upload",
];
const OAUTH_REFRESH_TIMEOUT_MS = 10_000; const OAUTH_REFRESH_TIMEOUT_MS = 10_000;
const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000; const OAUTH_REFRESH_FAILURE_COOLDOWN_MS = 30_000;
@@ -212,6 +223,10 @@ async function refreshAnthropicOAuthCredential(credential: StoredCredential): Pr
Fusion must renew expired Claude OAuth credentials with the stored refresh token so users are not forced through repeated manual Claude re-login when the access token expires. Fusion must renew expired Claude OAuth credentials with the stored refresh token so users are not forced through repeated manual Claude re-login when the access token expires.
Persist the rotated access token in Fusion auth storage because model execution and dashboard usage resolve credentials through different runtime paths. Persist the rotated access token in Fusion auth storage because model execution and dashboard usage resolve credentials through different runtime paths.
*/ */
/*
FNXC:ClaudeOAuth 2026-07-05-18:52:
Do NOT send `scope` on refresh. RFC 6749 §6: a refresh request that includes `scope` re-issues the access token with EXACTLY that scope (never broader), so sending our stored/derived scope list can only strip capabilities — and did: it narrowed refreshed tokens to `user:profile` and broke inference. Omitting `scope` makes Anthropic preserve the originally-granted scopes (this is what pi-ai's own `refreshAnthropicToken` does). `scopes` is still resolved above and used only as the parseScopes fallback for the persisted credential record.
*/
const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, { const response = await fetch(ANTHROPIC_TOKEN_ENDPOINT, {
method: "POST", method: "POST",
headers: { headers: {
@@ -222,7 +237,6 @@ async function refreshAnthropicOAuthCredential(credential: StoredCredential): Pr
grant_type: "refresh_token", grant_type: "refresh_token",
refresh_token: refresh, refresh_token: refresh,
client_id: ANTHROPIC_OAUTH_CLIENT_ID, client_id: ANTHROPIC_OAUTH_CLIENT_ID,
scope: scopes.join(" "),
}), }),
signal: controller.signal, signal: controller.signal,
}); });