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