@oxyhq/core 3.4.4 → 3.4.5

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.
@@ -80,7 +80,7 @@ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './uti
80
80
  export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
81
81
  export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
82
82
  export { generateSsoState } from './mixins/OxyServices.sso';
83
- export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoNavigate, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce';
83
+ export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce';
84
84
  export { runColdBoot } from './utils/coldBoot';
85
85
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
86
86
  export { packageInfo } from './constants/version';
@@ -79,6 +79,29 @@ export declare function ssoNoSessionKey(origin: string): string;
79
79
  * centrally) can probe again.
80
80
  */
81
81
  export declare function ssoAttemptedKey(origin: string): string;
82
+ /**
83
+ * Per-origin marker written by the pre-hydration callback bootstrap.
84
+ *
85
+ * Static Expo exports render unknown paths as `+not-found`; on
86
+ * `/__oxy/sso-callback` that can fail hydration before the React provider has a
87
+ * chance to run `consumeSsoReturn`. The bootstrap runs in the HTML head, moves
88
+ * the URL to a hydratable route while preserving the SSO fragment, and writes
89
+ * this marker so `consumeSsoReturn` still restores the original destination as
90
+ * if the page were physically on the callback path.
91
+ */
92
+ export declare function ssoCallbackBootstrapKey(origin: string): string;
93
+ /**
94
+ * Inline script for Expo/static web apps.
95
+ *
96
+ * Must run before the app bundle hydrates. It is intentionally tiny and
97
+ * dependency-free: if the browser lands on the internal callback route with an
98
+ * Oxy SSO fragment, it marks the handoff and rewrites the path to `/` while
99
+ * preserving `#oxy_sso=...`. The normal SDK cold-boot `sso-return` step then
100
+ * consumes the fragment from a route that can hydrate. If the internal route is
101
+ * reached without a valid SSO fragment, it leaves the route via a hard root
102
+ * navigation because there is no session material to preserve.
103
+ */
104
+ export declare function getSsoCallbackBootstrapScript(): string;
82
105
  /**
83
106
  * Perform the terminal top-level SSO bounce navigation.
84
107
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "3.4.4",
3
+ "version": "3.4.5",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
package/src/index.ts CHANGED
@@ -458,7 +458,9 @@ export {
458
458
  ssoDestKey,
459
459
  ssoNoSessionKey,
460
460
  ssoAttemptedKey,
461
+ ssoCallbackBootstrapKey,
461
462
  ssoNavigate,
463
+ getSsoCallbackBootstrapScript,
462
464
  buildSsoBounceUrl,
463
465
  isCentralIdPOrigin,
464
466
  guardActive,
@@ -7,7 +7,42 @@
7
7
  * `null` for anything that is not an oxy_sso fragment.
8
8
  */
9
9
 
10
- import { parseSsoReturnFragment } from '../ssoReturn';
10
+ import type { SessionLoginResponse } from '../../models/session';
11
+ import { consumeSsoReturn, parseSsoReturnFragment } from '../ssoReturn';
12
+ import {
13
+ getSsoCallbackBootstrapScript,
14
+ ssoAttemptedKey,
15
+ ssoCallbackBootstrapKey,
16
+ ssoDestKey,
17
+ ssoNoSessionKey,
18
+ ssoStateKey,
19
+ } from '../ssoBounce';
20
+
21
+ class MemorySsoStorage implements Pick<Storage, 'getItem' | 'setItem' | 'removeItem'> {
22
+ private readonly values = new Map<string, string>();
23
+
24
+ getItem(key: string): string | null {
25
+ return this.values.get(key) ?? null;
26
+ }
27
+
28
+ setItem(key: string, value: string): void {
29
+ this.values.set(key, value);
30
+ }
31
+
32
+ removeItem(key: string): void {
33
+ this.values.delete(key);
34
+ }
35
+ }
36
+
37
+ const ORIGIN = 'https://app.mention.earth';
38
+
39
+ const exchangedSession: SessionLoginResponse = {
40
+ sessionId: 'sess_sso',
41
+ deviceId: 'device_sso',
42
+ accessToken: 'access_sso',
43
+ expiresAt: '2030-01-01T00:00:00.000Z',
44
+ user: { id: 'user_sso', username: 'sso-user' },
45
+ };
11
46
 
12
47
  describe('parseSsoReturnFragment', () => {
13
48
  describe('ok', () => {
@@ -118,3 +153,99 @@ describe('parseSsoReturnFragment', () => {
118
153
  });
119
154
  });
120
155
  });
