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