@oxyhq/core 2.3.0 → 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.
@@ -77,7 +77,7 @@ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './uti
77
77
  export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
78
78
  export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
79
79
  export { generateSsoState } from './mixins/OxyServices.sso';
80
- export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoNavigate, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce';
80
+ export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoNavigate, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce';
81
81
  export { runColdBoot } from './utils/coldBoot';
82
82
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
83
83
  export { packageInfo } from './constants/version';
@@ -26,11 +26,15 @@
26
26
  * the session, then restores the original destination.
27
27
  *
28
28
  * Loop proof (logged-out): first load all steps skip → `sso-bounce` sets
29
- * guard/state/dest and navigates; the IdP (no central session) returns
29
+ * guard/state/dest + the outcome-independent attempted-flag
30
+ * ({@link ssoAttemptedKey}) and navigates; the IdP (no central session) returns
30
31
  * `#oxy_sso=none`; the callback load's `sso-return` sees `none`, sets the
31
32
  * NO_SESSION flag ({@link ssoNoSessionKey}), and `sso-bounce` is then disabled.
32
33
  * Exactly ONE bounce, no loop. An interrupted bounce (user hit back
33
34
  * mid-redirect) self-heals once the {@link SSO_GUARD_TTL_MS} guard TTL lapses.
35
+ * The attempted-flag is the definitive, outcome-INDEPENDENT loop breaker: it is
36
+ * set pre-bounce so even if the return-side NO_SESSION write never lands, the
37
+ * bounce can never re-fire this tab after the self-heal TTL lapses.
34
38
  *
35
39
  * All state lives in `sessionStorage` (per tab, cleared on tab close) and is
36
40
  * keyed per-origin so two RPs hosted in the same browser never collide. The
@@ -65,6 +69,16 @@ export declare function ssoDestKey(origin: string): string;
65
69
  * fire again this tab — the definitive loop breaker.
66
70
  */
67
71
  export declare function ssoNoSessionKey(origin: string): string;
