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