@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.
Files changed (121) hide show
  1. package/lib/commonjs/index.js +13 -0
  2. package/lib/commonjs/index.js.map +1 -1
  3. package/lib/commonjs/ui/boot/runProviderColdBoot.js +12 -2
  4. package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
  5. package/lib/commonjs/ui/components/OxyAccountDialog.js +46 -19
  6. package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
  7. package/lib/commonjs/ui/components/OxyProvider.js +2 -0
  8. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  9. package/lib/commonjs/ui/context/OxyContext.js +80 -59
  10. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  11. package/lib/commonjs/ui/context/passkeyFlow.js +118 -0
  12. package/lib/commonjs/ui/context/passkeyFlow.js.map +1 -0
  13. package/lib/commonjs/ui/hooks/queries/queryKeys.js +18 -2
  14. package/lib/commonjs/ui/hooks/queries/queryKeys.js.map +1 -1
  15. package/lib/commonjs/ui/hooks/queries/useAuthMethods.js +49 -0
  16. package/lib/commonjs/ui/hooks/queries/useAuthMethods.js.map +1 -0
  17. package/lib/commonjs/ui/utils/crossOriginRestore.js +26 -0
  18. package/lib/commonjs/ui/utils/crossOriginRestore.js.map +1 -1
  19. package/lib/commonjs/webauthn/passkeyClient.js +39 -0
  20. package/lib/commonjs/webauthn/passkeyClient.js.map +1 -0
  21. package/lib/commonjs/webauthn/passkeyClient.native.js +34 -0
  22. package/lib/commonjs/webauthn/passkeyClient.native.js.map +1 -0
  23. package/lib/commonjs/webauthn/passkeyClient.web.js +57 -0
  24. package/lib/commonjs/webauthn/passkeyClient.web.js.map +1 -0
  25. package/lib/module/index.js +2 -1
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/module/ui/boot/runProviderColdBoot.js +13 -3
  28. package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
  29. package/lib/module/ui/components/OxyAccountDialog.js +46 -19
  30. package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
  31. package/lib/module/ui/components/OxyProvider.js +2 -0
  32. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  33. package/lib/module/ui/context/OxyContext.js +80 -59
  34. package/lib/module/ui/context/OxyContext.js.map +1 -1
  35. package/lib/module/ui/context/passkeyFlow.js +111 -0
  36. package/lib/module/ui/context/passkeyFlow.js.map +1 -0
  37. package/lib/module/ui/hooks/queries/queryKeys.js +15 -0
  38. package/lib/module/ui/hooks/queries/queryKeys.js.map +1 -1
  39. package/lib/module/ui/hooks/queries/useAuthMethods.js +45 -0
  40. package/lib/module/ui/hooks/queries/useAuthMethods.js.map +1 -0
  41. package/lib/module/ui/utils/crossOriginRestore.js +26 -1
  42. package/lib/module/ui/utils/crossOriginRestore.js.map +1 -1
  43. package/lib/module/webauthn/passkeyClient.js +33 -0
  44. package/lib/module/webauthn/passkeyClient.js.map +1 -0
  45. package/lib/module/webauthn/passkeyClient.native.js +28 -0
  46. package/lib/module/webauthn/passkeyClient.native.js.map +1 -0
  47. package/lib/module/webauthn/passkeyClient.web.js +52 -0
  48. package/lib/module/webauthn/passkeyClient.web.js.map +1 -0
  49. package/lib/typescript/commonjs/index.d.ts +3 -2
  50. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +6 -0
  52. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  53. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +1 -1
  56. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +41 -20
  58. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts +96 -0
  60. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts.map +1 -0
  61. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
  62. package/lib/typescript/commonjs/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts +9 -0
  64. package/lib/typescript/commonjs/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/ui/hooks/queries/useAuthMethods.d.ts +212 -0
  66. package/lib/typescript/commonjs/ui/hooks/queries/useAuthMethods.d.ts.map +1 -0
  67. package/lib/typescript/commonjs/ui/types/navigation.d.ts +8 -0
  68. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/utils/crossOriginRestore.d.ts +19 -0
  70. package/lib/typescript/commonjs/ui/utils/crossOriginRestore.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/webauthn/passkeyClient.d.ts +20 -0
  72. package/lib/typescript/commonjs/webauthn/passkeyClient.d.ts.map +1 -0
  73. package/lib/typescript/commonjs/webauthn/passkeyClient.native.d.ts +15 -0
  74. package/lib/typescript/commonjs/webauthn/passkeyClient.native.d.ts.map +1 -0
  75. package/lib/typescript/commonjs/webauthn/passkeyClient.web.d.ts +36 -0
  76. package/lib/typescript/commonjs/webauthn/passkeyClient.web.d.ts.map +1 -0
  77. package/lib/typescript/module/index.d.ts +3 -2
  78. package/lib/typescript/module/index.d.ts.map +1 -1
  79. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +6 -0
  80. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
  82. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/context/OxyContext.d.ts +1 -1
  84. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  85. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +41 -20
  86. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  87. package/lib/typescript/module/ui/context/passkeyFlow.d.ts +96 -0
  88. package/lib/typescript/module/ui/context/passkeyFlow.d.ts.map +1 -0
  89. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts +2 -2
  90. package/lib/typescript/module/ui/hooks/mutations/useAccountMutations.d.ts.map +1 -1
  91. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts +9 -0
  92. package/lib/typescript/module/ui/hooks/queries/queryKeys.d.ts.map +1 -1
  93. package/lib/typescript/module/ui/hooks/queries/useAuthMethods.d.ts +212 -0
  94. package/lib/typescript/module/ui/hooks/queries/useAuthMethods.d.ts.map +1 -0
  95. package/lib/typescript/module/ui/types/navigation.d.ts +8 -0
  96. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  97. package/lib/typescript/module/ui/utils/crossOriginRestore.d.ts +19 -0
  98. package/lib/typescript/module/ui/utils/crossOriginRestore.d.ts.map +1 -1
  99. package/lib/typescript/module/webauthn/passkeyClient.d.ts +20 -0
  100. package/lib/typescript/module/webauthn/passkeyClient.d.ts.map +1 -0
  101. package/lib/typescript/module/webauthn/passkeyClient.native.d.ts +15 -0
  102. package/lib/typescript/module/webauthn/passkeyClient.native.d.ts.map +1 -0
  103. package/lib/typescript/module/webauthn/passkeyClient.web.d.ts +36 -0
  104. package/lib/typescript/module/webauthn/passkeyClient.web.d.ts.map +1 -0
  105. package/package.json +6 -5
  106. package/src/index.ts +3 -1
  107. package/src/ui/boot/runProviderColdBoot.ts +24 -1
  108. package/src/ui/components/OxyAccountDialog.tsx +57 -14
  109. package/src/ui/components/OxyProvider.tsx +2 -0
  110. package/src/ui/context/OxyContext.tsx +85 -63
  111. package/src/ui/context/__tests__/passkeyFlow.test.ts +244 -0
  112. package/src/ui/context/oxyContextTypes.ts +41 -19
  113. package/src/ui/context/passkeyFlow.ts +154 -0
  114. package/src/ui/hooks/queries/queryKeys.ts +14 -0
  115. package/src/ui/hooks/queries/useAuthMethods.ts +45 -0
  116. package/src/ui/types/navigation.ts +8 -0
  117. package/src/ui/utils/__tests__/crossOriginRestore.test.ts +143 -0
  118. package/src/ui/utils/crossOriginRestore.ts +34 -0
  119. package/src/webauthn/passkeyClient.native.ts +26 -0
  120. package/src/webauthn/passkeyClient.ts +31 -0
  121. 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