72
+ /**
73
+ * Per-origin, OUTCOME-INDEPENDENT once-guard. Set in `sessionStorage` BEFORE
74
+ * the terminal SSO bounce navigates. Gates the bounce so the silent
75
+ * cross-domain probe fires AT MOST ONCE per tab session — independent of
76
+ * whether the return-side NO_SESSION flag ever lands. The definitive loop
77
+ * breaker; survives the 30s self-heal `ssoGuardKey` TTL. Cleared only on an
78
+ * explicit sign-out/clear so a later cold boot (after the user signs in
79
+ * centrally) can probe again.
80
+ */
81
+ export declare function ssoAttemptedKey(origin: string): string;
68
82
  /**
69
83
  * Perform the terminal top-level SSO bounce navigation.
70
84
  *
@@ -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
@@ -91,14 +99,20 @@ export interface ConsumeSsoReturnDeps {
91
99
  * or a `Referer` header even if a later step throws.
92
100
  * - `state` must match (CSRF). A mismatch or a missing code sets the
93
101
  * NO_SESSION flag so `sso-bounce` is disabled (no rebounce loop).
94
- * - `none`/`error` outcomes set the NO_SESSION flag (the load2 half of the
95
- * loop proof).
102
+ * - `none`/`error` outcomes set BOTH the NO_SESSION flag and the
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.0",
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",
package/src/index.ts CHANGED
@@ -384,6 +384,7 @@ export {
384
384
  ssoGuardKey,
385
385
  ssoDestKey,
386
386
  ssoNoSessionKey,
387
+ ssoAttemptedKey,
387
388
  ssoNavigate,
388
389
  buildSsoBounceUrl,
389
390
  isCentralIdPOrigin,
@@ -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';
@@ -14,6 +19,7 @@ import {
14
19
  ssoGuardKey,
15
20
  ssoDestKey,
16
21
  ssoNoSessionKey,
22
+ ssoAttemptedKey,
17
23
  } from '../ssoBounce';
18
24
  import type { SessionLoginResponse } from '../../models/session';
19
25
 
@@ -87,13 +93,15 @@ describe('consumeSsoReturn', () => {
87
93
 
88
94
  it('returns null for a non-oxy fragment without touching any flags', async () => {
89
95
  const oxy = okExchange();
90
- const storage = makeStorage();
96
+ const storage = makeStorage({ [ssoDestKey(ORIGIN)]: `${ORIGIN}/profile` });
91
97
  const history = makeHistory();
98
+ const dispatchPopState = jest.fn();
92
99
  const result = await consumeSsoReturn(oxy, {
93
100
  isWeb: () => true,
94
101
  storage,
95
102
  location: makeLocation({ hash: '#section=about' }),
96
103
  history,
104
+ dispatchPopState,
97
105
  });
98
106
 
99
107
  expect(result).toBeNull();
@@ -101,6 +109,9 @@ describe('consumeSsoReturn', () => {
101
109
  expect(storage.map.has(ssoNoSessionKey(ORIGIN))).toBe(false);
102
110
  // No fragment strip for an unrelated fragment.
103
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();
104
115
  });
105
116
 
106
117
  it('sets NO_SESSION and returns null on a "none" outcome', async () => {
@@ -116,10 +127,31 @@ describe('consumeSsoReturn', () => {
116
127
  expect(result).toBeNull();
117
128
  expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
118
129
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
130
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
119
131
  expect(storage.map.has(ssoStateKey(ORIGIN))).toBe(false);
120
132
  expect(storage.map.has(ssoGuardKey(ORIGIN))).toBe(false);
121
133
  });
122
134
 
135
+ it('sets BOTH ssoNoSessionKey AND ssoAttemptedKey on a none outcome', async () => {
136
+ const oxy = okExchange();
137
+ const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
138
+ const history = makeHistory();
139
+ const result = await consumeSsoReturn(oxy, {
140
+ isWeb: () => true,
141
+ storage,
142
+ location: makeLocation({ hash: '#oxy_sso=none&state=s' }),
143
+ history,
144
+ });
145
+
146
+ expect(result).toBeNull();
147
+ expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
148
+ expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
149
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
150
+ // Fragment stripped FIRST (history.replaceState called).
151
+ expect(history.calls.length).toBeGreaterThanOrEqual(1);
152
+ expect(history.calls[0]?.[2]).toBe(SSO_CALLBACK_PATH);
153
+ });
154
+
123
155
  it('sets NO_SESSION and returns null on an "error" outcome', async () => {
124
156
  const oxy = okExchange();
125
157
  const storage = makeStorage({ [ssoStateKey(ORIGIN)]: 's' });
@@ -132,6 +164,7 @@ describe('consumeSsoReturn', () => {
132
164
 
133
165
  expect(result).toBeNull();
134
166
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
167
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
135
168
  });
136
169
 
137
170
  it('sets NO_SESSION and returns null on a state mismatch (CSRF)', async () => {
@@ -147,6 +180,7 @@ describe('consumeSsoReturn', () => {
147
180
  expect(result).toBeNull();
148
181
  expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
149
182
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
183
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
150
184
  });
151
185
 
152
186
  it('sets NO_SESSION and returns null when ok carries no code', async () => {
@@ -162,6 +196,7 @@ describe('consumeSsoReturn', () => {
162
196
  expect(result).toBeNull();
163
197
  expect(oxy.exchangeSsoCode).not.toHaveBeenCalled();
164
198
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
199
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
165
200
  });
166
201
 
167
202
  it('exchanges, returns the session, strips the fragment, and removes state/guard keys on ok', async () => {
@@ -187,6 +222,9 @@ describe('consumeSsoReturn', () => {
187
222
  expect(storage.map.has(ssoStateKey(ORIGIN))).toBe(false);
188
223
  expect(storage.map.has(ssoGuardKey(ORIGIN))).toBe(false);
189
224
  expect(storage.map.has(ssoNoSessionKey(ORIGIN))).toBe(false);
225
+ // The ok happy-path must NOT set the attempted-flag — a future sign-out
226
+ // should be able to re-probe the central IdP.
227
+ expect(storage.map.has(ssoAttemptedKey(ORIGIN))).toBe(false);
190
228
  // Fragment stripped to pathname+search (the first replaceState).
191
229
  expect(history.calls[0]?.[2]).toBe('/feed?tab=home');
192
230
  });
@@ -242,6 +280,7 @@ describe('consumeSsoReturn', () => {
242
280
  expect(result).toBeNull();
243
281
  expect(onExchangeError).toHaveBeenCalledWith(boom);
244
282
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
283
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
245
284
  });
246
285
 
247
286
  it('does not throw when the exchange throws and no onExchangeError hook is given', async () => {
@@ -277,16 +316,18 @@ describe('consumeSsoReturn', () => {
277
316
 
278
317
  expect(result).toBeNull();
279
318
  expect(storage.map.get(ssoNoSessionKey(ORIGIN))).toBe('1');
319
+ expect(storage.map.get(ssoAttemptedKey(ORIGIN))).toBe('1');
280
320
  });
281
321
 
282
322
  describe('dest restore', () => {
283
- 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 () => {
284
324
  const oxy = okExchange();
285
325
  const storage = makeStorage({
286
326
  [ssoStateKey(ORIGIN)]: 's',
287
327
  [ssoDestKey(ORIGIN)]: `${ORIGIN}/profile?x=1#frag`,
288
328
  });
289
329
  const history = makeHistory();
330
+ const dispatchPopState = jest.fn();
290
331
 
291
332
  const result = await consumeSsoReturn(oxy, {
292
333
  isWeb: () => true,
@@ -296,6 +337,7 @@ describe('consumeSsoReturn', () => {
296
337
  pathname: SSO_CALLBACK_PATH,
297
338
  }),
298
339
  history,
340
+ dispatchPopState,
299
341
  });
300
342
 
301
343
  expect(result).toEqual(SESSION);
@@ -303,6 +345,8 @@ describe('consumeSsoReturn', () => {
303
345
  const last = history.calls[history.calls.length - 1];
304
346
  expect(last?.[2]).toBe('/profile?x=1#frag');
305
347
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
348
+ // URL-driven routers must be told the location changed.
349
+ expect(dispatchPopState).toHaveBeenCalledTimes(1);
306
350
  });
307
351
 
308
352
  it('restores a relative same-origin destination (new URL(dest, origin))', async () => {
@@ -397,5 +441,184 @@ describe('consumeSsoReturn', () => {
397
441
  expect(history.calls[0]?.[2]).toBe('/already-here?a=1');
398
442
  expect(storage.map.has(ssoDestKey(ORIGIN))).toBe(false);
399
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
+ });
400
623
  });
401
624
  });
@@ -14,6 +14,7 @@ import {
14
14
  ssoGuardKey,
15
15
  ssoDestKey,
16
16
  ssoNoSessionKey,
17
+ ssoAttemptedKey,
17
18
  buildSsoBounceUrl,
18
19
  isCentralIdPOrigin,
19
20
  guardActive,
@@ -35,6 +36,7 @@ describe('per-origin key builders', () => {
35
36
  expect(ssoGuardKey(origin)).toBe('oxy_sso_guard:https://mention.earth');
36
37
  expect(ssoDestKey(origin)).toBe('oxy_sso_dest:https://mention.earth');
37
38
  expect(ssoNoSessionKey(origin)).toBe('oxy_sso_no_session:https://mention.earth');
39
+ expect(ssoAttemptedKey(origin)).toBe('oxy_sso_attempted:https://mention.earth');
38
40
  });
39
41
 
40
42
  it('namespaces keys per origin so two RPs never collide', () => {
@@ -26,11 +26,15 @@
26
26
  * the session, then restores the original destination.
27
27
  *
28
28
  * Loop proof (logged-out): first load all steps skip → `sso-bounce` sets
29
- * guard/state/dest and navigates; the IdP (no central session) returns
29
+ * guard/state/dest + the outcome-independent attempted-flag
30
+ * ({@link ssoAttemptedKey}) and navigates; the IdP (no central session) returns
30
31
  * `#oxy_sso=none`; the callback load's `sso-return` sees `none`, sets the
31
32
  * NO_SESSION flag ({@link ssoNoSessionKey}), and `sso-bounce` is then disabled.
32
33
  * Exactly ONE bounce, no loop. An interrupted bounce (user hit back
33
34
  * mid-redirect) self-heals once the {@link SSO_GUARD_TTL_MS} guard TTL lapses.
35
+ * The attempted-flag is the definitive, outcome-INDEPENDENT loop breaker: it is
36
+ * set pre-bounce so even if the return-side NO_SESSION write never lands, the
37
+ * bounce can never re-fire this tab after the self-heal TTL lapses.
34
38
  *
35
39
  * All state lives in `sessionStorage` (per tab, cleared on tab close) and is
36
40
  * keyed per-origin so two RPs hosted in the same browser never collide. The
@@ -62,6 +66,7 @@ const STATE_KEY_PREFIX = 'oxy_sso_state:';
62
66
  const GUARD_KEY_PREFIX = 'oxy_sso_guard:';
63
67
  const DEST_KEY_PREFIX = 'oxy_sso_dest:';
64
68
  const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
69
+ const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
65
70
 
66
71
  /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
67
72
  export function ssoStateKey(origin: string): string {
@@ -87,6 +92,19 @@ export function ssoNoSessionKey(origin: string): string {
87
92
  return `${NO_SESSION_KEY_PREFIX}${origin}`;
88
93
  }
89
94
 
95
+ /**
96
+ * Per-origin, OUTCOME-INDEPENDENT once-guard. Set in `sessionStorage` BEFORE
97
+ * the terminal SSO bounce navigates. Gates the bounce so the silent
98
+ * cross-domain probe fires AT MOST ONCE per tab session — independent of
99
+ * whether the return-side NO_SESSION flag ever lands. The definitive loop
100
+ * breaker; survives the 30s self-heal `ssoGuardKey` TTL. Cleared only on an
101
+ * explicit sign-out/clear so a later cold boot (after the user signs in
102
+ * centrally) can probe again.
103
+ */
104
+ export function ssoAttemptedKey(origin: string): string {
105
+ return `${ATTEMPTED_KEY_PREFIX}${origin}`;
106
+ }
107
+
90
108
  /**
91
109
  * Perform the terminal top-level SSO bounce navigation.
92
110
  *
@@ -28,6 +28,7 @@ import {
28
28
  ssoGuardKey,
29
29
  ssoDestKey,
30
30
  ssoNoSessionKey,
31
+ ssoAttemptedKey,
31
32
  } from './ssoBounce';
32
33
 
33
34
  /**
@@ -129,6 +130,14 @@ export interface ConsumeSsoReturnDeps {
129
130
  * fails. NEVER rethrown — `consumeSsoReturn` is total. Default: no-op.
130
131
  */
