@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.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/utils/ssoBounce.js +19 -1
- package/dist/cjs/utils/ssoReturn.js +60 -26
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/utils/ssoBounce.js +18 -1
- package/dist/esm/utils/ssoReturn.js +61 -27
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/utils/ssoBounce.d.ts +15 -1
- package/dist/types/utils/ssoReturn.d.ts +20 -6
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/utils/__tests__/consumeSsoReturn.test.ts +226 -3
- package/src/utils/__tests__/ssoBounce.test.ts +2 -0
- package/src/utils/ssoBounce.ts +19 -1
- package/src/utils/ssoReturn.ts +74 -29
package/dist/types/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
* -
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* DEST key is
|
|
106
|
+
* - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
|
|
107
|
+
* failed-exchange, no-sessionId) — not just ok — if 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
package/src/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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', () => {
|
package/src/utils/ssoBounce.ts
CHANGED
|
@@ -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
|
|
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
|
*
|
package/src/utils/ssoReturn.ts
CHANGED
|
@@ -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
|
|
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
|
-
* -
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* DEST key is
|
|
165
|
+
* - On EVERY consumed outcome (ok, none, error, state-mismatch, no-code,
|
|
166
|
+
* failed-exchange, no-sessionId) — not just ok — if 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
|
-
|
|
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
|
}
|