@oxyhq/services 21.0.1 → 22.1.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 (117) hide show
  1. package/lib/commonjs/index.js +12 -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 +27 -546
  6. package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
  7. package/lib/commonjs/ui/components/OxyAuthChooser.js +881 -0
  8. package/lib/commonjs/ui/components/OxyAuthChooser.js.map +1 -0
  9. package/lib/commonjs/ui/components/OxyProvider.js +2 -0
  10. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  11. package/lib/commonjs/ui/components/oxyAuthChooserStyles.js +158 -0
  12. package/lib/commonjs/ui/components/oxyAuthChooserStyles.js.map +1 -0
  13. package/lib/commonjs/ui/context/OxyContext.js +50 -72
  14. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  15. package/lib/commonjs/ui/context/passkeyFlow.js +9 -8
  16. package/lib/commonjs/ui/context/passkeyFlow.js.map +1 -1
  17. package/lib/commonjs/ui/context/useOxyAccountGraph.js +6 -2
  18. package/lib/commonjs/ui/context/useOxyAccountGraph.js.map +1 -1
  19. package/lib/commonjs/ui/hooks/useSwitchableAccounts.js +2 -1
  20. package/lib/commonjs/ui/hooks/useSwitchableAccounts.js.map +1 -1
  21. package/lib/commonjs/ui/utils/commonsStoreLinks.js +42 -0
  22. package/lib/commonjs/ui/utils/commonsStoreLinks.js.map +1 -0
  23. package/lib/commonjs/ui/utils/crossOriginRestore.js +26 -0
  24. package/lib/commonjs/ui/utils/crossOriginRestore.js.map +1 -1
  25. package/lib/module/index.js +5 -0
  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 +29 -550
  30. package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
  31. package/lib/module/ui/components/OxyAuthChooser.js +878 -0
  32. package/lib/module/ui/components/OxyAuthChooser.js.map +1 -0
  33. package/lib/module/ui/components/OxyProvider.js +2 -0
  34. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  35. package/lib/module/ui/components/oxyAuthChooserStyles.js +155 -0
  36. package/lib/module/ui/components/oxyAuthChooserStyles.js.map +1 -0
  37. package/lib/module/ui/context/OxyContext.js +50 -72
  38. package/lib/module/ui/context/OxyContext.js.map +1 -1
  39. package/lib/module/ui/context/passkeyFlow.js +9 -8
  40. package/lib/module/ui/context/passkeyFlow.js.map +1 -1
  41. package/lib/module/ui/context/useOxyAccountGraph.js +6 -2
  42. package/lib/module/ui/context/useOxyAccountGraph.js.map +1 -1
  43. package/lib/module/ui/hooks/useSwitchableAccounts.js +2 -1
  44. package/lib/module/ui/hooks/useSwitchableAccounts.js.map +1 -1
  45. package/lib/module/ui/utils/commonsStoreLinks.js +37 -0
  46. package/lib/module/ui/utils/commonsStoreLinks.js.map +1 -0
  47. package/lib/module/ui/utils/crossOriginRestore.js +26 -1
  48. package/lib/module/ui/utils/crossOriginRestore.js.map +1 -1
  49. package/lib/typescript/commonjs/index.d.ts +3 -1
  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 +10 -20
  54. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts +60 -0
  56. package/lib/typescript/commonjs/ui/components/OxyAuthChooser.d.ts.map +1 -0
  57. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts +148 -0
  59. package/lib/typescript/commonjs/ui/components/oxyAuthChooserStyles.d.ts.map +1 -0
  60. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +1 -1
  61. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +22 -27
  63. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts +15 -4
  65. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts +1 -0
  67. package/lib/typescript/commonjs/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/hooks/useSwitchableAccounts.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/types/navigation.d.ts +8 -0
  70. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/ui/utils/commonsStoreLinks.d.ts +24 -0
  72. package/lib/typescript/commonjs/ui/utils/commonsStoreLinks.d.ts.map +1 -0
  73. package/lib/typescript/commonjs/ui/utils/crossOriginRestore.d.ts +19 -0
  74. package/lib/typescript/commonjs/ui/utils/crossOriginRestore.d.ts.map +1 -1
  75. package/lib/typescript/module/index.d.ts +3 -1
  76. package/lib/typescript/module/index.d.ts.map +1 -1
  77. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +6 -0
  78. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  79. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts +10 -20
  80. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts +60 -0
  82. package/lib/typescript/module/ui/components/OxyAuthChooser.d.ts.map +1 -0
  83. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  84. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts +148 -0
  85. package/lib/typescript/module/ui/components/oxyAuthChooserStyles.d.ts.map +1 -0
  86. package/lib/typescript/module/ui/context/OxyContext.d.ts +1 -1
  87. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  88. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +22 -27
  89. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  90. package/lib/typescript/module/ui/context/passkeyFlow.d.ts +15 -4
  91. package/lib/typescript/module/ui/context/passkeyFlow.d.ts.map +1 -1
  92. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts +1 -0
  93. package/lib/typescript/module/ui/context/useOxyAccountGraph.d.ts.map +1 -1
  94. package/lib/typescript/module/ui/hooks/useSwitchableAccounts.d.ts.map +1 -1
  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/commonsStoreLinks.d.ts +24 -0
  98. package/lib/typescript/module/ui/utils/commonsStoreLinks.d.ts.map +1 -0
  99. package/lib/typescript/module/ui/utils/crossOriginRestore.d.ts +19 -0
  100. package/lib/typescript/module/ui/utils/crossOriginRestore.d.ts.map +1 -1
  101. package/package.json +5 -5
  102. package/src/index.ts +8 -1
  103. package/src/ui/boot/runProviderColdBoot.ts +24 -1
  104. package/src/ui/components/OxyAccountDialog.tsx +33 -571
  105. package/src/ui/components/OxyAuthChooser.tsx +883 -0
  106. package/src/ui/components/OxyProvider.tsx +2 -0
  107. package/src/ui/components/oxyAuthChooserStyles.ts +152 -0
  108. package/src/ui/context/OxyContext.tsx +56 -82
  109. package/src/ui/context/__tests__/passkeyFlow.test.ts +22 -15
  110. package/src/ui/context/oxyContextTypes.ts +27 -26
  111. package/src/ui/context/passkeyFlow.ts +17 -9
  112. package/src/ui/context/useOxyAccountGraph.ts +4 -2
  113. package/src/ui/hooks/useSwitchableAccounts.ts +1 -0
  114. package/src/ui/types/navigation.ts +8 -0
  115. package/src/ui/utils/__tests__/crossOriginRestore.test.ts +143 -0
  116. package/src/ui/utils/commonsStoreLinks.ts +38 -0
  117. package/src/ui/utils/crossOriginRestore.ts +34 -0
