diff --git a/.changeset/anthropic-oauth-refresh-scope.md b/.changeset/anthropic-oauth-refresh-scope.md index 31c262f722..6dbb6e0632 100644 --- a/.changeset/anthropic-oauth-refresh-scope.md +++ b/.changeset/anthropic-oauth-refresh-scope.md @@ -4,4 +4,4 @@ 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. +dev: Two-part fix. (1) 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. (2) `/auth/status` now reports an unexpired Anthropic OAuth token that lacks an inference scope as not-connected (authenticated:false, expired:true so the re-login banner fires) with a scope-specific loginError, instead of falsely claiming a live session. Existing narrowed tokens need one re-login to obtain a fresh broad grant. diff --git a/packages/dashboard/src/__tests__/routes-auth.test.ts b/packages/dashboard/src/__tests__/routes-auth.test.ts index a0de003484..52f8a86e62 100644 --- a/packages/dashboard/src/__tests__/routes-auth.test.ts +++ b/packages/dashboard/src/__tests__/routes-auth.test.ts @@ -1184,6 +1184,80 @@ describe("GET /auth/status", () => { expect(anthropic).toMatchObject({ authenticated: false, expired: true }); }); + /* + FNXC:ClaudeOAuth 2026-07-05-19:10: + A present, unexpired Anthropic subscription OAuth token whose scopes lack an inference + capability (e.g. profile-only grant, or one a buggy refresh narrowed to user:profile) + authenticates identity but 403s every model call. /auth/status must report it as + not-connected (authenticated:false, expired:true so the re-login banner fires) with a + scope-specific loginError — not as a healthy session. The complementary test below + guards against a false negative: a token that DOES carry user:inference stays connected. + */ + it("reports an unexpired Anthropic subscription OAuth token that lacks the inference scope as not-connected", async () => { + const now = Date.now(); + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription"); + (authStorage.get as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription" ? ({ + type: "oauth", + access: "profile-only-token", + refresh: "refresh", + expires: now + 3_600_000, + scopes: ["user:profile"], + }) : undefined); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + expect(anthropic).toMatchObject({ authenticated: false, expired: true }); + expect(anthropic.loginError).toMatch(/inference/i); + }); + + it("keeps an unexpired Anthropic subscription OAuth token that carries user:inference connected", async () => { + const now = Date.now(); + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription"); + (authStorage.get as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription" ? ({ + type: "oauth", + access: "inference-capable-token", + refresh: "refresh", + expires: now + 3_600_000, + scopes: ["user:profile", "user:inference"], + }) : undefined); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + expect(anthropic).toMatchObject({ authenticated: true, expired: false }); + expect(anthropic.loginError).toBeUndefined(); + }); + + it("does not penalize an unexpired Anthropic subscription OAuth token that records no scopes", async () => { + const now = Date.now(); + (authStorage.getOAuthProviders as ReturnType).mockReturnValue([ + { id: "anthropic", name: "Anthropic" }, + ]); + (authStorage.hasAuth as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription"); + (authStorage.get as ReturnType).mockImplementation((provider: string) => provider === "anthropic-subscription" ? ({ + type: "oauth", + access: "fresh-login-token", + refresh: "refresh", + expires: now + 3_600_000, + // Fresh pi-ai login persists no `scopes` field — must be treated as usable. + }) : undefined); + + const res = await GET(app, "/api/auth/status"); + + expect(res.status).toBe(200); + const anthropic = res.body.providers.find((p: any) => p.id === "anthropic-subscription"); + expect(anthropic).toMatchObject({ authenticated: true, expired: false }); + }); + /* FNXC:ProviderAuth 2026-07-05-00:00: FN-7574 symptom verification: an expired, unrefreshable Anthropic Subscription OAuth diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index dd72663774..cf38503980 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -114,6 +114,47 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { return Date.now() >= credential.expires; } + /* + FNXC:ClaudeOAuth 2026-07-05-19:10: + An Anthropic subscription OAuth token can be present AND unexpired yet still be unable to run models — e.g. a profile-only grant, or a token that a buggy refresh narrowed to `user:profile` (root cause fixed in packages/engine/src/auth-storage.ts). Such a token proves identity but 403s on every model call ("OAuth token does not meet scope requirement any_of(user:inference, ...)"), which is exactly how the status card came to claim "logged in" while all inference failed. So /auth/status must treat an inference-incapable token as not-connected, not authenticated. + Mirror the API's any_of inference set. Only penalize when scopes ARE recorded and none is inference-capable: a fresh pi-ai login persists NO `scopes` field, so absent/empty scopes are treated as unknown-but-usable to avoid falsely reporting a good login as disconnected. + */ + const ANTHROPIC_INFERENCE_SCOPES = new Set([ + "user:inference", + "user:developer", + "user:ccr_inference", + "user:voice", + "org:service_key_inference", + "workspace:developer", + "workspace:inference", + ]); + + function isAnthropicOauthProviderId(providerId: string): boolean { + return providerId === ANTHROPIC_OAUTH_PROVIDER_ID || providerId === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID; + } + + function isInferenceIncapableAnthropicOauth(providerId: string, storage: AuthStorageLike): boolean { + // Scope semantics are Anthropic-specific — never apply this to github-copilot, + // openai-codex, or other OAuth providers whose scope sets are unrelated. + if (!isAnthropicOauthProviderId(providerId)) { + return false; + } + const credential = getOauthStatusCredential(providerId, storage); + if (!credential) { + return false; + } + const rawScopes = (credential as { scopes?: unknown }).scopes; + if (!Array.isArray(rawScopes)) { + return false; + } + const scopes = rawScopes.filter((scope): scope is string => typeof scope === "string" && scope.trim().length > 0); + if (scopes.length === 0) { + // Unknown scopes (e.g. fresh login that records none) — assume usable. + return false; + } + return !scopes.some((scope) => ANTHROPIC_INFERENCE_SCOPES.has(scope)); + } + type ManualCodeConfig = { prompt: string; placeholder?: string; @@ -501,15 +542,25 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { hasAuth = storage.hasAuth(storageProviderId); expired = hasAuth && isExpiredOauthCredential(storageProviderId, storage); } + /* + FNXC:ClaudeOAuth 2026-07-05-19:10: + A present, unexpired Anthropic OAuth token that lacks an inference scope cannot run models, so it must report as not-connected with the same remediation as an expired session (re-login). Fold it into `expired` so the existing OAuthReloginBanner (which keys on `expired===true`) prompts re-authentication, and set a specific `loginError` so SettingsModal explains the cause rather than showing a bare "expired". Evaluated only when the token is otherwise live (has auth and not already expired) to avoid redundant messaging. + */ + const missingInferenceScope = hasAuth + && !expired + && isInferenceIncapableAnthropicOauth(storageProviderId, storage); + const scopeLoginError = missingInferenceScope + ? "This Anthropic login is missing the model-access (inference) scope, so model calls will fail. Re-login to grant full access." + : undefined; return { id: statusProvider.id, name: statusProvider.name, - authenticated: hasAuth && !expired, + authenticated: hasAuth && !expired && !missingInferenceScope, type: "oauth" as const, - expired, + expired: expired || missingInferenceScope, loginInProgress: loginInProgress.has(statusProvider.id), requiresManualCode: getManualCodeConfig(toOauthLoginProviderId(statusProvider.id), origin) !== undefined || undefined, - loginError: lastLoginError.get(statusProvider.id), + loginError: lastLoginError.get(statusProvider.id) ?? scopeLoginError, }; }));