From 11472c05ff41a4ae48ce3bdbf59aef6f3c62963b Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 12 Nov 2026 16:10:14 +0100 Subject: [PATCH] fix(native): a failed SSO round trip from the app still hands back on the retry A sign-in the app starts runs through the server's handoff page in the system browser. When the SSO callback failed (an expired or overwritten state), the login page dropped the way back to that page, so the retry from there was a plain web login and ended in the web app inside the app's browser sheet instead of signing in the app. The login page now keeps a way back that points at the handoff page, and only that, and renews its fallback copy for a slow retry. State, nonce, PKCE and the handoff verifier are unchanged; the handoff page still asks for a tap before it passes anything on. --- client/src/native/browserSignIn.test.ts | 9 +++++++ client/src/native/browserSignIn.ts | 9 +++++-- client/src/pages/login/useLogin.test.tsx | 31 ++++++++++++++++++++++++ client/src/pages/login/useLogin.ts | 11 ++++++++- 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/client/src/native/browserSignIn.test.ts b/client/src/native/browserSignIn.test.ts index 0161931d6..a662e8552 100644 --- a/client/src/native/browserSignIn.test.ts +++ b/client/src/native/browserSignIn.test.ts @@ -8,6 +8,7 @@ import { BrowserSignInCancelled, createHandoffSecret, handoffUrl, + isHandoffReturn, rememberHandoffReturn, requestHandoffCode, resumeBrowserSignIn, @@ -199,6 +200,14 @@ describe('the remembered handoff return', () => { localStorage.setItem('trek_native_handoff_return', 'not json'); expect(takeHandoffReturn()).toBeNull(); }); + + it('tells a way back to the handoff page from any other stored path', () => { + expect(isHandoffReturn(path)).toBe(true); + expect(isHandoffReturn('/native-handoff')).toBe(false); + expect(isHandoffReturn('/oauth/consent?client_id=foo')).toBe(false); + expect(isHandoffReturn(null)).toBe(false); + expect(isHandoffReturn(42)).toBe(false); + }); }); describe('signInWithPasskey', () => { diff --git a/client/src/native/browserSignIn.ts b/client/src/native/browserSignIn.ts index 9895b0eec..55ac557dd 100644 --- a/client/src/native/browserSignIn.ts +++ b/client/src/native/browserSignIn.ts @@ -50,6 +50,11 @@ export function rememberHandoffReturn(path: string): void { } } +/** A path back to the handoff page itself, and nothing else a stored value could point at. */ +export function isHandoffReturn(path: unknown): path is string { + return typeof path === 'string' && path.startsWith(`${HANDOFF_PATH}?challenge=`); +} + /** The remembered handoff page, once: only a fresh path to the handoff page itself. */ export function takeHandoffReturn(): string | null { try { @@ -57,9 +62,9 @@ export function takeHandoffReturn(): string | null { localStorage.removeItem(HANDOFF_RETURN_KEY); if (!raw) return null; const { path, at } = JSON.parse(raw) as { path?: unknown; at?: unknown }; - if (typeof path !== 'string' || typeof at !== 'number') return null; + if (!isHandoffReturn(path) || typeof at !== 'number') return null; if (Date.now() - at > HANDOFF_RETURN_TTL_MS) return null; - return path.startsWith(`${HANDOFF_PATH}?challenge=`) ? path : null; + return path; } catch { return null; } diff --git a/client/src/pages/login/useLogin.test.tsx b/client/src/pages/login/useLogin.test.tsx index 0b23b7e49..5d07e8dc2 100644 --- a/client/src/pages/login/useLogin.test.tsx +++ b/client/src/pages/login/useLogin.test.tsx @@ -417,6 +417,37 @@ describe('useLogin — OIDC callback', () => { const { result } = renderLogin(); await waitFor(() => expect(result.current.error).toBe('some_new_provider_code')); }); + + it('FE-LOGIN-HOOK-213: a failed round trip the app started keeps the way back to the handoff page', async () => { + const handoff = `/native-handoff?challenge=${'a'.repeat(64)}`; + sessionStorage.setItem('oidc_redirect', handoff); + localStorage.removeItem('trek_native_handoff_return'); + setSearch('?oidc_error=invalid_state'); + + const { result } = renderLogin(); + await waitFor(() => expect(result.current.error).toBe('Invalid session. Please try again.')); + expect(sessionStorage.getItem('oidc_redirect')).toBe(handoff); + expect(JSON.parse(localStorage.getItem('trek_native_handoff_return') ?? '{}').path).toBe(handoff); + }); + + it('FE-LOGIN-HOOK-214: the SSO retry after that failure ends on the handoff page, not in the web app', async () => { + const handoff = `/native-handoff?challenge=${'b'.repeat(64)}`; + sessionStorage.setItem('oidc_redirect', handoff); + localStorage.removeItem('trek_native_handoff_return'); + setSearch('?oidc_error=invalid_state'); + + const failed = renderLogin(); + await waitFor(() => expect(failed.result.current.error).toBe('Invalid session. Please try again.')); + failed.unmount(); + + setSearch('?oidc_code=code-retry'); + server.use(http.get('/api/auth/oidc/exchange', () => HttpResponse.json({ success: true }))); + renderLogin(); + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith(handoff, { replace: true })); + expect(sessionStorage.getItem('oidc_redirect')).toBeNull(); + expect(localStorage.getItem('trek_native_handoff_return')).toBeNull(); + }); }); describe('useLogin — language detection', () => { diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index bda520b5e..5e696b8c7 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -12,6 +12,8 @@ import { passwordErrorKey } from '../../utils/passwordError' import { isNativeApp } from '../../native/platform' import { BrowserSignInCancelled, + isHandoffReturn, + rememberHandoffReturn, resumeBrowserSignIn, signInThroughBrowser, takeHandoffReturn, @@ -256,7 +258,14 @@ export function useLogin() { email_link_blocked: t('login.oidc.emailLinkBlocked'), } setError(errorMessages[oidcError] || oidcError) - sessionStorage.removeItem('oidc_redirect') + // A round trip the app's sign-in started keeps its way back. The next SSO + // attempt from this page is a plain web login, and without the handoff page + // to return to it ends in the web app inside the app's browser sheet. The + // handoff page asks for a tap before it passes anything on, so keeping the + // path hands nothing over by itself; the copy is renewed for a slow retry. + const stashed = sessionStorage.getItem('oidc_redirect') + if (isHandoffReturn(stashed)) rememberHandoffReturn(stashed) + else sessionStorage.removeItem('oidc_redirect') window.history.replaceState({}, '', '/login') loadConfig(false) return