@@ -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}
@@ -0,0 +1,152 @@
1
+ import { StyleSheet } from 'react-native';
2
+
3
+ /** High-contrast QR plate background — kept in sync with `OxyAuthChooser`'s QR colors. */
4
+ const QR_PLATE_BG = '#FFFFFF';
5
+
6
+ /**
7
+ * Shared styles for `OxyAuthChooser`'s views. Extracted from `OxyAccountDialog`
8
+ * so both the Dialog-wrapped host and any bare host (e.g. a future
9
+ * auth.oxy.so hub page) render identically without duplicating a StyleSheet.
10
+ */
11
+ export const authChooserStyles = StyleSheet.create({
12
+ rows: {
13
+ width: '100%',
14
+ gap: 8,
15
+ },
16
+ accountRow: {
17
+ flexDirection: 'row',
18
+ alignItems: 'center',
19
+ gap: 12,
20
+ borderWidth: StyleSheet.hairlineWidth,
21
+ borderRadius: 16,
22
+ paddingVertical: 10,
23
+ paddingHorizontal: 12,
24
+ },
25
+ rowDisabled: {
26
+ opacity: 0.6,
27
+ },
28
+ avatarRing: {
29
+ borderRadius: 9999,
30
+ borderWidth: 2,
31
+ padding: 1,
32
+ },
33
+ rowMeta: {
34
+ flex: 1,
35
+ minWidth: 0,
36
+ },
37
+ rowName: {
38
+ fontSize: 15,
39
+ fontWeight: '600',
40
+ },
41
+ rowHandle: {
42
+ fontSize: 12.5,
43
+ marginTop: 1,
44
+ },
45
+ addRow: {
46
+ flexDirection: 'row',
47
+ alignItems: 'center',
48
+ gap: 12,
49
+ borderWidth: StyleSheet.hairlineWidth,
50
+ borderStyle: 'dashed',
51
+ borderRadius: 16,
52
+ paddingVertical: 10,
53
+ paddingHorizontal: 12,
54
+ marginTop: 8,
55
+ },
56
+ addBadge: {
57
+ width: 40,
58
+ height: 40,
59
+ borderRadius: 9999,
60
+ borderWidth: StyleSheet.hairlineWidth,
61
+ alignItems: 'center',
62
+ justifyContent: 'center',
63
+ },
64
+ footerLinks: {
65
+ alignItems: 'center',
66
+ marginTop: 16,
67
+ },
68
+ footerLink: {
69
+ alignSelf: 'center',
70
+ paddingVertical: 10,
71
+ marginTop: 12,
72
+ },
73
+ linkText: {
74
+ fontSize: 14,
75
+ fontWeight: '600',
76
+ },
77
+ signInBlock: {
78
+ width: '100%',
79
+ },
80
+ primaryButton: {
81
+ width: '100%',
82
+ borderRadius: 14,
83
+ marginTop: 8,
84
+ },
85
+ secondaryButton: {
86
+ width: '100%',
87
+ borderRadius: 14,
88
+ marginTop: 10,
89
+ },
90
+ usernameInput: {
91
+ width: '100%',
92
+ borderWidth: StyleSheet.hairlineWidth,
93
+ borderRadius: 14,
94
+ paddingVertical: 12,
95
+ paddingHorizontal: 14,
96
+ fontSize: 16,
97
+ marginTop: 8,
98
+ },
99
+ dividerRow: {
100
+ flexDirection: 'row',
101
+ alignItems: 'center',
102
+ width: '100%',
103
+ marginVertical: 14,
104
+ },
105
+ dividerLine: {
106
+ flex: 1,
107
+ height: StyleSheet.hairlineWidth,
108
+ },
109
+ dividerText: {
110
+ marginHorizontal: 12,
111
+ fontSize: 13,
112
+ },
113
+ centeredBlock: {
114
+ alignItems: 'center',
115
+ justifyContent: 'center',
116
+ gap: 12,
117
+ paddingVertical: 24,
118
+ },
119
+ mutedText: {
120
+ fontSize: 14,
121
+ textAlign: 'center',
122
+ },
123
+ qrHeadline: {
124
+ fontSize: 16,
125
+ fontWeight: '700',
126
+ textAlign: 'center',
127
+ },
128
+ errorText: {
129
+ fontSize: 14,
130
+ textAlign: 'center',
131
+ },
132
+ errorBanner: {
133
+ flexDirection: 'row',
134
+ alignItems: 'center',
135
+ gap: 8,
136
+ width: '100%',
137
+ borderRadius: 12,
138
+ paddingVertical: 10,
139
+ paddingHorizontal: 12,
140
+ marginBottom: 12,
141
+ },
142
+ errorBannerText: {
143
+ flex: 1,
144
+ fontSize: 13.5,
145
+ lineHeight: 18,
146
+ },
147
+ qrPlate: {
148
+ padding: 16,
149
+ borderRadius: 16,
150
+ backgroundColor: QR_PLATE_BG,
151
+ },
152
+ });
@@ -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,7 +58,6 @@ import {
59
58
  } from '../session';
