@oxyhq/services 20.0.2 → 21.0.1

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.
Files changed (89) hide show
  1. package/lib/commonjs/index.js +13 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/components/OxyAccountDialog.js +50 -18
  4. package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
  5. package/lib/commonjs/ui/context/OxyContext.js +69 -1
  6. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  7. package/lib/commonjs/ui/context/passkeyFlow.js +117 -0
  8. package/lib/commonjs/ui/context/passkeyFlow.js.map +1 -0
  9. package/lib/commonjs/ui/hooks/queries/queryKeys.js +18 -2
  10. package/lib/commonjs/ui/hooks/queries/queryKeys.js.map +1 -1
  11. package/lib/commonjs/ui/hooks/queries/useAuthMethods.js +49 -0
  12. package/lib/commonjs/ui/hooks/queries/useAuthMethods.js.map +1 -0
  13. package/lib/commonjs/ui/stores/authStore.js +5 -3
  14. package/lib/commonjs/ui/stores/authStore.js.map +1 -1
  15. package/lib/commonjs/webauthn/passkeyClient.js +39 -0
  16. package/lib/commonjs/webauthn/passkeyClient.js.map +1 -0
  17. package/lib/commonjs/webauthn/passkeyClient.native.js +34 -0
  18. package/lib/commonjs/webauthn/passkeyClient.native.js.map +1 -0
  19. package/lib/commonjs/webauthn/passkeyClient.web.js +57 -0
  20. package/lib/commonjs/webauthn/passkeyClient.web.js.map +1 -0
  21. package/lib/module/index.js +2 -1
  22. package/lib/module/index.js.map +1 -1
  23. package/lib/module/ui/components/OxyAccountDialog.js +51 -19
  24. package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
  25. package/lib/module/ui/context/OxyContext.js +69 -1
  26. package/lib/module/ui/context/OxyContext.js.map +1 -1
  27. package/lib/module/ui/context/passkeyFlow.js +110 -0
  28. package/lib/module/ui/context/passkeyFlow.js.map +1 -0
  29. package/lib/module/ui/hooks/queries/queryKeys.js +15 -0
  30. package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
  31. package/lib/module/ui/hooks/queries/useAuthMethods.js +45 -0
  32. package/lib/module/ui/hooks/queries/useAuthMethods.js.map +1 -0
  33. package/lib/module/ui/stores/authStore.js +6 -4
  34. package/lib/module/ui/stores/authStore.js.map +1 -1
  35. package/lib/module/webauthn/passkeyClient.js +33 -0
  36. package/lib/module/webauthn/passkeyClient.js.map +1 -0
  37. package/lib/module/webauthn/passkeyClient.native.js +28 -0
  38. package/lib/module/webauthn/passkeyClient.native.js.map +1 -0
  39. package/lib/module/webauthn/passkeyClient.web.js +52 -0
  40. package/lib/module/webauthn/passkeyClient.web.js.map +1 -0
  41. package/lib/typescript/commonjs/index.d.ts +2 -1
  42. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +26 -0
  46. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts +85 -0
  48. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts.map +1 -0
  49. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +9 -0
  50. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/ui/hooks/queries/useAuthMethods.d.ts +212 -0
  52. package/lib/typescript/commonjs/ui/hooks/queries/useAuthMethods.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/webauthn/passkeyClient.d.ts +20 -0
  54. package/lib/typescript/commonjs/webauthn/passkeyClient.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/webauthn/passkeyClient.native.d.ts +15 -0
  56. package/lib/typescript/commonjs/webauthn/passkeyClient.native.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/webauthn/passkeyClient.web.d.ts +36 -0
  58. package/lib/typescript/commonjs/webauthn/passkeyClient.web.d.ts.map +1 -0
  59. package/lib/typescript/module/index.d.ts +2 -1
  60. package/lib/typescript/module/index.d.ts.map +1 -1
  61. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
  62. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  63. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +26 -0
  64. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  65. package/lib/typescript/module/ui/context/passkeyFlow.d.ts +85 -0
  66. package/lib/typescript/module/ui/context/passkeyFlow.d.ts.map +1 -0
  67. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts +9 -0
  68. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  69. package/lib/typescript/module/ui/hooks/queries/useAuthMethods.d.ts +212 -0
  70. package/lib/typescript/module/ui/hooks/queries/useAuthMethods.d.ts.map +1 -0
  71. package/lib/typescript/module/webauthn/passkeyClient.d.ts +20 -0
  72. package/lib/typescript/module/webauthn/passkeyClient.d.ts.map +1 -0
  73. package/lib/typescript/module/webauthn/passkeyClient.native.d.ts +15 -0
  74. package/lib/typescript/module/webauthn/passkeyClient.native.d.ts.map +1 -0
  75. package/lib/typescript/module/webauthn/passkeyClient.web.d.ts +36 -0
  76. package/lib/typescript/module/webauthn/passkeyClient.web.d.ts.map +1 -0
  77. package/package.json +6 -5
  78. package/src/index.ts +2 -0
  79. package/src/ui/components/OxyAccountDialog.tsx +58 -14
  80. package/src/ui/context/OxyContext.tsx +76 -0
  81. package/src/ui/context/__tests__/passkeyFlow.test.ts +237 -0
  82. package/src/ui/context/oxyContextTypes.ts +21 -0
  83. package/src/ui/context/passkeyFlow.ts +146 -0
  84. package/src/ui/hooks/queries/queryKeys.ts +14 -0
  85. package/src/ui/hooks/queries/useAuthMethods.ts +45 -0
  86. package/src/ui/stores/authStore.ts +4 -4
  87. package/src/webauthn/passkeyClient.native.ts +26 -0
  88. package/src/webauthn/passkeyClient.ts +31 -0
  89. package/src/webauthn/passkeyClient.web.ts +56 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/services",
