@oxyhq/services 21.0.0 → 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 (88) 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 +48 -4
  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/webauthn/passkeyClient.js +39 -0
  14. package/lib/commonjs/webauthn/passkeyClient.js.map +1 -0
  15. package/lib/commonjs/webauthn/passkeyClient.native.js +34 -0
  16. package/lib/commonjs/webauthn/passkeyClient.native.js.map +1 -0
  17. package/lib/commonjs/webauthn/passkeyClient.web.js +57 -0
  18. package/lib/commonjs/webauthn/passkeyClient.web.js.map +1 -0
  19. package/lib/module/index.js +2 -1
  20. package/lib/module/index.js.map +1 -1
  21. package/lib/module/ui/components/OxyAccountDialog.js +48 -4
  22. package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
  23. package/lib/module/ui/context/OxyContext.js +69 -1
  24. package/lib/module/ui/context/OxyContext.js.map +1 -1
  25. package/lib/module/ui/context/passkeyFlow.js +110 -0
  26. package/lib/module/ui/context/passkeyFlow.js.map +1 -0
  27. package/lib/module/ui/hooks/queries/queryKeys.js +15 -0
  28. package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
  29. package/lib/module/ui/hooks/queries/useAuthMethods.js +45 -0
  30. package/lib/module/ui/hooks/queries/useAuthMethods.js.map +1 -0
  31. package/lib/module/webauthn/passkeyClient.js +33 -0
  32. package/lib/module/webauthn/passkeyClient.js.map +1 -0
  33. package/lib/module/webauthn/passkeyClient.native.js +28 -0
  34. package/lib/module/webauthn/passkeyClient.native.js.map +1 -0
  35. package/lib/module/webauthn/passkeyClient.web.js +52 -0
  36. package/lib/module/webauthn/passkeyClient.web.js.map +1 -0
  37. package/lib/typescript/commonjs/index.d.ts +2 -1
  38. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
  40. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +26 -0
  42. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts +85 -0
  44. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts.map +1 -0
  45. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
  46. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +9 -0
  48. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/ui/hooks/queries/useAuthMethods.d.ts +212 -0
  50. package/lib/typescript/commonjs/ui/hooks/queries/useAuthMethods.d.ts.map +1 -0
  51. package/lib/typescript/commonjs/webauthn/passkeyClient.d.ts +20 -0
  52. package/lib/typescript/commonjs/webauthn/passkeyClient.d.ts.map +1 -0
  53. package/lib/typescript/commonjs/webauthn/passkeyClient.native.d.ts +15 -0
  54. package/lib/typescript/commonjs/webauthn/passkeyClient.native.d.ts.map +1 -0
  55. package/lib/typescript/commonjs/webauthn/passkeyClient.web.d.ts +36 -0
  56. package/lib/typescript/commonjs/webauthn/passkeyClient.web.d.ts.map +1 -0
  57. package/lib/typescript/module/index.d.ts +2 -1
  58. package/lib/typescript/module/index.d.ts.map +1 -1
  59. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
  60. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  61. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +26 -0
  62. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  63. package/lib/typescript/module/ui/context/passkeyFlow.d.ts +85 -0
  64. package/lib/typescript/module/ui/context/passkeyFlow.d.ts.map +1 -0
  65. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
  66. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  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 +4 -3
  78. package/src/index.ts +2 -0
  79. package/src/ui/components/OxyAccountDialog.tsx +57 -0
  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/webauthn/passkeyClient.native.ts +26 -0
  87. package/src/webauthn/passkeyClient.ts +31 -0
  88. 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": "21.0.0",
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",
129
+ "@oxyhq/contracts": "0.14.2",
130
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": "11.0.0",
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",
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)
@@ -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.
@@ -222,6 +251,9 @@ const OxyAccountDialog: React.FC = () => {
222
251
  onSignInWithOxy={() => void controller.signInWithOxy()}
223
252
  onScanQr={() => void controller.showQr()}
224
253
  onUsePassword={() => void controller.openPasswordAtOxyAuth()}
254
+ onSignInWithPasskey={passkeyAvailable ? () => void handleSignInWithPasskey() : undefined}
255
+ passkeyPending={passkeyPending}
256
+ passkeyError={passkeyError}
225
257
  />
226
258
  )}
