@oxyhq/services 21.0.0 → 22.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/index.js +13 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/boot/runProviderColdBoot.js +12 -2
- package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAccountDialog.js +46 -19
- package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/commonjs/ui/components/OxyProvider.js +2 -0
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +80 -59
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/passkeyFlow.js +118 -0
- package/lib/commonjs/ui/context/passkeyFlow.js.map +1 -0
- package/lib/commonjs/ui/hooks/queries/queryKeys.js +18 -2
- package/lib/commonjs/ui/hooks/queries/queryKeys.js.map +1 -1
- package/lib/commonjs/ui/hooks/queries/useAuthMethods.js +49 -0
- package/lib/commonjs/ui/hooks/queries/useAuthMethods.js.map +1 -0
- package/lib/commonjs/ui/utils/crossOriginRestore.js +26 -0
- package/lib/commonjs/ui/utils/crossOriginRestore.js.map +1 -1
- package/lib/commonjs/webauthn/passkeyClient.js +39 -0
- package/lib/commonjs/webauthn/passkeyClient.js.map +1 -0
- package/lib/commonjs/webauthn/passkeyClient.native.js +34 -0
- package/lib/commonjs/webauthn/passkeyClient.native.js.map +1 -0
- package/lib/commonjs/webauthn/passkeyClient.web.js +57 -0
- package/lib/commonjs/webauthn/passkeyClient.web.js.map +1 -0
- package/lib/module/index.js +2 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/boot/runProviderColdBoot.js +13 -3
- package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/module/ui/components/OxyAccountDialog.js +46 -19
- package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/module/ui/components/OxyProvider.js +2 -0
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +80 -59
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/passkeyFlow.js +111 -0
- package/lib/module/ui/context/passkeyFlow.js.map +1 -0
- package/lib/module/ui/hooks/queries/queryKeys.js +15 -0
- package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
- package/lib/module/ui/hooks/queries/useAuthMethods.js +45 -0
- package/lib/module/ui/hooks/queries/useAuthMethods.js.map +1 -0
- package/lib/module/ui/utils/crossOriginRestore.js +26 -1
- package/lib/module/ui/utils/crossOriginRestore.js.map +1 -1
- package/lib/module/webauthn/passkeyClient.js +33 -0
- package/lib/module/webauthn/passkeyClient.js.map +1 -0
- package/lib/module/webauthn/passkeyClient.native.js +28 -0
- package/lib/module/webauthn/passkeyClient.native.js.map +1 -0
- package/lib/module/webauthn/passkeyClient.web.js +52 -0
- package/lib/module/webauthn/passkeyClient.web.js.map +1 -0
- package/lib/typescript/commonjs/index.d.ts +3 -2
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +6 -0
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +41 -20
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts +96 -0
- package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
- package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +9 -0
- package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useAuthMethods.d.ts +212 -0
- package/lib/typescript/commonjs/ui/hooks/queries/useAuthMethods.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +8 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/crossOriginRestore.d.ts +19 -0
- package/lib/typescript/commonjs/ui/utils/crossOriginRestore.d.ts.map +1 -1
- package/lib/typescript/commonjs/webauthn/passkeyClient.d.ts +20 -0
- package/lib/typescript/commonjs/webauthn/passkeyClient.d.ts.map +1 -0
- package/lib/typescript/commonjs/webauthn/passkeyClient.native.d.ts +15 -0
- package/lib/typescript/commonjs/webauthn/passkeyClient.native.d.ts.map +1 -0
- package/lib/typescript/commonjs/webauthn/passkeyClient.web.d.ts +36 -0
- package/lib/typescript/commonjs/webauthn/passkeyClient.web.d.ts.map +1 -0
- package/lib/typescript/module/index.d.ts +3 -2
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +6 -0
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +41 -20
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/passkeyFlow.d.ts +96 -0
- package/lib/typescript/module/ui/context/passkeyFlow.d.ts.map +1 -0
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
- package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts +9 -0
- package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/useAuthMethods.d.ts +212 -0
- package/lib/typescript/module/ui/hooks/queries/useAuthMethods.d.ts.map +1 -0
- package/lib/typescript/module/ui/types/navigation.d.ts +8 -0
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/crossOriginRestore.d.ts +19 -0
- package/lib/typescript/module/ui/utils/crossOriginRestore.d.ts.map +1 -1
- package/lib/typescript/module/webauthn/passkeyClient.d.ts +20 -0
- package/lib/typescript/module/webauthn/passkeyClient.d.ts.map +1 -0
- package/lib/typescript/module/webauthn/passkeyClient.native.d.ts +15 -0
- package/lib/typescript/module/webauthn/passkeyClient.native.d.ts.map +1 -0
- package/lib/typescript/module/webauthn/passkeyClient.web.d.ts +36 -0
- package/lib/typescript/module/webauthn/passkeyClient.web.d.ts.map +1 -0
- package/package.json +6 -5
- package/src/index.ts +3 -1
- package/src/ui/boot/runProviderColdBoot.ts +24 -1
- package/src/ui/components/OxyAccountDialog.tsx +57 -14
- package/src/ui/components/OxyProvider.tsx +2 -0
- package/src/ui/context/OxyContext.tsx +85 -63
- package/src/ui/context/__tests__/passkeyFlow.test.ts +244 -0
- package/src/ui/context/oxyContextTypes.ts +41 -19
- package/src/ui/context/passkeyFlow.ts +154 -0
- package/src/ui/hooks/queries/queryKeys.ts +14 -0
- package/src/ui/hooks/queries/useAuthMethods.ts +45 -0
- package/src/ui/types/navigation.ts +8 -0
- package/src/ui/utils/__tests__/crossOriginRestore.test.ts +143 -0
- package/src/ui/utils/crossOriginRestore.ts +34 -0
- package/src/webauthn/passkeyClient.native.ts +26 -0
- package/src/webauthn/passkeyClient.ts +31 -0
- package/src/webauthn/passkeyClient.web.ts +56 -0
|
@@ -8,6 +8,7 @@ import type { SessionClient } from '@oxyhq/core';
|
|
|
8
8
|
import { loadPersistedDeviceCredential } from '../utils/deviceCredential';
|
|
9
9
|
import {
|
|
10
10
|
consumeSilentOAuthError,
|
|
11
|
+
isSilentRestoreEligibleOrigin,
|
|
11
12
|
maybeStartSilentOAuthRestore,
|
|
12
13
|
} from '../utils/crossOriginRestore';
|
|
13
14
|
import { tryCompleteOAuthReturn, consumeHubSyncFailure } from '../utils/oauthReturn';
|
|
@@ -22,6 +23,12 @@ export interface RunProviderColdBootOptions {
|
|
|
22
23
|
authStore: AuthStateStore;
|
|
23
24
|
clientId?: string;
|
|
24
25
|
authRedirectUri?: string;
|
|
26
|
+
/**
|
|
27
|
+
* Authorize endpoint override for silent cross-origin restore. Defaults to
|
|
28
|
+
* the production Oxy IdP when unset; a local/staging deployment sets it so the
|
|
29
|
+
* silent-restore redirect targets its own IdP, never production.
|
|
30
|
+
*/
|
|
31
|
+
authorizeBaseUrl?: string;
|
|
25
32
|
sessionClient: SessionClient;
|
|
26
33
|
syncDeviceCredentialToHost: () => Promise<void>;
|
|
27
34
|
commitSession: (
|
|
@@ -46,6 +53,7 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
|
|
|
46
53
|
authStore,
|
|
47
54
|
clientId,
|
|
48
55
|
authRedirectUri,
|
|
56
|
+
authorizeBaseUrl,
|
|
49
57
|
sessionClient,
|
|
50
58
|
syncDeviceCredentialToHost,
|
|
51
59
|
commitSession,
|
|
@@ -129,11 +137,26 @@ export async function runProviderColdBoot(opts: RunProviderColdBootOptions): Pro
|
|
|
129
137
|
},
|
|
130
138
|
});
|
|
131
139
|
|
|
132
|
-
|
|
140
|
+
// Silent cross-origin OAuth restore (web cross-app SSO). Gate it the SAME
|
|
141
|
+
// way the hub-sync WRITE side (`syncHubAfterSignIn`) gates: official web
|
|
142
|
+
// origins only, never the IdP hub itself, and — unlike the write side —
|
|
143
|
+
// never a loopback / local-dev origin (which must not be bounced to a hosted
|
|
144
|
+
// IdP on cold boot). The authorize endpoint is env-configurable so a
|
|
145
|
+
// local/staging app targets its own IdP instead of production.
|
|
146
|
+
const webOrigin = isWebBrowser()
|
|
147
|
+
? (globalThis as { location?: Location }).location?.origin
|
|
148
|
+
: undefined;
|
|
149
|
+
if (
|
|
150
|
+
clientId &&
|
|
151
|
+
outcome.kind !== 'session' &&
|
|
152
|
+
webOrigin &&
|
|
153
|
+
isSilentRestoreEligibleOrigin(webOrigin)
|
|
154
|
+
) {
|
|
133
155
|
const redirected = await maybeStartSilentOAuthRestore({
|
|
134
156
|
oxyServices,
|
|
135
157
|
clientId,
|
|
136
158
|
redirectUri: authRedirectUri,
|
|
159
|
+
authorizeBaseUrl,
|
|
137
160
|
});
|
|
138
161
|
if (redirected) {
|
|
139
162
|
return;
|
|
@@ -62,9 +62,11 @@ import {
|
|
|
62
62
|
type AppColorName,
|
|
63
63
|
} from '@oxyhq/bloom/theme';
|
|
64
64
|
import type { SwitchableAccount, AccountDialogSnapshot } from '@oxyhq/core';
|
|
65
|
+
import { isOxyRpOrigin } from '@oxyhq/core';
|
|
65
66
|
import { useQueryClient } from '@tanstack/react-query';
|
|
66
67
|
import { useOxy } from '../context/OxyContext';
|
|
67
68
|
import { useI18n } from '../hooks/useI18n';
|
|
69
|
+
import { isWebBrowser } from '../utils/isWebBrowser';
|
|
68
70
|
import { LogoIcon } from './logo/LogoIcon';
|
|
69
71
|
|
|
70
72
|
/** Diameter of a row avatar. */
|
|
@@ -112,11 +114,38 @@ const OxyAccountDialog: React.FC = () => {
|
|
|
112
114
|
showBottomSheet,
|
|
113
115
|
logoutAll,
|
|
114
116
|
refreshAccounts,
|
|
117
|
+
signInWithPasskey,
|
|
115
118
|
} = useOxy();
|
|
116
119
|
const theme = useTheme();
|
|
117
120
|
const { t } = useI18n();
|
|
118
121
|
const queryClient = useQueryClient();
|
|
119
122
|
|
|
123
|
+
// Passkey sign-in is offered ONLY on a first-party Oxy web origin (a credential
|
|
124
|
+
// minted for `oxy.so` can only be asserted there or on a loopback dev host).
|
|
125
|
+
// Off the web / on a non-Oxy origin the button is hidden entirely (a hub popup
|
|
126
|
+
// for arbitrary web origins is a later phase). This is environment-static.
|
|
127
|
+
const passkeyAvailable = useMemo(() => isWebBrowser() && isOxyRpOrigin(), []);
|
|
128
|
+
const [passkeyPending, setPasskeyPending] = useState(false);
|
|
129
|
+
const [passkeyError, setPasskeyError] = useState<string | null>(null);
|
|
130
|
+
|
|
131
|
+
const handleSignInWithPasskey = useCallback(async () => {
|
|
132
|
+
if (passkeyPending) return;
|
|
133
|
+
setPasskeyPending(true);
|
|
134
|
+
setPasskeyError(null);
|
|
135
|
+
try {
|
|
136
|
+
await signInWithPasskey();
|
|
137
|
+
closeAccountDialog();
|
|
138
|
+
} catch (error) {
|
|
139
|
+
// A cancelled/failed ceremony keeps the dialog open so the user can retry
|
|
140
|
+
// or fall back to another method — surface a concise message, never swallow.
|
|
141
|
+
setPasskeyError(
|
|
142
|
+
error instanceof Error && error.message ? error.message : 'Passkey sign-in failed.',
|
|
143
|
+
);
|
|
144
|
+
} finally {
|
|
145
|
+
setPasskeyPending(false);
|
|
146
|
+
}
|
|
147
|
+
}, [passkeyPending, signInWithPasskey, closeAccountDialog]);
|
|
148
|
+
|
|
120
149
|
// Bind the headless controller. `getSnapshot` returns a stable reference
|
|
121
150
|
// between changes, so it is `useSyncExternalStore`-safe. Guard the no-provider
|
|
122
151
|
// loading state (`controller` is `null`) with an inert store.
|
|
@@ -221,7 +250,9 @@ const OxyAccountDialog: React.FC = () => {
|
|
|
221
250
|
handlers={handlers}
|
|
222
251
|
onSignInWithOxy={() => void controller.signInWithOxy()}
|
|
223
252
|
onScanQr={() => void controller.showQr()}
|
|
224
|
-
|
|
253
|
+
onSignInWithPasskey={passkeyAvailable ? () => void handleSignInWithPasskey() : undefined}
|
|
254
|
+
passkeyPending={passkeyPending}
|
|
255
|
+
passkeyError={passkeyError}
|
|
225
256
|
/>
|
|
226
257
|
)}
|
|
227
258
|
</ScrollView>
|
|
@@ -415,7 +446,10 @@ interface SignInViewProps {
|
|
|
415
446
|
handlers: OxyAccountDialogHandlers;
|
|
416
447
|
onSignInWithOxy: () => void;
|
|
417
448
|
onScanQr: () => void;
|
|
418
|
-
|
|
449
|
+
/** When present, offer a "Sign in with a passkey" button (first-party Oxy web only). */
|
|
450
|
+
onSignInWithPasskey?: () => void;
|
|
451
|
+
passkeyPending: boolean;
|
|
452
|
+
passkeyError: string | null;
|
|
419
453
|
}
|
|
420
454
|
|
|
421
455
|
const SignInView: React.FC<SignInViewProps> = ({
|
|
@@ -425,7 +459,9 @@ const SignInView: React.FC<SignInViewProps> = ({
|
|
|
425
459
|
handlers,
|
|
426
460
|
onSignInWithOxy,
|
|
427
461
|
onScanQr,
|
|
428
|
-
|
|
462
|
+
onSignInWithPasskey,
|
|
463
|
+
passkeyPending,
|
|
464
|
+
passkeyError,
|
|
429
465
|
}) => (
|
|
430
466
|
<View style={styles.signInBlock}>
|
|
431
467
|
{snapshot.accounts.length > 0 ? (
|
|
@@ -448,15 +484,27 @@ const SignInView: React.FC<SignInViewProps> = ({
|
|
|
448
484
|
Sign in with Oxy
|
|
449
485
|
</Button>
|
|
450
486
|
|
|
487
|
+
{onSignInWithPasskey ? (
|
|
488
|
+
<Button
|
|
489
|
+
variant="secondary"
|
|
490
|
+
onPress={onSignInWithPasskey}
|
|
491
|
+
disabled={passkeyPending}
|
|
492
|
+
style={styles.secondaryButton}
|
|
493
|
+
testID="passkey-signin-button"
|
|
494
|
+
>
|
|
495
|
+
{passkeyPending
|
|
496
|
+
? t('accountSwitcher.passkeySigningIn') || 'Signing in…'
|
|
497
|
+
: t('accountSwitcher.signInWithPasskey') || 'Sign in with a passkey'}
|
|
498
|
+
</Button>
|
|
499
|
+
) : null}
|
|
500
|
+
|
|
501
|
+
{passkeyError ? (
|
|
502
|
+
<Text style={[styles.errorText, { color: theme.colors.error }]}>{passkeyError}</Text>
|
|
503
|
+
) : null}
|
|
504
|
+
|
|
451
505
|
<Button variant="secondary" onPress={onScanQr} style={styles.secondaryButton}>
|
|
452
506
|
{t('accountSwitcher.scanQr') || 'Scan a QR from another device'}
|
|
453
507
|
</Button>
|
|
454
|
-
|
|
455
|
-
<Pressable onPress={onUsePassword} accessibilityRole="button" style={styles.passwordLink}>
|
|
456
|
-
<Text style={[styles.linkText, { color: theme.colors.textSecondary }]}>
|
|
457
|
-
Use a password instead
|
|
458
|
-
</Text>
|
|
459
|
-
</Pressable>
|
|
460
508
|
</View>
|
|
461
509
|
);
|
|
462
510
|
|
|
@@ -681,11 +729,6 @@ const styles = StyleSheet.create({
|
|
|
681
729
|
borderRadius: 14,
|
|
682
730
|
marginTop: 10,
|
|
683
731
|
},
|
|
684
|
-
passwordLink: {
|
|
685
|
-
alignSelf: 'center',
|
|
686
|
-
paddingVertical: 10,
|
|
687
|
-
marginTop: 12,
|
|
688
|
-
},
|
|
689
732
|
dividerRow: {
|
|
690
733
|
flexDirection: 'row',
|
|
691
734
|
alignItems: 'center',
|
|
@@ -114,6 +114,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
|
|
|
114
114
|
baseURL,
|
|
115
115
|
authWebUrl,
|
|
116
116
|
authRedirectUri,
|
|
117
|
+
authorizeBaseUrl,
|
|
117
118
|
queryClient: providedQueryClient,
|
|
118
119
|
requireAuth = 'off',
|
|
119
120
|
hubSync = true,
|
|
@@ -309,6 +310,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
|
|
|
309
310
|
baseURL={baseURL}
|
|
310
311
|
authWebUrl={authWebUrl}
|
|
311
312
|
authRedirectUri={authRedirectUri}
|
|
313
|
+
authorizeBaseUrl={authorizeBaseUrl}
|
|
312
314
|
storageKeyPrefix={storageKeyPrefix}
|
|
313
315
|
clientId={clientId}
|
|
314
316
|
hubSync={hubSync}
|
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
logger as loggerUtil,
|
|
28
28
|
syncHubAfterSignIn,
|
|
29
29
|
} from '@oxyhq/core';
|
|
30
|
-
import type { SecurityAlert } from '@oxyhq/contracts';
|
|
31
30
|
import {
|
|
32
31
|
registerAccountDialogControls,
|
|
33
32
|
notifyAccountDialogVisibility,
|
|
@@ -59,15 +58,21 @@ import {
|
|
|
59
58
|
} from '../session';
|
|
60
59
|
import type {
|
|
61
60
|
OxyContextState,
|
|
62
|
-
PasswordSignInResult,
|
|
63
61
|
OxyContextProviderProps,
|
|
64
62
|
CommitInput,
|
|
65
63
|
} from './oxyContextTypes';
|
|
66
64
|
import { DEFAULT_SESSION_VALIDITY_MS, loadUseFollowHook } from './oxyContextHelpers';
|
|
67
65
|
import { commitDeviceSetAndResolve } from './commitSessionFlow';
|
|
66
|
+
import { runPasskeyLogin, runPasskeyRegister, runPasskeyAdd } from './passkeyFlow';
|
|
67
|
+
import {
|
|
68
|
+
isPasskeySupported,
|
|
69
|
+
runRegistrationCeremony,
|
|
70
|
+
runAuthenticationCeremony,
|
|
71
|
+
} from '../../webauthn/passkeyClient';
|
|
72
|
+
import { queryKeys } from '../hooks/queries/queryKeys';
|
|
68
73
|
import { useOxyAccountGraph } from './useOxyAccountGraph';
|
|
69
74
|
|
|
70
|
-
export type { OxyContextState,
|
|
75
|
+
export type { OxyContextState, OxyContextProviderProps } from './oxyContextTypes';
|
|
71
76
|
|
|
72
77
|
const OxyContext = createContext<OxyContextState | null>(null);
|
|
73
78
|
|
|
@@ -77,6 +82,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
77
82
|
baseURL,
|
|
78
83
|
authWebUrl,
|
|
79
84
|
authRedirectUri,
|
|
85
|
+
authorizeBaseUrl,
|
|
80
86
|
storageKeyPrefix = 'oxy_session',
|
|
81
87
|
clientId: clientIdProp,
|
|
82
88
|
hubSync = true,
|
|
@@ -615,7 +621,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
615
621
|
oxyServices,
|
|
616
622
|
sessionClient,
|
|
617
623
|
clientId,
|
|
618
|
-
authRedirectUri,
|
|
619
624
|
locale: currentLanguage,
|
|
620
625
|
// Same statically-injected `io` as the SessionClient: gives the QR flow an
|
|
621
626
|
// instant `/auth-session` `auth_update` wake instead of a slow poll.
|
|
@@ -669,72 +674,81 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
669
674
|
notifyAccountDialogVisibility(accountDialogOpen);
|
|
670
675
|
}, [accountDialogOpen]);
|
|
671
676
|
|
|
672
|
-
//
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
677
|
+
// ── Passkey (WebAuthn) ─────────────────────────────────────────────────────
|
|
678
|
+
// Web-only sign-in / registration via the browser WebAuthn ceremony. The fixed
|
|
679
|
+
// `options → ceremony → verify → commit` ordering lives in the pure
|
|
680
|
+
// `passkeyFlow` helpers (deps-injected, unit-tested); these wrappers supply the
|
|
681
|
+
// real deps (core `webauthn*` methods, the platform ceremony client, and the
|
|
682
|
+
// `commitSession` funnel). All three GATE on `isPasskeySupported()` so a native
|
|
683
|
+
// / unsupported surface throws loudly instead of stalling in a ceremony.
|
|
684
|
+
|
|
685
|
+
// Passkey sign-in. No `username` → usernameless (discoverable) flow. With a
|
|
686
|
+
// `username` → username-first: the server scopes `allowCredentials` to that
|
|
687
|
+
// user's passkeys so a non-discoverable hardware key (U2F/security key) can
|
|
688
|
+
// be selected.
|
|
689
|
+
const signInWithPasskey = useCallback(
|
|
690
|
+
async (opts?: { username?: string; deviceName?: string; deviceFingerprint?: string }): Promise<void> => {
|
|
679
691
|
const persisted = await authStore.load();
|
|
680
|
-
|
|
692
|
+
await runPasskeyLogin({
|
|
693
|
+
isSupported: isPasskeySupported,
|
|
694
|
+
getLoginOptions: (username) => oxyServices.webauthnLoginOptions(username),
|
|
695
|
+
runCeremony: runAuthenticationCeremony,
|
|
696
|
+
loginVerify: (response, envelope) => oxyServices.webauthnLoginVerify(response, envelope),
|
|
697
|
+
commit: (input) => commitSession(input, { activate: true, hubSync: true }),
|
|
698
|
+
username: opts?.username,
|
|
699
|
+
deviceId: persisted?.deviceId,
|
|
681
700
|
deviceName: opts?.deviceName,
|
|
682
701
|
deviceFingerprint: opts?.deviceFingerprint,
|
|
683
|
-
deviceId: persisted?.deviceId,
|
|
684
702
|
});
|
|
685
|
-
if ('twoFactorRequired' in result) {
|
|
686
|
-
return { status: '2fa_required', loginToken: result.loginToken };
|
|
687
|
-
}
|
|
688
|
-
// `passwordSignIn` already planted the access token on the session arm.
|
|
689
|
-
await commitSession(
|
|
690
|
-
{
|
|
691
|
-
sessionId: result.sessionId,
|
|
692
|
-
accessToken: result.accessToken,
|
|
693
|
-
deviceSecret: result.deviceSecret,
|
|
694
|
-
deviceId: result.deviceId,
|
|
695
|
-
expiresAt: result.expiresAt,
|
|
696
|
-
userId: result.user.id,
|
|
697
|
-
user: result.user,
|
|
698
|
-
},
|
|
699
|
-
{ activate: true, hubSync: true },
|
|
700
|
-
);
|
|
701
|
-
return { status: 'ok', ...(result.securityAlert ? { securityAlert: result.securityAlert } : {}) };
|
|
702
703
|
},
|
|
703
|
-
[oxyServices, commitSession],
|
|
704
|
+
[oxyServices, authStore, commitSession],
|
|
704
705
|
);
|
|
705
706
|
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
token: params.token,
|
|
717
|
-
backupCode: params.backupCode,
|
|
707
|
+
// Create a brand-new account whose first auth method is a passkey.
|
|
708
|
+
const registerWithPasskey = useCallback(
|
|
709
|
+
async (params: { username: string; deviceName?: string }): Promise<void> => {
|
|
710
|
+
await runPasskeyRegister({
|
|
711
|
+
isSupported: isPasskeySupported,
|
|
712
|
+
getRegisterOptions: (username) => oxyServices.webauthnRegisterOptions(username),
|
|
713
|
+
runCeremony: runRegistrationCeremony,
|
|
714
|
+
registerVerify: (response, envelope) => oxyServices.webauthnRegisterVerify(response, envelope),
|
|
715
|
+
commit: (input) => commitSession(input, { activate: true, hubSync: true }),
|
|
716
|
+
username: params.username,
|
|
718
717
|
deviceName: params.deviceName,
|
|
719
|
-
deviceId: persisted?.deviceId,
|
|
720
718
|
});
|
|
721
|
-
await commitSession(
|
|
722
|
-
{
|
|
723
|
-
sessionId: result.sessionId,
|
|
724
|
-
accessToken: result.accessToken,
|
|
725
|
-
deviceSecret: result.deviceSecret,
|
|
726
|
-
deviceId: result.deviceId,
|
|
727
|
-
expiresAt: result.expiresAt,
|
|
728
|
-
userId: result.user.id,
|
|
729
|
-
user: result.user,
|
|
730
|
-
},
|
|
731
|
-
{ activate: true, hubSync: true },
|
|
732
|
-
);
|
|
733
|
-
return { securityAlert: result.securityAlert };
|
|
734
719
|
},
|
|
735
720
|
[oxyServices, commitSession],
|
|
736
721
|
);
|
|
737
722
|
|
|
723
|
+
// Add a passkey to the already-signed-in account (bearer present). No new
|
|
724
|
+
// session is committed — just refresh the linked auth-methods list.
|
|
725
|
+
const addPasskey = useCallback(
|
|
726
|
+
async (params?: { deviceName?: string }): Promise<void> => {
|
|
727
|
+
await runPasskeyAdd({
|
|
728
|
+
isSupported: isPasskeySupported,
|
|
729
|
+
getRegisterOptions: () => oxyServices.webauthnRegisterOptions(),
|
|
730
|
+
runCeremony: runRegistrationCeremony,
|
|
731
|
+
registerVerify: (response, envelope) => oxyServices.webauthnRegisterVerify(response, envelope),
|
|
732
|
+
onLinked: () => {
|
|
733
|
+
void queryClient.invalidateQueries({ queryKey: queryKeys.authMethods.all });
|
|
734
|
+
},
|
|
735
|
+
deviceName: params?.deviceName,
|
|
736
|
+
});
|
|
737
|
+
},
|
|
738
|
+
[oxyServices, queryClient],
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
// Remove a passkey from the already-signed-in account by credential id. Not a
|
|
742
|
+
// ceremony — a plain unlink — so it needs no `isPasskeySupported` gate; it just
|
|
743
|
+
// refreshes the linked auth-methods list on success.
|
|
744
|
+
const removePasskey = useCallback(
|
|
745
|
+
async (credentialId: string): Promise<void> => {
|
|
746
|
+
await oxyServices.removePasskey(credentialId);
|
|
747
|
+
void queryClient.invalidateQueries({ queryKey: queryKeys.authMethods.all });
|
|
748
|
+
},
|
|
749
|
+
[oxyServices, queryClient],
|
|
750
|
+
);
|
|
751
|
+
|
|
738
752
|
// ── Cold boot ────────────────────────────────────────────────────────────
|
|
739
753
|
// Device-first session restore via `runProviderColdBoot` (see boot/runProviderColdBoot.ts).
|
|
740
754
|
const runColdBoot = useCallback(async (): Promise<void> => {
|
|
@@ -743,6 +757,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
743
757
|
authStore,
|
|
744
758
|
clientId: clientIdProp,
|
|
745
759
|
authRedirectUri,
|
|
760
|
+
authorizeBaseUrl,
|
|
746
761
|
sessionClient,
|
|
747
762
|
syncDeviceCredentialToHost,
|
|
748
763
|
commitSession: (input, options) => commitSessionRef.current(input, options),
|
|
@@ -754,6 +769,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
754
769
|
authStore,
|
|
755
770
|
clientIdProp,
|
|
756
771
|
authRedirectUri,
|
|
772
|
+
authorizeBaseUrl,
|
|
757
773
|
syncDeviceCredentialToHost,
|
|
758
774
|
sessionClient,
|
|
759
775
|
]);
|
|
@@ -883,8 +899,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
883
899
|
hasIdentity,
|
|
884
900
|
getPublicKey,
|
|
885
901
|
signIn,
|
|
886
|
-
|
|
887
|
-
|
|
902
|
+
signInWithPasskey,
|
|
903
|
+
registerWithPasskey,
|
|
904
|
+
addPasskey,
|
|
905
|
+
removePasskey,
|
|
888
906
|
revokeSuspiciousSignIn,
|
|
889
907
|
handleWebSession,
|
|
890
908
|
logout,
|
|
@@ -935,8 +953,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
935
953
|
hasIdentity,
|
|
936
954
|
getPublicKey,
|
|
937
955
|
signIn,
|
|
938
|
-
|
|
939
|
-
|
|
956
|
+
signInWithPasskey,
|
|
957
|
+
registerWithPasskey,
|
|
958
|
+
addPasskey,
|
|
959
|
+
removePasskey,
|
|
940
960
|
revokeSuspiciousSignIn,
|
|
941
961
|
handleWebSession,
|
|
942
962
|
logout,
|
|
@@ -1005,8 +1025,10 @@ const LOADING_STATE: OxyContextState = {
|
|
|
1005
1025
|
hasIdentity: () => Promise.resolve(false),
|
|
1006
1026
|
getPublicKey: () => Promise.resolve(null),
|
|
1007
1027
|
signIn: () => rejectMissingProvider<User>(),
|
|
1008
|
-
|
|
1009
|
-
|
|
1028
|
+
signInWithPasskey: () => rejectMissingProvider<void>(),
|
|
1029
|
+
registerWithPasskey: () => rejectMissingProvider<void>(),
|
|
1030
|
+
addPasskey: () => rejectMissingProvider<void>(),
|
|
1031
|
+
removePasskey: () => rejectMissingProvider<void>(),
|
|
1010
1032
|
revokeSuspiciousSignIn: () => rejectMissingProvider<void>(),
|
|
1011
1033
|
handleWebSession: () => rejectMissingProvider<void>(),
|
|
1012
1034
|
logout: () => rejectMissingProvider<void>(),
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `passkeyFlow` — the pure, deps-injected passkey (WebAuthn) orchestration that
|
|
3
|
+
* backs `useOxy().signInWithPasskey` / `registerWithPasskey` / `addPasskey`.
|
|
4
|
+
*
|
|
5
|
+
* These tests assert the fixed `options → ceremony → verify → commit` ordering,
|
|
6
|
+
* the exact `commitSession` input projected from a session-arm login result, the
|
|
7
|
+
* `isSupported()` gate, and the branch discipline (sign-in/register commit a
|
|
8
|
+
* session; add links WITHOUT committing and fires `onLinked`) — the same
|
|
9
|
+
* deps-injection style `commitSessionFlow.test.ts` uses.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { LoginSessionResult } from '@oxyhq/contracts';
|
|
13
|
+
import {
|
|
14
|
+
runPasskeyLogin,
|
|
15
|
+
runPasskeyRegister,
|
|
16
|
+
runPasskeyAdd,
|
|
17
|
+
PASSKEY_UNSUPPORTED_MESSAGE,
|
|
18
|
+
type RunPasskeyLoginDeps,
|
|
19
|
+
type RunPasskeyRegisterDeps,
|
|
20
|
+
type RunPasskeyAddDeps,
|
|
21
|
+
type PasskeyRegisterVerifyResult,
|
|
22
|
+
} from '../passkeyFlow';
|
|
23
|
+
import type { CommitInput } from '../oxyContextTypes';
|
|
24
|
+
|
|
25
|
+
const SESSION_USER_ID = 'user_pk_1';
|
|
26
|
+
|
|
27
|
+
/** A full session arm of the login contract (mirrors `webauthn*Verify`'s signup/login output). */
|
|
28
|
+
const sessionResult: LoginSessionResult = {
|
|
29
|
+
sessionId: 'sess_pk',
|
|
30
|
+
deviceId: 'dev_pk',
|
|
31
|
+
accessToken: 'pk.access.token',
|
|
32
|
+
deviceSecret: 'pk.device.secret',
|
|
33
|
+
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
|
34
|
+
user: { id: SESSION_USER_ID, username: 'pkuser' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The `commitSession` input the flow projects from `sessionResult`. */
|
|
38
|
+
const expectedCommitInput: CommitInput = {
|
|
39
|
+
sessionId: 'sess_pk',
|
|
40
|
+
accessToken: 'pk.access.token',
|
|
41
|
+
deviceSecret: 'pk.device.secret',
|
|
42
|
+
deviceId: 'dev_pk',
|
|
43
|
+
expiresAt: sessionResult.expiresAt,
|
|
44
|
+
userId: SESSION_USER_ID,
|
|
45
|
+
user: { id: SESSION_USER_ID, username: 'pkuser' },
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const linkResult: PasskeyRegisterVerifyResult = { success: true, message: 'Passkey added' };
|
|
49
|
+
|
|
50
|
+
describe('runPasskeyLogin', () => {
|
|
51
|
+
const buildDeps = (
|
|
52
|
+
order: string[],
|
|
53
|
+
overrides: Partial<RunPasskeyLoginDeps> = {},
|
|
54
|
+
): RunPasskeyLoginDeps => ({
|
|
55
|
+
isSupported: jest.fn(() => true),
|
|
56
|
+
getLoginOptions: jest.fn(async () => {
|
|
57
|
+
order.push('getLoginOptions');
|
|
58
|
+
return { challenge: 'login-opts' };
|
|
59
|
+
}),
|
|
60
|
+
runCeremony: jest.fn(async (options) => {
|
|
61
|
+
order.push('runCeremony');
|
|
62
|
+
expect(options).toEqual({ challenge: 'login-opts' });
|
|
63
|
+
return { id: 'assertion' };
|
|
64
|
+
}),
|
|
65
|
+
loginVerify: jest.fn(async () => {
|
|
66
|
+
order.push('loginVerify');
|
|
67
|
+
return sessionResult;
|
|
68
|
+
}),
|
|
69
|
+
commit: jest.fn(async () => {
|
|
70
|
+
order.push('commit');
|
|
71
|
+
}),
|
|
72
|
+
deviceId: 'persisted-dev',
|
|
73
|
+
deviceName: 'My Laptop',
|
|
74
|
+
deviceFingerprint: 'fp-123',
|
|
75
|
+
...overrides,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('drives options → ceremony → verify → commit and threads the device envelope', async () => {
|
|
79
|
+
const order: string[] = [];
|
|
80
|
+
const deps = buildDeps(order);
|
|
81
|
+
|
|
82
|
+
await runPasskeyLogin(deps);
|
|
83
|
+
|
|
84
|
+
expect(order).toEqual(['getLoginOptions', 'runCeremony', 'loginVerify', 'commit']);
|
|
85
|
+
// The assertion response is forwarded to verify with the device envelope.
|
|
86
|
+
expect(deps.loginVerify).toHaveBeenCalledWith(
|
|
87
|
+
{ id: 'assertion' },
|
|
88
|
+
{ deviceName: 'My Laptop', deviceFingerprint: 'fp-123', deviceId: 'persisted-dev' },
|
|
89
|
+
);
|
|
90
|
+
// The session arm is projected onto the exact commitSession input.
|
|
91
|
+
expect(deps.commit).toHaveBeenCalledWith(expectedCommitInput);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('forwards the username to getLoginOptions for the username-first (hardware-key) path', async () => {
|
|
95
|
+
const order: string[] = [];
|
|
96
|
+
const deps = buildDeps(order, { username: 'alice' });
|
|
97
|
+
|
|
98
|
+
await runPasskeyLogin(deps);
|
|
99
|
+
|
|
100
|
+
// The username scopes login options to that user's passkeys — the path a
|
|
101
|
+
// non-discoverable U2F/security key needs.
|
|
102
|
+
expect(deps.getLoginOptions).toHaveBeenCalledWith('alice');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('passes undefined to getLoginOptions for the usernameless (discoverable) path', async () => {
|
|
106
|
+
const order: string[] = [];
|
|
107
|
+
const deps = buildDeps(order);
|
|
108
|
+
|
|
109
|
+
await runPasskeyLogin(deps);
|
|
110
|
+
|
|
111
|
+
// No username → discoverable-credential ceremony (server returns an empty allow-list).
|
|
112
|
+
expect(deps.getLoginOptions).toHaveBeenCalledWith(undefined);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('throws and touches nothing when passkeys are unsupported', async () => {
|
|
116
|
+
const order: string[] = [];
|
|
117
|
+
const deps = buildDeps(order, { isSupported: jest.fn(() => false) });
|
|
118
|
+
|
|
119
|
+
await expect(runPasskeyLogin(deps)).rejects.toThrow(PASSKEY_UNSUPPORTED_MESSAGE);
|
|
120
|
+
expect(deps.getLoginOptions).not.toHaveBeenCalled();
|
|
121
|
+
expect(deps.commit).not.toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('runPasskeyRegister', () => {
|
|
126
|
+
const buildDeps = (
|
|
127
|
+
order: string[],
|
|
128
|
+
overrides: Partial<RunPasskeyRegisterDeps> = {},
|
|
129
|
+
): RunPasskeyRegisterDeps => ({
|
|
130
|
+
isSupported: jest.fn(() => true),
|
|
131
|
+
getRegisterOptions: jest.fn(async (username) => {
|
|
132
|
+
order.push('getRegisterOptions');
|
|
133
|
+
expect(username).toBe('newuser');
|
|
134
|
+
return { challenge: 'reg-opts' };
|
|
135
|
+
}),
|
|
136
|
+
runCeremony: jest.fn(async () => {
|
|
137
|
+
order.push('runCeremony');
|
|
138
|
+
return { id: 'attestation' };
|
|
139
|
+
}),
|
|
140
|
+
registerVerify: jest.fn(async () => {
|
|
141
|
+
order.push('registerVerify');
|
|
142
|
+
return sessionResult;
|
|
143
|
+
}),
|
|
144
|
+
commit: jest.fn(async () => {
|
|
145
|
+
order.push('commit');
|
|
146
|
+
}),
|
|
147
|
+
username: 'newuser',
|
|
148
|
+
deviceName: 'My Laptop',
|
|
149
|
+
...overrides,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('creates the account (signup branch) and commits the minted session', async () => {
|
|
153
|
+
const order: string[] = [];
|
|
154
|
+
const deps = buildDeps(order);
|
|
155
|
+
|
|
156
|
+
await runPasskeyRegister(deps);
|
|
157
|
+
|
|
158
|
+
expect(order).toEqual(['getRegisterOptions', 'runCeremony', 'registerVerify', 'commit']);
|
|
159
|
+
expect(deps.registerVerify).toHaveBeenCalledWith(
|
|
160
|
+
{ id: 'attestation' },
|
|
161
|
+
{ username: 'newuser', deviceName: 'My Laptop' },
|
|
162
|
+
);
|
|
163
|
+
expect(deps.commit).toHaveBeenCalledWith(expectedCommitInput);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('throws (no commit) when verify returns a link branch instead of a session', async () => {
|
|
167
|
+
const order: string[] = [];
|
|
168
|
+
const deps = buildDeps(order, {
|
|
169
|
+
registerVerify: jest.fn(async () => linkResult),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await expect(runPasskeyRegister(deps)).rejects.toThrow(/did not establish a session/i);
|
|
173
|
+
expect(deps.commit).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('throws and touches nothing when passkeys are unsupported', async () => {
|
|
177
|
+
const order: string[] = [];
|
|
178
|
+
const deps = buildDeps(order, { isSupported: jest.fn(() => false) });
|
|
179
|
+
|
|
180
|
+
await expect(runPasskeyRegister(deps)).rejects.toThrow(PASSKEY_UNSUPPORTED_MESSAGE);
|
|
181
|
+
expect(deps.getRegisterOptions).not.toHaveBeenCalled();
|
|
182
|
+
expect(deps.commit).not.toHaveBeenCalled();
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('runPasskeyAdd', () => {
|
|
187
|
+
const buildDeps = (
|
|
188
|
+
order: string[],
|
|
189
|
+
overrides: Partial<RunPasskeyAddDeps> = {},
|
|
190
|
+
): RunPasskeyAddDeps => ({
|
|
191
|
+
isSupported: jest.fn(() => true),
|
|
192
|
+
getRegisterOptions: jest.fn(async () => {
|
|
193
|
+
order.push('getRegisterOptions');
|
|
194
|
+
return { challenge: 'add-opts' };
|
|
195
|
+
}),
|
|
196
|
+
runCeremony: jest.fn(async () => {
|
|
197
|
+
order.push('runCeremony');
|
|
198
|
+
return { id: 'attestation' };
|
|
199
|
+
}),
|
|
200
|
+
registerVerify: jest.fn(async () => {
|
|
201
|
+
order.push('registerVerify');
|
|
202
|
+
return linkResult;
|
|
203
|
+
}),
|
|
204
|
+
onLinked: jest.fn(() => {
|
|
205
|
+
order.push('onLinked');
|
|
206
|
+
}),
|
|
207
|
+
deviceName: 'My Laptop',
|
|
208
|
+
...overrides,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('links the passkey WITHOUT committing a session and fires onLinked', async () => {
|
|
212
|
+
const order: string[] = [];
|
|
213
|
+
const deps = buildDeps(order);
|
|
214
|
+
|
|
215
|
+
await runPasskeyAdd(deps);
|
|
216
|
+
|
|
217
|
+
expect(order).toEqual(['getRegisterOptions', 'runCeremony', 'registerVerify', 'onLinked']);
|
|
218
|
+
// Add requests options with NO username (bearer scopes it to the signed-in user).
|
|
219
|
+
expect(deps.getRegisterOptions).toHaveBeenCalledWith();
|
|
220
|
+
expect(deps.registerVerify).toHaveBeenCalledWith({ id: 'attestation' }, { deviceName: 'My Laptop' });
|
|
221
|
+
expect(deps.onLinked).toHaveBeenCalledTimes(1);
|
|
222
|
+
// There is no `commit` dependency at all — structurally cannot commit a session.
|
|
223
|
+
expect('commit' in deps).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('throws (no onLinked) if verify unexpectedly mints a session', async () => {
|
|
227
|
+
const order: string[] = [];
|
|
228
|
+
const deps = buildDeps(order, {
|
|
229
|
+
registerVerify: jest.fn(async () => sessionResult),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
await expect(runPasskeyAdd(deps)).rejects.toThrow(/instead of linking/i);
|
|
233
|
+
expect(deps.onLinked).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('throws and touches nothing when passkeys are unsupported', async () => {
|
|
237
|
+
const order: string[] = [];
|
|
238
|
+
const deps = buildDeps(order, { isSupported: jest.fn(() => false) });
|
|
239
|
+
|
|
240
|
+
await expect(runPasskeyAdd(deps)).rejects.toThrow(PASSKEY_UNSUPPORTED_MESSAGE);
|
|
241
|
+
expect(deps.getRegisterOptions).not.toHaveBeenCalled();
|
|
242
|
+
expect(deps.onLinked).not.toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
});
|