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