@oxyhq/core 5.2.0 → 5.3.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.
Files changed (117) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +32 -42
  3. package/dist/cjs/i18n/locales/ar-SA.json +1 -0
  4. package/dist/cjs/i18n/locales/ca-ES.json +1 -0
  5. package/dist/cjs/i18n/locales/de-DE.json +1 -0
  6. package/dist/cjs/i18n/locales/en-US.json +1 -0
  7. package/dist/cjs/i18n/locales/es-ES.json +1 -0
  8. package/dist/cjs/i18n/locales/fr-FR.json +1 -0
  9. package/dist/cjs/i18n/locales/it-IT.json +1 -0
  10. package/dist/cjs/i18n/locales/ja-JP.json +1 -0
  11. package/dist/cjs/i18n/locales/ko-KR.json +1 -0
  12. package/dist/cjs/i18n/locales/locales/ar-SA.json +1 -0
  13. package/dist/cjs/i18n/locales/locales/ca-ES.json +1 -0
  14. package/dist/cjs/i18n/locales/locales/de-DE.json +1 -0
  15. package/dist/cjs/i18n/locales/locales/en-US.json +1 -0
  16. package/dist/cjs/i18n/locales/locales/es-ES.json +1 -0
  17. package/dist/cjs/i18n/locales/locales/fr-FR.json +1 -0
  18. package/dist/cjs/i18n/locales/locales/it-IT.json +1 -0
  19. package/dist/cjs/i18n/locales/locales/ja-JP.json +1 -0
  20. package/dist/cjs/i18n/locales/locales/ko-KR.json +1 -0
  21. package/dist/cjs/i18n/locales/locales/pt-PT.json +1 -0
  22. package/dist/cjs/i18n/locales/locales/zh-CN.json +1 -0
  23. package/dist/cjs/i18n/locales/pt-PT.json +1 -0
  24. package/dist/cjs/i18n/locales/zh-CN.json +1 -0
  25. package/dist/cjs/index.js +24 -2
  26. package/dist/cjs/mixins/OxyServices.sso.js +36 -0
  27. package/dist/cjs/mixins/OxyServices.user.js +24 -0
  28. package/dist/cjs/server/index.js +13 -1
  29. package/dist/cjs/session/SessionClient.js +142 -0
  30. package/dist/cjs/session/createSessionClient.js +26 -0
  31. package/dist/cjs/session/projectSessionState.js +75 -0
  32. package/dist/cjs/session/sessionClientHost.js +30 -0
  33. package/dist/cjs/session/socketLoader.js +55 -0
  34. package/dist/cjs/utils/cache.js +2 -0
  35. package/dist/cjs/utils/ssoBounce.js +9 -9
  36. package/dist/cjs/utils/ssoEstablish.js +110 -0
  37. package/dist/esm/.tsbuildinfo +1 -1
  38. package/dist/esm/CrossDomainAuth.js +32 -42
  39. package/dist/esm/i18n/locales/ar-SA.json +1 -0
  40. package/dist/esm/i18n/locales/ca-ES.json +1 -0
  41. package/dist/esm/i18n/locales/de-DE.json +1 -0
  42. package/dist/esm/i18n/locales/en-US.json +1 -0
  43. package/dist/esm/i18n/locales/es-ES.json +1 -0
  44. package/dist/esm/i18n/locales/fr-FR.json +1 -0
  45. package/dist/esm/i18n/locales/it-IT.json +1 -0
  46. package/dist/esm/i18n/locales/ja-JP.json +1 -0
  47. package/dist/esm/i18n/locales/ko-KR.json +1 -0
  48. package/dist/esm/i18n/locales/locales/ar-SA.json +1 -0
  49. package/dist/esm/i18n/locales/locales/ca-ES.json +1 -0
  50. package/dist/esm/i18n/locales/locales/de-DE.json +1 -0
  51. package/dist/esm/i18n/locales/locales/en-US.json +1 -0
  52. package/dist/esm/i18n/locales/locales/es-ES.json +1 -0
  53. package/dist/esm/i18n/locales/locales/fr-FR.json +1 -0
  54. package/dist/esm/i18n/locales/locales/it-IT.json +1 -0
  55. package/dist/esm/i18n/locales/locales/ja-JP.json +1 -0
  56. package/dist/esm/i18n/locales/locales/ko-KR.json +1 -0
  57. package/dist/esm/i18n/locales/locales/pt-PT.json +1 -0
  58. package/dist/esm/i18n/locales/locales/zh-CN.json +1 -0
  59. package/dist/esm/i18n/locales/pt-PT.json +1 -0
  60. package/dist/esm/i18n/locales/zh-CN.json +1 -0
  61. package/dist/esm/index.js +14 -0
  62. package/dist/esm/mixins/OxyServices.sso.js +36 -0
  63. package/dist/esm/mixins/OxyServices.user.js +24 -0
  64. package/dist/esm/server/index.js +10 -0
  65. package/dist/esm/session/SessionClient.js +138 -0
  66. package/dist/esm/session/createSessionClient.js +23 -0
  67. package/dist/esm/session/projectSessionState.js +69 -0
  68. package/dist/esm/session/sessionClientHost.js +27 -0
  69. package/dist/esm/session/socketLoader.js +19 -0
  70. package/dist/esm/utils/cache.js +2 -0
  71. package/dist/esm/utils/ssoBounce.js +9 -9
  72. package/dist/esm/utils/ssoEstablish.js +107 -0
  73. package/dist/types/.tsbuildinfo +1 -1
  74. package/dist/types/CrossDomainAuth.d.ts +33 -13
  75. package/dist/types/index.d.ts +7 -0
  76. package/dist/types/mixins/OxyServices.sso.d.ts +24 -0
  77. package/dist/types/mixins/OxyServices.user.d.ts +14 -0
  78. package/dist/types/server/index.d.ts +2 -0
  79. package/dist/types/session/SessionClient.d.ts +55 -0
  80. package/dist/types/session/createSessionClient.d.ts +23 -0
  81. package/dist/types/session/projectSessionState.d.ts +43 -0
  82. package/dist/types/session/sessionClientHost.d.ts +18 -0
  83. package/dist/types/session/socketLoader.d.ts +9 -0
  84. package/dist/types/utils/ssoBounce.d.ts +9 -9
  85. package/dist/types/utils/ssoEstablish.d.ts +85 -0
  86. package/package.json +1 -1
  87. package/src/CrossDomainAuth.ts +33 -44
  88. package/src/__tests__/crossDomainAuth.test.ts +33 -16
  89. package/src/i18n/locales/ar-SA.json +1 -0
  90. package/src/i18n/locales/ca-ES.json +1 -0
  91. package/src/i18n/locales/de-DE.json +1 -0
  92. package/src/i18n/locales/en-US.json +1 -0
  93. package/src/i18n/locales/es-ES.json +1 -0
  94. package/src/i18n/locales/fr-FR.json +1 -0
  95. package/src/i18n/locales/it-IT.json +1 -0
  96. package/src/i18n/locales/ja-JP.json +1 -0
  97. package/src/i18n/locales/ko-KR.json +1 -0
  98. package/src/i18n/locales/pt-PT.json +1 -0
  99. package/src/i18n/locales/zh-CN.json +1 -0
  100. package/src/index.ts +24 -0
  101. package/src/mixins/OxyServices.sso.ts +55 -0
  102. package/src/mixins/OxyServices.user.ts +26 -0
  103. package/src/mixins/__tests__/sso.test.ts +41 -0
  104. package/src/server/index.ts +12 -0
  105. package/src/session/SessionClient.ts +175 -0
  106. package/src/session/__tests__/SessionClient.rest.test.ts +79 -0
  107. package/src/session/__tests__/SessionClient.socket.test.ts +132 -0
  108. package/src/session/__tests__/SessionClient.state.test.ts +64 -0
  109. package/src/session/__tests__/sessionIntegration.test.ts +202 -0
  110. package/src/session/createSessionClient.ts +31 -0
  111. package/src/session/projectSessionState.ts +83 -0
  112. package/src/session/sessionClientHost.ts +32 -0
  113. package/src/session/socketLoader.ts +28 -0
  114. package/src/utils/__tests__/ssoEstablish.test.ts +204 -0
  115. package/src/utils/cache.ts +2 -0
  116. package/src/utils/ssoBounce.ts +9 -9
  117. package/src/utils/ssoEstablish.ts +174 -0
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSessionClient = createSessionClient;
4
+ const SessionClient_1 = require("./SessionClient");
5
+ const sessionClientHost_1 = require("./sessionClientHost");
6
+ /**
7
+ * Wires a `SessionClient` over the given `OxyServices` instance: builds the
8
+ * `SessionClientHost` adapter and passes it through together with a
9
+ * caller-supplied `TokenTransport`.
10
+ *
11
+ * The transport is a required parameter (not constructed here) because it is
12
+ * the one piece of this integration that is NOT platform-agnostic: `services`
13
+ * branches native (shared-keychain sign-in) vs. web (silent sign-in), while
14
+ * `auth-sdk` is web-only. Each consumer builds its own transport and passes
15
+ * it in; this factory only wires the platform-agnostic parts (host + client)
16
+ * so neither consumer re-implements them.
17
+ *
18
+ * The host is returned alongside the client (not just the client) so the
19
+ * caller can call `host.setCurrentAccountId(...)` as the active account
20
+ * changes.
21
+ */
22
+ function createSessionClient(oxyServices, transport) {
23
+ const host = (0, sessionClientHost_1.createSessionClientHost)(oxyServices);
24
+ const client = new SessionClient_1.SessionClient(host, { transport });
25
+ return { client, host };
26
+ }
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deviceStateToClientSessions = deviceStateToClientSessions;
4
+ exports.activeSessionIdOf = activeSessionIdOf;
5
+ exports.activeUserOf = activeUserOf;
6
+ exports.accountIdsOf = accountIdsOf;
7
+ /**
8
+ * Pure projection helpers: `DeviceSessionState` (the device-scoped
9
+ * multi-account session-sync state produced by `SessionClient`) -> the
10
+ * shapes consumers (`@oxyhq/services`, `@oxyhq/auth`) render today
11
+ * (`ClientSession[]`, an active session id, an active `User`).
12
+ *
13
+ * No I/O. The caller fetches profiles via
14
+ * `oxyServices.getUsersByIds(accountIdsOf(state))` and builds `usersById`
15
+ * from the result before calling `deviceStateToClientSessions` /
16
+ * `activeUserOf`.
17
+ */
18
+ /**
19
+ * Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
20
+ *
21
+ * `DeviceSessionState` carries no per-account `expiresAt` / `lastActive` —
22
+ * both are set to `state.updatedAt` (converted to an ISO-8601 string; the
23
+ * wire value is an epoch-ms number) as a provisional value.
24
+ *
25
+ * `usersById` is accepted for signature symmetry with `activeUserOf` even
26
+ * though `ClientSession` only stores `userId` — a session is still
27
+ * projected for an account whose id is absent from `usersById` (no
28
+ * placeholder user is fabricated).
29
+ */
30
+ function deviceStateToClientSessions(state, usersById) {
31
+ const provisionalTimestamp = new Date(state.updatedAt).toISOString();
32
+ return state.accounts.map((account) => ({
33
+ sessionId: account.sessionId,
34
+ deviceId: state.deviceId,
35
+ // provisional: expiresAt/lastActive are not carried on DeviceSessionState
36
+ expiresAt: provisionalTimestamp,
37
+ lastActive: provisionalTimestamp,
38
+ userId: account.accountId,
39
+ isCurrent: account.accountId === state.activeAccountId,
40
+ authuser: account.authuser,
41
+ }));
42
+ }
43
+ /**
44
+ * The active account's `sessionId`, or `null` when there is no state or no
45
+ * active account is set.
46
+ */
47
+ function activeSessionIdOf(state) {
48
+ if (state === null || state.activeAccountId === null) {
49
+ return null;
50
+ }
51
+ const activeAccountId = state.activeAccountId;
52
+ const activeAccount = state.accounts.find((account) => account.accountId === activeAccountId);
53
+ return activeAccount?.sessionId ?? null;
54
+ }
55
+ /**
56
+ * The active account's `User`, resolved from `usersById`. `null` when there
57
+ * is no state, no active account is set, or the active account id is absent
58
+ * from `usersById`.
59
+ */
60
+ function activeUserOf(state, usersById) {
61
+ if (state === null || state.activeAccountId === null) {
62
+ return null;
63
+ }
64
+ return usersById.get(state.activeAccountId) ?? null;
65
+ }
66
+ /**
67
+ * All account ids in `state`, suitable for an `oxyServices.getUsersByIds(...)`
68
+ * fetch. `[]` for `null` state.
69
+ */
70
+ function accountIdsOf(state) {
71
+ if (state === null) {
72
+ return [];
73
+ }
74
+ return state.accounts.map((account) => account.accountId);
75
+ }
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSessionClientHost = createSessionClientHost;
4
+ /**
5
+ * Thin `SessionClientHost` adapter over an `OxyServices` instance.
6
+ *
7
+ * `SessionClient` is host-agnostic: it only needs a REST + token surface.
8
+ * `OxyServices` already exposes all of that except `getCurrentAccountId`,
9
+ * which has no direct equivalent — the adapter holds a mutable ref set by
10
+ * the caller (`OxyContext` in `@oxyhq/services`, `WebOxyProvider` in
11
+ * `@oxyhq/auth`) via `setCurrentAccountId`.
12
+ *
13
+ * Shared here (rather than duplicated per consumer) because it is entirely
14
+ * platform-agnostic: every method it calls exists identically on
15
+ * `OxyServices` regardless of host (web, Expo/RN, Node).
16
+ */
17
+ function createSessionClientHost(oxyServices) {
18
+ let currentAccountId = null;
19
+ return {
20
+ makeRequest: (method, url, data, options) => oxyServices.makeRequest(method, url, data, options),
21
+ getBaseURL: () => oxyServices.getBaseURL(),
22
+ getAccessToken: () => oxyServices.getAccessToken(),
23
+ onTokensChanged: (listener) => oxyServices.onTokensChanged(listener),
24
+ setTokens: (accessToken) => oxyServices.setTokens(accessToken),
25
+ getCurrentAccountId: () => currentAccountId,
26
+ setCurrentAccountId: (id) => {
27
+ currentAccountId = id;
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getSocketIO = getSocketIO;
37
+ const loggerUtils_1 = require("../utils/loggerUtils");
38
+ let cachedFactory = null;
39
+ let loadAttempted = false;
40
+ async function getSocketIO() {
41
+ if (cachedFactory)
42
+ return cachedFactory;
43
+ if (loadAttempted)
44
+ return null;
45
+ loadAttempted = true;
46
+ try {
47
+ const mod = (await Promise.resolve().then(() => __importStar(require('socket.io-client'))));
48
+ cachedFactory = mod.io ?? mod.default ?? null;
49
+ return cachedFactory;
50
+ }
51
+ catch (error) {
52
+ loggerUtils_1.logger.warn('[SessionClient] socket.io-client import failed; realtime session sync disabled', { component: 'SessionClient' }, error);
53
+ return null;
54
+ }
55
+ }
@@ -206,6 +206,8 @@ function registerCacheForCleanup(cache) {
206
206
  cache.cleanup();
207
207
  }
208
208
  }, 60000); // Every minute
209
+ // Never block process/worker exit on a housekeeping timer.
210
+ cleanupInterval.unref?.();
209
211
  }