3
- "version": "20.0.2",
3
+ "version": "21.0.1",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -126,15 +126,16 @@
126
126
  }
127
127
  },
128
128
  "dependencies": {
129
- "@oxyhq/contracts": "0.14.1",
130
- "@oxyhq/core": "^10.1.3",
129
+ "@oxyhq/contracts": "0.14.2",
130
+ "@oxyhq/core": "^11.0.0",
131
+ "@simplewebauthn/browser": "^13.3.0",
131
132
  "color": "^4.2.3"
132
133
  },
133
134
  "devDependencies": {
134
135
  "@biomejs/biome": "^1.9.4",
135
136
  "@commitlint/cli": "^17.6.5",
136
137
  "@commitlint/config-conventional": "^17.6.5",
137
- "@oxyhq/core": "10.1.5",
138
+ "@oxyhq/core": "11.0.1",
138
139
  "@react-native-async-storage/async-storage": "^2.0.0",
139
140
  "@react-native-community/netinfo": "^11.4.1",
140
141
  "@react-native/babel-preset": "^0.86.0",
@@ -175,7 +176,7 @@
175
176
  "peerDependencies": {
176
177
  "@expo/vector-icons": "^15.0.3",
177
178
  "@oxyhq/bloom": ">=0.16.0",
178
- "@oxyhq/core": "^10.1.3",
179
+ "@oxyhq/core": "^11.0.0",
179
180
  "@react-native-async-storage/async-storage": "^2.0.0",
180
181
  "@react-native-community/netinfo": "^11.4.1",
181
182
  "@tanstack/query-async-storage-persister": "^5.101",
package/src/index.ts CHANGED
@@ -95,6 +95,7 @@ export {
95
95
  useRecentSecurityActivity,
96
96
  useInfiniteSecurityActivity,
97
97
  } from './ui/hooks/queries/useSecurityQueries';
98
+ export { useAuthMethods } from './ui/hooks/queries/useAuthMethods';
98
99
  export {
99
100
  useUserSubscription,
100
101
  useUserPayments,
@@ -166,6 +167,7 @@ export {
166
167
  invalidateStorageQueries,
167
168
  invalidatePaymentsQueries,
168
169
  invalidateConnectedAppsQueries,
170
+ invalidateAuthMethodsQueries,
169
171
  } from './ui/hooks/queries/queryKeys';
170
172
 
171
173
  // Mutation status aggregator (for "Syncing..." indicators)
@@ -41,7 +41,6 @@
41
41
  import type React from 'react';
42
42
  import { useCallback, useMemo, useState, useSyncExternalStore } from 'react';
43
43
  import {
44
- Platform,
45
44
  Pressable,
46
45
  ScrollView,
47
46
  StyleSheet,
@@ -63,9 +62,11 @@ import {
63
62
  type AppColorName,
64
63
  } from '@oxyhq/bloom/theme';
65
64
  import type { SwitchableAccount, AccountDialogSnapshot } from '@oxyhq/core';
65
+ import { isOxyRpOrigin } from '@oxyhq/core';
66
66
  import { useQueryClient } from '@tanstack/react-query';
67
67
  import { useOxy } from '../context/OxyContext';
68
68
  import { useI18n } from '../hooks/useI18n';
69
+ import { isWebBrowser } from '../utils/isWebBrowser';
69
70
  import { LogoIcon } from './logo/LogoIcon';
70
71
 
71
72
  /** Diameter of a row avatar. */
@@ -75,8 +76,6 @@ const QR_PLATE_BG = '#FFFFFF';
75
76
  const QR_FOREGROUND = '#000000';
76
77
  const QR_SIZE = 196;
77
78
 
78
- const isWeb = Platform.OS === 'web';
79
-
80
79
  /**
81
80
  * Resolve an account's stored color (a named Bloom preset, e.g. `'purple'`) to
82
81
  * a concrete brand hex for the row accent. Falls back to the theme primary when
@@ -115,11 +114,38 @@ const OxyAccountDialog: React.FC = () => {
115
114
  showBottomSheet,
116
115
  logoutAll,
117
116
  refreshAccounts,
117
+ signInWithPasskey,
118
118
  } = useOxy();
119
119
  const theme = useTheme();
120
120
  const { t } = useI18n();
121
121
  const queryClient = useQueryClient();
122
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
+
123
149
  // Bind the headless controller. `getSnapshot` returns a stable reference
124
150
  // between changes, so it is `useSyncExternalStore`-safe. Guard the no-provider
125
151
  // loading state (`controller` is `null`) with an inert store.
@@ -194,17 +220,7 @@ const OxyAccountDialog: React.FC = () => {
194
220
  <Dialog
195
221
  open={isAccountDialogOpen}
196
222
  onClose={closeAccountDialog}
197
- // On web ALWAYS use the centered placement. Bloom's `bottom` placement
198
- // routes through its `BottomSheet`, which on web is built on
199
- // react-native-web's `Modal`. That `Modal` creates its `ModalPortal` host
200
- // node as a side effect during render and removes it in an effect cleanup;
201
- // under React 19 concurrent rendering (and StrictMode's dev double-invoke)
202
- // the host is orphaned and never re-attached, so the sheet never paints on
203
- // narrow web viewports (reproduced on console.oxy.so / auth.oxy.so, Pixel-7
204
- // width). The `center` placement uses Bloom's react-dom Portal (no RN
205
- // Modal) and paints reliably at every width. Native keeps the real bottom
206
- // sheet on narrow screens.
207
- placement={isWeb ? 'center' : { base: 'bottom', md: 'center' }}
223
+ placement={{ base: 'bottom', md: 'center' }}
208
224
  dismissOnBackdrop={false}
209
225
  maxWidth={420}
210
226
  label={headerCopy(view, snapshot.accounts.length, t).title}
@@ -235,6 +251,9 @@ const OxyAccountDialog: React.FC = () => {
235
251
  onSignInWithOxy={() => void controller.signInWithOxy()}
236
252
  onScanQr={() => void controller.showQr()}
237
253
  onUsePassword={() => void controller.openPasswordAtOxyAuth()}
254
+ onSignInWithPasskey={passkeyAvailable ? () => void handleSignInWithPasskey() : undefined}
255
+ passkeyPending={passkeyPending}
256
+ passkeyError={passkeyError}
238
257
  />
239
258
  )}
240
259
  </ScrollView>
@@ -429,6 +448,10 @@ interface SignInViewProps {
429
448
  onSignInWithOxy: () => void;
430
449
  onScanQr: () => void;
431
450
  onUsePassword: () => void;
451
+ /** When present, offer a "Sign in with a passkey" button (first-party Oxy web only). */
452
+ onSignInWithPasskey?: () => void;
453
+ passkeyPending: boolean;
454
+ passkeyError: string | null;
432
455
  }
433
456
 
434
457
  const SignInView: React.FC<SignInViewProps> = ({
@@ -439,6 +462,9 @@ const SignInView: React.FC<SignInViewProps> = ({
439
462
  onSignInWithOxy,
440
463
  onScanQr,
441
464
  onUsePassword,
465
+ onSignInWithPasskey,
466
+ passkeyPending,
467
+ passkeyError,
442
468
  }) => (
443
469
  <View style={styles.signInBlock}>
444
470
  {snapshot.accounts.length > 0 ? (
@@ -461,6 +487,24 @@ const SignInView: React.FC<SignInViewProps> = ({
461
487
  Sign in with Oxy
462
488
  </Button>
463
489
 
490
+ {onSignInWithPasskey ? (
491
+ <Button
492
+ variant="secondary"
493
+ onPress={onSignInWithPasskey}
494
+ disabled={passkeyPending}
495
+ style={styles.secondaryButton}
496
+ testID="passkey-signin-button"
497
+ >
498
+ {passkeyPending
499
+ ? t('accountSwitcher.passkeySigningIn') || 'Signing in…'
500
+ : t('accountSwitcher.signInWithPasskey') || 'Sign in with a passkey'}
501
+ </Button>
502
+ ) : null}
503
+
504
+ {passkeyError ? (
505
+ <Text style={[styles.errorText, { color: theme.colors.error }]}>{passkeyError}</Text>
506
+ ) : null}
507
+
464
508
  <Button variant="secondary" onPress={onScanQr} style={styles.secondaryButton}>
465
509
  {t('accountSwitcher.scanQr') || 'Scan a QR from another device'}
466
510
  </Button>
@@ -65,6 +65,13 @@ import type {
65
65
  } from './oxyContextTypes';
66
66
  import { DEFAULT_SESSION_VALIDITY_MS, loadUseFollowHook } from './oxyContextHelpers';
67
67
  import { commitDeviceSetAndResolve } from './commitSessionFlow';
68
+ import { runPasskeyLogin, runPasskeyRegister, runPasskeyAdd } from './passkeyFlow';
69
+ import {
70
+ isPasskeySupported,
71
+ runRegistrationCeremony,
72
+ runAuthenticationCeremony,
73
+ } from '../../webauthn/passkeyClient';
74
+ import { queryKeys } from '../hooks/queries/queryKeys';
68
75
  import { useOxyAccountGraph } from './useOxyAccountGraph';
69
76
 
70
77
  export type { OxyContextState, PasswordSignInResult, OxyContextProviderProps } from './oxyContextTypes';
@@ -735,6 +742,66 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
735
742
  [oxyServices, commitSession],
736
743
  );
737
744
 
745
+ // ── Passkey (WebAuthn) ─────────────────────────────────────────────────────
746
+ // Web-only sign-in / registration via the browser WebAuthn ceremony. The fixed
747
+ // `options → ceremony → verify → commit` ordering lives in the pure
748
+ // `passkeyFlow` helpers (deps-injected, unit-tested); these wrappers supply the
749
+ // real deps (core `webauthn*` methods, the platform ceremony client, and the
750
+ // `commitSession` funnel). All three GATE on `isPasskeySupported()` so a native
751
+ // / unsupported surface throws loudly instead of stalling in a ceremony.
752
+
753
+ // Usernameless (discoverable) passkey sign-in.
754
+ const signInWithPasskey = useCallback(
755
+ async (opts?: { deviceName?: string; deviceFingerprint?: string }): Promise<void> => {
756
+ const persisted = await authStore.load();
757
+ await runPasskeyLogin({
758
+ isSupported: isPasskeySupported,
759
+ getLoginOptions: () => oxyServices.webauthnLoginOptions(),
760
+ runCeremony: runAuthenticationCeremony,
761
+ loginVerify: (response, envelope) => oxyServices.webauthnLoginVerify(response, envelope),
762
+ commit: (input) => commitSession(input, { activate: true, hubSync: true }),
763
+ deviceId: persisted?.deviceId,
764
+ deviceName: opts?.deviceName,
765
+ deviceFingerprint: opts?.deviceFingerprint,
766
+ });
767
+ },
768
+ [oxyServices, authStore, commitSession],
769
+ );
770
+
771
+ // Create a brand-new account whose first auth method is a passkey.
772
+ const registerWithPasskey = useCallback(
773
+ async (params: { username: string; deviceName?: string }): Promise<void> => {
774
+ await runPasskeyRegister({
775
+ isSupported: isPasskeySupported,
776
+ getRegisterOptions: (username) => oxyServices.webauthnRegisterOptions(username),
777
+ runCeremony: runRegistrationCeremony,
778
+ registerVerify: (response, envelope) => oxyServices.webauthnRegisterVerify(response, envelope),
779
+ commit: (input) => commitSession(input, { activate: true, hubSync: true }),
780
+ username: params.username,
781
+ deviceName: params.deviceName,
782
+ });
783
+ },
784
+ [oxyServices, commitSession],
785
+ );
786
+
787
+ // Add a passkey to the already-signed-in account (bearer present). No new
788
+ // session is committed — just refresh the linked auth-methods list.
789
+ const addPasskey = useCallback(
790
+ async (params?: { deviceName?: string }): Promise<void> => {
791
+ await runPasskeyAdd({
792
+ isSupported: isPasskeySupported,
793
+ getRegisterOptions: () => oxyServices.webauthnRegisterOptions(),
794
+ runCeremony: runRegistrationCeremony,
795
+ registerVerify: (response, envelope) => oxyServices.webauthnRegisterVerify(response, envelope),
796
+ onLinked: () => {
797
+ void queryClient.invalidateQueries({ queryKey: queryKeys.authMethods.all });
798
+ },
799
+ deviceName: params?.deviceName,
800
+ });
801
+ },
802
+ [oxyServices, queryClient],
803
+ );
804
+
738
805
  // ── Cold boot ────────────────────────────────────────────────────────────
739
806
  // Device-first session restore via `runProviderColdBoot` (see boot/runProviderColdBoot.ts).
740
807
  const runColdBoot = useCallback(async (): Promise<void> => {
@@ -885,6 +952,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
885
952
  signIn,
886
953
  signInWithPassword,
887
954
  completeTwoFactorSignIn,
955
+ signInWithPasskey,
956
+ registerWithPasskey,
957
+ addPasskey,
888
958
  revokeSuspiciousSignIn,
889
959
  handleWebSession,
890
960
  logout,
@@ -937,6 +1007,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
937
1007
  signIn,
938
1008
  signInWithPassword,
939
1009
  completeTwoFactorSignIn,
1010
+ signInWithPasskey,
1011
+ registerWithPasskey,
1012
+ addPasskey,
940
1013
  revokeSuspiciousSignIn,
941
1014
  handleWebSession,
942
1015
  logout,
@@ -1007,6 +1080,9 @@ const LOADING_STATE: OxyContextState = {
1007
1080
  signIn: () => rejectMissingProvider<User>(),
1008
1081
  signInWithPassword: () => rejectMissingProvider<PasswordSignInResult>(),
1009
1082
  completeTwoFactorSignIn: () => rejectMissingProvider<{ securityAlert?: SecurityAlert }>(),
1083
+ signInWithPasskey: () => rejectMissingProvider<void>(),
1084
+ registerWithPasskey: () => rejectMissingProvider<void>(),
1085
+ addPasskey: () => rejectMissingProvider<void>(),
1010
1086
  revokeSuspiciousSignIn: () => rejectMissingProvider<void>(),
1011
1087
  handleWebSession: () => rejectMissingProvider<void>(),
1012
1088
  logout: () => rejectMissingProvider<void>(),
@@ -0,0 +1,237 @@
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 { LoginResult, 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 twoFactorResult: LoginResult = { twoFactorRequired: true, loginToken: 'lt_should_not_happen' };
49
+ const linkResult: PasskeyRegisterVerifyResult = { success: true, message: 'Passkey added' };
50
+
51
+ describe('runPasskeyLogin', () => {
52
+ const buildDeps = (
53
+ order: string[],
54
+ overrides: Partial<RunPasskeyLoginDeps> = {},
55
+ ): RunPasskeyLoginDeps => ({
56
+ isSupported: jest.fn(() => true),
57
+ getLoginOptions: jest.fn(async () => {
58
+ order.push('getLoginOptions');
59
+ return { challenge: 'login-opts' };
60
+ }),
61
+ runCeremony: jest.fn(async (options) => {
62
+ order.push('runCeremony');
63
+ expect(options).toEqual({ challenge: 'login-opts' });
64
+ return { id: 'assertion' };
65
+ }),
66
+ loginVerify: jest.fn(async () => {
67
+ order.push('loginVerify');
68
+ return sessionResult;
69
+ }),
70
+ commit: jest.fn(async () => {
71
+ order.push('commit');
72
+ }),
73
+ deviceId: 'persisted-dev',
74
+ deviceName: 'My Laptop',
75
+ deviceFingerprint: 'fp-123',
76
+ ...overrides,
77
+ });
78
+
79
+ it('drives options → ceremony → verify → commit and threads the device envelope', async () => {
80
+ const order: string[] = [];
81
+ const deps = buildDeps(order);
82
+
83
+ await runPasskeyLogin(deps);
84
+
85
+ expect(order).toEqual(['getLoginOptions', 'runCeremony', 'loginVerify', 'commit']);
86
+ // The assertion response is forwarded to verify with the device envelope.
87
+ expect(deps.loginVerify).toHaveBeenCalledWith(
88
+ { id: 'assertion' },
89
+ { deviceName: 'My Laptop', deviceFingerprint: 'fp-123', deviceId: 'persisted-dev' },
90
+ );
91
+ // The session arm is projected onto the exact commitSession input.
92
+ expect(deps.commit).toHaveBeenCalledWith(expectedCommitInput);
93
+ });
94
+
95
+ it('throws and touches nothing when passkeys are unsupported', async () => {
96
+ const order: string[] = [];
97
+ const deps = buildDeps(order, { isSupported: jest.fn(() => false) });
98
+
99
+ await expect(runPasskeyLogin(deps)).rejects.toThrow(PASSKEY_UNSUPPORTED_MESSAGE);
100
+ expect(deps.getLoginOptions).not.toHaveBeenCalled();
101
+ expect(deps.commit).not.toHaveBeenCalled();
102
+ });
103
+
104
+ it('refuses a 2FA arm (a passkey assertion is the strong factor) and never commits', async () => {
105
+ const order: string[] = [];
106
+ const deps = buildDeps(order, {
107
+ loginVerify: jest.fn(async () => {
108
+ order.push('loginVerify');
109
+ return twoFactorResult;
110
+ }),
111
+ });
112
+
113
+ await expect(runPasskeyLogin(deps)).rejects.toThrow(/second factor/i);
114
+ expect(deps.commit).not.toHaveBeenCalled();
115
+ });
116
+ });
117
+
118
+ describe('runPasskeyRegister', () => {
119
+ const buildDeps = (
120
+ order: string[],
121
+ overrides: Partial<RunPasskeyRegisterDeps> = {},
122
+ ): RunPasskeyRegisterDeps => ({
123
+ isSupported: jest.fn(() => true),
124
+ getRegisterOptions: jest.fn(async (username) => {
125
+ order.push('getRegisterOptions');
126
+ expect(username).toBe('newuser');
127
+ return { challenge: 'reg-opts' };
128
+ }),
129
+ runCeremony: jest.fn(async () => {
130
+ order.push('runCeremony');
131
+ return { id: 'attestation' };
132
+ }),
133
+ registerVerify: jest.fn(async () => {
134
+ order.push('registerVerify');
135
+ return sessionResult;
136
+ }),
137
+ commit: jest.fn(async () => {
138
+ order.push('commit');
139
+ }),
140
+ username: 'newuser',
141
+ deviceName: 'My Laptop',
142
+ ...overrides,
143
+ });
144
+
145
+ it('creates the account (signup branch) and commits the minted session', async () => {
146
+ const order: string[] = [];
147
+ const deps = buildDeps(order);
148
+
149
+ await runPasskeyRegister(deps);
150
+
151
+ expect(order).toEqual(['getRegisterOptions', 'runCeremony', 'registerVerify', 'commit']);
152
+ expect(deps.registerVerify).toHaveBeenCalledWith(
153
+ { id: 'attestation' },
154
+ { username: 'newuser', deviceName: 'My Laptop' },
155
+ );
156
+ expect(deps.commit).toHaveBeenCalledWith(expectedCommitInput);
157
+ });
158
+
159
+ it('throws (no commit) when verify returns a link branch instead of a session', async () => {
160
+ const order: string[] = [];
161
+ const deps = buildDeps(order, {
162
+ registerVerify: jest.fn(async () => linkResult),
163
+ });
164
+
165
+ await expect(runPasskeyRegister(deps)).rejects.toThrow(/did not establish a session/i);
166
+ expect(deps.commit).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it('throws and touches nothing when passkeys are unsupported', async () => {
170
+ const order: string[] = [];
171
+ const deps = buildDeps(order, { isSupported: jest.fn(() => false) });
172
+
173
+ await expect(runPasskeyRegister(deps)).rejects.toThrow(PASSKEY_UNSUPPORTED_MESSAGE);
174
+ expect(deps.getRegisterOptions).not.toHaveBeenCalled();
175
+ expect(deps.commit).not.toHaveBeenCalled();
176
+ });
177
+ });
178
+
179
+ describe('runPasskeyAdd', () => {
180
+ const buildDeps = (
181
+ order: string[],
182
+ overrides: Partial<RunPasskeyAddDeps> = {},
183
+ ): RunPasskeyAddDeps => ({
184
+ isSupported: jest.fn(() => true),
185
+ getRegisterOptions: jest.fn(async () => {
186
+ order.push('getRegisterOptions');
187
+ return { challenge: 'add-opts' };
188
+ }),
189
+ runCeremony: jest.fn(async () => {
190
+ order.push('runCeremony');
191
+ return { id: 'attestation' };
192
+ }),
193
+ registerVerify: jest.fn(async () => {
194
+ order.push('registerVerify');
195
+ return linkResult;
196
+ }),
197
+ onLinked: jest.fn(() => {
198
+ order.push('onLinked');
199
+ }),
200
+ deviceName: 'My Laptop',
201
+ ...overrides,
202
+ });
203
+
204
+ it('links the passkey WITHOUT committing a session and fires onLinked', async () => {
205
+ const order: string[] = [];
206
+ const deps = buildDeps(order);
207
+
208
+ await runPasskeyAdd(deps);
209
+
210
+ expect(order).toEqual(['getRegisterOptions', 'runCeremony', 'registerVerify', 'onLinked']);
211
+ // Add requests options with NO username (bearer scopes it to the signed-in user).
212
+ expect(deps.getRegisterOptions).toHaveBeenCalledWith();
213
+ expect(deps.registerVerify).toHaveBeenCalledWith({ id: 'attestation' }, { deviceName: 'My Laptop' });
214
+ expect(deps.onLinked).toHaveBeenCalledTimes(1);
215
+ // There is no `commit` dependency at all — structurally cannot commit a session.
216
+ expect('commit' in deps).toBe(false);
217
+ });
218
+
219
+ it('throws (no onLinked) if verify unexpectedly mints a session', async () => {
220
+ const order: string[] = [];
221
+ const deps = buildDeps(order, {
222
+ registerVerify: jest.fn(async () => sessionResult),
223
+ });
224
+
225
+ await expect(runPasskeyAdd(deps)).rejects.toThrow(/instead of linking/i);
226
+ expect(deps.onLinked).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it('throws and touches nothing when passkeys are unsupported', async () => {
230
+ const order: string[] = [];
231
+ const deps = buildDeps(order, { isSupported: jest.fn(() => false) });
232
+
233
+ await expect(runPasskeyAdd(deps)).rejects.toThrow(PASSKEY_UNSUPPORTED_MESSAGE);
234
+ expect(deps.getRegisterOptions).not.toHaveBeenCalled();
235
+ expect(deps.onLinked).not.toHaveBeenCalled();
236
+ });
237
+ });
@@ -54,6 +54,27 @@ export interface OxyContextState {
54
54
  deviceName?: string;
55
55
  }) => Promise<{ securityAlert?: SecurityAlert }>;
56
56
 
57
+ /**
58
+ * Sign in with a passkey (WebAuthn). Usernameless / discoverable-credential
59
+ * flow: the browser prompts for any resident Oxy passkey. WEB-ONLY — throws on
60
+ * native or an unsupported browser (native passkeys are Commons' job). On
61
+ * `useOxy()`, NOT re-exposed on `useAuth()`.
62
+ */
63
+ signInWithPasskey: (opts?: { deviceName?: string; deviceFingerprint?: string }) => Promise<void>;
64
+
65
+ /**
66
+ * Create a brand-new account whose first authentication method is a passkey.
67
+ * WEB-ONLY — throws on native or an unsupported browser.
68
+ */
69
+ registerWithPasskey: (params: { username: string; deviceName?: string }) => Promise<void>;
70
+
71
+ /**
72
+ * Add a passkey to the already-signed-in account (bearer present). Does NOT
73
+ * commit a new session; refreshes the linked auth-methods list on success.
74
+ * WEB-ONLY — throws on native or an unsupported browser.
75
+ */
76
+ addPasskey: (params?: { deviceName?: string }) => Promise<void>;
77
+
57
78
  revokeSuspiciousSignIn: () => Promise<void>;
58
79
  handleWebSession: (session: SessionLoginResponse) => Promise<void>;
59
80