@oxyhq/services 21.0.1 → 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 (68) hide show
  1. package/lib/commonjs/ui/boot/runProviderColdBoot.js +12 -2
  2. package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
  3. package/lib/commonjs/ui/components/OxyAccountDialog.js +0 -17
  4. package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
  5. package/lib/commonjs/ui/components/OxyProvider.js +2 -0
  6. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  7. package/lib/commonjs/ui/context/OxyContext.js +22 -69
  8. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  9. package/lib/commonjs/ui/context/passkeyFlow.js +9 -8
  10. package/lib/commonjs/ui/context/passkeyFlow.js.map +1 -1
  11. package/lib/commonjs/ui/utils/crossOriginRestore.js +26 -0
  12. package/lib/commonjs/ui/utils/crossOriginRestore.js.map +1 -1
  13. package/lib/module/ui/boot/runProviderColdBoot.js +13 -3
  14. package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
  15. package/lib/module/ui/components/OxyAccountDialog.js +0 -17
  16. package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
  17. package/lib/module/ui/components/OxyProvider.js +2 -0
  18. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  19. package/lib/module/ui/context/OxyContext.js +22 -69
  20. package/lib/module/ui/context/OxyContext.js.map +1 -1
  21. package/lib/module/ui/context/passkeyFlow.js +9 -8
  22. package/lib/module/ui/context/passkeyFlow.js.map +1 -1
  23. package/lib/module/ui/utils/crossOriginRestore.js +26 -1
  24. package/lib/module/ui/utils/crossOriginRestore.js.map +1 -1
  25. package/lib/typescript/commonjs/index.d.ts +1 -1
  26. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +6 -0
  28. package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  29. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
  30. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  31. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +1 -1
  32. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  33. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +22 -27
  34. package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
  35. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts +15 -4
  36. package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/types/navigation.d.ts +8 -0
  38. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/utils/crossOriginRestore.d.ts +19 -0
  40. package/lib/typescript/commonjs/ui/utils/crossOriginRestore.d.ts.map +1 -1
  41. package/lib/typescript/module/index.d.ts +1 -1
  42. package/lib/typescript/module/index.d.ts.map +1 -1
  43. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +6 -0
  44. package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
  45. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
  46. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  47. package/lib/typescript/module/ui/context/OxyContext.d.ts +1 -1
  48. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +22 -27
  50. package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/context/passkeyFlow.d.ts +15 -4
  52. package/lib/typescript/module/ui/context/passkeyFlow.d.ts.map +1 -1
  53. package/lib/typescript/module/ui/types/navigation.d.ts +8 -0
  54. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  55. package/lib/typescript/module/ui/utils/crossOriginRestore.d.ts +19 -0
  56. package/lib/typescript/module/ui/utils/crossOriginRestore.d.ts.map +1 -1
  57. package/package.json +5 -5
  58. package/src/index.ts +1 -1
  59. package/src/ui/boot/runProviderColdBoot.ts +24 -1
  60. package/src/ui/components/OxyAccountDialog.tsx +0 -14
  61. package/src/ui/components/OxyProvider.tsx +2 -0
  62. package/src/ui/context/OxyContext.tsx +25 -79
  63. package/src/ui/context/__tests__/passkeyFlow.test.ts +22 -15
  64. package/src/ui/context/oxyContextTypes.ts +27 -26
  65. package/src/ui/context/passkeyFlow.ts +17 -9
  66. package/src/ui/types/navigation.ts +8 -0
  67. package/src/ui/utils/__tests__/crossOriginRestore.test.ts +143 -0
  68. package/src/ui/utils/crossOriginRestore.ts +34 -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;
