Skip to content

Commit 246e0e1

Browse files
hotlongCopilot
andcommitted
fix(auth): break /_account ↔ /_console login redirect loop
Root cause: Console's @object-ui/auth AuthProvider has a Bearer-token fetch wrapper that pulls auth-session-token from localStorage and sends it as Authorization on every /api/* call. The Account SPA's sign-in/sign-out paths use a different client (client-react) that touches cookies but never touches that localStorage key. After a user signs out on Account and a new user signs in, Account writes fresh cookies but localStorage still holds the previous user's Bearer token. Console then sends the stale Bearer to /get-session, the server prefers Bearer over Cookie and returns null, AuthProvider thinks the session is invalid, AuthGuard renders AccountLoginRedirect, the browser navigates to /_account/login, Account sees a valid cookie session and bounces back to /_console/home — infinite loop. Two-layer fix: 1. apps/console/src/lib/auth-preflight.ts (NEW) — before mounting <AuthProvider>, probe /get-session with the localStorage Bearer alone (no cookie). If the response is unauthenticated, purge the stale auth-session-token + auth-active-organization-id keys so AuthProvider's first getSession() falls back to the cookie cleanly. 2. apps/account/src/hooks/useSession.ts — mirror cookie session into localStorage on every refresh() and clear it on logout(), so the keys never go stale in the first place. Also stamp the active org id when the user changes orgs. Net effect: the loop is fixed at the source (Account keeps localStorage in sync) AND any historical stale state is self-healed on first visit (Console preflight). Single round-trip cost when token is valid; zero UI flicker because it runs before React mounts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 4e94e65 commit 246e0e1

3 files changed

Lines changed: 156 additions & 7 deletions

File tree