60
59
  import type {
61
60
  OxyContextState,
62
- PasswordSignInResult,
63
61
  OxyContextProviderProps,
64
62
  CommitInput,
65
63
  } from './oxyContextTypes';
@@ -74,7 +72,7 @@ import {
74
72
  import { queryKeys } from '../hooks/queries/queryKeys';
75
73
  import { useOxyAccountGraph } from './useOxyAccountGraph';
76
74
 
77
- export type { OxyContextState, PasswordSignInResult, OxyContextProviderProps } from './oxyContextTypes';
75
+ export type { OxyContextState, OxyContextProviderProps } from './oxyContextTypes';
78
76
 
79
77
  const OxyContext = createContext<OxyContextState | null>(null);
80
78
 
@@ -84,6 +82,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
84
82
  baseURL,
85
83
  authWebUrl,
86
84
  authRedirectUri,
85
+ authorizeBaseUrl,
87
86
  storageKeyPrefix = 'oxy_session',
88
87
  clientId: clientIdProp,
89
88
  hubSync = true,
@@ -607,14 +606,41 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
607
606
  [commitSession],
608
607
  );
609
608
 
609
+ // Commit a minted graph-SWITCH session. Same funnel as `handleWebSession` but
610
+ // IN-PLACE: `hubSync: false` so switching accounts never triggers the
611
+ // cross-origin hub-sync full-page redirect. The switch already propagates
612
+ // across tabs/apps via the server's `session_state` socket broadcast.
613
+ const commitSwitchedSession = useCallback(
614
+ async (session: SessionLoginResponse): Promise<void> => {
615
+ if (!session?.user || !session?.sessionId || !session.accessToken) {
616
+ throw new Error('Session response did not include a usable session');
617
+ }
618
+ await commitSession(
619
+ {
620
+ sessionId: session.sessionId,
621
+ accessToken: session.accessToken,
622
+ deviceSecret: session.deviceSecret,
623
+ deviceId: session.deviceId,
624
+ expiresAt: session.expiresAt,
625
+ userId: session.user.id,
626
+ user: session.user,
627
+ },
628
+ { activate: true, hubSync: false },
629
+ );
630
+ },
631
+ [commitSession],
632
+ );
633
+
610
634
  // ── Unified account dialog ─────────────────────────────────────────────────
