mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user