fix: report scope-incapable Anthropic OAuth as not-connected in /auth/status

A present, unexpired Anthropic subscription OAuth token that lacks an
inference scope (e.g. a profile-only grant) authenticates identity but
403s on every model call. /auth/status previously validated only token
presence + expiry, so it reported such a token as connected while all
inference failed. It now treats an inference-incapable Anthropic OAuth
token as not-connected (authenticated:false, expired:true so the
re-login banner fires) with a scope-specific loginError. Gated to
Anthropic providers only; tokens with no recorded scopes are treated as
usable to avoid false negatives on fresh logins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-05 18:57:08 -07:00
parent b9d60b3c39
commit c2eb89b8d4
3 changed files with 129 additions and 4 deletions

View File

@@ -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.

View File

@@ -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<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic-subscription");
(authStorage.get as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic-subscription");
(authStorage.get as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue([
{ id: "anthropic", name: "Anthropic" },
]);
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockImplementation((provider: string) => provider === "anthropic-subscription");
(authStorage.get as ReturnType<typeof vi.fn>).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

View File

@@ -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,
};
}));