210
212
  }
211
213
  /**
@@ -142,18 +142,18 @@ function ssoPriorSessionKey(origin) {
142
142
  * per-tab `sessionStorage` the loop-breaker keys use — it must survive a reload.
143
143
  *
144
144
  * It exists purely to suppress AUTOMATIC silent restore after a deliberate
145
- * sign-out: a still-live IdP session (the central `fedcm_session` / the FedCM
146
- * credential association) would otherwise let `fedcm-silent` / the per-apex
147
- * `/auth/silent` iframe re-mint a session on the very next cold boot, so a user
148
- * who pressed "Sign out" gets silently signed back in on reload. With this flag
149
- * set, those silent cold-boot steps are skipped while the Gmail-style
150
- * returning-account fast-path is otherwise preserved.
145
+ * sign-out: a still-live IdP session (the central `fedcm_session`) would
146
+ * otherwise let the per-apex `/auth/silent` iframe re-mint a session on the
147
+ * very next cold boot, so a user who pressed "Sign out" gets silently signed
148
+ * back in on reload. With this flag set, that silent cold-boot step is
149
+ * skipped while the Gmail-style returning-account fast-path is otherwise
150
+ * preserved.
151
151
  *
152
152
  * Lifecycle (mirrors the existing gate machinery — set on a definitive event,
153
153
  * cleared on its inverse):
154
154
  * - SET on EXPLICIT full sign-out (alongside clearing the prior-session hint
155
155
  * and the SSO bounce state).
156
- * - CLEARED on ANY deliberate sign-in (password, FedCM, account switch, device
156
+ * - CLEARED on ANY deliberate sign-in (password, account switch, device
157
157
  * claim) so a real sign-in fully re-enables silent restore — there is no
158
158
  * "stuck signed out" state.
159
159
  *
@@ -297,8 +297,8 @@ function guardActive(storage, origin, now = Date.now()) {
297
297
  * Whether AUTOMATIC silent restore is SUPPRESSED for this origin because the
298
298
  * user deliberately signed out (the durable {@link ssoSignedOutKey} flag).
299
299
  *
300
- * When `true`, the silent cold-boot steps that can re-mint a session from a
301
- * still-live IdP session WITHOUT user intent — `fedcm-silent` and the per-apex
300
+ * When `true`, the silent cold-boot step that can re-mint a session from a
301
+ * still-live IdP session WITHOUT user intent — the per-apex
302
302
  * `/auth/silent` iframe — MUST be skipped, so a user who pressed "Sign out" is
303
303
  * not silently signed back in on the next reload. Interactive sign-in clears the
304
304
  * flag, so this never blocks a deliberate re-sign-in.
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ /**
3
+ * Post-claim durable-session establish hop (web device-flow / "Sign in with
4
+ * Oxy" QR).
5
+ *
6
+ * A WEB device-flow claim (`claimSessionByToken`) plants only IN-MEMORY tokens:
7
+ * unlike a redirect/FedCM/silent sign-in, it never causes the IdP to plant a
8
+ * `fedcm_session` cookie. So a reload has nothing to restore from — the
9
+ * silent-iframe and `/sso` paths find no IdP session and the session is lost.
10
+ *
11
+ * This primitive closes that gap. AFTER the claim has committed and the session
12
+ * has been durably persisted, it performs ONE top-level establish hop through
13
+ * the RP's own per-apex IdP host (`auth.<rp-apex>`), reusing the EXISTING
14
+ * `/sso/establish` endpoint: the server mints a short-lived, host+audience-bound
15
+ * establish-token and returns a fully-formed establish URL; navigating to it
16
+ * plants the durable first-party `fedcm_session` cookie and bounces back to the
17
+ * RP callback with an opaque code the standard `sso-return` cold-boot step
18
+ * exchanges.
19
+ *
20
+ * It reuses the SAME per-origin `sessionStorage` bounce contract
21
+ * (`ssoStateKey` / `ssoGuardKey` / `ssoDestKey`) that {@link buildSsoBounceUrl}
22
+ * primes for the terminal `/sso` bounce, so the post-bounce `sso-return` step
23
+ * (`consumeSsoReturn`) validates the CSRF `state`, exchanges the code, and
24
+ * restores the user's real destination with no extra wiring.
25
+ *
26
+ * Contract:
27
+ * - WEB only — off-web / native it is a no-op returning `false`.
28
+ * - NEVER fires while sitting on the central IdP origin (that would loop the
29
+ * IdP against itself).
30
+ * - Bounce state is persisted ONLY after the establish-URL request succeeds, so
31
+ * a failed request leaves no stale state behind.
32
+ * - SINGLE attempt: the caller invokes this exactly once per successful claim.
33
+ * On ANY failure it does NOT navigate and returns `false`, leaving the
34
+ * committed in-memory session exactly as-is (the user is no worse off than
35
+ * before this hop existed).
36
+ * - Total: never throws. Failures are reported via {@link deps.onError} only.
37
+ */
38
+ Object.defineProperty(exports, "__esModule", { value: true });
39
+ exports.establishIdpSessionAfterClaim = establishIdpSessionAfterClaim;
40
+ const ssoBounce_1 = require("./ssoBounce");
41
+ const OxyServices_sso_1 = require("../mixins/OxyServices.sso");
42
+ /**
43
+ * Perform the post-claim establish hop. Returns `true` when a navigation to the
44
+ * establish URL was initiated (the document is being torn down and replaced by
45
+ * the IdP), `false` on every no-op / failure path.
46
+ *
47
+ * @param client - The exchange surface (`oxyServices.requestSsoEstablishUrl`).
48
+ * @param deps - Injectable web seams; see {@link EstablishAfterClaimDeps}.
49
+ */
50
+ async function establishIdpSessionAfterClaim(client, deps = {}) {
51
+ const isWeb = deps.isWeb ??
52
+ (() => typeof window !== 'undefined' &&
53
+ typeof window.sessionStorage !== 'undefined');
54
+ if (!isWeb()) {
55
+ return false;
56
+ }
57
+ const storage = deps.storage ?? window.sessionStorage;
58
+ const location = deps.location ?? window.location;
59
+ const navigate = deps.navigate ?? ssoBounce_1.ssoNavigate;
60
+ const generateState = deps.generateState ?? OxyServices_sso_1.generateSsoState;
61
+ const now = deps.now ?? (() => Date.now());
62
+ const origin = location.origin;
63
+ // Never establish while sitting on the central IdP itself — that would bounce
64
+ // the IdP against itself. (The device-flow claim can only happen on an RP.)
65
+ if ((0, ssoBounce_1.isCentralIdPOrigin)(origin)) {
66
+ return false;
67
+ }
68
+ const state = generateState();
69
+ let establishUrl;
70
+ try {
71
+ const result = await client.requestSsoEstablishUrl(origin, state);
72
+ if (!result ||
73
+ typeof result.establishUrl !== 'string' ||
74
+ result.establishUrl.length === 0) {
75
+ return false;
76
+ }
77
+ establishUrl = result.establishUrl;
78
+ // Defense-in-depth before a TOP-LEVEL navigation: the establish URL is
79
+ // server-formed, but never follow anything that is not a well-formed https
80
+ // `/sso/establish` on an `auth.` host. A malformed or unexpected URL aborts
81
+ // silently (no navigation, no state persisted) — same total/no-throw
82
+ // contract — rather than navigating the document somewhere unintended.
83
+ let parsedEstablishUrl;
84
+ try {
85
+ parsedEstablishUrl = new URL(establishUrl);
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ if (parsedEstablishUrl.protocol !== 'https:' ||
91
+ parsedEstablishUrl.pathname !== '/sso/establish' ||
92
+ !parsedEstablishUrl.hostname.toLowerCase().startsWith('auth.')) {
93
+ return false;
94
+ }
95
+ // Persist the bounce state ONLY now that the request has succeeded and the
96
+ // URL is validated — so a failed/rejected request never leaves stale state
97
+ // behind. This is the exact contract the terminal `/sso` bounce primes via
98
+ // `buildSsoBounceUrl`, so the post-bounce `sso-return` step (`consumeSsoReturn`)
99
+ // validates `state`, exchanges the returned code, and restores the dest.
100
+ storage.setItem((0, ssoBounce_1.ssoStateKey)(origin), state);
101
+ storage.setItem((0, ssoBounce_1.ssoGuardKey)(origin), String(now()));
102
+ storage.setItem((0, ssoBounce_1.ssoDestKey)(origin), location.href);
103
+ }
104
+ catch (error) {
105
+ deps.onError?.(error);
106
+ return false;
107
+ }
108
+ navigate(establishUrl);
109
+ return true;
110
+ }