Skip to content

Commit f011479

Browse files
xuyushun441-sysos-zhuangclaude
authored
fix(auth): getSession self-heals a stale bearer shadowing a valid cookie session (#1683)
A leftover auth-session-token in localStorage is injected on every auth call and SHADOWS a fresh cookie session — SSO landings (cloud console sso-exchange into a tenant env) only set the cookie and cannot touch the target origin's localStorage, so affected users bounce back to the login page forever (staging repro: env open → cloud login page). On a bearer get-session miss, retry once cookie-only: - live cookie session → adopt it, its token replaces the stale one - affirmative double-miss (200 + no session) → drop the dead token - transport errors → keep the token (they prove nothing) and return null instead of throwing (better-fetch rethrows network errors) Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 76c3810 commit f011479

3 files changed

Lines changed: 107 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@object-ui/auth": patch
3+
---
4+
5+
getSession self-heals a stale localStorage bearer: an invalid `auth-session-token` used to SHADOW a perfectly valid cookie session — SSO landings (e.g. the cloud console's sso-exchange into a tenant environment) only set the cookie and cannot touch the target origin's localStorage, so users with a leftover token bounced back to the login page forever. On a bearer get-session miss the client now retries once cookie-only: a live cookie session wins (its token replaces the stale one); an affirmative double-miss drops the dead token; transport errors keep it. getSession also no longer throws on network errors (better-fetch rethrows them).

packages/auth/src/__tests__/createAuthClient.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,58 @@ describe('createAuthClient', () => {
144144
expect(result?.user.id).toBe('1');
145145
});
146146

147+
it('getSession self-heals a stale bearer: cookie session wins and replaces the token', async () => {
148+
const { TokenStorage } = await import('../createAuthClient');
149+
TokenStorage.set('stale-token');
150+
const session = { token: 'fresh-cookie-token', id: 's9', userId: '1', expiresAt: '2027-01-01' };
151+
const user = { id: '1', name: 'Jack', email: 'jack@test.com' };
152+
// Bearer-carrying call sees no session (stale token); the cookie-only
153+
// retry (no Authorization header) sees the live SSO session.
154+
const mockFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
155+
const headers = new Headers(init?.headers);
156+
const hasBearer = Boolean(headers.get('Authorization'));
157+
const body = hasBearer ? null : { user, session };
158+
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
159+
});
160+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
161+
162+
const result = await client.getSession();
163+
164+
expect(result?.user.id).toBe('1');
165+
expect(result?.session.token).toBe('fresh-cookie-token');
166+
// The stale token was replaced by the live session's token.
167+
expect(TokenStorage.get()).toBe('fresh-cookie-token');
168+
TokenStorage.clear();
169+
});
170+
171+
it('getSession drops a dead bearer when the cookie has no session either', async () => {
172+
const { TokenStorage } = await import('../createAuthClient');
173+
TokenStorage.set('dead-token');
174+
// Both the bearer call and the cookie-only retry affirmatively report
175+
// "no session" (200 + null) — the stored token is dead weight.
176+
const mockFn = vi.fn(async () =>
177+
new Response(JSON.stringify(null), { status: 200, headers: { 'Content-Type': 'application/json' } }));
178+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
179+
180+
const result = await client.getSession();
181+
182+
expect(result).toBeNull();
183+
expect(TokenStorage.get()).toBeNull();
184+
});
185+
186+
it('getSession keeps the bearer on transport errors (proves nothing about validity)', async () => {
187+
const { TokenStorage } = await import('../createAuthClient');
188+
TokenStorage.set('maybe-good-token');
189+
const mockFn = vi.fn(async () => { throw new Error('network down'); });
190+
const client = createAuthClient({ baseURL: 'http://localhost/api/auth', fetchFn: mockFn });
191+
192+
const result = await client.getSession();
193+
194+
expect(result).toBeNull();
195+
expect(TokenStorage.get()).toBe('maybe-good-token');
196+
TokenStorage.clear();
197+
});
198+
147199
it('getSession returns null on failure', async () => {
148200
const { mockFn } = createMockFetch({
149201
'/get-session': { status: 401, body: { message: 'Unauthorized' } },

packages/auth/src/createAuthClient.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -274,14 +274,57 @@ export function createAuthClient(config: AuthClientConfig): AuthClient {
274274
},
275275

276276
async getSession() {
277-
const { data, error } = await betterAuth.getSession();
278-
if (error || !data) return null;
279-
const payload = data as unknown as { user: AuthUser; session: AuthSession };
280-
// Keep localStorage in sync if the server returns a fresh token
281-
if (payload.session?.token) {
282-
TokenStorage.set(payload.session.token);
277+
// better-fetch RETHROWS transport errors (it does not wrap them in
278+
// `{ error }`); a network hiccup must read as "signed out for now",
279+
// not an exception in every AuthProvider boot.
280+
let data: unknown = null;
281+
try {
282+
const res = await betterAuth.getSession();
283+
if (!res.error) data = res.data;
284+
} catch { /* transport error — fall through to the retry/null path */ }
285+
if (data) {
286+
const payload = data as unknown as { user: AuthUser; session: AuthSession };
287+
// Keep localStorage in sync if the server returns a fresh token
288+
if (payload.session?.token) {
289+
TokenStorage.set(payload.session.token);
290+
}
291+
return { user: payload.user, session: payload.session };
283292
}
284-
return { user: payload.user, session: payload.session };
293+
294+
// Stale-bearer self-heal: every request injects the localStorage
295+
// bearer, and an INVALID bearer shadows a perfectly valid cookie
296+
// session. SSO landings (e.g. the cloud console's sso-exchange into a
297+
// tenant environment) only set the cookie — they cannot touch this
298+
// origin's localStorage — so a leftover token from an earlier login
299+
// bounces a freshly signed-in user back to the login page forever.
300+
// Retry once WITHOUT the bearer: a cookie session means the stored
301+
// token was stale — adopt the live session (and its token) instead.
302+
if (TokenStorage.get()) {
303+
try {
304+
const rawFetch = fetchFn || globalThis.fetch.bind(globalThis);
305+
const resp = await rawFetch(`${origin}${basePath}/get-session`, {
306+
method: 'GET',
307+
credentials: 'include',
308+
headers: { 'Content-Type': 'application/json' },
309+
});
310+
if (resp.ok) {
311+
const body = (await resp.json().catch(() => null)) as
312+
| { user?: AuthUser; session?: AuthSession }
313+
| null;
314+
if (body?.user && body?.session) {
315+
if (body.session.token) TokenStorage.set(body.session.token);
316+
else TokenStorage.clear();
317+
return { user: body.user, session: body.session };
318+
}
319+
// The server affirmatively says the cookie has no session either
320+
// — the stored bearer is dead weight; drop it so the next
321+
// sign-in starts clean. (Transport errors keep the token: they
322+
// prove nothing about its validity.)
323+
TokenStorage.clear();
324+
}
325+
} catch { /* network hiccup — treat as signed out, keep the token */ }
326+
}
327+
return null;
285328
},
286329

287330
async forgotPassword(email: string) {

0 commit comments

Comments
 (0)