diff --git a/.changeset/anthropic-oauth-refresh-preserve-scopes.md b/.changeset/anthropic-oauth-refresh-preserve-scopes.md new file mode 100644 index 0000000000..796fdd5d7f --- /dev/null +++ b/.changeset/anthropic-oauth-refresh-preserve-scopes.md @@ -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. diff --git a/.changeset/anthropic-oauth-refresh-scope.md b/.changeset/anthropic-oauth-refresh-scope.md new file mode 100644 index 0000000000..31c262f722 --- /dev/null +++ b/.changeset/anthropic-oauth-refresh-scope.md @@ -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. diff --git a/packages/engine/src/__tests__/auth-storage.test.ts b/packages/engine/src/__tests__/auth-storage.test.ts index bbbfd3ac19..bcb56fa352 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -415,11 +415,16 @@ describe("createFusionAuthStorage", () => { // subscription id only — the raw `anthropic` slot stays empty. expect(await authStorage.getApiKey("anthropic")).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( "https://platform.claude.com/v1/oauth/token", expect.objectContaining({ method: "POST", - body: expect.stringContaining("\"scope\":\"user:profile org:create_api_key\""), + body: expect.not.stringContaining("\"scope\""), }), ); expect(authStorage.get("anthropic-subscription")).toEqual({ @@ -739,11 +744,13 @@ describe("createFusionAuthStorage", () => { const authStorage = createFusionAuthStorage(); 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( "https://platform.claude.com/v1/oauth/token", expect.objectContaining({ method: "POST", - body: expect.stringContaining("\"scope\":\"user:profile org:create_api_key\""), + body: expect.not.stringContaining("\"scope\""), }), ); expect(authStorage.get("anthropic")).toEqual({ diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index da3fb15595..2a830942b1 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -33,7 +33,18 @@ const ANTHROPIC_PROVIDER_ID = "anthropic"; const ANTHROPIC_SUBSCRIPTION_PROVIDER_ID = "anthropic-subscription"; const ANTHROPIC_TOKEN_ENDPOINT = "https://platform.claude.com/v1/oauth/token"; 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_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. 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, { method: "POST", headers: { @@ -222,7 +237,6 @@ async function refreshAnthropicOAuthCredential(credential: StoredCredential): Pr grant_type: "refresh_token", refresh_token: refresh, client_id: ANTHROPIC_OAUTH_CLIENT_ID, - scope: scopes.join(" "), }), signal: controller.signal, });