@oxyhq/core 2.3.1 → 2.3.2

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.
@@ -72,6 +72,14 @@ export interface ConsumeSsoReturnDeps {
72
72
  * fails. NEVER rethrown — `consumeSsoReturn` is total. Default: no-op.
73
73
  */
74
74
  onExchangeError?: (error: unknown) => void;
75
+ /**
76
+ * Notify URL-driven routers (Expo Router / React Navigation web) that the
77
+ * location changed via `history.replaceState`, which does NOT itself emit
78
+ * `popstate`. Default: dispatch a real `PopStateEvent` on `window` when
79
+ * present; no-op off-web. Called ONLY after a successful same-origin
80
+ * dest restore (never when the dest is rejected/absent). NEVER throws.
81
+ */
82
+ dispatchPopState?: () => void;
75
83
  }
76
84
  /**
77
85
  * Consume an SSO return: the commit-free, security-critical kernel of the
@@ -95,10 +103,16 @@ export interface ConsumeSsoReturnDeps {
95
103
  * outcome-independent attempted-flag (the load2 half of the loop proof).
96
104
  * - A throwing exchange is caught, reported via `onExchangeError`, and
97
105
  * treated exactly like "no session" (never loops, never rethrows).
98
- * - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
99
- * destination is restored from the DEST key same-origin only (an
100
- * attacker-planted cross-origin or relative-evil dest is rejected). The
101
- * DEST key is removed unconditionally.
106
+ * - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
107
+ * failed-exchange, no-sessionId) not just okif the page landed on
108
+ * {@link SSO_CALLBACK_PATH}, the real pre-bounce destination is restored
109
+ * from the DEST key so the user is never stranded on the internal callback
110
+ * path. Same-origin only (an attacker-planted cross-origin or relative-evil
111
+ * dest is rejected). The DEST key is removed unconditionally.
112
+ * - After a same-origin dest restore (which uses `history.replaceState`, that
113
+ * does NOT itself emit `popstate`), a synthetic `popstate` is dispatched so
114
+ * URL-driven routers (Expo Router / React Navigation web) re-sync to the
115
+ * restored route. It is NOT dispatched when the dest is rejected/absent.
102
116
  *
103
117
  * Total: this function NEVER throws. Off-web it is a no-op returning `null`.
104
118
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "2.3.1",
3
+ "version": "2.3.2",
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",
@@ -1,10 +1,15 @@
1
1
  /**
2
+ * @jest-environment jsdom
3
+ *
2
4
  * `consumeSsoReturn` — the commit-free, security-critical kernel of the
3
5
  * cross-domain SSO `sso-return` step.
4
6
  *
5
7
  * Every web seam is injected (storage / location / history / isWeb) so the
6
8
  * full CSRF / fragment-strip-order / exchange / dest-restore / loop-breaker
7
- * sequence is asserted deterministically without a DOM.
9
+ * sequence is asserted deterministically. The injected-seam tests do not need
10
+ * a DOM; the `default dispatchPopState` suite exercises the real
11
+ * `window.dispatchEvent(new PopStateEvent(...))` path, so this file runs under
12
+ * the jsdom environment.
8
13
  */
9
14
 
10
15
  import { consumeSsoReturn } from '../ssoReturn';
