fix(native): a passkey sign-in from the app asks for the passkey straight away in the browser and returns to the app, instead of showing the whole login a second time

This commit is contained in:
Maurice
2026-11-08 17:50:50 +01:00
parent 4ba7db90e6
commit 36c8c5bc48
6 changed files with 139 additions and 17 deletions
+2 -1
View File
@@ -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;
+38 -10
View File
@@ -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' && (
<p role="alert" className="text-body text-danger">
{t(passkeyStep ? 'login.passkey.failed' : 'native.login.failed')}
</p>
);
return (
<div className="flex min-h-screen items-center justify-center bg-surface-secondary p-6">
<div className="flex w-full max-w-sm flex-col gap-4 rounded-xl border border-edge bg-surface-card p-7 text-center">
<Smartphone className="mx-auto text-content-muted" size={32} aria-hidden />
{passkeyStep ? (
<Fingerprint className="mx-auto text-content-muted" size={32} aria-hidden />
) : (
<Smartphone className="mx-auto text-content-muted" size={32} aria-hidden />
)}
{!valid ? (
<p className="text-body text-content">{t('native.login.failed')}</p>
) : passkeyStep ? (
<>
<h1 className="text-title font-semibold text-content">{t('login.passkey.signIn')}</h1>
{failed}
<button
type="button"
onClick={() => {
void continueWithPasskey();
}}
disabled={status === 'sending'}
className={PRIMARY}
>
{t('login.passkey.signIn')}
</button>
<button type="button" onClick={otherSignIn} className={SECONDARY}>
{t('native.handoff.switchAccount')}
</button>
</>
) : !ready ? (
<div
className="mx-auto h-8 w-8 animate-spin rounded-full border-4 border-edge border-t-accent"
@@ -21,18 +53,14 @@ export default function NativeHandoffPage() {
<>
<h1 className="text-title font-semibold text-content">{t('native.handoff.title')}</h1>
<p className="text-body text-content-muted">{t('native.handoff.hint', { name: userName })}</p>
{status === 'failed' && (
<p role="alert" className="text-body text-danger">
{t('native.login.failed')}
</p>
)}
{failed}
<button
type="button"
onClick={() => {
void openApp();
}}
disabled={status === 'sending'}
className="w-full rounded-lg bg-accent py-3 font-semibold text-accent-text disabled:opacity-60"
className={PRIMARY}
>
{t('native.handoff.open')}
</button>
@@ -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')}
</button>
@@ -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();
});
+1 -1
View File
@@ -326,7 +326,7 @@ export function useLogin() {
}
const handlePasskeyLogin = async (): Promise<void> => {
if (nativeApp) return handleBrowserSignIn()
if (nativeApp) return handleBrowserSignIn('passkey')
setError('')
setIsLoading(true)
try {
@@ -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 }
)
);
});
});
});
@@ -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<void> => {
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<void> => {
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<void> => {
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,