diff --git a/client/src/native/browserSignIn.test.ts b/client/src/native/browserSignIn.test.ts index 7c95f3548..0ff592795 100644 --- a/client/src/native/browserSignIn.test.ts +++ b/client/src/native/browserSignIn.test.ts @@ -6,8 +6,10 @@ import { BrowserSignInCancelled, createHandoffSecret, handoffUrl, + rememberHandoffReturn, requestHandoffCode, signInThroughBrowser, + takeHandoffReturn, } from './browserSignIn'; import { TrekShell } from './trekShell'; @@ -77,3 +79,34 @@ describe('signInThroughBrowser', () => { expect(nativeAuthApi.exchange).not.toHaveBeenCalled(); }); }); + +describe('the remembered handoff return', () => { + const path = `/native-handoff?challenge=${'a'.repeat(64)}`; + + beforeEach(() => { + localStorage.clear(); + vi.useRealTimers(); + }); + + it('survives a sign-in that comes back in another tab, once', () => { + rememberHandoffReturn(path); + expect(takeHandoffReturn()).toBe(path); + expect(takeHandoffReturn()).toBeNull(); + }); + + it('is dropped once it is older than a quarter of an hour', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-11-08T12:00:00Z')); + rememberHandoffReturn(path); + vi.setSystemTime(new Date('2026-11-08T12:16:00Z')); + expect(takeHandoffReturn()).toBeNull(); + vi.useRealTimers(); + }); + + it('only ever leads back to the handoff page', () => { + rememberHandoffReturn('/admin'); + expect(takeHandoffReturn()).toBeNull(); + localStorage.setItem('trek_native_handoff_return', 'not json'); + expect(takeHandoffReturn()).toBeNull(); + }); +}); diff --git a/client/src/native/browserSignIn.ts b/client/src/native/browserSignIn.ts index c088855c7..2b49eace6 100644 --- a/client/src/native/browserSignIn.ts +++ b/client/src/native/browserSignIn.ts @@ -21,6 +21,40 @@ export const HANDOFF_PATH = '/native-handoff'; export type BrowserSignInStart = 'oidc'; +const HANDOFF_RETURN_KEY = 'trek_native_handoff_return'; +const HANDOFF_RETURN_TTL_MS = 15 * 60 * 1000; + +/** + * Remembers the handoff page an SSO round trip has to come back to. The login + * keeps its return path in sessionStorage, which is per tab, and an identity + * provider such as Google may finish its sign-in in another tab or in its own + * app on Android. The return then arrives without it and the user lands in the + * web app instead of being handed back, so the handoff page also leaves a short + * lived copy here. + */ +export function rememberHandoffReturn(path: string): void { + try { + localStorage.setItem(HANDOFF_RETURN_KEY, JSON.stringify({ path, at: Date.now() })); + } catch { + /* storage blocked: the sessionStorage copy still covers a single-tab sign-in */ + } +} + +/** The remembered handoff page, once: only a fresh path to the handoff page itself. */ +export function takeHandoffReturn(): string | null { + try { + const raw = localStorage.getItem(HANDOFF_RETURN_KEY); + 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 (Date.now() - at > HANDOFF_RETURN_TTL_MS) return null; + return path.startsWith(`${HANDOFF_PATH}?challenge=`) ? path : null; + } catch { + return null; + } +} + export function createHandoffSecret(): { verifier: string; challenge: string } { const bytes = crypto.getRandomValues(new Uint8Array(32)); const verifier = bytesToHex(bytes); diff --git a/client/src/pages/NativeHandoffPage.tsx b/client/src/pages/NativeHandoffPage.tsx index 706ebf7ba..3d9f6f268 100644 --- a/client/src/pages/NativeHandoffPage.tsx +++ b/client/src/pages/NativeHandoffPage.tsx @@ -32,7 +32,7 @@ export default function NativeHandoffPage() { void openApp(); }} disabled={status === 'sending'} - className="w-full rounded-lg bg-accent py-3 font-semibold text-accent-on disabled:opacity-60" + className="w-full rounded-lg bg-accent py-3 font-semibold text-accent-text disabled:opacity-60" > {t('native.handoff.open')} diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index e2aaa421f..c2ebd82c8 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -10,7 +10,12 @@ import { getApiErrorMessage } from '../../types' import { START_DESTINATION_ROUTE } from '../../utils/startDestination' import { passwordErrorKey } from '../../utils/passwordError' import { isNativeApp } from '../../native/platform' -import { BrowserSignInCancelled, signInThroughBrowser, type BrowserSignInStart } from '../../native/browserSignIn' +import { + BrowserSignInCancelled, + signInThroughBrowser, + takeHandoffReturn, + type BrowserSignInStart, +} from '../../native/browserSignIn' interface AppConfig { has_users: boolean @@ -221,7 +226,9 @@ export function useLogin() { // body is never read, so it can go without breaking SSO sign-in. if (ok && data.success === true) { await loadUser() - const savedRedirect = sessionStorage.getItem('oidc_redirect') || START_DESTINATION_ROUTE + // Taken either way, so a handoff left from an abandoned sign-in cannot catch a later one. + const handoffReturn = takeHandoffReturn() + const savedRedirect = sessionStorage.getItem('oidc_redirect') || handoffReturn || START_DESTINATION_ROUTE sessionStorage.removeItem('oidc_redirect') navigate(savedRedirect, { replace: true }) } else { diff --git a/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx b/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx index 58a2bae6a..c17c008ab 100644 --- a/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx +++ b/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx @@ -4,7 +4,7 @@ import { MemoryRouter } from 'react-router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, renderHook, waitFor } from '../../../tests/helpers/render'; import { resetAllStores } from '../../../tests/helpers/store'; -import { requestHandoffCode } from '../../native/browserSignIn'; +import { rememberHandoffReturn, requestHandoffCode } from '../../native/browserSignIn'; import { useAuthStore } from '../../store/authStore'; import { useNativeHandoff } from './useNativeHandoff'; @@ -13,7 +13,11 @@ vi.mock('react-router', async () => { const actual = await vi.importActual('react-router'); return { ...actual, useNavigate: () => mockNavigate }; }); -vi.mock('../../native/browserSignIn', () => ({ HANDOFF_PATH: '/native-handoff', requestHandoffCode: vi.fn() })); +vi.mock('../../native/browserSignIn', () => ({ + HANDOFF_PATH: '/native-handoff', + rememberHandoffReturn: vi.fn(), + requestHandoffCode: vi.fn(), +})); const challenge = 'ab'.repeat(32); const realLocation = window.location; @@ -85,6 +89,7 @@ describe('useNativeHandoff', () => { renderHandoff(); expect(window.location.replace).toHaveBeenCalledWith('/api/auth/oidc/login?remember=1'); expect(sessionStorage.getItem('oidc_redirect')).toBe(`/native-handoff?challenge=${challenge}`); + expect(rememberHandoffReturn).toHaveBeenCalledWith(`/native-handoff?challenge=${challenge}`); }); it('FE-NATIVE-HANDOFF-005: waits while the session is still being checked', () => { diff --git a/client/src/pages/nativeHandoff/useNativeHandoff.ts b/client/src/pages/nativeHandoff/useNativeHandoff.ts index 4acd2df09..b4c59de90 100644 --- a/client/src/pages/nativeHandoff/useNativeHandoff.ts +++ b/client/src/pages/nativeHandoff/useNativeHandoff.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { useShallow } from 'zustand/react/shallow'; -import { HANDOFF_PATH, requestHandoffCode } from '../../native/browserSignIn'; +import { HANDOFF_PATH, rememberHandoffReturn, requestHandoffCode } from '../../native/browserSignIn'; import { APP_URL_SCHEME } from '../../native/trekShell'; import { useAuthStore } from '../../store/authStore'; @@ -35,6 +35,7 @@ export function useNativeHandoff() { if (!valid || isLoading || isAuthenticated) return; if (params.get('start') === 'oidc') { sessionStorage.setItem('oidc_redirect', returnPath); + rememberHandoffReturn(returnPath); window.location.replace('/api/auth/oidc/login?remember=1'); return; }