131
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;
132
141
  }
133
142
 
134
143
  /**
@@ -149,14 +158,20 @@ export interface ConsumeSsoReturnDeps {
149
158
  * or a `Referer` header even if a later step throws.
150
159
  * - `state` must match (CSRF). A mismatch or a missing code sets the
151
160
  * NO_SESSION flag so `sso-bounce` is disabled (no rebounce loop).
152
- * - `none`/`error` outcomes set the NO_SESSION flag (the load2 half of the
153
- * loop proof).
161
+ * - `none`/`error` outcomes set BOTH the NO_SESSION flag and the
162
+ * outcome-independent attempted-flag (the load2 half of the loop proof).
154
163
  * - A throwing exchange is caught, reported via `onExchangeError`, and
155
164
  * treated exactly like "no session" (never loops, never rethrows).
156
- * - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
157
- * destination is restored from the DEST key same-origin only (an
158
- * attacker-planted cross-origin or relative-evil dest is rejected). The
159
- * 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.
160
175
  *
161
176
  * Total: this function NEVER throws. Off-web it is a no-op returning `null`.
162
177
  *
@@ -183,6 +198,22 @@ export async function consumeSsoReturn(
183
198
  const history = deps.history ?? window.history;
184
199
  const onExchangeError = deps.onExchangeError;
185
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
+
186
217
  const ret = parseSsoReturnFragment(location.hash);
187
218
  if (!ret) {
188
219
  // Not an oxy_sso fragment — nothing to do (do NOT touch any flags).
@@ -204,12 +235,45 @@ export async function consumeSsoReturn(
204
235
 
205
236
  const markNoSession = () => {
206
237
  storage.setItem(ssoNoSessionKey(origin), '1');
238
+ // A return was consumed, so the probe definitively happened. Set the
239
+ // outcome-independent attempted-flag too so the bounce can never re-fire
240
+ // even if some consumer path skipped setting it pre-bounce.
241
+ storage.setItem(ssoAttemptedKey(origin), '1');
242
+ };
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));
207
270
  };
208
271
 
209
272
  if (ret.kind === 'none' || ret.kind === 'error') {
210
273
  // The central IdP had no session (or the bounce failed). Record it so we do
211
274
  // not bounce again this tab — the definitive loop breaker.
212
275
  markNoSession();
276
+ restoreDest();
213
277
  return null;
214
278
  }
215
279
 
@@ -217,6 +281,7 @@ export async function consumeSsoReturn(
217
281
  // Forged / replayed / stale fragment, or a malformed ok with no code. Treat
218
282
  // exactly like "no session": never exchange, never loop.
219
283
  markNoSession();
284
+ restoreDest();
220
285
  return null;
221
286
  }
222
287
 
@@ -226,37 +291,17 @@ export async function consumeSsoReturn(
226
291
  } catch (error) {
227
292
  onExchangeError?.(error);
228
293
  markNoSession();
294
+ restoreDest();
229
295
  return null;
230
296
  }
231
297
 
232
298
  if (!session?.sessionId) {
233
299
  markNoSession();
300
+ restoreDest();
234
301
  return null;
235
302
  }
236
303
 
237
- // If we landed on the internal callback path, restore the user's real
238
- // destination (captured at bounce time). Same-origin only — never honour a
239
- // cross-origin destination that could have been planted to redirect the
240
- // freshly signed-in user. `new URL(dest, origin)` tolerates relative dests
241
- // and is still re-checked against the page origin.
242
- if (location.pathname === SSO_CALLBACK_PATH) {
243
- const dest = storage.getItem(ssoDestKey(origin));
244
- if (dest) {
245
- try {
246
- const destUrl = new URL(dest, origin);
247
- if (destUrl.origin === origin) {
248
- history.replaceState(
249
- null,
250
- '',
251
- destUrl.pathname + destUrl.search + destUrl.hash,
252
- );
253
- }
254
- } catch {
255
- // Malformed stored destination — leave the URL on the callback path.
256
- }
257
- }
258
- }
259
- storage.removeItem(ssoDestKey(origin));
304
+ restoreDest();
260
305
 
261
306
  return session;
262
307
  }