- if (isWebBrowser() && clientId && outcome.kind !== 'session') {
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
- onUsePassword={() => void controller.openPasswordAtOxyAuth()}
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
- onUsePassword: () => void;
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
- onUsePassword,
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, PasswordSignInResult, OxyContextProviderProps } from './oxyContextTypes';
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
- // Keyless password sign-in: username/email + password.
673
- const signInWithPassword = useCallback(
674
- async (
675
- identifier: string,
676
- password: string,
677
- opts?: { deviceName?: string; deviceFingerprint?: string },
678
- ): Promise<PasswordSignInResult> => {
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
- const result = await oxyServices.passwordSignIn(identifier, password, {
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
- const completeTwoFactorSignIn = useCallback(
707
- async (params: {
708
- loginToken: string;
709
- token?: string;
710
- backupCode?: string;
711
- deviceName?: string;
712
- }): Promise<{ securityAlert?: SecurityAlert }> => {
713
- const persisted = await authStore.load();
714
- const result = await oxyServices.completeTwoFactorSignIn({
715
- loginToken: params.loginToken,
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
- signInWithPassword,
887
- completeTwoFactorSignIn,
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
- signInWithPassword,
939
- completeTwoFactorSignIn,
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
- signInWithPassword: () => rejectMissingProvider<PasswordSignInResult>(),
1009
- completeTwoFactorSignIn: () => rejectMissingProvider<{ securityAlert?: SecurityAlert }>(),
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
+ });