@@ -250,7 +250,6 @@ const OxyAccountDialog: React.FC = () => {
250
250
  handlers={handlers}
251
251
  onSignInWithOxy={() => void controller.signInWithOxy()}
252
252
  onScanQr={() => void controller.showQr()}
253
- onUsePassword={() => void controller.openPasswordAtOxyAuth()}
254
253
  onSignInWithPasskey={passkeyAvailable ? () => void handleSignInWithPasskey() : undefined}
255
254
  passkeyPending={passkeyPending}
256
255
  passkeyError={passkeyError}
@@ -447,7 +446,6 @@ interface SignInViewProps {
447
446
  handlers: OxyAccountDialogHandlers;
448
447
  onSignInWithOxy: () => void;
449
448
  onScanQr: () => void;
450
- onUsePassword: () => void;
451
449
  /** When present, offer a "Sign in with a passkey" button (first-party Oxy web only). */
452
450
  onSignInWithPasskey?: () => void;
453
451
  passkeyPending: boolean;
@@ -461,7 +459,6 @@ const SignInView: React.FC<SignInViewProps> = ({
461
459
  handlers,
462
460
  onSignInWithOxy,
463
461
  onScanQr,
464
- onUsePassword,
465
462
  onSignInWithPasskey,
466
463
  passkeyPending,
467
464
  passkeyError,
@@ -508,12 +505,6 @@ const SignInView: React.FC<SignInViewProps> = ({
508
505
  <Button variant="secondary" onPress={onScanQr} style={styles.secondaryButton}>
509
506
  {t('accountSwitcher.scanQr') || 'Scan a QR from another device'}
510
507
  </Button>
511
-
512
- <Pressable onPress={onUsePassword} accessibilityRole="button" style={styles.passwordLink}>
513
- <Text style={[styles.linkText, { color: theme.colors.textSecondary }]}>
514
- Use a password instead
515
- </Text>
516
- </Pressable>
517
508
  </View>
518
509
  );
519
510
 
@@ -738,11 +729,6 @@ const styles = StyleSheet.create({
738
729
  borderRadius: 14,
739
730
  marginTop: 10,
740
731
  },
741
- passwordLink: {
742
- alignSelf: 'center',
743
- paddingVertical: 10,
744
- marginTop: 12,
745
- },
746
732
  dividerRow: {
747
733
  flexDirection: 'row',
748
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,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,
@@ -622,7 +621,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
622
621
  oxyServices,
623
622
  sessionClient,
624
623
  clientId,
625
- authRedirectUri,
626
624
  locale: currentLanguage,
627
625
  // Same statically-injected `io` as the SessionClient: gives the QR flow an
628
626
  // instant `/auth-session` `auth_update` wake instead of a slow poll.
@@ -676,72 +674,6 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
676
674
  notifyAccountDialogVisibility(accountDialogOpen);
677
675
  }, [accountDialogOpen]);
678
676
 
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
677
  // ── Passkey (WebAuthn) ─────────────────────────────────────────────────────
746
678
  // Web-only sign-in / registration via the browser WebAuthn ceremony. The fixed
747
679
  // `options → ceremony → verify → commit` ordering lives in the pure
@@ -750,16 +682,20 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
750
682
  // `commitSession` funnel). All three GATE on `isPasskeySupported()` so a native
751
683
  // / unsupported surface throws loudly instead of stalling in a ceremony.
752
684
 
753
- // Usernameless (discoverable) passkey sign-in.
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.
754
689
  const signInWithPasskey = useCallback(
755
- async (opts?: { deviceName?: string; deviceFingerprint?: string }): Promise<void> => {
690
+ async (opts?: { username?: string; deviceName?: string; deviceFingerprint?: string }): Promise<void> => {
756
691
  const persisted = await authStore.load();
757
692
  await runPasskeyLogin({
758
693
  isSupported: isPasskeySupported,
759
- getLoginOptions: () => oxyServices.webauthnLoginOptions(),
694
+ getLoginOptions: (username) => oxyServices.webauthnLoginOptions(username),
760
695
  runCeremony: runAuthenticationCeremony,
761
696
  loginVerify: (response, envelope) => oxyServices.webauthnLoginVerify(response, envelope),
762
697
  commit: (input) => commitSession(input, { activate: true, hubSync: true }),
698
+ username: opts?.username,
763
699
  deviceId: persisted?.deviceId,
764
700
  deviceName: opts?.deviceName,
765
701
  deviceFingerprint: opts?.deviceFingerprint,
@@ -802,6 +738,17 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
802
738
  [oxyServices, queryClient],
803
739
  );
804
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
+
805
752
  // ── Cold boot ────────────────────────────────────────────────────────────
806
753
  // Device-first session restore via `runProviderColdBoot` (see boot/runProviderColdBoot.ts).
807
754
  const runColdBoot = useCallback(async (): Promise<void> => {
@@ -810,6 +757,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
810
757
  authStore,
811
758
  clientId: clientIdProp,
812
759
  authRedirectUri,
760
+ authorizeBaseUrl,
813
761
  sessionClient,
814
762
  syncDeviceCredentialToHost,
815
763
  commitSession: (input, options) => commitSessionRef.current(input, options),
@@ -821,6 +769,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
821
769
  authStore,
822
770
  clientIdProp,
823
771
  authRedirectUri,
772
+ authorizeBaseUrl,
824
773
  syncDeviceCredentialToHost,
825
774
  sessionClient,
826
775
  ]);
@@ -950,11 +899,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
950
899
  hasIdentity,
951
900
  getPublicKey,
952
901
  signIn,
953
- signInWithPassword,
954
- completeTwoFactorSignIn,
955
902
  signInWithPasskey,
956
903
  registerWithPasskey,
957
904
  addPasskey,
905
+ removePasskey,
958
906
  revokeSuspiciousSignIn,
959
907
  handleWebSession,
960
908
  logout,
@@ -1005,11 +953,10 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1005
953
  hasIdentity,
1006
954
  getPublicKey,
1007
955
  signIn,
1008
- signInWithPassword,
1009
- completeTwoFactorSignIn,
1010
956
  signInWithPasskey,
1011
957
  registerWithPasskey,
1012
958
  addPasskey,
959
+ removePasskey,
1013
960
  revokeSuspiciousSignIn,
1014
961
  handleWebSession,
1015
962
  logout,
@@ -1078,11 +1025,10 @@ const LOADING_STATE: OxyContextState = {
1078
1025
  hasIdentity: () => Promise.resolve(false),
1079
1026
  getPublicKey: () => Promise.resolve(null),
1080
1027
  signIn: () => rejectMissingProvider<User>(),
1081
- signInWithPassword: () => rejectMissingProvider<PasswordSignInResult>(),
1082
- completeTwoFactorSignIn: () => rejectMissingProvider<{ securityAlert?: SecurityAlert }>(),
1083
1028
  signInWithPasskey: () => rejectMissingProvider<void>(),
1084
1029
  registerWithPasskey: () => rejectMissingProvider<void>(),
1085
1030
  addPasskey: () => rejectMissingProvider<void>(),
1031
+ removePasskey: () => rejectMissingProvider<void>(),
1086
1032
  revokeSuspiciousSignIn: () => rejectMissingProvider<void>(),
1087
1033
  handleWebSession: () => rejectMissingProvider<void>(),
1088
1034
  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
  }
@@ -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;