227
259
  </ScrollView>
@@ -416,6 +448,10 @@ interface SignInViewProps {
416
448
  onSignInWithOxy: () => void;
417
449
  onScanQr: () => void;
418
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;
419
455
  }
420
456
 
421
457
  const SignInView: React.FC<SignInViewProps> = ({
@@ -426,6 +462,9 @@ const SignInView: React.FC<SignInViewProps> = ({
426
462
  onSignInWithOxy,
427
463
  onScanQr,
428
464
  onUsePassword,
465
+ onSignInWithPasskey,
466
+ passkeyPending,
467
+ passkeyError,
429
468
  }) => (
430
469
  <View style={styles.signInBlock}>
431
470
  {snapshot.accounts.length > 0 ? (
@@ -448,6 +487,24 @@ const SignInView: React.FC<SignInViewProps> = ({
448
487
  Sign in with Oxy
449
488
  </Button>
450
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
+
451
508
  <Button variant="secondary" onPress={onScanQr} style={styles.secondaryButton}>
452
509
  {t('accountSwitcher.scanQr') || 'Scan a QR from another device'}
453
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
 
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Pure passkey (WebAuthn) sign-in / registration orchestration.
3
+ *
4
+ * Extracted from `OxyContext` so the fixed `options → ceremony → verify → commit`
5
+ * ordering is unit-testable with injected deps — the exact pattern
6
+ * `commitSessionFlow.ts` (`commitDeviceSetAndResolve`) uses. The context methods
7
+ * (`signInWithPasskey` / `registerWithPasskey` / `addPasskey`) are thin wrappers
8
+ * that supply the real deps: the core `webauthn*` methods, the platform ceremony
9
+ * client (`webauthn/passkeyClient`), and the internal `commitSession` funnel.
10
+ *
11
+ * All three GATE on `isSupported()` first (the platform client returns `false`
12
+ * off the web) so an unsupported surface fails loudly before touching a ceremony.
13
+ */
14
+
15
+ import type { LoginResult, LoginSessionResult } from '@oxyhq/contracts';
16
+ import type { CommitInput } from './oxyContextTypes';
17
+
18
+ /**
19
+ * The result of `OxyServices.webauthnRegisterVerify` — either the LINK branch
20
+ * (`{ success, message }`, bearer present, no session) or the SIGNUP branch (a
21
+ * full {@link LoginResult}, which carries a `sessionId` on its session arm).
22
+ */
23
+ export type PasskeyRegisterVerifyResult = { success: true; message: string } | LoginResult;
24
+
25
+ /** Thrown when a passkey flow is attempted on a surface that cannot run WebAuthn. */
26
+ export const PASSKEY_UNSUPPORTED_MESSAGE =
27
+ 'Passkeys are not available in this environment. Use another sign-in method.';
28
+
29
+ /** Project a session-arm login result onto the internal `commitSession` input. */
30
+ function toCommitInput(result: LoginSessionResult): CommitInput {
31
+ return {
32
+ sessionId: result.sessionId,
33
+ accessToken: result.accessToken,
34
+ deviceSecret: result.deviceSecret,
35
+ deviceId: result.deviceId,
36
+ expiresAt: result.expiresAt,
37
+ userId: result.user.id,
38
+ user: result.user,
39
+ };
40
+ }
41
+
42
+ /** Injected dependencies for {@link runPasskeyLogin}. */
43
+ export interface RunPasskeyLoginDeps {
44
+ isSupported: () => boolean;
45
+ getLoginOptions: () => Promise<unknown>;
46
+ runCeremony: (optionsJSON: unknown) => Promise<unknown>;
47
+ loginVerify: (
48
+ response: unknown,
49
+ envelope: { deviceName?: string; deviceFingerprint?: string; deviceId?: string },
50
+ ) => Promise<LoginResult>;
51
+ commit: (input: CommitInput) => Promise<void>;
52
+ deviceId?: string;
53
+ deviceName?: string;
54
+ deviceFingerprint?: string;
55
+ }
56
+
57
+ /**
58
+ * Usernameless (discoverable-credential) passkey SIGN-IN: request login options,
59
+ * run the authentication ceremony, verify, then commit the session. A passkey
60
+ * assertion is itself the strong factor, so a 2FA arm here is a protocol error.
61
+ */
62
+ export async function runPasskeyLogin(deps: RunPasskeyLoginDeps): Promise<void> {
63
+ if (!deps.isSupported()) {
64
+ throw new Error(PASSKEY_UNSUPPORTED_MESSAGE);
65
+ }
66
+ const options = await deps.getLoginOptions();
67
+ const response = await deps.runCeremony(options);
68
+ const result = await deps.loginVerify(response, {
69
+ deviceName: deps.deviceName,
70
+ deviceFingerprint: deps.deviceFingerprint,
71
+ deviceId: deps.deviceId,
72
+ });
73
+ if ('twoFactorRequired' in result) {
74
+ throw new Error('Passkey sign-in unexpectedly required a second factor.');
75
+ }
76
+ await deps.commit(toCommitInput(result));
77
+ }
78
+
79
+ /** Injected dependencies for {@link runPasskeyRegister}. */
80
+ export interface RunPasskeyRegisterDeps {
81
+ isSupported: () => boolean;
82
+ getRegisterOptions: (username: string) => Promise<unknown>;
83
+ runCeremony: (optionsJSON: unknown) => Promise<unknown>;
84
+ registerVerify: (
85
+ response: unknown,
86
+ envelope: { username: string; deviceName?: string },
87
+ ) => Promise<PasskeyRegisterVerifyResult>;
88
+ commit: (input: CommitInput) => Promise<void>;
89
+ username: string;
90
+ deviceName?: string;
91
+ }
92
+
93
+ /**
94
+ * Passkey SIGNUP: create a brand-new account whose first auth method is a
95
+ * passkey. The verify signup branch mints a session (a {@link LoginSessionResult}
96
+ * carrying `sessionId`), which is committed exactly like a password signup.
97
+ */
98
+ export async function runPasskeyRegister(deps: RunPasskeyRegisterDeps): Promise<void> {
99
+ if (!deps.isSupported()) {
100
+ throw new Error(PASSKEY_UNSUPPORTED_MESSAGE);
101
+ }
102
+ const options = await deps.getRegisterOptions(deps.username);
103
+ const response = await deps.runCeremony(options);
104
+ const result = await deps.registerVerify(response, {
105
+ username: deps.username,
106
+ deviceName: deps.deviceName,
107
+ });
108
+ // Only the signup session arm carries `sessionId`; the link branch
109
+ // (`{ success, message }`) and any 2FA arm do not.
110
+ if (!('sessionId' in result)) {
111
+ throw new Error('Passkey registration did not establish a session.');
112
+ }
113
+ await deps.commit(toCommitInput(result));
114
+ }
115
+
116
+ /** Injected dependencies for {@link runPasskeyAdd}. */
117
+ export interface RunPasskeyAddDeps {
118
+ isSupported: () => boolean;
119
+ getRegisterOptions: () => Promise<unknown>;
120
+ runCeremony: (optionsJSON: unknown) => Promise<unknown>;
121
+ registerVerify: (
122
+ response: unknown,
123
+ envelope: { deviceName?: string },
124
+ ) => Promise<PasskeyRegisterVerifyResult>;
125
+ onLinked: () => void;
126
+ deviceName?: string;
127
+ }
128
+
129
+ /**
130
+ * ADD a passkey to the already-signed-in account (bearer present). The verify
131
+ * link branch returns `{ success, message }` with NO session — so this never
132
+ * commits a session; it just fires `onLinked` (which invalidates the
133
+ * auth-methods query). A session arm here would be a server contract violation.
134
+ */
135
+ export async function runPasskeyAdd(deps: RunPasskeyAddDeps): Promise<void> {
136
+ if (!deps.isSupported()) {
137
+ throw new Error(PASSKEY_UNSUPPORTED_MESSAGE);
138
+ }
139
+ const options = await deps.getRegisterOptions();
140
+ const response = await deps.runCeremony(options);
141
+ const result = await deps.registerVerify(response, { deviceName: deps.deviceName });
142
+ if ('sessionId' in result) {
143
+ throw new Error('addPasskey unexpectedly minted a new session instead of linking.');
144
+ }
145
+ deps.onLinked();
146
+ }