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:
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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" });
|
||||
}, []);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user