@@ -88,13 +93,15 @@ describe('consumeSsoReturn', () => {
88
93
 
89
94
  it('returns null for a non-oxy fragment without touching any flags', async () => {
90
95
  const oxy = okExchange();
91
- const storage = makeStorage();
96
+ const storage = makeStorage({ [ssoDestKey(ORIGIN)]: `${ORIGIN}/profile` });
92
97
  const history = makeHistory();
98
+ const dispatchPopState = jest.fn();
93
99
  const result = await consumeSsoReturn(oxy, {
94
100
  isWeb: () => true,
95
101
  storage,
96
102
  location: makeLocation({ hash: '#section=about' }),
97
103
  history,
104
+ dispatchPopState,
98
105
  });
99
106
 
100
107
  expect(result).toBeNull();
@@ -102,6 +109,9 @@ describe('consumeSsoReturn', () => {
102
109
  expect(storage.map.has(ssoNoSessionKey(ORIGIN))).toBe(false);
103
110
  // No fragment strip for an unrelated fragment.
104
111
  expect(history.calls).toHaveLength(0);
112
+ // Nothing was consumed — the dest key must be untouched and no popstate.
113
+ expect(storage.map.get(ssoDestKey(ORIGIN))).toBe(`${ORIGIN}/profile`);
114
+ expect(dispatchPopState).not.toHaveBeenCalled();
105
115
  });
106
116
 
107
117
  it('sets NO_SESSION and returns null on a "none" outcome', async () => {
@@ -310,13 +320,14 @@ describe('consumeSsoReturn', () => {
310
320
  });
311
321
 
312
322
  describe('dest restore', () => {
313
- it('restores a same-origin destination when on the callback path and removes the dest key', async () => {
323
+ it('restores a same-origin destination when on the callback path, removes the dest key, and dispatches popstate', async () => {
314
324
  const oxy = okExchange();
315
325
  const storage = makeStorage({
316
326
  [ssoStateKey(ORIGIN)]: 's',
317
327
  [ssoDestKey(ORIGIN)]: `${ORIGIN}/profile?x=1#frag`,
318
328
  });
319
329
  const history = makeHistory();
330
+ const dispatchPopState = jest.fn();
320
331
 
321
332
  const result = await consumeSsoReturn(oxy, {
322
333
  isWeb: () => true,
@@ -326,6 +337,7 @@ describe('consumeSsoReturn', () => {
326
337
  pathname: SSO_CALLBACK_PATH,
327
338
  }),
328
339
  history,
340
+ dispatchPopState,
329
341
  });
330
342
 
331
343
  expect(result).toEqual(SESSION);
@@ -333,6 +345,8 @@ describe('consumeSsoReturn', () => {
333
345
  const last = history.calls[history.calls.length - 1];
334
346
  expect(last?.[2]).toBe('/profile?x=1#frag');
335
347
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
348
+ // URL-driven routers must be told the location changed.
349
+ expect(dispatchPopState).toHaveBeenCalledTimes(1);
336
350
  });
337
351
 
338
352
  it('restores a relative same-origin destination (new URL(dest, origin))', async () => {
@@ -427,5 +441,184 @@ describe('consumeSsoReturn', () => {
427
441
  expect(history.calls[0]?.[2]).toBe('/already-here?a=1');
428
442
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
429
443
  });
444
+
445
+ it('restores dest + dispatches popstate on a "none" outcome (no-stranding regression)', async () => {
446
+ const oxy = okExchange();
447
+ const storage = makeStorage({
448
+ [ssoStateKey(ORIGIN)]: 's',
449
+ [ssoDestKey(ORIGIN)]: `${ORIGIN}/explore?x=1#sec`,
450
+ });
451
+ const history = makeHistory();
452
+ const dispatchPopState = jest.fn();
453
+
454
+ const result = await consumeSsoReturn(oxy, {
455
+ isWeb: () => true,
456
+ storage,
457
+ location: makeLocation({
458
+ hash: '#oxy_sso=none&state=s',
459
+ pathname: SSO_CALLBACK_PATH,
460
+ }),
461
+ history,
462
+ dispatchPopState,
463
+ });
464
+
465
+ expect(result).toBeNull();
466
+ expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
467
+ // Last replaceState targets the captured dest — the user is returned.
468
+ const last = history.calls[history.calls.length - 1];
469
+ expect(last?.[2]).toBe('/explore?x=1#sec');
470
+ expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
471
+ expect(dispatchPopState).toHaveBeenCalledTimes(1);
472
+ // Loop-breaker flags must still be set.
473
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
474
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
475
+ });
476
+
477
+ it('restores dest + dispatches popstate on an "error" outcome', async () => {
478
+ const oxy = okExchange();
479
+ const storage = makeStorage({
480
+ [ssoStateKey(ORIGIN)]: 's',
481
+ [ssoDestKey(ORIGIN)]: `${ORIGIN}/feed`,
482
+ });
483
+ const history = makeHistory();
484
+ const dispatchPopState = jest.fn();
485
+
486
+ const result = await consumeSsoReturn(oxy, {
487
+ isWeb: () => true,
488
+ storage,
489
+ location: makeLocation({
490
+ hash: '#oxy_sso=error&state=s',
491
+ pathname: SSO_CALLBACK_PATH,
492
+ }),
493
+ history,
494
+ dispatchPopState,
495
+ });
496
+
497
+ expect(result).toBeNull();
498
+ const last = history.calls[history.calls.length - 1];
499
+ expect(last?.[2]).toBe('/feed');
500
+ expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
501
+ expect(dispatchPopState).toHaveBeenCalledTimes(1);
502
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
503
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
504
+ });
505
+
506
+ it('restores dest + dispatches popstate on a state mismatch', async () => {
507
+ const oxy = okExchange();
508
+ const storage = makeStorage({
509
+ [ssoStateKey(ORIGIN)]: 'expected',
510
+ [ssoDestKey(ORIGIN)]: `${ORIGIN}/notifications`,
511
+ });
512
+ const history = makeHistory();
513
+ const dispatchPopState = jest.fn();
514
+
515
+ const result = await consumeSsoReturn(oxy, {
516
+ isWeb: () => true,
517
+ storage,
518
+ location: makeLocation({
519
+ hash: '#oxy_sso=ok&code=c&state=forged',
520
+ pathname: SSO_CALLBACK_PATH,
521
+ }),
522
+ history,
523
+ dispatchPopState,
524
+ });
525
+
526
+ expect(result).toBeNull();
527
+ expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
528
+ const last = history.calls[history.calls.length - 1];
529
+ expect(last?.[2]).toBe('/notifications');
530
+ expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
531
+ expect(dispatchPopState).toHaveBeenCalledTimes(1);
532
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
533
+ });
534
+
535
+ it('does NOT restore dest on a "none" outcome when not on the callback path', async () => {
536
+ const oxy = okExchange();
537
+ const storage = makeStorage({
538
+ [ssoStateKey(ORIGIN)]: 's',
539
+ [ssoDestKey(ORIGIN)]: `${ORIGIN}/should-not-apply`,
540
+ });
541
+ const history = makeHistory();
542
+ const dispatchPopState = jest.fn();
543
+
544
+ const result = await consumeSsoReturn(oxy, {
545
+ isWeb: () => true,
546
+ storage,
547
+ location: makeLocation({
548
+ hash: '#oxy_sso=none&state=s',
549
+ pathname: '/explore',
550
+ search: '?a=1',
551
+ }),
552
+ history,
553
+ dispatchPopState,
554
+ });
555
+
556
+ expect(result).toBeNull();
557
+ // Only the fragment strip ran — no dest restore off the callback path.
558
+ expect(history.calls).toHaveLength(1);
559
+ expect(history.calls[0]?.[2]).toBe('/explore?a=1');
560
+ expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
561
+ expect(dispatchPopState).not.toHaveBeenCalled();
562
+ });
563
+
564
+ it('rejects a cross-origin dest on a "none" outcome and does NOT dispatch popstate', async () => {
565
+ const oxy = okExchange();
566
+ const storage = makeStorage({
567
+ [ssoStateKey(ORIGIN)]: 's',
568
+ [ssoDestKey(ORIGIN)]: 'https://evil.example/phish',
569
+ });
570
+ const history = makeHistory();
571
+ const dispatchPopState = jest.fn();
572
+
573
+ const result = await consumeSsoReturn(oxy, {
574
+ isWeb: () => true,
575
+ storage,
576
+ location: makeLocation({
577
+ hash: '#oxy_sso=none&state=s',
578
+ pathname: SSO_CALLBACK_PATH,
579
+ }),
580
+ history,
581
+ dispatchPopState,
582
+ });
583
+
584
+ expect(result).toBeNull();
585
+ // Only the fragment-strip replaceState ran — the cross-origin dest is rejected.
586
+ expect(history.calls).toHaveLength(1);
587
+ expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
588
+ expect(dispatchPopState).not.toHaveBeenCalled();
589
+ });
590
+ });
591
+
592
+ describe('default dispatchPopState (jsdom)', () => {
593
+ it('fires a real popstate listener after a "none" dest restore on the callback path', async () => {
594
+ const oxy = okExchange();
595
+ const storage = makeStorage({
596
+ [ssoStateKey(ORIGIN)]: 's',
597
+ [ssoDestKey(ORIGIN)]: '/dashboard?tab=home',
598
+ });
599
+ const history = makeHistory();
600
+ const onPopState = jest.fn();
601
+ window.addEventListener('popstate', onPopState);
602
+
603
+ try {
604
+ const result = await consumeSsoReturn(oxy, {
605
+ isWeb: () => true,
606
+ storage,
607
+ location: makeLocation({
608
+ hash: '#oxy_sso=none&state=s',
609
+ pathname: SSO_CALLBACK_PATH,
610
+ }),
611
+ history,
612
+ // No injected dispatchPopState — exercise the real default.
613
+ });
614
+
615
+ expect(result).toBeNull();
616
+ const last = history.calls[history.calls.length - 1];
617
+ expect(last?.[2]).toBe('/dashboard?tab=home');
618
+ expect(onPopState).toHaveBeenCalledTimes(1);
619
+ } finally {
620
+ window.removeEventListener('popstate', onPopState);
621
+ }
622
+ });
430
623
  });
431
624
  });
@@ -130,6 +130,14 @@ export interface ConsumeSsoReturnDeps {
130
130
  * fails. NEVER rethrown — `consumeSsoReturn` is total. Default: no-op.
131
131
  */
132
132
  onExchangeError?: (error: unknown) => void;
133
+ /**
134
+ * Notify URL-driven routers (Expo Router / React Navigation web) that the
135
+ * location changed via `history.replaceState`, which does NOT itself emit
136
+ * `popstate`. Default: dispatch a real `PopStateEvent` on `window` when
137
+ * present; no-op off-web. Called ONLY after a successful same-origin
138
+ * dest restore (never when the dest is rejected/absent). NEVER throws.
139
+ */
140
+ dispatchPopState?: () => void;
133
141
  }
134
142
 
135
143
  /**
@@ -154,10 +162,16 @@ export interface ConsumeSsoReturnDeps {
154
162
  * outcome-independent attempted-flag (the load2 half of the loop proof).
155
163
  * - A throwing exchange is caught, reported via `onExchangeError`, and
156
164
  * treated exactly like "no session" (never loops, never rethrows).
157
- * - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
158
- * destination is restored from the DEST key same-origin only (an
159
- * attacker-planted cross-origin or relative-evil dest is rejected). The
160
- * DEST key is removed unconditionally.
165
+ * - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
166
+ * failed-exchange, no-sessionId) not just okif the page landed on
167
+ * {@link SSO_CALLBACK_PATH}, the real pre-bounce destination is restored
168
+ * from the DEST key so the user is never stranded on the internal callback
169
+ * path. Same-origin only (an attacker-planted cross-origin or relative-evil
170
+ * dest is rejected). The DEST key is removed unconditionally.
171
+ * - After a same-origin dest restore (which uses `history.replaceState`, that
172
+ * does NOT itself emit `popstate`), a synthetic `popstate` is dispatched so
173
+ * URL-driven routers (Expo Router / React Navigation web) re-sync to the
174
+ * restored route. It is NOT dispatched when the dest is rejected/absent.
161
175
  *
162
176
  * Total: this function NEVER throws. Off-web it is a no-op returning `null`.
163
177
  *
@@ -184,6 +198,22 @@ export async function consumeSsoReturn(
184
198
  const history = deps.history ?? window.history;
185
199
  const onExchangeError = deps.onExchangeError;
186
200
 
201
+ // Default: emit a synthetic `popstate` so URL-driven routers re-sync after a
202
+ // `history.replaceState` (which does NOT emit `popstate` on its own). Feature-
203
+ // detected end to end so it never throws in any environment.
204
+ const dispatchPopState =
205
+ deps.dispatchPopState ??
206
+ (() => {
207
+ if (typeof window === 'undefined' || typeof window.dispatchEvent !== 'function') {
208
+ return;
209
+ }
210
+ if (typeof PopStateEvent !== 'undefined') {
211
+ window.dispatchEvent(new PopStateEvent('popstate'));
212
+ } else if (typeof Event !== 'undefined') {
213
+ window.dispatchEvent(new Event('popstate'));
214
+ }
215
+ });
216
+
187
217
  const ret = parseSsoReturnFragment(location.hash);
188
218
  if (!ret) {
189
219
  // Not an oxy_sso fragment — nothing to do (do NOT touch any flags).
@@ -211,10 +241,39 @@ export async function consumeSsoReturn(
211
241
  storage.setItem(ssoAttemptedKey(origin), '1');
212
242
  };
213
243
 
244
+ // Restore the user's real pre-bounce destination so they are never stranded
245
+ // on the internal callback path — invoked on EVERY consumed outcome, not just
246
+ // success. Same-origin only — never honour a cross-origin/protocol-relative
247
+ // dest that could have been planted to redirect the user. The DEST key is
248
+ // removed unconditionally. After a successful same-origin restore a synthetic
249
+ // `popstate` is dispatched so URL-driven routers re-sync.
250
+ const restoreDest = (): void => {
251
+ if (location.pathname === SSO_CALLBACK_PATH) {
252
+ const dest = storage.getItem(ssoDestKey(origin));
253
+ if (dest) {
254
+ try {
255
+ const destUrl = new URL(dest, origin);
256
+ if (destUrl.origin === origin) {
257
+ history.replaceState(
258
+ null,
259
+ '',
260
+ destUrl.pathname + destUrl.search + destUrl.hash,
261
+ );
262
+ dispatchPopState();
263
+ }
264
+ } catch {
265
+ // Malformed stored destination — leave the URL on the callback path.
266
+ }
267
+ }
268
+ }
269
+ storage.removeItem(ssoDestKey(origin));
270
+ };
271
+
214
272
  if (ret.kind === 'none' || ret.kind === 'error') {
215
273
  // The central IdP had no session (or the bounce failed). Record it so we do
216
274
  // not bounce again this tab — the definitive loop breaker.
217
275
  markNoSession();
276
+ restoreDest();
218
277
  return null;
219
278
  }
220
279
 
@@ -222,6 +281,7 @@ export async function consumeSsoReturn(
222
281
  // Forged / replayed / stale fragment, or a malformed ok with no code. Treat
223
282
  // exactly like "no session": never exchange, never loop.
224
283
  markNoSession();
284
+ restoreDest();
225
285
  return null;
226
286
  }
227
287
 
@@ -231,37 +291,17 @@ export async function consumeSsoReturn(
231
291
  } catch (error) {
232
292
  onExchangeError?.(error);
233
293
  markNoSession();
294
+ restoreDest();
234
295
  return null;
235
296
  }
236
297
 
237
298
  if (!session?.sessionId) {
238
299
  markNoSession();
300
+ restoreDest();
239
301
  return null;
240
302
  }
241
303
 
242
- // If we landed on the internal callback path, restore the user's real
243
- // destination (captured at bounce time). Same-origin only — never honour a
244
- // cross-origin destination that could have been planted to redirect the
245
- // freshly signed-in user. `new URL(dest, origin)` tolerates relative dests
246
- // and is still re-checked against the page origin.
247
- if (location.pathname === SSO_CALLBACK_PATH) {
248
- const dest = storage.getItem(ssoDestKey(origin));
249
- if (dest) {
250
- try {
251
- const destUrl = new URL(dest, origin);
252
- if (destUrl.origin === origin) {
253
- history.replaceState(
254
- null,
255
- '',
256
- destUrl.pathname + destUrl.search + destUrl.hash,
257
- );
258
- }
259
- } catch {
260
- // Malformed stored destination — leave the URL on the callback path.
261
- }
262
- }
263
- }
264
- storage.removeItem(ssoDestKey(origin));
304
+ restoreDest();
265
305
 
266
306
  return session;
267
307
  }