@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.
Files changed (74) hide show
  1. package/README.md +77 -98
  2. package/lib/commonjs/index.js +19 -0
  3. package/lib/commonjs/index.js.map +1 -1
  4. package/lib/commonjs/ui/components/OxyAccountDialog.js +62 -84
  5. package/lib/commonjs/ui/components/OxyAccountDialog.js.map +1 -1
  6. package/lib/commonjs/ui/components/OxyConsentScreen.js +461 -0
  7. package/lib/commonjs/ui/components/OxyConsentScreen.js.map +1 -0
  8. package/lib/commonjs/ui/components/OxyProvider.js +3 -1
  9. package/lib/commonjs/ui/components/OxyProvider.js.map +1 -1
  10. package/lib/commonjs/ui/components/OxySignInButton.js +176 -7
  11. package/lib/commonjs/ui/components/OxySignInButton.js.map +1 -1
  12. package/lib/commonjs/ui/components/oauthNavigation.js +73 -0
  13. package/lib/commonjs/ui/components/oauthNavigation.js.map +1 -0
  14. package/lib/commonjs/ui/context/OxyContext.js +16 -2
  15. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  16. package/lib/module/index.js +2 -2
  17. package/lib/module/index.js.map +1 -1
  18. package/lib/module/ui/components/OxyAccountDialog.js +63 -85
  19. package/lib/module/ui/components/OxyAccountDialog.js.map +1 -1
  20. package/lib/module/ui/components/OxyConsentScreen.js +454 -0
  21. package/lib/module/ui/components/OxyConsentScreen.js.map +1 -0
  22. package/lib/module/ui/components/OxyProvider.js +3 -1
  23. package/lib/module/ui/components/OxyProvider.js.map +1 -1
  24. package/lib/module/ui/components/OxySignInButton.js +177 -7
  25. package/lib/module/ui/components/OxySignInButton.js.map +1 -1
  26. package/lib/module/ui/components/oauthNavigation.js +70 -0
  27. package/lib/module/ui/components/oauthNavigation.js.map +1 -0
  28. package/lib/module/ui/context/OxyContext.js +16 -2
  29. package/lib/module/ui/context/OxyContext.js.map +1 -1
  30. package/lib/typescript/commonjs/index.d.ts +4 -1
  31. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  32. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts +18 -2
  33. package/lib/typescript/commonjs/ui/components/OxyAccountDialog.d.ts.map +1 -1
  34. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts +45 -0
  35. package/lib/typescript/commonjs/ui/components/OxyConsentScreen.d.ts.map +1 -0
  36. package/lib/typescript/commonjs/ui/components/OxyProvider.d.ts.map +1 -1
  37. package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts +56 -0
  38. package/lib/typescript/commonjs/ui/components/OxySignInButton.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts +36 -0
  40. package/lib/typescript/commonjs/ui/components/oauthNavigation.d.ts.map +1 -0
  41. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts +14 -0
  42. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts +1 -1
  44. package/lib/typescript/commonjs/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/ui/types/navigation.d.ts +18 -0
  46. package/lib/typescript/commonjs/ui/types/navigation.d.ts.map +1 -1
  47. package/lib/typescript/module/index.d.ts +4 -1
  48. package/lib/typescript/module/index.d.ts.map +1 -1
  49. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts +18 -2
  50. package/lib/typescript/module/ui/components/OxyAccountDialog.d.ts.map +1 -1
  51. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts +45 -0
  52. package/lib/typescript/module/ui/components/OxyConsentScreen.d.ts.map +1 -0
  53. package/lib/typescript/module/ui/components/OxyProvider.d.ts.map +1 -1
  54. package/lib/typescript/module/ui/components/OxySignInButton.d.ts +56 -0
  55. package/lib/typescript/module/ui/components/OxySignInButton.d.ts.map +1 -1
  56. package/lib/typescript/module/ui/components/oauthNavigation.d.ts +36 -0
  57. package/lib/typescript/module/ui/components/oauthNavigation.d.ts.map +1 -0
  58. package/lib/typescript/module/ui/context/OxyContext.d.ts +14 -0
  59. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  60. package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts +1 -1
  61. package/lib/typescript/module/ui/hooks/queries/useServicesQueries.d.ts.map +1 -1
  62. package/lib/typescript/module/ui/types/navigation.d.ts +18 -0
  63. package/lib/typescript/module/ui/types/navigation.d.ts.map +1 -1
  64. package/package.json +4 -4
  65. package/src/index.ts +12 -1
  66. package/src/ui/components/OxyAccountDialog.tsx +60 -85
  67. package/src/ui/components/OxyConsentScreen.tsx +456 -0
  68. package/src/ui/components/OxyProvider.tsx +2 -0
  69. package/src/ui/components/OxySignInButton.tsx +222 -6
  70. package/src/ui/components/__tests__/OxyConsentScreen.test.tsx +130 -0
  71. package/src/ui/components/oauthNavigation.ts +89 -0
  72. package/src/ui/context/OxyContext.tsx +30 -2
  73. package/src/ui/context/__tests__/OxyContext.coldBoot.test.tsx +165 -0
  74. 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
- // Open the unified account dialog on its sign-in view (same surface on web +
99
- // native), or defer to a caller-supplied handler.
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
- openAccountDialog('signin');
106
- }, [onPress, openAccountDialog]);
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 (!storage || initialized) {
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.