156
+
157
+ describe('consumeSsoReturn pre-hydration callback bootstrap', () => {
158
+ it('continues an ok callback after the HTML bootstrap moved the URL to a hydratable route', async () => {
159
+ const storage = new MemorySsoStorage();
160
+ const replaceStateCalls: string[] = [];
161
+ const dispatchPopState = jest.fn();
162
+ const hardRedirect = jest.fn();
163
+ const exchangeSsoCode = jest.fn(async (): Promise<SessionLoginResponse> => exchangedSession);
164
+
165
+ storage.setItem(ssoStateKey(ORIGIN), 'state-ok');
166
+ storage.setItem(ssoDestKey(ORIGIN), `${ORIGIN}/explore?tab=home#top`);
167
+ storage.setItem(ssoCallbackBootstrapKey(ORIGIN), '1');
168
+
169
+ const session = await consumeSsoReturn(
170
+ { exchangeSsoCode },
171
+ {
172
+ isWeb: () => true,
173
+ storage,
174
+ location: {
175
+ hash: '#oxy_sso=ok&code=opaque-code&state=state-ok',
176
+ origin: ORIGIN,
177
+ pathname: '/',
178
+ search: '',
179
+ },
180
+ history: {
181
+ replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
182
+ replaceStateCalls.push(String(url ?? ''));
183
+ },
184
+ },
185
+ dispatchPopState,
186
+ hardRedirect,
187
+ },
188
+ );
189
+
190
+ expect(session).toBe(exchangedSession);
191
+ expect(exchangeSsoCode).toHaveBeenCalledWith('opaque-code');
192
+ expect(replaceStateCalls).toEqual(['/', '/explore?tab=home#top']);
193
+ expect(dispatchPopState).toHaveBeenCalledTimes(1);
194
+ expect(hardRedirect).not.toHaveBeenCalled();
195
+ expect(storage.getItem(ssoCallbackBootstrapKey(ORIGIN))).toBeNull();
196
+ expect(storage.getItem(ssoDestKey(ORIGIN))).toBeNull();
197
+ expect(storage.getItem(ssoNoSessionKey(ORIGIN))).toBeNull();
198
+ });
199
+
200
+ it('leaves a bootstrapped none callback with loop breakers set and no exchange', async () => {
201
+ const storage = new MemorySsoStorage();
202
+ const replaceStateCalls: string[] = [];
203
+ const dispatchPopState = jest.fn();
204
+ const hardRedirect = jest.fn();
205
+ const exchangeSsoCode = jest.fn(async (): Promise<SessionLoginResponse> => exchangedSession);
206
+
207
+ storage.setItem(ssoStateKey(ORIGIN), 'state-none');
208
+ storage.setItem(ssoDestKey(ORIGIN), `${ORIGIN}/library`);
209
+ storage.setItem(ssoCallbackBootstrapKey(ORIGIN), '1');
210
+
211
+ const session = await consumeSsoReturn(
212
+ { exchangeSsoCode },
213
+ {
214
+ isWeb: () => true,
215
+ storage,
216
+ location: {
217
+ hash: '#oxy_sso=none&state=state-none',
218
+ origin: ORIGIN,
219
+ pathname: '/',
220
+ search: '',
221
+ },
222
+ history: {
223
+ replaceState: (_data: unknown, _unused: string, url?: string | URL | null): void => {
224
+ replaceStateCalls.push(String(url ?? ''));
225
+ },
226
+ },
227
+ dispatchPopState,
228
+ hardRedirect,
229
+ },
230
+ );
231
+
232
+ expect(session).toBeNull();
233
+ expect(exchangeSsoCode).not.toHaveBeenCalled();
234
+ expect(replaceStateCalls).toEqual(['/']);
235
+ expect(dispatchPopState).not.toHaveBeenCalled();
236
+ expect(hardRedirect).toHaveBeenCalledWith(`${ORIGIN}/library`);
237
+ expect(storage.getItem(ssoCallbackBootstrapKey(ORIGIN))).toBeNull();
238
+ expect(storage.getItem(ssoDestKey(ORIGIN))).toBeNull();
239
+ expect(storage.getItem(ssoNoSessionKey(ORIGIN))).toBe('1');
240
+ expect(storage.getItem(ssoAttemptedKey(ORIGIN))).toBe('1');
241
+ });
242
+
243
+ it('exposes a pre-hydration script that preserves the SSO fragment', () => {
244
+ const script = getSsoCallbackBootstrapScript();
245
+
246
+ expect(script).toContain('/__oxy/sso-callback');
247
+ expect(script).toContain('oxy_sso=');
248
+ expect(script).toContain('window.history.replaceState');
249
+ expect(script).toContain('window.location.hash');
250
+ });
251
+ });
@@ -67,6 +67,7 @@ const GUARD_KEY_PREFIX = 'oxy_sso_guard:';
67
67
  const DEST_KEY_PREFIX = 'oxy_sso_dest:';
