From 4b530a65de9c3643c8abe382873e4fa64b5464ef Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 5 Jul 2026 09:36:53 -0700 Subject: [PATCH] fix: restore Anthropic subscription card after in-session logout + re-login Subscription OAuth is aliased across the legacy `anthropic` id (where login persists the credential) and `anthropic-subscription` (where the settings card and status read are keyed). After an in-session logout, re-login wrote only `anthropic` and never cleared the in-memory `anthropic-subscription` logged-out flag, so the card reported "Login did not complete" despite a valid stored credential until the process restarted. auth-storage's proxy now clears the logged-out suppression on both aliases when either is re-authenticated (new `login` trap + hardened `set` trap via clearReauthenticatedLogoutState); raw api_key writes stay scoped to their own card. Also surface previously-swallowed background OAuth login failures on GET /auth/status (`loginError`) plus server logs and a settings toast, so real paste-callback failures are diagnosable instead of a generic error. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ropic-subscription-relogin-after-logout.md | 7 +++ packages/dashboard/app/api/legacy.ts | 9 ++++ .../app/components/SettingsModal.tsx | 22 ++++++++ .../src/routes/register-auth-routes.ts | 23 ++++++++- .../engine/src/__tests__/auth-storage.test.ts | 50 +++++++++++++++++++ packages/engine/src/auth-storage.ts | 42 +++++++++++++++- 6 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-anthropic-subscription-relogin-after-logout.md diff --git a/.changeset/fix-anthropic-subscription-relogin-after-logout.md b/.changeset/fix-anthropic-subscription-relogin-after-logout.md new file mode 100644 index 0000000000..e6c3bb56b3 --- /dev/null +++ b/.changeset/fix-anthropic-subscription-relogin-after-logout.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix Claude/Anthropic subscription re-login showing "Login did not complete" after logging out. +category: fix +dev: Anthropic subscription OAuth is aliased across the legacy `anthropic` row (where interactive login persists the credential) and the `anthropic-subscription` id (where the settings card's in-memory logged-out suppression and status read are keyed). Re-login wrote only `anthropic`, so `loggedOutProviders` kept suppressing `anthropic-subscription` and the card reported failure despite a valid stored credential until process restart. auth-storage's proxy now clears the logged-out state on both aliases when either is re-authenticated (new `login` trap + hardened `set` trap via `clearReauthenticatedLogoutState`; raw api_key writes stay scoped to their own card). Also surfaces background OAuth login failures on `GET /auth/status` (`loginError`) + server logs so future paste-callback failures are diagnosable instead of a generic error. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index b7dde38529..b83b9a43cb 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -1773,6 +1773,15 @@ export interface AuthProvider { expired?: boolean; /** True when the redirect cannot reach this dashboard host and the user must paste the URL/code back manually. */ requiresManualCode?: boolean; + /** + * Reason the most recent background OAuth login attempt failed, if any. + * Interactive logins resolve the auth URL immediately and finish in the + * background; when that background flow rejects (bad/expired code, token + * exchange rejection, redirect_uri mismatch) this carries the cause so the + * UI can show why login failed instead of a generic error. Cleared when a + * fresh login for the provider starts. + */ + loginError?: string; /** * How this provider authenticates / is activated. * - "oauth": OAuth flow (user clicks Login → redirect) diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 5737f86e5b..df4fe43bb0 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1807,6 +1807,28 @@ export function SettingsModal({ return () => clearInterval(interval); }, [activeSection, authProviders, loadAuthStatus]); + /* + FNXC:ProviderAuth 2026-07-05-00:00: + Interactive OAuth login finishes in the background after the auth URL/paste box is shown, so a failure (bad/expired pasted code, token-exchange rejection) only appears in the polled `/auth/status` as `loginError`. Surface that cause to the user once per distinct error instead of leaving login silently stuck, so paste-callback failures are diagnosable from the UI rather than only "it fails". + */ + const shownLoginErrorsRef = useRef>({}); + useEffect(() => { + for (const provider of authProviders) { + const error = provider.loginError; + if (!error) { + if (provider.id in shownLoginErrorsRef.current) { + delete shownLoginErrorsRef.current[provider.id]; + } + continue; + } + if (shownLoginErrorsRef.current[provider.id] === error) { + continue; + } + shownLoginErrorsRef.current[provider.id] = error; + addToast(`${provider.name} login failed: ${error}`, "error"); + } + }, [authProviders, addToast]); + const scrollSettingsToTop = useCallback(() => { settingsContentRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }, []); diff --git a/packages/dashboard/src/routes/register-auth-routes.ts b/packages/dashboard/src/routes/register-auth-routes.ts index 2e04baf7ff..dd72663774 100644 --- a/packages/dashboard/src/routes/register-auth-routes.ts +++ b/packages/dashboard/src/routes/register-auth-routes.ts @@ -135,6 +135,13 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { */ const loginInProgress = new Map(); + /* + FNXC:ProviderAuth 2026-07-05-00:00: + Interactive OAuth login (e.g. Anthropic subscription paste-callback flow) resolves the auth URL to the client immediately, then the real login continues in the background. When the background `storage.login` rejects — bad/expired code, token-exchange rejection, redirect_uri mismatch — that error was previously dropped on the floor: `rejectAuthInfo` is a no-op once the auth URL has been sent, and nothing logged or surfaced it. The UI (which only polls `/auth/status`) then showed a generic "login failed" with no cause, making the failure undiagnosable for both users and maintainers. + Retain the last background login error per provider so `/auth/status` can report why it failed, and always log it server-side. Cleared when a fresh login for the same provider starts. + */ + const lastLoginError = new Map(); + const OAUTH_SESSION_TTL_MS = 5 * 60 * 1000; const oauthSessions = new Map(); @@ -474,6 +481,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { keyHint?: string; loginInProgress?: boolean; requiresManualCode?: boolean; + loginError?: string; }[] = await Promise.all(oauthProviders.map(async (p) => { const statusProvider = toAuthStatusProvider(p); const storageProviderId = toOauthCredentialProviderId(statusProvider.id); @@ -501,6 +509,7 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { expired, loginInProgress: loginInProgress.has(statusProvider.id), requiresManualCode: getManualCodeConfig(toOauthLoginProviderId(statusProvider.id), origin) !== undefined || undefined, + loginError: lastLoginError.get(statusProvider.id), }; })); @@ -1082,6 +1091,9 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { throw conflict(`Login already in progress for ${provider}`); } + // Fresh login attempt clears any prior background failure for this provider. + lastLoginError.delete(provider); + const storage = getAuthStorage(); const oauthProviders = storage.getOAuthProviders(); const found = oauthProviders.find((p) => p.id === provider || p.id === storageProvider); @@ -1201,7 +1213,16 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => { }) .catch((err: unknown) => { // Login failed — also reject auth URL if not yet received - rejectAuthInfo(err instanceof Error ? err : new Error(String(err))); + const error = err instanceof Error ? err : new Error(String(err)); + // Surface the real cause: reject the auth-URL promise if it hasn't + // resolved yet, and always retain + log the error. Once the auth URL + // is already sent, rejectAuthInfo is a no-op, so this retained error + // is the only channel by which the client learns why login failed. + rejectAuthInfo(error); + if (error.message !== "cancelled") { + lastLoginError.set(provider, error.message); + console.error(`[auth/login] background login failed for ${provider}: ${error.message}`); + } }) .finally(() => { clearTimeout(timeout); diff --git a/packages/engine/src/__tests__/auth-storage.test.ts b/packages/engine/src/__tests__/auth-storage.test.ts index 168254e7d4..bbbfd3ac19 100644 --- a/packages/engine/src/__tests__/auth-storage.test.ts +++ b/packages/engine/src/__tests__/auth-storage.test.ts @@ -262,6 +262,56 @@ describe("createFusionAuthStorage", () => { expect(await authStorage.getApiKey("anthropic-subscription")).toBe("legacy-subscription-access-token"); }); + it("restores the subscription card when re-login writes the credential under the legacy anthropic id", async () => { + // Repro of FN: interactive subscription login persists OAuth under `anthropic`, + // but the settings card / status read is keyed on `anthropic-subscription`. + // After an in-session logout the subscription id is suppressed; a successful + // re-login must clear that suppression on BOTH aliases or the card is stuck + // reporting "Login did not complete" despite a valid stored credential. + const authStorage = createFusionAuthStorage(); + + authStorage.logout("anthropic-subscription"); + expect(authStorage.hasAuth("anthropic-subscription")).toBe(false); + + // `set` under the legacy id mirrors what interactive login persists. + authStorage.set("anthropic", { + type: "oauth", + access: "relogin-access-token", + refresh: "relogin-refresh-token", + expires: Date.now() + 3_600_000, + }); + + expect(authStorage.hasAuth("anthropic-subscription")).toBe(true); + expect(await authStorage.getApiKey("anthropic-subscription")).toBe("relogin-access-token"); + }); + + it("restores the subscription card when re-auth writes under the subscription id", async () => { + const authStorage = createFusionAuthStorage(); + + authStorage.logout("anthropic-subscription"); + expect(authStorage.hasAuth("anthropic-subscription")).toBe(false); + + authStorage.set("anthropic-subscription", { + type: "oauth", + access: "subscription-relogin-token", + refresh: "subscription-relogin-refresh", + expires: Date.now() + 3_600_000, + }); + + expect(authStorage.hasAuth("anthropic-subscription")).toBe(true); + }); + + it("does not revive a logged-out subscription card from a raw anthropic API key", async () => { + // A raw `anthropic` API key belongs to its own card and must not alias into + // the subscription's logged-out state — only OAuth credentials do. + const authStorage = createFusionAuthStorage(); + + authStorage.logout("anthropic-subscription"); + authStorage.set("anthropic", { type: "api_key", key: "sk-ant-api03-raw-key" }); + + expect(authStorage.hasAuth("anthropic-subscription")).toBe(false); + }); + it("refreshes legacy Anthropic OAuth in place for direct runtime auth", async () => { writeFusionAuth(homeDir, { anthropic: { diff --git a/packages/engine/src/auth-storage.ts b/packages/engine/src/auth-storage.ts index d8d1486c79..da3fb15595 100644 --- a/packages/engine/src/auth-storage.ts +++ b/packages/engine/src/auth-storage.ts @@ -349,6 +349,26 @@ export function createFusionAuthStorage(): AuthStorage { // Cleared when the user re-authenticates via set(). const loggedOutProviders = new Set(); + /* + FNXC:ProviderAuth 2026-07-05-00:00: + Re-authenticating a provider must clear its in-memory logged-out suppression so the settings card flips back to connected. + Anthropic subscription OAuth is aliased across the legacy `anthropic` row — where interactive login persists the credential — and the separated `anthropic-subscription` id, where the card's logged-out flag and status read are keyed. Because a re-login only writes `anthropic`, clearing just the written id left `anthropic-subscription` suppressed: a user who logged out of the subscription earlier in the same dashboard session saw every successful re-login reported as "Login did not complete. Please try again." until the process restarted (the credential was valid on disk the whole time). Clear BOTH aliases when either is re-authenticated. Only OAuth credentials alias this way; a raw `anthropic` API key stays scoped to its own card, so api_key writes never clear the subscription alias. + */ + const clearReauthenticatedLogoutState = ( + provider: string, + credentialType?: StoredCredential["type"], + ) => { + loggedOutProviders.delete(provider); + oauthRefreshCooldownUntil.delete(provider); + const isAnthropicAlias = + provider === ANTHROPIC_PROVIDER_ID || provider === ANTHROPIC_SUBSCRIPTION_PROVIDER_ID; + const aliasesSubscriptionOAuth = credentialType === undefined || credentialType === "oauth"; + if (isAnthropicAlias && aliasesSubscriptionOAuth) { + loggedOutProviders.delete(ANTHROPIC_PROVIDER_ID); + loggedOutProviders.delete(ANTHROPIC_SUBSCRIPTION_PROVIDER_ID); + } + }; + const syncSupplementalOauthCredentials = () => { for (const [provider, credential] of Object.entries(supplementalCredentials)) { if (loggedOutProviders.has(provider)) { @@ -629,11 +649,29 @@ export function createFusionAuthStorage(): AuthStorage { }; } + if (prop === "login") { + // Preserve the original invocation semantics (bind `this` to the proxy so + // any internal credential writes still flow through the set/logout traps), + // and only ADD the alias-aware logged-out clearing on top. + const originalLogin = Reflect.get(target, prop, receiver) as ( + provider: string, + callbacks: unknown, + ) => Promise; + return async (provider: string, callbacks: unknown) => { + const result = await originalLogin.call(receiver, provider, callbacks); + /* + FNXC:ProviderAuth 2026-07-05-00:00: + A completed interactive login means the user re-authenticated this provider, so lift its logged-out suppression (and the Anthropic subscription alias) even though the credential is persisted under `anthropic`. Without this, subscription re-login after an in-session logout stays invisible to the status card. See clearReauthenticatedLogoutState. + */ + clearReauthenticatedLogoutState(provider); + return result; + }; + } + if (prop === "set") { return (provider: string, credential: AuthCredential) => { target.set(provider, credential); - loggedOutProviders.delete(provider); - oauthRefreshCooldownUntil.delete(provider); + clearReauthenticatedLogoutState(provider, (credential as StoredCredential | undefined)?.type); }; }