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:
7
.changeset/anthropic-oauth-refresh-preserve-scopes.md
Normal file
7
.changeset/anthropic-oauth-refresh-preserve-scopes.md
Normal 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.
|
||||
7
.changeset/anthropic-oauth-refresh-scope.md
Normal file
7
.changeset/anthropic-oauth-refresh-scope.md
Normal 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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user