611
635
  // The single account-chooser + sign-in surface. Built ONCE per provider mount
612
636
  // and bound to the live `oxyServices` + `sessionClient` + this provider's
613
- // `handleWebSession` commit funnel. `handleWebSession` is threaded through a ref
614
- // so the controller keeps a STABLE `commitSession` (rebuilding the controller
615
- // on every commit-identity change would drop its subscription + state).
637
+ // commit funnels. Both funnels are threaded through refs so the controller
638
+ // keeps STABLE commit callbacks (rebuilding the controller on every
639
+ // commit-identity change would drop its subscription + state).
616
640
  const handleWebSessionRef = useRef(handleWebSession);
617
641
  handleWebSessionRef.current = handleWebSession;
642
+ const commitSwitchedSessionRef = useRef(commitSwitchedSession);
643
+ commitSwitchedSessionRef.current = commitSwitchedSession;
618
644
 
619
645
  const accountDialogControllerRef = useRef<AccountDialogController | null>(null);
620
646
  if (!accountDialogControllerRef.current) {
@@ -622,12 +648,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
622
648
  oxyServices,
623
649
  sessionClient,
624
650
  clientId,
625
- authRedirectUri,
626
651
  locale: currentLanguage,
627
652
  // Same statically-injected `io` as the SessionClient: gives the QR flow an
628
653
  // instant `/auth-session` `auth_update` wake instead of a slow poll.
629
654
  socketFactory: io,
630
655
  commitSession: (session) => handleWebSessionRef.current(session),
656
+ commitSwitchedSession: (session) => commitSwitchedSessionRef.current(session),
631
657
  onSignedIn: () => setAccountDialogOpen(false),
632
658
  openUrl: (url) => {
633
659
  if (isWebBrowser()) {
@@ -676,72 +702,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
676
702
  notifyAccountDialogVisibility(accountDialogOpen);
677
703
  }, [accountDialogOpen]);
678
704
 
679
- // Keyless password sign-in: username/email + password.
680
- const signInWithPassword = useCallback(
681
- async (
682
- identifier: string,
683
- password: string,
684
- opts?: { deviceName?: string; deviceFingerprint?: string },
685
- ): Promise<PasswordSignInResult> => {
686
- const persisted = await authStore.load();
687
- const result = await oxyServices.passwordSignIn(identifier, password, {
688
- deviceName: opts?.deviceName,
689
- deviceFingerprint: opts?.deviceFingerprint,
690
- deviceId: persisted?.deviceId,
691
- });
692
- if ('twoFactorRequired' in result) {
693
- return { status: '2fa_required', loginToken: result.loginToken };
694
- }
695
- // `passwordSignIn` already planted the access token on the session arm.
696
- await commitSession(
697
- {
698
- sessionId: result.sessionId,
699
- accessToken: result.accessToken,
700
- deviceSecret: result.deviceSecret,
701
- deviceId: result.deviceId,
702
- expiresAt: result.expiresAt,
703
- userId: result.user.id,
704
- user: result.user,
705
- },
706
- { activate: true, hubSync: true },
707
- );
708
- return { status: 'ok', ...(result.securityAlert ? { securityAlert: result.securityAlert } : {}) };
709
- },
710
- [oxyServices, commitSession],
711
- );
712
-
713
- const completeTwoFactorSignIn = useCallback(
714
- async (params: {
715
- loginToken: string;
716
- token?: string;
717
- backupCode?: string;
718
- deviceName?: string;
719
- }): Promise<{ securityAlert?: SecurityAlert }> => {
720
- const persisted = await authStore.load();
721
- const result = await oxyServices.completeTwoFactorSignIn({
722
- loginToken: params.loginToken,
723
- token: params.token,
724
- backupCode: params.backupCode,
725
- deviceName: params.deviceName,
726
- deviceId: persisted?.deviceId,
727
- });
728
- await commitSession(
729
- {
730
- sessionId: result.sessionId,
731
- accessToken: result.accessToken,
732
- deviceSecret: result.deviceSecret,
733
- deviceId: result.deviceId,
734
- expiresAt: result.expiresAt,
735
- userId: result.user.id,
736
- user: result.user,
737
- },
738
- { activate: true, hubSync: true },
739
- );
740
- return { securityAlert: result.securityAlert };
741
- },
742
- [oxyServices, commitSession],
743
- );
744
-
745
705
  // ── Passkey (WebAuthn) ─────────────────────────────────────────────────────
746
706
  // Web-only sign-in / registration via the browser WebAuthn ceremony. The fixed
747
707
  // `options → ceremony → verify → commit` ordering lives in the pure
@@ -750,16 +710,20 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
750
710
  // `commitSession` funnel). All three GATE on `isPasskeySupported()` so a native
751
711
  // / unsupported surface throws loudly instead of stalling in a ceremony.
752
712
 
753
- // Usernameless (discoverable) passkey sign-in.
713
+ // Passkey sign-in. No `username` → usernameless (discoverable) flow. With a
714
+ // `username` → username-first: the server scopes `allowCredentials` to that
715
+ // user's passkeys so a non-discoverable hardware key (U2F/security key) can
716
+ // be selected.
754
717
  const signInWithPasskey = useCallback(
755
- async (opts?: { deviceName?: string; deviceFingerprint?: string }): Promise<void> => {
718
+ async (opts?: { username?: string; deviceName?: string; deviceFingerprint?: string }): Promise<void> => {
756
719
  const persisted = await authStore.load();
757
720
  await runPasskeyLogin({
758
721
  isSupported: isPasskeySupported,
759
- getLoginOptions: () => oxyServices.webauthnLoginOptions(),
722
+ getLoginOptions: (username) => oxyServices.webauthnLoginOptions(username),
760
723
  runCeremony: runAuthenticationCeremony,
761
724
  loginVerify: (response, envelope) => oxyServices.webauthnLoginVerify(response, envelope),
762
725
  commit: (input) => commitSession(input, { activate: true, hubSync: true }),
726
+ username: opts?.username,
763
727
  deviceId: persisted?.deviceId,
764
728
  deviceName: opts?.deviceName,
765
729
  deviceFingerprint: opts?.deviceFingerprint,
@@ -802,6 +766,17 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
802
766
  [oxyServices, queryClient],
803
767
  );
804
768
 
769
+ // Remove a passkey from the already-signed-in account by credential id. Not a
770
+ // ceremony — a plain unlink — so it needs no `isPasskeySupported` gate; it just
771
+ // refreshes the linked auth-methods list on success.
772
+ const removePasskey = useCallback(
773
+ async (credentialId: string): Promise<void> => {
774
+ await oxyServices.removePasskey(credentialId);
775
+ void queryClient.invalidateQueries({ queryKey: queryKeys.authMethods.all });
776
+ },
777
+ [oxyServices, queryClient],
778
+ );
779
+
805
780
  // ── Cold boot ────────────────────────────────────────────────────────────
806
781
  // Device-first session restore via `runProviderColdBoot` (see boot/runProviderColdBoot.ts).
807
782
  const runColdBoot = useCallback(async (): Promise<void> => {
@@ -810,6 +785,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
810
785
  authStore,
811
786
  clientId: clientIdProp,
812
787
  authRedirectUri,
788
+ authorizeBaseUrl,
813
789
  sessionClient,
814
790
  syncDeviceCredentialToHost,
815
791
  commitSession: (input, options) => commitSessionRef.current(input, options),
@@ -821,6 +797,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
821
797
  authStore,
822
798
  clientIdProp,
823
799
  authRedirectUri,
800
+ authorizeBaseUrl,
824
801
  syncDeviceCredentialToHost,
825
802
  sessionClient,
826
803
  ]);
@@ -950,11 +927,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
950
927
  hasIdentity,
951
928
  getPublicKey,
952
929
  signIn,
953
- signInWithPassword,
954
- completeTwoFactorSignIn,
955
930
  signInWithPasskey,
956
931
  registerWithPasskey,
957
932
  addPasskey,
933
+ removePasskey,
958
934
  revokeSuspiciousSignIn,
959
935
  handleWebSession,
960
936
  logout,
@@ -1005,11 +981,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1005
981
  hasIdentity,
1006
982
  getPublicKey,
1007
983
  signIn,
1008
- signInWithPassword,
1009
- completeTwoFactorSignIn,
1010
984
  signInWithPasskey,
1011
985
  registerWithPasskey,
1012
986
  addPasskey,
987
+ removePasskey,
1013
988
  revokeSuspiciousSignIn,
1014
989
  handleWebSession,
1015
990
  logout,
@@ -1078,11 +1053,10 @@ const LOADING_STATE: OxyContextState = {
1078
1053
  hasIdentity: () => Promise.resolve(false),
1079
1054
  getPublicKey: () => Promise.resolve(null),
1080
1055
  signIn: () => rejectMissingProvider<User>(),
1081
- signInWithPassword: () => rejectMissingProvider<PasswordSignInResult>(),
1082
- completeTwoFactorSignIn: () => rejectMissingProvider<{ securityAlert?: SecurityAlert }>(),
1083
1056
  signInWithPasskey: () => rejectMissingProvider<void>(),
1084
1057
  registerWithPasskey: () => rejectMissingProvider<void>(),
1085
1058
  addPasskey: () => rejectMissingProvider<void>(),
1059
+ removePasskey: () => rejectMissingProvider<void>(),
1086
1060
  revokeSuspiciousSignIn: () => rejectMissingProvider<void>(),
1087
1061
  handleWebSession: () => rejectMissingProvider<void>(),
1088
1062
  logout: () => rejectMissingProvider<void>(),
@@ -9,7 +9,7 @@
9
9
  * deps-injection style `commitSessionFlow.test.ts` uses.
10
10
  */
11
11
 
12
- import type { LoginResult, LoginSessionResult } from '@oxyhq/contracts';
12
+ import type { LoginSessionResult } from '@oxyhq/contracts';
13
13
  import {
14
14
  runPasskeyLogin,
15
15
  runPasskeyRegister,
@@ -45,7 +45,6 @@ const expectedCommitInput: CommitInput = {
45
45
  user: { id: SESSION_USER_ID, username: 'pkuser' },
46
46
  };
47
47
 
48
- const twoFactorResult: LoginResult = { twoFactorRequired: true, loginToken: 'lt_should_not_happen' };
49
48
  const linkResult: PasskeyRegisterVerifyResult = { success: true, message: 'Passkey added' };
50
49
 
51
50
  describe('runPasskeyLogin', () => {
@@ -92,25 +91,33 @@ describe('runPasskeyLogin', () => {
92
91
  expect(deps.commit).toHaveBeenCalledWith(expectedCommitInput);
93
92
  });
94
93
 
95
- it('throws and touches nothing when passkeys are unsupported', async () => {
94
+ it('forwards the username to getLoginOptions for the username-first (hardware-key) path', async () => {
96
95
  const order: string[] = [];
97
- const deps = buildDeps(order, { isSupported: jest.fn(() => false) });
96
+ const deps = buildDeps(order, { username: 'alice' });
98
97
 
99
- await expect(runPasskeyLogin(deps)).rejects.toThrow(PASSKEY_UNSUPPORTED_MESSAGE);
100
- expect(deps.getLoginOptions).not.toHaveBeenCalled();
101
- expect(deps.commit).not.toHaveBeenCalled();
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');
102
103
  });
103
104
 
104
- it('refuses a 2FA arm (a passkey assertion is the strong factor) and never commits', async () => {
105
+ it('passes undefined to getLoginOptions for the usernameless (discoverable) path', async () => {
105
106
  const order: string[] = [];
106
- const deps = buildDeps(order, {
107
- loginVerify: jest.fn(async () => {
108
- order.push('loginVerify');
109
- return twoFactorResult;
110
- }),
111
- });
107
+ const deps = buildDeps(order);
112
108
 
113
- await expect(runPasskeyLogin(deps)).rejects.toThrow(/second factor/i);
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();
114
121
  expect(deps.commit).not.toHaveBeenCalled();
115
122
  });
116
123
  });
@@ -1,6 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { OxyServices, User, SessionLoginResponse, AccountNode, CreateAccountInput, ClientSession, AccountDialogController, AccountDialogView, ApiError, SessionClient } from '@oxyhq/core';
3
- import type { SecurityAlert } from '@oxyhq/contracts';
4
3
  import type { UseFollowHook } from '../hooks/useFollow.types';
5
4
  import type { useLanguageManagement } from '../hooks/useLanguageManagement';
6
5
  import type { RouteName } from '../navigation/routes';
@@ -41,26 +40,20 @@ export interface OxyContextState {
41
40
 
42
41
  signIn: (publicKey: string, deviceName?: string) => Promise<User>;
43
42
 
44
- signInWithPassword: (
45
- identifier: string,
46
- password: string,
47
- opts?: { deviceName?: string; deviceFingerprint?: string },
48
- ) => Promise<PasswordSignInResult>;
49
-
50
- completeTwoFactorSignIn: (params: {
51
- loginToken: string;
52
- token?: string;
53
- backupCode?: string;
54
- deviceName?: string;
55
- }) => Promise<{ securityAlert?: SecurityAlert }>;
56
-
57
43
  /**
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()`.
44
+ * Sign in with a passkey (WebAuthn). With no `username` this is the
45
+ * usernameless / discoverable-credential flow: the browser prompts for any
46
+ * resident Oxy passkey. Pass `username` for the username-first flow the
47
+ * server scopes `allowCredentials` to that user's passkeys so a
48
+ * NON-discoverable hardware key (e.g. a U2F/security key) can be used.
49
+ * WEB-ONLY — throws on native or an unsupported browser (native passkeys are
50
+ * Commons' job). On `useOxy()`, NOT re-exposed on `useAuth()`.
62
51
  */
63
- signInWithPasskey: (opts?: { deviceName?: string; deviceFingerprint?: string }) => Promise<void>;
52
+ signInWithPasskey: (opts?: {
53
+ username?: string;
54
+ deviceName?: string;
55
+ deviceFingerprint?: string;
56
+ }) => Promise<void>;
64
57
 
65
58
  /**
66
59
  * Create a brand-new account whose first authentication method is a passkey.
@@ -75,6 +68,14 @@ export interface OxyContextState {
75
68
  */
76
69
  addPasskey: (params?: { deviceName?: string }) => Promise<void>;
77
70
 
71
+ /**
72
+ * Remove a passkey from the current account by its credential id
73
+ * (`AuthMethodEntry.credentialId`). Refreshes the linked auth-methods list on
74
+ * success. Works on any platform (it is a plain unlink, not a WebAuthn
75
+ * ceremony) but is only reachable from surfaces that list passkeys.
76
+ */
77
+ removePasskey: (credentialId: string) => Promise<void>;
78
+
78
79
  revokeSuspiciousSignIn: () => Promise<void>;
79
80
  handleWebSession: (session: SessionLoginResponse) => Promise<void>;
80
81
 
@@ -117,19 +118,19 @@ export interface OxyContextState {
117
118
  createAccount: (data: CreateAccountInput) => Promise<AccountNode>;
118
119
  }
119
120
 
120
- /**
121
- * Result of {@link OxyContextState.signInWithPassword}.
122
- */
123
- export type PasswordSignInResult =
124
- | { status: 'ok'; securityAlert?: SecurityAlert }
125
- | { status: '2fa_required'; loginToken: string };
126
-
127
121
  export interface OxyContextProviderProps {
128
122
  children: ReactNode;
129
123
  oxyServices?: OxyServices;
130
124
  baseURL?: string;
131
125
  authWebUrl?: string;
132
126
  authRedirectUri?: string;
127
+ /**
128
+ * Authorize endpoint override for silent cross-origin session restore
129
+ * (web cross-app SSO). Defaults to the production Oxy IdP when unset; a
130
+ * local/staging deployment points it at its own IdP so cold boot never
131
+ * bounces the tab to production `auth.oxy.so`.
132
+ */
133
+ authorizeBaseUrl?: string;
133
134
  storageKeyPrefix?: string;
134
135
  clientId?: string;
135
136
  /** Sync device credentials to auth.oxy.so after interactive sign-in. @default true */
@@ -42,37 +42,45 @@ function toCommitInput(result: LoginSessionResult): CommitInput {
42
42
  /** Injected dependencies for {@link runPasskeyLogin}. */
43
43
  export interface RunPasskeyLoginDeps {
44
44
  isSupported: () => boolean;
45
- getLoginOptions: () => Promise<unknown>;
45
+ getLoginOptions: (username?: string) => Promise<unknown>;
46
46
  runCeremony: (optionsJSON: unknown) => Promise<unknown>;
47
47
  loginVerify: (
48
48
  response: unknown,
49
49
  envelope: { deviceName?: string; deviceFingerprint?: string; deviceId?: string },
50
50
  ) => Promise<LoginResult>;
51
51
  commit: (input: CommitInput) => Promise<void>;
52
+ /**
53
+ * When present, scopes login options to that user's registered passkeys
54
+ * (username-first) — the path a NON-discoverable hardware key (e.g. a U2F
55
+ * security key) needs, since it can't be found by a usernameless ceremony.
56
+ * Omit for the discoverable / resident-credential path.
57
+ */
58
+ username?: string;
52
59
  deviceId?: string;
53
60
  deviceName?: string;
54
61
  deviceFingerprint?: string;
55
62
  }
56
63
 
57
64
  /**
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.
65
+ * Passkey SIGN-IN. With no `username` this is the usernameless
66
+ * (discoverable-credential) flow; with a `username` it is username-first
67
+ * the server scopes `allowCredentials` to that user's passkeys so a
68
+ * non-discoverable hardware key (U2F/security key) can be selected. Either way:
69
+ * request login options, run the authentication ceremony, verify, then commit
70
+ * the session. A passkey assertion is itself the strong factor — sign-in always
71
+ * resolves to a completed session.
61
72
  */
62
73
  export async function runPasskeyLogin(deps: RunPasskeyLoginDeps): Promise<void> {
63
74
  if (!deps.isSupported()) {
64
75
  throw new Error(PASSKEY_UNSUPPORTED_MESSAGE);
65
76
  }
66
- const options = await deps.getLoginOptions();
77
+ const options = await deps.getLoginOptions(deps.username);
67
78
  const response = await deps.runCeremony(options);
68
79
  const result = await deps.loginVerify(response, {
69
80
  deviceName: deps.deviceName,
70
81
  deviceFingerprint: deps.deviceFingerprint,
71
82
  deviceId: deps.deviceId,
72
83
  });
73
- if ('twoFactorRequired' in result) {
74
- throw new Error('Passkey sign-in unexpectedly required a second factor.');
75
- }
76
84
  await deps.commit(toCommitInput(result));
77
85
  }
78
86
 
@@ -106,7 +114,7 @@ export async function runPasskeyRegister(deps: RunPasskeyRegisterDeps): Promise<
106
114
  deviceName: deps.deviceName,
107
115
  });
108
116
  // Only the signup session arm carries `sessionId`; the link branch
109
- // (`{ success, message }`) and any 2FA arm do not.
117
+ // (`{ success, message }`) does not.
110
118
  if (!('sessionId' in result)) {
111
119
  throw new Error('Passkey registration did not establish a session.');
112
120
  }
@@ -13,7 +13,7 @@ interface UseOxyAccountGraphParams {
13
13
  oxyServices: OxyServices;
14
14
  sessionClient: SessionClient;
15
15
  syncFromClient: () => Promise<void>;
16
- commitSession: (input: CommitInput, options: { activate: boolean }) => Promise<void>;
16
+ commitSession: (input: CommitInput, options: { activate: boolean; hubSync?: boolean }) => Promise<void>;
17
17
  queryClient: QueryClient;
18
18
  accountDialogControllerRef: RefObject<AccountDialogController | null>;
19
19
  clearSessionStateRef: RefObject<() => Promise<void>>;
@@ -89,7 +89,9 @@ export function useOxyAccountGraph({
89
89
  userId: result.user.id,
90
90
  user: result.user,
91
91
  },
92
- { activate: true },
92
+ // A switch is IN-PLACE: never run the cross-origin hub-sync full-page
93
+ // redirect. Cross-tab/app propagation rides the `session_state` socket.
94
+ { activate: true, hubSync: false },
93
95
  );
94
96
  await runPostAccountSwitchSideEffects();
95
97
  },
@@ -23,6 +23,7 @@ const EMPTY_SNAPSHOT: AccountDialogSnapshot = {
23
23
  error: null,
24
24
  switchingAccountId: null,
25
25
  signIn: { phase: 'idle', authorizeCode: null, qrPayload: null, expiresAt: null, error: null },
26
+ commonsAvailability: 'unknown',
26
27
  };
27
28
 
28
29
  /**
@@ -62,6 +62,14 @@ export interface OxyProviderProps {
62
62
  baseURL?: string;
63
63
  authWebUrl?: string;
64
64
  authRedirectUri?: string;
65
+ /**
66
+ * Authorize endpoint override for silent cross-origin session restore
67
+ * (web cross-app SSO on cold boot). Defaults to the production Oxy IdP
68
+ * (`https://auth.oxy.so/authorize`) when unset. Set this from an env var
69
+ * (e.g. Vite `VITE_OXY_AUTHORIZE_URL`, Expo `EXPO_PUBLIC_OXY_AUTHORIZE_URL`)
70
+ * so a local/staging deployment targets its own IdP instead of production.
71
+ */
72
+ authorizeBaseUrl?: string;
65
73
  queryClient?: QueryClient;
66
74
  /** Sync device credentials to auth.oxy.so after interactive sign-in. @default true */
67
75
  hubSync?: boolean;