@oxyhq/core 2.3.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +8 -5
- package/dist/cjs/mixins/OxyServices.auth.js +24 -1
- package/dist/cjs/mixins/OxyServices.fedcm.js +25 -6
- package/dist/cjs/mixins/OxyServices.popup.js +16 -0
- package/dist/cjs/utils/ssoReturn.js +54 -24
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +8 -5
- package/dist/esm/mixins/OxyServices.auth.js +24 -1
- package/dist/esm/mixins/OxyServices.fedcm.js +25 -6
- package/dist/esm/mixins/OxyServices.popup.js +16 -0
- package/dist/esm/utils/ssoReturn.js +54 -24
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/AuthManager.d.ts +3 -3
- package/dist/types/AuthManagerTypes.d.ts +17 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +20 -1
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -1
- package/dist/types/utils/ssoReturn.d.ts +18 -4
- package/package.json +1 -1
- package/src/AuthManager.ts +9 -5
- package/src/AuthManagerTypes.ts +18 -0
- package/src/index.ts +1 -0
- package/src/mixins/OxyServices.auth.ts +44 -1
- package/src/mixins/OxyServices.fedcm.ts +26 -6
- package/src/mixins/OxyServices.popup.ts +17 -0
- package/src/mixins/__tests__/fedcm.test.ts +63 -2
- package/src/mixins/__tests__/popup.test.ts +67 -0
- package/src/utils/__tests__/consumeSsoReturn.test.ts +196 -3
- package/src/utils/ssoReturn.ts +67 -27
|
@@ -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';
|
|
@@ -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
|
|
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
|
});
|
package/src/utils/ssoReturn.ts
CHANGED
|
@@ -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
|
-
* -
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
* 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.
|
|
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
|
-
|
|
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
|
}
|