@oxyhq/services 17.0.0 → 18.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/README.md +77 -98
- package/lib/commonjs/index.js +19 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/ui/components/OxyAccountDialog.js +62 -84
- package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/commonjs/ui/components/OxyConsentScreen.js +461 -0
- package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -0
- package/lib/commonjs/ui/components/OxyProvider.js +3 -1
- package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
- package/lib/commonjs/ui/components/OxySignInButton.js +176 -7
- package/lib/commonjs/ui/components/OxySignInButton.js.map +1 -1
- package/lib/commonjs/ui/components/oauthNavigation.js +73 -0
- package/lib/commonjs/ui/components/oauthNavigation.js.map +1 -0
- package/lib/commonjs/ui/context/OxyContext.js +16 -2
- package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
- package/lib/module/index.js +2 -2
- package/lib/module/index.js.map +1 -1
- package/lib/module/ui/components/OxyAccountDialog.js +63 -85
- package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
- package/lib/module/ui/components/OxyConsentScreen.js +454 -0
- package/lib/module/ui/components/OxyConsentScreen.js.map +1 -0
- package/lib/module/ui/components/OxyProvider.js +3 -1
- package/lib/module/ui/components/OxyProvider.js.map +1 -1
- package/lib/module/ui/components/OxySignInButton.js +177 -7
- package/lib/module/ui/components/OxySignInButton.js.map +1 -1
- package/lib/module/ui/components/oauthNavigation.js +70 -0
- package/lib/module/ui/components/oauthNavigation.js.map +1 -0
- package/lib/module/ui/context/OxyContext.js +16 -2
- package/lib/module/ui/context/OxyContext.js.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts +18 -2
- package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +45 -0
- package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts +56 -0
- package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts +36 -0
- package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +14 -0
- package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts +1 -1
- package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/types/navigation.d.ts +18 -0
- package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts +18 -2
- package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +45 -0
- package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/OxySignInButton.d.ts +56 -0
- package/lib/typescript/module/ui/components/OxySignInButton.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/oauthNavigation.d.ts +36 -0
- package/lib/typescript/module/ui/components/oauthNavigation.d.ts.map +1 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts +14 -0
- package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts +1 -1
- package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
- package/lib/typescript/module/ui/types/navigation.d.ts +18 -0
- package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/index.ts +12 -1
- package/src/ui/components/OxyAccountDialog.tsx +60 -85
- package/src/ui/components/OxyConsentScreen.tsx +456 -0
- package/src/ui/components/OxyProvider.tsx +2 -0
- package/src/ui/components/OxySignInButton.tsx +222 -6
- package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +130 -0
- package/src/ui/components/oauthNavigation.ts +89 -0
- package/src/ui/context/OxyContext.tsx +30 -2
- package/src/ui/context/__tests__/OxyContext.coldBoot.test.tsx +165 -0
- package/src/ui/types/navigation.ts +18 -0
|
@@ -116,6 +116,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
|
|
|
116
116
|
authRedirectUri,
|
|
117
117
|
queryClient: providedQueryClient,
|
|
118
118
|
requireAuth = 'off',
|
|
119
|
+
coldBoot = true,
|
|
119
120
|
}) => {
|
|
120
121
|
|
|
121
122
|
// Dynamic KeyboardProvider for native. Uses variable indirection
|
|
@@ -310,6 +311,7 @@ const OxyProvider: FC<OxyProviderProps> = ({
|
|
|
310
311
|
authRedirectUri={authRedirectUri}
|
|
311
312
|
storageKeyPrefix={storageKeyPrefix}
|
|
312
313
|
clientId={clientId}
|
|
314
|
+
coldBoot={coldBoot}
|
|
313
315
|
onAuthStateChange={onAuthStateChange as OxyContextProviderProps['onAuthStateChange']}
|
|
314
316
|
>
|
|
315
317
|
{requireAuth === 'off' ? (
|
|
@@ -1,12 +1,73 @@
|
|
|
1
1
|
import type React from 'react';
|
|
2
|
-
import { useCallback, useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import { useCallback, useState, useEffect, useMemo, useRef } from 'react';
|
|
3
3
|
import { TouchableOpacity, Text, View, StyleSheet, type ViewStyle, type TextStyle, type StyleProp, Platform } from 'react-native';
|
|
4
|
+
import {
|
|
5
|
+
logger,
|
|
6
|
+
generatePkcePair,
|
|
7
|
+
generateOAuthState,
|
|
8
|
+
buildOAuthAuthorizeUrl,
|
|
9
|
+
type PublicApplication,
|
|
10
|
+
} from '@oxyhq/core';
|
|
4
11
|
import { useAuthStore } from '../stores/authStore';
|
|
5
12
|
import { useShallow } from 'zustand/react/shallow';
|
|
6
13
|
import { useTheme } from '@oxyhq/bloom/theme';
|
|
7
14
|
import { useOxy } from '../context/OxyContext';
|
|
8
15
|
import OxyLogo from './OxyLogo';
|
|
9
16
|
import { subscribeToSignInModal } from '../navigation/accountDialogManager';
|
|
17
|
+
import { redirectToAuthorize, openAuthorizeUrlNative } from './oauthNavigation';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `sessionStorage` keys under which a third-party "Sign in with Oxy" OAuth flow
|
|
21
|
+
* persists its CSRF `state` and PKCE `code_verifier` across the authorize
|
|
22
|
+
* redirect. The Relying Party's redirect-URI callback reads them back to
|
|
23
|
+
* validate the returned `state` and replay the verifier on the token exchange.
|
|
24
|
+
*
|
|
25
|
+
* Web only: a browser RP navigates away to `auth.oxy.so` and back, so the
|
|
26
|
+
* handshake must survive a full-page redirect. Native completes the flow inside
|
|
27
|
+
* a single `WebBrowser` auth session and surfaces the handshake via
|
|
28
|
+
* {@link OxySignInButtonProps.onOAuthResult} instead.
|
|
29
|
+
*/
|
|
30
|
+
export const OXY_OAUTH_STATE_STORAGE_KEY = 'oxy_oauth_state';
|
|
31
|
+
export const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = 'oxy_oauth_code_verifier';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The OAuth handshake surfaced to a NATIVE third-party RP via
|
|
35
|
+
* {@link OxySignInButtonProps.onOAuthResult} so it can finish the code exchange
|
|
36
|
+
* (`POST /auth/oauth/token`). Web RPs read the same `state` / `code_verifier`
|
|
37
|
+
* back from `sessionStorage` across the redirect and do not need this callback.
|
|
38
|
+
*/
|
|
39
|
+
export interface OxyOAuthResult {
|
|
40
|
+
/** Deep-link URL the native auth session returned to (`?code=…&state=…`), or `null` if unobserved. */
|
|
41
|
+
redirectUrl: string | null;
|
|
42
|
+
/** The CSRF `state` sent on the authorize request; the RP must match it on return. */
|
|
43
|
+
state: string;
|
|
44
|
+
/** The PKCE `code_verifier` to replay on the token exchange. */
|
|
45
|
+
codeVerifier: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Persist the OAuth CSRF `state` + PKCE `code_verifier` for the RP callback.
|
|
50
|
+
* Returns `false` when the handshake could not be stored — no `sessionStorage`
|
|
51
|
+
* (SSR / non-browser host) or a write that threw (`SecurityError` /
|
|
52
|
+
* `QuotaExceededError`, e.g. Safari private mode) — so the caller aborts the
|
|
53
|
+
* flow cleanly rather than redirect to a callback that cannot validate `state`.
|
|
54
|
+
*/
|
|
55
|
+
function persistOAuthHandshake(state: string, codeVerifier: string): boolean {
|
|
56
|
+
const store = (globalThis as { sessionStorage?: Storage }).sessionStorage;
|
|
57
|
+
try {
|
|
58
|
+
if (!store) throw new Error('sessionStorage is unavailable');
|
|
59
|
+
store.setItem(OXY_OAUTH_STATE_STORAGE_KEY, state);
|
|
60
|
+
store.setItem(OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, codeVerifier);
|
|
61
|
+
return true;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
logger.warn(
|
|
64
|
+
'OxySignInButton: could not persist the OAuth handshake to sessionStorage; aborting third-party sign-in',
|
|
65
|
+
{ component: 'OxySignInButton' },
|
|
66
|
+
error,
|
|
67
|
+
);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
10
71
|
|
|
11
72
|
export interface OxySignInButtonProps {
|
|
12
73
|
/**
|
|
@@ -48,6 +109,37 @@ export interface OxySignInButtonProps {
|
|
|
48
109
|
* @default false
|
|
49
110
|
*/
|
|
50
111
|
showWhenAuthenticated?: boolean;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Exact registered redirect URI the OAuth authorization code is returned to.
|
|
115
|
+
* REQUIRED only for third-party (`type: 'third_party'`) applications, which
|
|
116
|
+
* sign in via an OAuth + PKCE redirect to `auth.oxy.so`. First-party /
|
|
117
|
+
* official apps open the in-app dialog and ignore this prop. If a third-party
|
|
118
|
+
* app resolves without it, the button logs an error and does nothing (it will
|
|
119
|
+
* not invent a redirect URI).
|
|
120
|
+
*/
|
|
121
|
+
oauthRedirectUri?: string;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Native only: receives the OAuth handshake after a third-party auth session
|
|
125
|
+
* so the RP can finish the token exchange. On web the handshake is read back
|
|
126
|
+
* from `sessionStorage` across the full-page redirect, so this is not used
|
|
127
|
+
* there. A native third-party sign-in with NO `onOAuthResult` handler cannot
|
|
128
|
+
* complete (the `state` + `code_verifier` are lost) and logs a warning.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* ```tsx
|
|
132
|
+
* <OxySignInButton
|
|
133
|
+
* oauthRedirectUri="myapp://oauth/callback"
|
|
134
|
+
* onOAuthResult={({ redirectUrl, state, codeVerifier }) => {
|
|
135
|
+
* if (!redirectUrl) return;
|
|
136
|
+
* const code = new URL(redirectUrl).searchParams.get('code');
|
|
137
|
+
* // → POST /auth/oauth/token { code, code_verifier: codeVerifier, state }
|
|
138
|
+
* }}
|
|
139
|
+
* />
|
|
140
|
+
* ```
|
|
141
|
+
*/
|
|
142
|
+
onOAuthResult?: (result: OxyOAuthResult) => void;
|
|
51
143
|
}
|
|
52
144
|
|
|
53
145
|
/**
|
|
@@ -82,9 +174,11 @@ export const OxySignInButton: React.FC<OxySignInButtonProps> = ({
|
|
|
82
174
|
text = 'Sign in with Oxy',
|
|
83
175
|
disabled = false,
|
|
84
176
|
showWhenAuthenticated = false,
|
|
177
|
+
oauthRedirectUri,
|
|
178
|
+
onOAuthResult,
|
|
85
179
|
}) => {
|
|
86
180
|
const theme = useTheme();
|
|
87
|
-
const { openAccountDialog } = useOxy();
|
|
181
|
+
const { openAccountDialog, oxyServices, clientId } = useOxy();
|
|
88
182
|
const { isAuthenticated, isLoading } = useAuthStore(
|
|
89
183
|
useShallow((state) => ({ isAuthenticated: state.isAuthenticated, isLoading: state.isLoading }))
|
|
90
184
|
);
|
|
@@ -95,15 +189,137 @@ export const OxySignInButton: React.FC<OxySignInButtonProps> = ({
|
|
|
95
189
|
|
|
96
190
|
useEffect(() => subscribeToSignInModal(setIsModalOpen), []);
|
|
97
191
|
|
|
98
|
-
//
|
|
99
|
-
//
|
|
192
|
+
// The application's public identity is resolved lazily on first press and its
|
|
193
|
+
// promise cached, so rapid taps share one in-flight resolve. The cache is
|
|
194
|
+
// KEYED on the identity inputs (clientId + the oxyServices instance): if
|
|
195
|
+
// either changes the cache is invalidated and re-resolved — without a
|
|
196
|
+
// useEffect. A rejected resolve clears the cache so a later press can retry.
|
|
197
|
+
const appResolutionRef = useRef<{
|
|
198
|
+
clientId: string;
|
|
199
|
+
oxyServices: typeof oxyServices;
|
|
200
|
+
promise: Promise<PublicApplication>;
|
|
201
|
+
} | null>(null);
|
|
202
|
+
// Re-entrancy guard: a routing pass may await network + crypto before it
|
|
203
|
+
// redirects, so block a second concurrent press from racing the sessionStorage
|
|
204
|
+
// handshake against a different PKCE pair.
|
|
205
|
+
const routingRef = useRef(false);
|
|
206
|
+
|
|
207
|
+
const resolvePublicApplication = useCallback((): Promise<PublicApplication> | null => {
|
|
208
|
+
if (!clientId) return null;
|
|
209
|
+
const cached = appResolutionRef.current;
|
|
210
|
+
if (cached && cached.clientId === clientId && cached.oxyServices === oxyServices) {
|
|
211
|
+
return cached.promise;
|
|
212
|
+
}
|
|
213
|
+
const promise = oxyServices.getPublicApplication(clientId).catch((error) => {
|
|
214
|
+
// Only clear if this is still the live entry (a later resolve may have
|
|
215
|
+
// replaced it after a clientId/oxyServices change).
|
|
216
|
+
if (appResolutionRef.current?.promise === promise) {
|
|
217
|
+
appResolutionRef.current = null;
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
220
|
+
});
|
|
221
|
+
appResolutionRef.current = { clientId, oxyServices, promise };
|
|
222
|
+
return promise;
|
|
223
|
+
}, [clientId, oxyServices]);
|
|
224
|
+
|
|
225
|
+
// Official / first-party surface: the in-app account + sign-in dialog.
|
|
226
|
+
const startOfficialSignIn = useCallback(() => {
|
|
227
|
+
openAccountDialog('signin');
|
|
228
|
+
}, [openAccountDialog]);
|
|
229
|
+
|
|
230
|
+
// Third-party surface: an OAuth 2.0 authorization-code + PKCE redirect to
|
|
231
|
+
// auth.oxy.so. No FedCM, no SSO bounce, no Oxy session cookies.
|
|
232
|
+
const startThirdPartyOAuth = useCallback(
|
|
233
|
+
async (app: PublicApplication): Promise<void> => {
|
|
234
|
+
if (!clientId) {
|
|
235
|
+
startOfficialSignIn();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (!oauthRedirectUri) {
|
|
239
|
+
logger.error(
|
|
240
|
+
'OxySignInButton: a third_party application requires the `oauthRedirectUri` prop to start the OAuth redirect; sign-in aborted',
|
|
241
|
+
undefined,
|
|
242
|
+
{ component: 'OxySignInButton', clientId, application: app.name },
|
|
243
|
+
);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const [pkce, state] = await Promise.all([generatePkcePair(), generateOAuthState()]);
|
|
248
|
+
const authorizeUrl = buildOAuthAuthorizeUrl({
|
|
249
|
+
clientId,
|
|
250
|
+
redirectUri: oauthRedirectUri,
|
|
251
|
+
state,
|
|
252
|
+
codeChallenge: pkce.codeChallenge,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (Platform.OS === 'web') {
|
|
256
|
+
// Persist the handshake for the RP callback, then hand the
|
|
257
|
+
// top-level document to the IdP. Without storage the callback
|
|
258
|
+
// cannot validate `state`, so abort cleanly rather than redirect.
|
|
259
|
+
if (!persistOAuthHandshake(state, pkce.codeVerifier)) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
redirectToAuthorize(authorizeUrl);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Native: open the in-app auth session, then hand the handshake to the
|
|
267
|
+
// RP so it can complete the token exchange from its deep-link callback.
|
|
268
|
+
const { redirectUrl } = await openAuthorizeUrlNative(authorizeUrl, oauthRedirectUri);
|
|
269
|
+
if (onOAuthResult) {
|
|
270
|
+
onOAuthResult({ redirectUrl, state, codeVerifier: pkce.codeVerifier });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
logger.warn(
|
|
274
|
+
'OxySignInButton: native third-party sign-in cannot complete without an `onOAuthResult` handler; the code exchange is the RP\'s responsibility (state + code_verifier were not surfaced)',
|
|
275
|
+
{ component: 'OxySignInButton', application: app.name },
|
|
276
|
+
);
|
|
277
|
+
},
|
|
278
|
+
[clientId, oauthRedirectUri, onOAuthResult, startOfficialSignIn],
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
// Resolve the Application once, then route: third-party → OAuth redirect;
|
|
282
|
+
// first-party / official / unresolved → the in-app dialog. Resolution failure
|
|
283
|
+
// NEVER breaks an official app's sign-in — it falls back to the dialog.
|
|
284
|
+
const routeSignIn = useCallback(async (): Promise<void> => {
|
|
285
|
+
if (routingRef.current) return;
|
|
286
|
+
routingRef.current = true;
|
|
287
|
+
try {
|
|
288
|
+
const resolving = resolvePublicApplication();
|
|
289
|
+
if (!resolving) {
|
|
290
|
+
startOfficialSignIn();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
let app: PublicApplication;
|
|
294
|
+
try {
|
|
295
|
+
app = await resolving;
|
|
296
|
+
} catch (error) {
|
|
297
|
+
logger.warn(
|
|
298
|
+
'OxySignInButton: could not resolve the application; opening the sign-in dialog',
|
|
299
|
+
{ component: 'OxySignInButton', clientId },
|
|
300
|
+
error,
|
|
301
|
+
);
|
|
302
|
+
startOfficialSignIn();
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (app.type === 'third_party' && !app.isOfficial) {
|
|
306
|
+
await startThirdPartyOAuth(app);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
startOfficialSignIn();
|
|
310
|
+
} finally {
|
|
311
|
+
routingRef.current = false;
|
|
312
|
+
}
|
|
313
|
+
}, [resolvePublicApplication, startOfficialSignIn, startThirdPartyOAuth, clientId]);
|
|
314
|
+
|
|
315
|
+
// Defer to a caller-supplied handler, otherwise route by application type.
|
|
100
316
|
const handlePress = useCallback(() => {
|
|
101
317
|
if (onPress) {
|
|
102
318
|
onPress();
|
|
103
319
|
return;
|
|
104
320
|
}
|
|
105
|
-
|
|
106
|
-
}, [onPress,
|
|
321
|
+
void routeSignIn();
|
|
322
|
+
}, [onPress, routeSignIn]);
|
|
107
323
|
|
|
108
324
|
const themedStyles = useMemo(() => StyleSheet.create({
|
|
109
325
|
button: {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `OxyConsentScreen` — the unified OAuth consent surface. These tests exercise
|
|
3
|
+
* the pure presentational contract: scopes render, privacy/terms links appear
|
|
4
|
+
* only when supplied, allow/deny fire their handlers, `busy` disables both
|
|
5
|
+
* actions, and the account badge follows the display-name rule (`displayName`
|
|
6
|
+
* else `handle`).
|
|
7
|
+
*/
|
|
8
|
+
import { render, fireEvent } from '@testing-library/react';
|
|
9
|
+
import {
|
|
10
|
+
OxyConsentScreen,
|
|
11
|
+
type OxyConsentApplication,
|
|
12
|
+
type OxyConsentScreenProps,
|
|
13
|
+
} from '../OxyConsentScreen';
|
|
14
|
+
|
|
15
|
+
// OxyLogo renders through react-native-svg, which is not meaningful (and not
|
|
16
|
+
// resolvable) under the jsdom RN stub — replace it with an inert node.
|
|
17
|
+
jest.mock('../OxyLogo', () => ({ __esModule: true, default: () => null }));
|
|
18
|
+
|
|
19
|
+
const baseApp: OxyConsentApplication = { name: 'Acme Notes' };
|
|
20
|
+
|
|
21
|
+
function renderScreen(props: Partial<OxyConsentScreenProps> = {}) {
|
|
22
|
+
const onAllow = jest.fn();
|
|
23
|
+
const onDeny = jest.fn();
|
|
24
|
+
const view = render(
|
|
25
|
+
<OxyConsentScreen
|
|
26
|
+
application={props.application ?? baseApp}
|
|
27
|
+
scopes={props.scopes ?? ['openid', 'profile']}
|
|
28
|
+
user={props.user}
|
|
29
|
+
onAllow={props.onAllow ?? onAllow}
|
|
30
|
+
onDeny={props.onDeny ?? onDeny}
|
|
31
|
+
busy={props.busy}
|
|
32
|
+
error={props.error}
|
|
33
|
+
/>,
|
|
34
|
+
);
|
|
35
|
+
return { ...view, onAllow: props.onAllow ?? onAllow, onDeny: props.onDeny ?? onDeny };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('OxyConsentScreen', () => {
|
|
39
|
+
it('renders every requested scope with a friendly label and falls back to the raw scope for unknown ones', () => {
|
|
40
|
+
const { getByTestId } = renderScreen({ scopes: ['openid', 'files:write', 'custom:thing'] });
|
|
41
|
+
|
|
42
|
+
expect(getByTestId('consent-scope-openid').textContent).toContain('Confirm your identity');
|
|
43
|
+
expect(getByTestId('consent-scope-files:write').textContent).toContain('Upload and modify your files');
|
|
44
|
+
// Unknown scope → the raw scope string is shown, never an empty row.
|
|
45
|
+
expect(getByTestId('consent-scope-custom:thing').textContent).toContain('custom:thing');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('shows the basic-permissions fallback when no scopes are requested', () => {
|
|
49
|
+
const { getByTestId, queryByTestId } = renderScreen({ scopes: [] });
|
|
50
|
+
expect(getByTestId('consent-scope-basic').textContent).toContain('Sign you in and read your basic profile');
|
|
51
|
+
expect(queryByTestId('consent-scope-openid')).toBeNull();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('renders the provenance line for an official application', () => {
|
|
55
|
+
const { getByTestId } = renderScreen({ application: { name: 'Oxy Console', isOfficial: true } });
|
|
56
|
+
expect(getByTestId('consent-provenance').textContent).toContain('Official Oxy application');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('renders "Published by …" provenance for a third-party app with a developer', () => {
|
|
60
|
+
const { getByTestId } = renderScreen({
|
|
61
|
+
application: { name: 'Third Party', developerName: 'Widgets Inc' },
|
|
62
|
+
});
|
|
63
|
+
expect(getByTestId('consent-provenance').textContent).toContain('Widgets Inc');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('omits privacy/terms links when the application does not supply them', () => {
|
|
67
|
+
const { queryByTestId } = renderScreen();
|
|
68
|
+
expect(queryByTestId('consent-link-privacy')).toBeNull();
|
|
69
|
+
expect(queryByTestId('consent-link-terms')).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('renders privacy and terms links when supplied (field 2b)', () => {
|
|
73
|
+
const { getByTestId } = renderScreen({
|
|
74
|
+
application: {
|
|
75
|
+
name: 'Acme Notes',
|
|
76
|
+
privacyPolicyUrl: 'https://acme.example/privacy',
|
|
77
|
+
termsUrl: 'https://acme.example/terms',
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
expect(getByTestId('consent-link-privacy')).not.toBeNull();
|
|
81
|
+
expect(getByTestId('consent-link-terms')).not.toBeNull();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('fires onAllow and onDeny from the decision buttons', () => {
|
|
85
|
+
const { getByTestId, onAllow, onDeny } = renderScreen();
|
|
86
|
+
|
|
87
|
+
fireEvent.click(getByTestId('consent-allow'));
|
|
88
|
+
expect(onAllow).toHaveBeenCalledTimes(1);
|
|
89
|
+
|
|
90
|
+
fireEvent.click(getByTestId('consent-deny'));
|
|
91
|
+
expect(onDeny).toHaveBeenCalledTimes(1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('disables both actions while busy', () => {
|
|
95
|
+
const { getByTestId, onAllow, onDeny } = renderScreen({ busy: true });
|
|
96
|
+
|
|
97
|
+
const allow = getByTestId('consent-allow') as HTMLButtonElement;
|
|
98
|
+
const deny = getByTestId('consent-deny') as HTMLButtonElement;
|
|
99
|
+
expect(allow.disabled).toBe(true);
|
|
100
|
+
expect(deny.disabled).toBe(true);
|
|
101
|
+
|
|
102
|
+
// A disabled jsdom <button> does not fire click — the handlers stay untouched.
|
|
103
|
+
fireEvent.click(allow);
|
|
104
|
+
fireEvent.click(deny);
|
|
105
|
+
expect(onAllow).not.toHaveBeenCalled();
|
|
106
|
+
expect(onDeny).not.toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('renders the authorizing account using the display name when present', () => {
|
|
110
|
+
const { getByTestId } = renderScreen({
|
|
111
|
+
user: { displayName: ' Alice A ', handle: 'alice', avatarUri: 'file-1' },
|
|
112
|
+
});
|
|
113
|
+
expect(getByTestId('consent-account-name').textContent).toBe('Alice A');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('falls back to the handle when the account has no display name (D5)', () => {
|
|
117
|
+
const { getByTestId } = renderScreen({ user: { handle: 'bob' } });
|
|
118
|
+
expect(getByTestId('consent-account-name').textContent).toBe('bob');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('omits the account badge entirely when no user is supplied', () => {
|
|
122
|
+
const { queryByTestId } = renderScreen({ user: undefined });
|
|
123
|
+
expect(queryByTestId('consent-account')).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('renders a blocking error when supplied', () => {
|
|
127
|
+
const { getByTestId } = renderScreen({ error: 'This request has expired.' });
|
|
128
|
+
expect(getByTestId('consent-error').textContent).toContain('This request has expired.');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Platform navigation for the third-party "Sign in with Oxy" OAuth flow.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of `OxySignInButton` so the button's routing logic can be unit-tested
|
|
5
|
+
* without driving a real browser navigation (jsdom's `location.assign` is
|
|
6
|
+
* non-configurable and cannot be spied). Both entry points hand a fully-built
|
|
7
|
+
* `auth.oxy.so/authorize` URL to the platform — never FedCM, an SSO bounce, or
|
|
8
|
+
* an Oxy session cookie.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Linking } from 'react-native';
|
|
12
|
+
import { logger } from '@oxyhq/core';
|
|
13
|
+
|
|
14
|
+
/** Minimal shape of the optional `expo-web-browser` auth-session result. */
|
|
15
|
+
interface WebBrowserAuthResult {
|
|
16
|
+
type?: string;
|
|
17
|
+
url?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Minimal shape of the optional `expo-web-browser` native module we depend on. */
|
|
21
|
+
interface WebBrowserModule {
|
|
22
|
+
openAuthSessionAsync?: (url: string, redirectUrl: string) => Promise<WebBrowserAuthResult>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Outcome of opening the authorize URL on native. */
|
|
26
|
+
export interface OpenAuthorizeResult {
|
|
27
|
+
/**
|
|
28
|
+
* The deep-link URL the auth session returned to (carries `?code=…&state=…`)
|
|
29
|
+
* when `expo-web-browser` observed it, else `null`. `null` means the RP must
|
|
30
|
+
* complete the exchange from its own deep-link handler (e.g. after the
|
|
31
|
+
* `Linking.openURL` fallback, which cannot observe the return URL).
|
|
32
|
+
*/
|
|
33
|
+
redirectUrl: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Web: hand the TOP-LEVEL document to the OAuth authorize URL (a full-page
|
|
38
|
+
* redirect, not a popup) so the RP returns to its registered `redirect_uri`.
|
|
39
|
+
* No-op where `location` is unavailable (SSR / non-browser hosts).
|
|
40
|
+
*/
|
|
41
|
+
export function redirectToAuthorize(url: string): void {
|
|
42
|
+
(globalThis as { location?: Location }).location?.assign(url);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Native: open the authorize URL in an in-app auth session via the optional
|
|
47
|
+
* `expo-web-browser` module (`openAuthSessionAsync` returns to `redirectUri`),
|
|
48
|
+
* degrading to `Linking.openURL` when the module is not installed — the same
|
|
49
|
+
* dynamic-import-with-fallback pattern services uses for haptics/netinfo.
|
|
50
|
+
*
|
|
51
|
+
* Returns the deep-link URL the session came back to when it can be observed, so
|
|
52
|
+
* the caller can hand `?code=…&state=…` back to the RP for the token exchange.
|
|
53
|
+
*/
|
|
54
|
+
export async function openAuthorizeUrlNative(
|
|
55
|
+
url: string,
|
|
56
|
+
redirectUri: string,
|
|
57
|
+
): Promise<OpenAuthorizeResult> {
|
|
58
|
+
try {
|
|
59
|
+
const mod = (await import('expo-web-browser')) as unknown as WebBrowserModule;
|
|
60
|
+
if (mod && typeof mod.openAuthSessionAsync === 'function') {
|
|
61
|
+
const result = await mod.openAuthSessionAsync(url, redirectUri);
|
|
62
|
+
const redirectUrl =
|
|
63
|
+
result && result.type === 'success' && typeof result.url === 'string'
|
|
64
|
+
? result.url
|
|
65
|
+
: null;
|
|
66
|
+
return { redirectUrl };
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
logger.warn(
|
|
70
|
+
'OxySignInButton: expo-web-browser auth session failed; falling back to Linking.openURL',
|
|
71
|
+
{ component: 'oauthNavigation' },
|
|
72
|
+
error,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Fallback: Linking cannot observe the return URL, so the RP completes the
|
|
77
|
+
// exchange from its own deep-link handler. A rejected openURL (e.g. an
|
|
78
|
+
// unregistered scheme) must not throw out of the sign-in flow.
|
|
79
|
+
try {
|
|
80
|
+
await Linking.openURL(url);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
logger.warn(
|
|
83
|
+
'OxySignInButton: Linking.openURL rejected the authorize URL',
|
|
84
|
+
{ component: 'oauthNavigation' },
|
|
85
|
+
error,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return { redirectUrl: null };
|
|
89
|
+
}
|
|
@@ -234,6 +234,20 @@ export interface OxyContextProviderProps {
|
|
|
234
234
|
* for the cross-app device sign-in flow. See {@link OxyContextState.clientId}.
|
|
235
235
|
*/
|
|
236
236
|
clientId?: string;
|
|
237
|
+
/**
|
|
238
|
+
* Whether this provider is the device-first **session authority**. `true`
|
|
239
|
+
* (default) runs `runSessionColdBoot` on mount and opens the signed-out
|
|
240
|
+
* device-state socket — the correct behavior for every Relying Party app.
|
|
241
|
+
*
|
|
242
|
+
* `false` is the IdP host (`auth.oxy.so`) opt-out: the IdP is NOT a session
|
|
243
|
+
* authority (handoff "IdP vs RP"), so it must NOT restore or reproject an
|
|
244
|
+
* ambient device session. With `coldBoot={false}` the cold boot never runs
|
|
245
|
+
* and the signed-out device socket never opens; auth resolves immediately as
|
|
246
|
+
* signed out. Interactive sign-in still commits a normal session on this
|
|
247
|
+
* origin — only the automatic restore/reproject is suppressed.
|
|
248
|
+
* @default true
|
|
249
|
+
*/
|
|
250
|
+
coldBoot?: boolean;
|
|
237
251
|
onAuthStateChange?: (user: User | null) => void;
|
|
238
252
|
onError?: (error: ApiError) => void;
|
|
239
253
|
}
|
|
@@ -336,6 +350,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
336
350
|
authRedirectUri,
|
|
337
351
|
storageKeyPrefix = 'oxy_session',
|
|
338
352
|
clientId: clientIdProp,
|
|
353
|
+
coldBoot = true,
|
|
339
354
|
onAuthStateChange,
|
|
340
355
|
onError,
|
|
341
356
|
}) => {
|
|
@@ -1058,7 +1073,20 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1058
1073
|
runColdBootRef.current = runColdBoot;
|
|
1059
1074
|
|
|
1060
1075
|
useEffect(() => {
|
|
1061
|
-
if (
|
|
1076
|
+
if (initialized) {
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
// IdP mode (`coldBoot={false}`): this provider is NOT the ecosystem session
|
|
1080
|
+
// authority, so it never runs the device-first restore and never opens the
|
|
1081
|
+
// signed-out device-state socket (`runColdBoot` → `onSignedOut` is the sole
|
|
1082
|
+
// place that socket starts). Resolve auth immediately as signed out so there
|
|
1083
|
+
// is no boot spinner; a deliberate sign-in still commits a normal session.
|
|
1084
|
+
if (!coldBoot) {
|
|
1085
|
+
setInitialized(true);
|
|
1086
|
+
markAuthResolved();
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
if (!storage) {
|
|
1062
1090
|
return;
|
|
1063
1091
|
}
|
|
1064
1092
|
setInitialized(true);
|
|
@@ -1067,7 +1095,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
|
|
|
1067
1095
|
logger('Cold boot failed', error);
|
|
1068
1096
|
}
|
|
1069
1097
|
});
|
|
1070
|
-
}, [runColdBoot, storage, initialized, logger]);
|
|
1098
|
+
}, [coldBoot, runColdBoot, storage, initialized, logger, markAuthResolved]);
|
|
1071
1099
|
|
|
1072
1100
|
// Exposed `refreshSessions`: re-bootstrap the server-authoritative device
|
|
1073
1101
|
// state and reproject — the manual counterpart to the realtime socket.
|