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,