68
68
  const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
69
69
  const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
70
+ const CALLBACK_BOOTSTRAP_KEY_PREFIX = 'oxy_sso_callback_bootstrap:';
70
71
 
71
72
  /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
72
73
  export function ssoStateKey(origin: string): string {
@@ -105,6 +106,38 @@ export function ssoAttemptedKey(origin: string): string {
105
106
  return `${ATTEMPTED_KEY_PREFIX}${origin}`;
106
107
  }
107
108
 
109
+ /**
110
+ * Per-origin marker written by the pre-hydration callback bootstrap.
111
+ *
112
+ * Static Expo exports render unknown paths as `+not-found`; on
113
+ * `/__oxy/sso-callback` that can fail hydration before the React provider has a
114
+ * chance to run `consumeSsoReturn`. The bootstrap runs in the HTML head, moves
115
+ * the URL to a hydratable route while preserving the SSO fragment, and writes
116
+ * this marker so `consumeSsoReturn` still restores the original destination as
117
+ * if the page were physically on the callback path.
118
+ */
119
+ export function ssoCallbackBootstrapKey(origin: string): string {
120
+ return `${CALLBACK_BOOTSTRAP_KEY_PREFIX}${origin}`;
121
+ }
122
+
123
+ /**
124
+ * Inline script for Expo/static web apps.
125
+ *
126
+ * Must run before the app bundle hydrates. It is intentionally tiny and
127
+ * dependency-free: if the browser lands on the internal callback route with an
128
+ * Oxy SSO fragment, it marks the handoff and rewrites the path to `/` while
129
+ * preserving `#oxy_sso=...`. The normal SDK cold-boot `sso-return` step then
130
+ * consumes the fragment from a route that can hydrate. If the internal route is
131
+ * reached without a valid SSO fragment, it leaves the route via a hard root
132
+ * navigation because there is no session material to preserve.
133
+ */
134
+ export function getSsoCallbackBootstrapScript(): string {
135
+ const callbackPath = JSON.stringify(SSO_CALLBACK_PATH);
136
+ const bootstrapPrefix = JSON.stringify(CALLBACK_BOOTSTRAP_KEY_PREFIX);
137
+
138
+ return `(function(){var p=${callbackPath};if(window.location.pathname!==p)return;var h=window.location.hash||"";if(!/(?:^#|&)oxy_sso=(?:ok|none|error)(?:&|$)/.test(h)){window.location.replace("/");return;}try{window.sessionStorage.setItem(${bootstrapPrefix}+window.location.origin,"1");}catch(e){window.__oxySsoCallbackBootstrapError=e instanceof Error?e.message:String(e);}try{window.history.replaceState(null,"","/"+h);}catch(e){window.__oxySsoCallbackBootstrapError=e instanceof Error?e.message:String(e);window.location.replace("/"+h);}})();`;
139
+ }
140
+
108
141
  /**
109
142
  * Perform the terminal top-level SSO bounce navigation.
110
143
  *
@@ -29,6 +29,7 @@ import {
29
29
  ssoDestKey,
30
30
  ssoNoSessionKey,
31
31
  ssoAttemptedKey,
32
+ ssoCallbackBootstrapKey,
32
33
  } from './ssoBounce';
33
34
 
34
35
  /**
@@ -257,6 +258,8 @@ export async function consumeSsoReturn(
257
258
  }
258
259
 
259
260
  const origin = location.origin;
261
+ const callbackBootstrapKey = ssoCallbackBootstrapKey(origin);
262
+ const wasCallbackBootstrapped = storage.getItem(callbackBootstrapKey) === '1';
260
263
  const expectedState = storage.getItem(ssoStateKey(origin));
261
264
  const stateOk = !!ret.state && !!expectedState && ret.state === expectedState;
262
265
 
@@ -286,7 +289,8 @@ export async function consumeSsoReturn(
286
289
  // (so it can be fed to either `history.replaceState` or a `hardRedirect`),
287
290
  // or `null` when the page is not on the callback path (nothing to leave).
288
291
  const consumeCallbackTarget = (): string | null => {
289
- if (location.pathname !== SSO_CALLBACK_PATH) {
292
+ storage.removeItem(callbackBootstrapKey);
293
+ if (location.pathname !== SSO_CALLBACK_PATH && !wasCallbackBootstrapped) {
290
294
  // Not on the callback path — still drop the dest key (consumed) but there
291
295
  // is nothing to navigate away from.
292
296
  storage.removeItem(ssoDestKey(origin));