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.
|
// 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({
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user