@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.
- package/lib/commonjs/ui/boot/runProviderColdBoot.js +12 -2
- package/lib/commonjs/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAccountDialog.js +0 -17
- package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/commonjs/ui/components/OxyProvider.js +2 -0
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/context/OxyContext.js +22 -69
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/commonjs/ui/context/passkeyFlow.js +9 -8
- package/lib/commonjs/ui/context/passkeyFlow.js.map +1 -1
- package/lib/commonjs/ui/utils/crossOriginRestore.js +26 -0
- package/lib/commonjs/ui/utils/crossOriginRestore.js.map +1 -1
- package/lib/module/ui/boot/runProviderColdBoot.js +13 -3
- package/lib/module/ui/boot/runProviderColdBoot.js.map +1 -1
- package/lib/module/ui/components/OxyAccountDialog.js +0 -17
- package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/module/ui/components/OxyProvider.js +2 -0
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/context/OxyContext.js +22 -69
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/module/ui/context/passkeyFlow.js +9 -8
- package/lib/module/ui/context/passkeyFlow.js.map +1 -1
- package/lib/module/ui/utils/crossOriginRestore.js +26 -1
- package/lib/module/ui/utils/crossOriginRestore.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +1 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts +6 -0
- package/lib/typescript/commonjs/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +1 -1
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts +22 -27
- package/lib/typescript/commonjs/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts +15 -4
- package/lib/typescript/commonjs/ui/context/passkeyFlow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +8 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/crossOriginRestore.d.ts +19 -0
- package/lib/typescript/commonjs/ui/utils/crossOriginRestore.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +1 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts +6 -0
- package/lib/typescript/module/ui/boot/runProviderColdBoot.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts +1 -1
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts +22 -27
- package/lib/typescript/module/ui/context/oxyContextTypes.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/passkeyFlow.d.ts +15 -4
- package/lib/typescript/module/ui/context/passkeyFlow.d.ts.map +1 -1
- package/lib/typescript/module/ui/types/navigation.d.ts +8 -0
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/crossOriginRestore.d.ts +19 -0
- package/lib/typescript/module/ui/utils/crossOriginRestore.d.ts.map +1 -1
- package/package.json +5 -5
- package/src/index.ts +1 -1
- package/src/ui/boot/runProviderColdBoot.ts +24 -1
- package/src/ui/components/OxyAccountDialog.tsx +0 -14
- package/src/ui/components/OxyProvider.tsx +2 -0
- package/src/ui/context/OxyContext.tsx +25 -79
- package/src/ui/context/__tests__/passkeyFlow.test.ts +22 -15
- package/src/ui/context/oxyContextTypes.ts +27 -26
- package/src/ui/context/passkeyFlow.ts +17 -9
- package/src/ui/types/navigation.ts +8 -0
- package/src/ui/utils/__tests__/crossOriginRestore.test.ts +143 -0
- 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
|
-
|
|
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,
|
|
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
|
-
//
|
|
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 {
|
|
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('
|
|
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, {
|
|
96
|
+
const deps = buildDeps(order, { username: 'alice' });
|
|
98
97
|
|
|
99
|
-
await
|
|
100
|
-
|
|
101
|
-
|
|
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('
|
|
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
|
|
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).
|
|
59
|
-
* flow: the browser prompts for any
|
|
60
|
-
*
|
|
61
|
-
* `
|
|
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?: {
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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 }`)
|
|
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;
|