apps/account/src/hooks/useSession.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,44 @@ export interface Organization {
7777

7878
const SessionContext = createContext<SessionState | null>(null);
7979

80+
/**
81+
* Storage keys used by `@object-ui/auth`'s built-in Bearer fetch wrapper.
82+
* The Console SPA mounts an `<AuthProvider>` from that package which reads
83+
* `auth-session-token` out of localStorage and injects it as a Bearer header
84+
* on every API call. We don't use that AuthProvider here in Account, but we
85+
* MUST keep those keys in sync with our cookie session — otherwise a stale
86+
* token left by a previous user causes the Console to think the current
87+
* cookie session is invalid, bouncing it back to `/_account/login`, which
88+
* Account in turn bounces back to the Console (infinite loop).
89+
*
90+
* On every successful session refresh / login we mirror the server token
91+
* into `auth-session-token`; on logout (or when the server reports no
92+
* session) we clear both keys.
93+
*/
94+
const OBJECT_UI_AUTH_TOKEN_KEY = 'auth-session-token';
95+
const OBJECT_UI_AUTH_ACTIVE_ORG_KEY = 'auth-active-organization-id';
96+
97+
function syncObjectUiAuthStorage(session: SessionData | null): void {
98+
if (typeof window === 'undefined') return;
99+
try {
100+
if (session?.token) {
101+
localStorage.setItem(OBJECT_UI_AUTH_TOKEN_KEY, session.token);
102+
} else {
103+
localStorage.removeItem(OBJECT_UI_AUTH_TOKEN_KEY);
104+
}
105+
if (session?.activeOrganizationId) {
106+
localStorage.setItem(
107+
OBJECT_UI_AUTH_ACTIVE_ORG_KEY,
108+
session.activeOrganizationId,
109+
);
110+
} else {
111+
localStorage.removeItem(OBJECT_UI_AUTH_ACTIVE_ORG_KEY);
112+
}
113+
} catch {
114+
/* localStorage unavailable (Safari private, SSR, etc.) — ignore */
115+
}
116+
}
117+
80118
/**
81119
* Normalise the better-auth `/get-session` response shape. Depending on the
82120
* version, the body is either `{ user, session }` directly or wrapped in
@@ -125,10 +163,15 @@ export function SessionProvider({ children }: { children: ReactNode }) {
125163
const { user: u, session: s } = normaliseSessionResponse(raw);
126164
setUser(u);
127165
setSession(s);
166+
// Mirror cookie session → localStorage so `@object-ui/auth`'s
167+
// Bearer-based AuthProvider (used by Console) doesn't see a stale
168+
// token from a previous user.
169+
syncObjectUiAuthStorage(u ? s : null);
128170
} catch (err) {
129171
setError(err as Error);
130172
setUser(null);
131173
setSession(null);
174+
syncObjectUiAuthStorage(null);
132175
} finally {
133176
setLoading(false);
134177
}
@@ -156,6 +199,9 @@ export function SessionProvider({ children }: { children: ReactNode }) {
156199
setSession(null);
157200
setOrganizations([]);
158201
setOrganizationsFetched(false);
202+
// Clear `@object-ui/auth`'s localStorage so the Console SPA doesn't
203+
// carry a Bearer token across the sign-out boundary.
204+
syncObjectUiAuthStorage(null);
159205
}
160206
}, [client]);
161207

@@ -164,6 +210,14 @@ export function SessionProvider({ children }: { children: ReactNode }) {
164210
if (!client?.organizations) return;
165211
await client.organizations.setActive(organizationId);
166212
await refresh();
213+
// `refresh()` already syncs storage with the new session's active
214+
// org; this is just belt-and-braces in case `client.auth.me()` is
215+
// momentarily stale.
216+
try {
217+
localStorage.setItem(OBJECT_UI_AUTH_ACTIVE_ORG_KEY, organizationId);
218+
} catch {
219+
/* ignore */
220+
}
167221
},
168222
[client, refresh],
169223
);
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Auth preflight — validate any stale Bearer token sitting in localStorage
5+
* BEFORE `<AuthProvider>` mounts.
6+
*
7+
* # Why this exists
8+
*
9+
* `@object-ui/auth`'s `createAuthClient` wraps fetch in `createBearerFetch`,
10+
* which injects `Authorization: Bearer <token>` from
11+
* `localStorage['auth-session-token']` on every `/api/*` request.
12+
*
13+
* The Account SPA (`/_account/*`) uses a separate auth code path
14+
* (`useClient().auth.login/logout` from `@objectstack/client-react`) that
15+
* touches cookies but NOT this localStorage key. So when a user:
16+
*
17+
* 1. signs in on Account (cookie set, localStorage untouched), then
18+
* 2. signs out on Account (cookie cleared, localStorage still has the old
19+
* token from a previous Console visit), then
20+
* 3. signs in again as a different user, then
21+
* 4. visits `/_console/home`
22+
*
23+
* the Console's AuthProvider sends the **stale Bearer** to `get-session`,
24+
* the server prefers Bearer over cookie, returns null, AuthProvider thinks
25+
* the user is unauthenticated, AuthGuard renders `<AccountLoginRedirect>`,
26+
* which sends the browser back to `/_account/login` — but Account sees a
27+
* valid cookie and bounces straight back to `/_console/home`.
28+
*
29+
* ╔════════════════╗
30+
* ║ LOGIN LOOP ║
31+
* ╚════════════════╝
32+
*
33+
* # What this does
34+
*
35+
* Run BEFORE React renders. If `auth-session-token` is present, probe
36+
* `/api/v1/auth/get-session` with that Bearer (and no cookie). If the
37+
* response is not authenticated, delete the stale token + the stale
38+
* `auth-active-organization-id` so AuthProvider's first `getSession()`
39+
* falls back to cookie auth cleanly.
40+
*
41+
* Idempotent, runs once per page load, < 50 ms when the token is valid
42+
* (single round-trip), no UI flicker (happens before render).
43+
*/
44+
45+
const TOKEN_KEY = 'auth-session-token';
46+
const ACTIVE_ORG_KEY = 'auth-active-organization-id';
47+
48+
export async function preflightAuth(authBaseUrl: string): Promise<void> {
49+
if (typeof window === 'undefined') return;
50+
let token: string | null = null;
51+
try {
52+
token = localStorage.getItem(TOKEN_KEY);
53+
} catch {
54+
return;
55+
}
56+
if (!token) return;
57+
58+
try {
59+
const res = await fetch(`${authBaseUrl}/get-session`, {
60+
method: 'GET',
61+
// Send ONLY the Bearer (no cookie) — we want to know if the token
62+
// alone is valid. If we sent the cookie too the server might accept
63+
// the cookie and we wouldn't detect a stale token.
64+
credentials: 'omit',
65+
headers: {
66+
Authorization: `Bearer ${token}`,
67+
Accept: 'application/json',
68+
},
69+
});
70+
71+
if (res.ok) {
72+
const body = await res.json().catch(() => null);
73+
const user = body?.user ?? body?.data?.user ?? null;
74+
if (user) return; // token valid — leave localStorage alone
75+
}
76+
77+
// 401, 4xx, or 200-with-null — token is stale. Purge.
78+
localStorage.removeItem(TOKEN_KEY);
79+
localStorage.removeItem(ACTIVE_ORG_KEY);
80+
} catch {
81+
// Network error: assume token might still be good; don't punish the
82+
// user by clearing it. Worst case AuthProvider will detect it next.
83+
}
84+
}

apps/console/src/main.tsx

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,22 @@ import '@object-ui/plugin-map';
2222
import './index.css';
2323
import { App } from './App';
2424
import { loadLanguage } from './loadLanguage';
25+
import { preflightAuth } from './lib/auth-preflight';
2526

26-
ReactDOM.createRoot(document.getElementById('root')!).render(
27-
<React.StrictMode>
28-
<I18nProvider loadLanguage={loadLanguage}>
29-
<App />
30-
</I18nProvider>
31-
</React.StrictMode>,
32-
);
27+
const AUTH_URL = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`;
28+
29+
// Drop any stale Bearer token in localStorage BEFORE React mounts.
30+
// Without this, a token left by a previous user (or by sign-out via the
31+
// Account SPA, which doesn't touch this localStorage key) would cause
32+
// AuthProvider's first `get-session` to be rejected by the server, even
33+
// though the current cookie session is valid — producing an infinite
34+
// /_account/login ↔ /_console/home bounce. See auth-preflight.ts.
35+
preflightAuth(AUTH_URL).finally(() => {
36+
ReactDOM.createRoot(document.getElementById('root')!).render(
37+
<React.StrictMode>
38+
<I18nProvider loadLanguage={loadLanguage}>
39+
<App />
40+
</I18nProvider>
41+
</React.StrictMode>,
42+
);
43+
});

0 commit comments

Comments
 (0)