diff --git a/client/src/native/browserSignIn.ts b/client/src/native/browserSignIn.ts index 2b49eace6..585664cba 100644 --- a/client/src/native/browserSignIn.ts +++ b/client/src/native/browserSignIn.ts @@ -19,7 +19,8 @@ import { APP_URL_SCHEME, TrekShell } from './trekShell'; export const HANDOFF_PATH = '/native-handoff'; -export type BrowserSignInStart = 'oidc'; +/** Where the browser starts: straight into SSO, or straight at the passkey prompt. */ +export type BrowserSignInStart = 'oidc' | 'passkey'; const HANDOFF_RETURN_KEY = 'trek_native_handoff_return'; const HANDOFF_RETURN_TTL_MS = 15 * 60 * 1000; diff --git a/client/src/pages/NativeHandoffPage.tsx b/client/src/pages/NativeHandoffPage.tsx index 3d9f6f268..fb20cb6f2 100644 --- a/client/src/pages/NativeHandoffPage.tsx +++ b/client/src/pages/NativeHandoffPage.tsx @@ -1,17 +1,49 @@ -import { Smartphone } from 'lucide-react'; +import { Fingerprint, Smartphone } from 'lucide-react'; import { useTranslation } from '../i18n'; import { useNativeHandoff } from './nativeHandoff/useNativeHandoff'; +const PRIMARY = 'w-full rounded-lg bg-accent py-3 font-semibold text-accent-text disabled:opacity-60'; +const SECONDARY = 'py-1 text-caption text-content-muted'; + export default function NativeHandoffPage() { const { t } = useTranslation(); - const { valid, ready, userName, status, openApp, switchAccount } = useNativeHandoff(); + const { valid, passkeyStep, continueWithPasskey, otherSignIn, ready, userName, status, openApp, switchAccount } = + useNativeHandoff(); + + const failed = status === 'failed' && ( +

+ {t(passkeyStep ? 'login.passkey.failed' : 'native.login.failed')} +

+ ); return (
- + {passkeyStep ? ( + + ) : ( + + )} {!valid ? (

{t('native.login.failed')}

+ ) : passkeyStep ? ( + <> +

{t('login.passkey.signIn')}

+ {failed} + + + ) : !ready ? (

{t('native.handoff.title')}

{t('native.handoff.hint', { name: userName })}

- {status === 'failed' && ( -

- {t('native.login.failed')} -

- )} + {failed} @@ -41,7 +69,7 @@ export default function NativeHandoffPage() { onClick={() => { void switchAccount(); }} - className="py-1 text-caption text-content-muted" + className={SECONDARY} > {t('native.handoff.switchAccount')} diff --git a/client/src/pages/login/useLogin.native.test.tsx b/client/src/pages/login/useLogin.native.test.tsx index 8930bacd7..2513ffad2 100644 --- a/client/src/pages/login/useLogin.native.test.tsx +++ b/client/src/pages/login/useLogin.native.test.tsx @@ -106,7 +106,7 @@ describe('useLogin in the native app', () => { await result.current.handlePasskeyLogin(); }); - expect(signInThroughBrowser).toHaveBeenCalledWith(undefined); + expect(signInThroughBrowser).toHaveBeenCalledWith('passkey'); expect(startAuthentication).not.toHaveBeenCalled(); }); diff --git a/client/src/pages/login/useLogin.ts b/client/src/pages/login/useLogin.ts index c2ebd82c8..9a598ac68 100644 --- a/client/src/pages/login/useLogin.ts +++ b/client/src/pages/login/useLogin.ts @@ -326,7 +326,7 @@ export function useLogin() { } const handlePasskeyLogin = async (): Promise => { - if (nativeApp) return handleBrowserSignIn() + if (nativeApp) return handleBrowserSignIn('passkey') setError('') setIsLoading(true) try { diff --git a/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx b/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx index c17c008ab..db5002e69 100644 --- a/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx +++ b/client/src/pages/nativeHandoff/useNativeHandoff.test.tsx @@ -1,9 +1,11 @@ -// FE-NATIVE-HANDOFF-001 to FE-NATIVE-HANDOFF-008 +// FE-NATIVE-HANDOFF-001 to FE-NATIVE-HANDOFF-011 +import { startAuthentication } from '@simplewebauthn/browser'; import React from 'react'; 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 { authApi } from '../../api/client'; import { rememberHandoffReturn, requestHandoffCode } from '../../native/browserSignIn'; import { useAuthStore } from '../../store/authStore'; import { useNativeHandoff } from './useNativeHandoff'; @@ -19,6 +21,11 @@ vi.mock('../../native/browserSignIn', () => ({ requestHandoffCode: vi.fn(), })); +vi.mock('@simplewebauthn/browser', () => ({ startAuthentication: vi.fn() })); +vi.mock('../../api/client', () => ({ + authApi: { passkey: { loginOptions: vi.fn(), loginVerify: vi.fn() } }, +})); + const challenge = 'ab'.repeat(32); const realLocation = window.location; const logout = vi.fn(async () => {}); @@ -134,4 +141,57 @@ describe('useNativeHandoff', () => { ) ); }); + + describe('when the app asked for a passkey', () => { + const loadUser = vi.fn(async () => { + useAuthStore.setState({ isAuthenticated: true }); + }); + + beforeEach(() => { + setSearch(`?challenge=${challenge}&start=passkey`); + useAuthStore.setState({ isAuthenticated: false, loadUser }); + loadUser.mockClear(); + vi.mocked(authApi.passkey.loginOptions).mockReset().mockResolvedValue({ challenge: 'x' }); + vi.mocked(authApi.passkey.loginVerify).mockReset().mockResolvedValue({ token: '', user: {} }); + vi.mocked(startAuthentication) + .mockReset() + .mockResolvedValue({ id: 'cred' } as never); + }); + + it('FE-NATIVE-HANDOFF-009: asks for the passkey itself instead of sending the user to the login', () => { + const { result } = renderHandoff(); + expect(result.current.passkeyStep).toBe(true); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('FE-NATIVE-HANDOFF-010: signs in with the passkey and goes straight back to the app', async () => { + vi.mocked(requestHandoffCode).mockResolvedValue('c1'); + const { result } = renderHandoff(); + await act(async () => { + await result.current.continueWithPasskey(); + }); + expect(authApi.passkey.loginVerify).toHaveBeenCalledWith({ id: 'cred' }); + expect(loadUser).toHaveBeenCalled(); + expect(requestHandoffCode).toHaveBeenCalledWith(challenge); + expect(window.location.href).toBe('com.liketrek.trek://auth?code=c1'); + }); + + it('FE-NATIVE-HANDOFF-011: stays quiet when the prompt is dismissed and offers the full login on request', async () => { + vi.mocked(startAuthentication).mockRejectedValue(Object.assign(new Error('no'), { name: 'NotAllowedError' })); + const { result } = renderHandoff(); + await act(async () => { + await result.current.continueWithPasskey(); + }); + expect(result.current.status).toBe('idle'); + expect(requestHandoffCode).not.toHaveBeenCalled(); + + act(() => result.current.otherSignIn()); + await waitFor(() => + expect(mockNavigate).toHaveBeenCalledWith( + `/login?redirect=${encodeURIComponent(`/native-handoff?challenge=${challenge}`)}`, + { replace: true } + ) + ); + }); + }); }); diff --git a/client/src/pages/nativeHandoff/useNativeHandoff.ts b/client/src/pages/nativeHandoff/useNativeHandoff.ts index b4c59de90..803636d69 100644 --- a/client/src/pages/nativeHandoff/useNativeHandoff.ts +++ b/client/src/pages/nativeHandoff/useNativeHandoff.ts @@ -1,6 +1,8 @@ +import { startAuthentication } from '@simplewebauthn/browser'; import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router'; import { useShallow } from 'zustand/react/shallow'; +import { authApi } from '../../api/client'; import { HANDOFF_PATH, rememberHandoffReturn, requestHandoffCode } from '../../native/browserSignIn'; import { APP_URL_SCHEME } from '../../native/trekShell'; import { useAuthStore } from '../../store/authStore'; @@ -15,24 +17,28 @@ const CHALLENGE = /^[0-9a-f]{64}$/; */ export function useNativeHandoff() { const navigate = useNavigate(); - const { isAuthenticated, isLoading, userName, logout } = useAuthStore( + const { isAuthenticated, isLoading, userName, logout, loadUser } = useAuthStore( useShallow((s) => ({ isAuthenticated: s.isAuthenticated, isLoading: s.isLoading, userName: s.user?.username ?? '', logout: s.logout, + loadUser: s.loadUser, })) ); const [params] = useState(() => new URLSearchParams(window.location.search)); const challenge = params.get('challenge') ?? ''; const valid = CHALLENGE.test(challenge); const [status, setStatus] = useState<'idle' | 'sending' | 'failed'>('idle'); + // The app asked for a passkey: this page asks for it itself instead of showing + // the whole login a second time, until the user picks another way to sign in. + const [passkeyStep, setPasskeyStep] = useState(() => params.get('start') === 'passkey'); // Back here once signed in, without `start`, so an SSO round trip cannot loop. const returnPath = `${HANDOFF_PATH}?challenge=${challenge}`; useEffect(() => { - if (!valid || isLoading || isAuthenticated) return; + if (!valid || isLoading || isAuthenticated || passkeyStep) return; if (params.get('start') === 'oidc') { sessionStorage.setItem('oidc_redirect', returnPath); rememberHandoffReturn(returnPath); @@ -40,7 +46,7 @@ export function useNativeHandoff() { return; } void navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true }); - }, [valid, isLoading, isAuthenticated, params, returnPath, navigate]); + }, [valid, isLoading, isAuthenticated, passkeyStep, params, returnPath, navigate]); const openApp = async (): Promise => { setStatus('sending'); @@ -52,6 +58,30 @@ export function useNativeHandoff() { } }; + /** + * The passkey prompt, then straight back to the app: the tap that started it + * is the explicit consent the handoff asks for, so no second page follows. + */ + const continueWithPasskey = async (): Promise => { + setStatus('sending'); + try { + const options = await authApi.passkey.loginOptions(); + const assertion = await startAuthentication({ optionsJSON: options }); + await authApi.passkey.loginVerify(assertion); + await loadUser({ silent: true }); + } catch (err: unknown) { + const name = (err as { name?: string })?.name; + setStatus(name === 'NotAllowedError' || name === 'AbortError' ? 'idle' : 'failed'); + return; + } + await openApp(); + }; + + const otherSignIn = (): void => { + setStatus('idle'); + setPasskeyStep(false); + }; + const switchAccount = async (): Promise => { await logout(); void navigate(`/login?redirect=${encodeURIComponent(returnPath)}`, { replace: true }); @@ -59,6 +89,9 @@ export function useNativeHandoff() { return { valid, + passkeyStep: valid && !isLoading && !isAuthenticated && passkeyStep, + continueWithPasskey, + otherSignIn, ready: valid && !isLoading && isAuthenticated, userName, status,