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) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-05 09:36:53 -07:00
parent b471aece6a
commit 4b530a65de
6 changed files with 150 additions and 3 deletions

View File

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

View File

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

View File

@@ -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<Record<string, string>>({});
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" });
}, []);

View File

@@ -135,6 +135,13 @@ export const registerAuthRoutes: ApiRouteRegistrar = (ctx) => {
*/
const loginInProgress = new Map<string, PendingLogin>();
/*
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<string, string>();
const OAUTH_SESSION_TTL_MS = 5 * 60 * 1000;
const oauthSessions = new Map<string, { port: number; path: string; originalRedirectUri: string; expiresAt: number }>();
@@ -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);

View File

@@ -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: {

View File

@@ -349,6 +349,26 @@ export function createFusionAuthStorage(): AuthStorage {
// Cleared when the user re-authenticates via set().
const loggedOutProviders = new Set<string>();
/*
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<void>;
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);
};
}