@oxyhq/services 8.2.0 → 8.3.1

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.
@@ -0,0 +1,158 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.SSO_GUARD_TTL_MS = exports.SSO_CALLBACK_PATH = void 0;
7
+ exports.guardActive = guardActive;
8
+ exports.isCentralIdPOrigin = isCentralIdPOrigin;
9
+ exports.ssoDestKey = ssoDestKey;
10
+ exports.ssoGuardKey = ssoGuardKey;
11
+ exports.ssoNavigate = ssoNavigate;
12
+ exports.ssoNoSessionKey = ssoNoSessionKey;
13
+ exports.ssoStateKey = ssoStateKey;
14
+ var _core = require("@oxyhq/core");
15
+ /**
16
+ * Central cross-domain SSO bounce — per-origin sessionStorage keys and small
17
+ * pure predicates shared by the cold-boot `sso-return` / `sso-bounce` steps and
18
+ * the bfcache `pageshow` re-evaluation.
19
+ *
20
+ * TRUE central SSO (Google/Meta/Clerk style) works like this for a Relying
21
+ * Party (mention.earth, homiio.com, alia.onl, …) with no local session:
22
+ *
23
+ * 1. `sso-bounce` (terminal, once): top-level navigate to
24
+ * `auth.oxy.so/sso?prompt=none&client_id=<origin>&return_to=<origin>/__oxy/sso-callback&state=<s>`.
25
+ * Before navigating it records, in this origin's `sessionStorage`, the CSRF
26
+ * `state`, a guard timestamp (loop breaker), and the real destination URL
27
+ * to restore after the callback.
28
+ * 2. The central IdP worker reads its first-party `fedcm_session`, mints a
29
+ * session, stores it under an opaque single-use `code`, and 303-redirects
30
+ * back to `<origin>/__oxy/sso-callback#oxy_sso=ok&code=<code>&state=<s>`
31
+ * (or `#oxy_sso=none` / `#oxy_sso=error`).
32
+ * 3. `sso-return` parses the fragment (`parseSsoReturnFragment` from core),
33
+ * validates `state`, exchanges the `code` via `oxyServices.exchangeSsoCode`,
34
+ * and commits the session — then restores the original destination.
35
+ *
36
+ * Loop proof (logged-out): first load all steps skip → `sso-bounce` sets
37
+ * guard/state/dest and navigates; the IdP (no central session) returns
38
+ * `#oxy_sso=none`; the callback load's `sso-return` sees `none`, sets the
39
+ * no-session flag, and `sso-bounce` is then disabled. Exactly ONE bounce, no
40
+ * loop. An interrupted bounce (user hit back mid-redirect) self-heals once the
41
+ * 30s guard TTL lapses.
42
+ *
43
+ * All state lives in `sessionStorage` (per tab, cleared on tab close) and is
44
+ * keyed per-origin so two RPs hosted in the same browser never collide. This
45
+ * module is pure with respect to navigation: it only reads/writes
46
+ * `sessionStorage` and parses URLs; it performs no redirects itself.
47
+ */
48
+
49
+ /**
50
+ * The RP callback path the central IdP redirects back to. The SSO result is
51
+ * delivered in the fragment of this URL; `sso-return` consumes it and then
52
+ * restores the user's real destination.
53
+ */
54
+ const SSO_CALLBACK_PATH = exports.SSO_CALLBACK_PATH = '/__oxy/sso-callback';
55
+
56
+ /**
57
+ * Self-healing TTL (ms) for the bounce guard. If a bounce is interrupted before
58
+ * the callback lands (e.g. the user navigates back mid-redirect), the guard
59
+ * would otherwise pin the RP signed-out forever. After this window the guard is
60
+ * treated as stale and a fresh single bounce is permitted.
61
+ */
62
+ const SSO_GUARD_TTL_MS = exports.SSO_GUARD_TTL_MS = 30_000;
63
+ const STATE_KEY_PREFIX = 'oxy_sso_state:';
64
+ const GUARD_KEY_PREFIX = 'oxy_sso_guard:';
65
+ const DEST_KEY_PREFIX = 'oxy_sso_dest:';
66
+ const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
67
+
68
+ /**
69
+ * Perform the terminal top-level SSO bounce navigation.
70
+ *
71
+ * A thin wrapper over `window.location.assign(url)` so the single navigation
72
+ * seam lives in one place (and stays mockable in tests, where jsdom's
73
+ * `Location.assign` is a non-configurable native method). In production this is
74
+ * exactly `window.location.assign` — the document is torn down and replaced by
75
+ * the central IdP page. Off-browser it is a no-op (native never bounces).
76
+ */
77
+ function ssoNavigate(url) {
78
+ if (typeof window === 'undefined' || typeof window.location === 'undefined') {
79
+ return;
80
+ }
81
+ window.location.assign(url);
82
+ }
83
+
84
+ /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
85
+ function ssoStateKey(origin) {
86
+ return `${STATE_KEY_PREFIX}${origin}`;
87
+ }
88
+
89
+ /** Per-origin bounce guard key (a timestamp; loop breaker + self-heal TTL). */
90
+ function ssoGuardKey(origin) {
91
+ return `${GUARD_KEY_PREFIX}${origin}`;
92
+ }
93
+
94
+ /** Per-origin destination key (the real URL to restore after the callback). */
95
+ function ssoDestKey(origin) {
96
+ return `${DEST_KEY_PREFIX}${origin}`;
97
+ }
98
+
99
+ /**
100
+ * Per-origin "the central IdP has no session for me" key. Set after a
101
+ * `none`/`error` return (or a failed/forged exchange) so `sso-bounce` does not
102
+ * fire again this tab — the definitive loop breaker.
103
+ */
104
+ function ssoNoSessionKey(origin) {
105
+ return `${NO_SESSION_KEY_PREFIX}${origin}`;
106
+ }
107
+
108
+ /**
109
+ * Whether `origin` IS the central IdP origin. We must never bounce while on
110
+ * `auth.oxy.so` itself (it would bounce to itself). Compared by URL origin so a
111
+ * trailing-slash / path difference never defeats the guard.
112
+ */
113
+ function isCentralIdPOrigin(origin) {
114
+ let centralOrigin;
115
+ try {
116
+ centralOrigin = new URL(_core.CENTRAL_AUTH_URL).origin;
117
+ } catch {
118
+ return false;
119
+ }
120
+ let candidateOrigin;
121
+ try {
122
+ candidateOrigin = new URL(origin).origin;
123
+ } catch {
124
+ return false;
125
+ }
126
+ return candidateOrigin === centralOrigin;
127
+ }
128
+
129
+ /**
130
+ * Read the bounce guard and decide whether it is still ACTIVE.
131
+ *
132
+ * Active means: a guard value is present AND it parses to a finite timestamp AND
133
+ * less than {@link SSO_GUARD_TTL_MS} has elapsed since it was set. An active
134
+ * guard disables `sso-bounce` (a bounce is already in flight this tab). A
135
+ * missing, malformed, or expired guard is NOT active, so a fresh bounce may
136
+ * proceed (this is the 30s self-heal for an interrupted bounce).
137
+ *
138
+ * @param storage - The session storage to read (injected for testability).
139
+ * @param origin - The page origin whose guard to evaluate.
140
+ * @param now - Current epoch ms (injected for deterministic tests).
141
+ */
142
+ function guardActive(storage, origin, now) {
143
+ let raw;
144
+ try {
145
+ raw = storage.getItem(ssoGuardKey(origin));
146
+ } catch {
147
+ return false;
148
+ }
149
+ if (raw === null || raw.length === 0) {
150
+ return false;
151
+ }
152
+ const stamp = Number(raw);
153
+ if (!Number.isFinite(stamp)) {
154
+ return false;
155
+ }
156
+ return now - stamp < SSO_GUARD_TTL_MS;
157
+ }
158
+ //# sourceMappingURL=ssoBounce.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"names":["_core","require","SSO_CALLBACK_PATH","exports","SSO_GUARD_TTL_MS","STATE_KEY_PREFIX","GUARD_KEY_PREFIX","DEST_KEY_PREFIX","NO_SESSION_KEY_PREFIX","ssoNavigate","url","window","location","assign","ssoStateKey","origin","ssoGuardKey","ssoDestKey","ssoNoSessionKey","isCentralIdPOrigin","centralOrigin","URL","CENTRAL_AUTH_URL","candidateOrigin","guardActive","storage","now","raw","getItem","length","stamp","Number","isFinite"],"sourceRoot":"../../../../src","sources":["ui/utils/ssoBounce.ts"],"mappings":";;;;;;;;;;;;;AAkCA,IAAAA,KAAA,GAAAC,OAAA;AAlCA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;;AAIA;AACA;AACA;AACA;AACA;AACO,MAAMC,iBAAiB,GAAAC,OAAA,CAAAD,iBAAA,GAAG,qBAAqB;;AAEtD;AACA;AACA;AACA;AACA;AACA;AACO,MAAME,gBAAgB,GAAAD,OAAA,CAAAC,gBAAA,GAAG,MAAM;AAEtC,MAAMC,gBAAgB,GAAG,gBAAgB;AACzC,MAAMC,gBAAgB,GAAG,gBAAgB;AACzC,MAAMC,eAAe,GAAG,eAAe;AACvC,MAAMC,qBAAqB,GAAG,qBAAqB;;AAEnD;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,SAASC,WAAWA,CAACC,GAAW,EAAQ;EAC7C,IAAI,OAAOC,MAAM,KAAK,WAAW,IAAI,OAAOA,MAAM,CAACC,QAAQ,KAAK,WAAW,EAAE;IAC3E;EACF;EACAD,MAAM,CAACC,QAAQ,CAACC,MAAM,CAACH,GAAG,CAAC;AAC7B;;AAEA;AACO,SAASI,WAAWA,CAACC,MAAc,EAAU;EAClD,OAAO,GAAGV,gBAAgB,GAAGU,MAAM,EAAE;AACvC;;AAEA;AACO,SAASC,WAAWA,CAACD,MAAc,EAAU;EAClD,OAAO,GAAGT,gBAAgB,GAAGS,MAAM,EAAE;AACvC;;AAEA;AACO,SAASE,UAAUA,CAACF,MAAc,EAAU;EACjD,OAAO,GAAGR,eAAe,GAAGQ,MAAM,EAAE;AACtC;;AAEA;AACA;AACA;AACA;AACA;AACO,SAASG,eAAeA,CAACH,MAAc,EAAU;EACtD,OAAO,GAAGP,qBAAqB,GAAGO,MAAM,EAAE;AAC5C;;AAEA;AACA;AACA;AACA;AACA;AACO,SAASI,kBAAkBA,CAACJ,MAAc,EAAW;EAC1D,IAAIK,aAAqB;EACzB,IAAI;IACFA,aAAa,GAAG,IAAIC,GAAG,CAACC,sBAAgB,CAAC,CAACP,MAAM;EAClD,CAAC,CAAC,MAAM;IACN,OAAO,KAAK;EACd;EACA,IAAIQ,eAAuB;EAC3B,IAAI;IACFA,eAAe,GAAG,IAAIF,GAAG,CAACN,MAAM,CAAC,CAACA,MAAM;EAC1C,CAAC,CAAC,MAAM;IACN,OAAO,KAAK;EACd;EACA,OAAOQ,eAAe,KAAKH,aAAa;AAC1C;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO,SAASI,WAAWA,CAACC,OAAgB,EAAEV,MAAc,EAAEW,GAAW,EAAW;EAClF,IAAIC,GAAkB;EACtB,IAAI;IACFA,GAAG,GAAGF,OAAO,CAACG,OAAO,CAACZ,WAAW,CAACD,MAAM,CAAC,CAAC;EAC5C,CAAC,CAAC,MAAM;IACN,OAAO,KAAK;EACd;EACA,IAAIY,GAAG,KAAK,IAAI,IAAIA,GAAG,CAACE,MAAM,KAAK,CAAC,EAAE;IACpC,OAAO,KAAK;EACd;EACA,MAAMC,KAAK,GAAGC,MAAM,CAACJ,GAAG,CAAC;EACzB,IAAI,CAACI,MAAM,CAACC,QAAQ,CAACF,KAAK,CAAC,EAAE;IAC3B,OAAO,KAAK;EACd;EACA,OAAOJ,GAAG,GAAGI,KAAK,GAAG1B,gBAAgB;AACvC","ignoreList":[]}
@@ -3,8 +3,10 @@
3
3
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { OxyServices, oxyClient } from '@oxyhq/core';
5
5
  import { KeyManager } from '@oxyhq/core';
6
- import { autoDetectAuthWebUrl, runColdBoot } from '@oxyhq/core';
6
+ import { runColdBoot, resolveCentralAuthUrl, parseSsoReturnFragment, autoDetectAuthWebUrl } from '@oxyhq/core';
7
7
  import { toast } from '@oxyhq/bloom';
8
+ import { SSO_CALLBACK_PATH, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, isCentralIdPOrigin, guardActive } from "../utils/ssoBounce.js";
9
+ import * as ssoBounce from "../utils/ssoBounce.js";
8
10
  import { useAuthStore } from "../stores/authStore.js";
9
11
  import { useShallow } from 'zustand/react/shallow';
10
12
  import { useSessionSocket } from "../hooks/useSessionSocket.js";
@@ -30,19 +32,19 @@ const OxyContext = /*#__PURE__*/createContext(null);
30
32
  // can share them without re-importing this 1k-line context file.
31
33
 
32
34
  /**
33
- * Module-level run-once guard for the cold-boot silent SSO steps
34
- * (`fedcm-silent` and `silent-iframe`).
35
+ * Module-level run-once guard for the cold-boot `fedcm-silent` step.
35
36
  *
36
- * Both steps trigger a one-shot browser credential / iframe handshake that must
37
- * fire AT MOST ONCE per page load — otherwise a provider remount storm (route
38
- * churn, StrictMode double-invoke, error-boundary recovery) becomes a credential
39
- * request storm. A per-instance ref resets on every remount, so the guard must
40
- * live at module scope. Keyed on `origin|baseURL` so two providers pointed at
41
- * the same API from the same origin share one attempt; never cleared because
42
- * only a fresh page load can change the IdP session state, and a fresh page load
43
- * starts a fresh module scope.
37
+ * The FedCM silent step triggers a one-shot `navigator.credentials.get`
38
+ * handshake that must fire AT MOST ONCE per page load — otherwise a provider
39
+ * remount storm (route churn, StrictMode double-invoke, error-boundary
40
+ * recovery) becomes a credential request storm. A per-instance ref resets on
41
+ * every remount, so the guard must live at module scope. Keyed on
42
+ * `origin|baseURL` so two providers pointed at the same API from the same
43
+ * origin share one attempt; never cleared because only a fresh page load can
44
+ * change the central IdP session state, and a fresh page load starts a fresh
45
+ * module scope.
44
46
  *
45
- * This is a NEW, dedicated set — distinct from `useWebSSO`'s `silentSSOAttempted`
47
+ * This is a dedicated set — distinct from `useWebSSO`'s `silentSSOAttempted`
46
48
  * (which guards the post-boot INTERACTIVE button path) and never a core
47
49
  * module-level singleton (that re-evaluates under Metro web bundling and the
48
50
  * guard would not hold).
@@ -135,19 +137,19 @@ export const OxyProvider = ({
135
137
  if (providedOxyServices) {
136
138
  oxyServicesRef.current = providedOxyServices;
137
139
  } else if (baseURL) {
138
- // Auto-detect the FAPI (IdP) origin from the current browser hostname so
139
- // a consuming RP (mention.earth, homiio.com, alia.onl, …) targets
140
- // `auth.<rp-apex>` for FedCM + the silent iframe WITHOUT passing
141
- // `authWebUrl` explicitly that is what makes both the FedCM config and
142
- // the `/auth/silent` iframe first-party with the RP (Safari ITP /
143
- // Firefox TCP need first-party). An explicit `authWebUrl` prop still
144
- // wins. On native `autoDetectAuthWebUrl()` returns `undefined`
145
- // (off-browser), leaving the value unchanged. We only auto-detect on the
146
- // baseURL-only path a consumer-provided `OxyServices` instance is
147
- // never mutated.
140
+ // Target the CENTRAL IdP for TRUE cross-domain SSO. Every RP
141
+ // (mention.earth, homiio.com, alia.onl, …) delegates to the one central
142
+ // `auth.oxy.so` it owns the host-only `fedcm_session` cookie and the
143
+ // central session store reached via `api.oxy.so`, so a single sign-in
144
+ // there is observed by all RPs through the opaque-code `/sso` bounce.
145
+ // `resolveCentralAuthUrl(authWebUrl)` returns the explicit `authWebUrl`
146
+ // prop when provided (explicit always wins) and the central default
147
+ // otherwise. This is NOT per-apex auto-detection central SSO is
148
+ // deliberately central. A consumer-provided `OxyServices` instance is
149
+ // never mutated; only the baseURL-only construction path applies this.
148
150
  oxyServicesRef.current = new OxyServices({
149
151
  baseURL,
150
- authWebUrl: authWebUrl ?? autoDetectAuthWebUrl(),
152
+ authWebUrl: resolveCentralAuthUrl(authWebUrl),
151
153
  authRedirectUri
152
154
  });
153
155
  } else {
@@ -627,14 +629,105 @@ export const OxyProvider = ({
627
629
  return false;
628
630
  }, [logger, oxyServices, storage, storageKeys.activeSessionId, storageKeys.sessionIds]);
629
631
 
632
+ // Central cross-domain SSO return handler (web). Parses the IdP redirect
633
+ // fragment, validates the CSRF `state`, exchanges the opaque single-use code
634
+ // for the real session, commits it, and restores the user's pre-bounce
635
+ // destination. Shared by the `sso-return` cold-boot step AND the bfcache
636
+ // `pageshow` re-evaluation, so the same security-critical logic runs exactly
637
+ // once per delivered fragment regardless of how the page was (re)shown.
638
+ //
639
+ // Returns `true` when a session was committed (caller short-circuits), `false`
640
+ // otherwise. On ANY non-ok outcome — `none`/`error`, state mismatch, missing
641
+ // code, or a failed/forged exchange — it sets the per-origin NO_SESSION flag
642
+ // so `sso-bounce` is disabled and the page cannot loop. Off-browser it is a
643
+ // no-op returning `false` (native never reaches it).
644
+ const runSsoReturn = useCallback(async () => {
645
+ if (!isWebBrowser()) {
646
+ return false;
647
+ }
648
+ const ret = parseSsoReturnFragment(window.location.hash);
649
+ if (!ret) {
650
+ // Not an oxy_sso fragment — nothing to do (do NOT touch any flags).
651
+ return false;
652
+ }
653
+ const origin = window.location.origin;
654
+ const expectedState = window.sessionStorage.getItem(ssoStateKey(origin));
655
+ const stateOk = !!ret.state && !!expectedState && ret.state === expectedState;
656
+
657
+ // Strip the fragment FIRST so the opaque code never lingers in the address
658
+ // bar, history, or a copy-paste — even if a later step throws.
659
+ window.history.replaceState(null, '', window.location.pathname + window.location.search);
660
+ window.sessionStorage.removeItem(ssoStateKey(origin));
661
+ const markNoSession = () => {
662
+ window.sessionStorage.setItem(ssoNoSessionKey(origin), '1');
663
+ };
664
+ if (ret.kind === 'none' || ret.kind === 'error') {
665
+ // The central IdP had no session (or the bounce failed). Record it so we
666
+ // do not bounce again this tab — the definitive loop breaker.
667
+ markNoSession();
668
+ return false;
669
+ }
670
+ if (!stateOk || !ret.code) {
671
+ // Forged / replayed / stale fragment, or a malformed ok with no code.
672
+ // Treat exactly like "no session": never exchange, never loop.
673
+ markNoSession();
674
+ return false;
675
+ }
676
+ const commitWebSession = handleWebSSOSessionRef.current;
677
+ let session;
678
+ try {
679
+ session = await oxyServices.exchangeSsoCode(ret.code);
680
+ } catch (error) {
681
+ if (__DEV__) {
682
+ loggerUtil.debug('SSO code exchange failed (treating as no session)', {
683
+ component: 'OxyContext',
684
+ method: 'runSsoReturn'
685
+ }, error);
686
+ }
687
+ markNoSession();
688
+ return false;
689
+ }
690
+ if (!session?.sessionId || !commitWebSession) {
691
+ markNoSession();
692
+ return false;
693
+ }
694
+ await commitWebSession(session);
695
+
696
+ // Restore the user's real destination captured before the bounce. We only
697
+ // rewrite the URL when we are sitting on the callback path — otherwise the
698
+ // current URL is already the destination.
699
+ if (window.location.pathname === SSO_CALLBACK_PATH) {
700
+ const dest = window.sessionStorage.getItem(ssoDestKey(origin));
701
+ if (dest) {
702
+ try {
703
+ const destUrl = new URL(dest);
704
+ // Same-origin only — never honour a cross-origin destination that
705
+ // could have been planted to redirect the freshly signed-in user.
706
+ if (destUrl.origin === origin) {
707
+ window.history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
708
+ }
709
+ } catch {
710
+ // Malformed stored destination — leave the URL on the callback path.
711
+ }
712
+ }
713
+ }
714
+ window.sessionStorage.removeItem(ssoDestKey(origin));
715
+ return true;
716
+ }, [oxyServices]);
717
+
630
718
  // Cold boot — the single, ordered, short-circuit session-recovery sequence,
631
719
  // consuming the SAME `runColdBoot` core primitive as `WebOxyProvider`. The
632
720
  // FIRST step that yields a session wins; every later step is skipped. Each
633
721
  // web-only step is gated by `isWebBrowser()`, so on native ONLY
634
722
  // `stored-session` runs.
635
723
  //
636
- // Order (web): redirect callback → FedCM silent → silent iframe refresh
637
- // cookie stored session. Order (native): stored session only.
724
+ // Order (web): redirect callback → SSO returnFedCM silent (central)
725
+ // silent iframe (per-apex, the durable reload path) cookie restore →
726
+ // stored session → SSO bounce (terminal). The per-apex silent iframe is what
727
+ // restores a durable cross-domain session on reload WITHOUT a top-level
728
+ // bounce, so when it wins `sso-bounce` never fires (no flash, no loop).
729
+ // Order (native): stored session only (every web-only step is disabled
730
+ // off-browser).
638
731
  const restoreSessionsFromStorage = useCallback(async () => {
639
732
  if (!storage) {
640
733
  return;
@@ -646,7 +739,7 @@ export const OxyProvider = ({
646
739
  try {
647
740
  const outcome = await runColdBoot({
648
741
  steps: [{
649
- // 1) Redirect callback wins: a popup/redirect sign-in just landed
742
+ // 0) Redirect callback wins: a popup/redirect sign-in just landed
650
743
  // back on this page with `access_token`/`session_id` query params.
651
744
  // `handleAuthCallback` plants the token but returns a PLACEHOLDER
652
745
  // user (empty id), so we hydrate the REAL user via `getCurrentUser`
@@ -672,10 +765,29 @@ export const OxyProvider = ({
672
765
  };
673
766
  }
674
767
  }, {
675
- // 2) FedCM silent reauthn (Chrome). `silentSignInWithFedCM` plants
676
- // the access token internally; we commit the returned session via
768
+ // 1) Central SSO return: we are landing back from an `auth.oxy.so/sso`
769
+ // bounce with the result in the URL fragment. Parse it, validate the
770
+ // CSRF state, exchange the opaque code, and commit. On any non-ok
771
+ // outcome `runSsoReturn` sets the per-origin NO_SESSION flag so the
772
+ // terminal `sso-bounce` step is disabled — the loop breaker.
773
+ id: 'sso-return',
774
+ enabled: () => isWebBrowser(),
775
+ run: async () => {
776
+ const committed = await runSsoReturn();
777
+ return committed ? {
778
+ kind: 'session',
779
+ session: true
780
+ } : {
781
+ kind: 'skip'
782
+ };
783
+ }
784
+ }, {
785
+ // 2) FedCM silent reauthn (Chrome) against the CENTRAL IdP
786
+ // (auth.oxy.so). `silentSignInWithFedCM` plants the access token
787
+ // internally; we commit the returned session via
677
788
  // `handleWebSSOSession`. Guarded so it fires at most once per page
678
- // load across remounts.
789
+ // load across remounts. This is an enhancement layered above the
790
+ // opaque-code bounce: when it succeeds the bounce never fires.
679
791
  id: 'fedcm-silent',
680
792
  enabled: () => fedcmSupported && !servicesSilentAttempted.has(silentKey),
681
793
  run: async () => {
@@ -693,17 +805,38 @@ export const OxyProvider = ({
693
805
  };
694
806
  }
695
807
  }, {
696
- // 3) Silent first-party iframe ({authWebUrl}/auth/silent) for
697
- // browsers without FedCM (Safari / Firefox). After auto-detection
698
- // `authWebUrl` is `auth.<rp-apex>`, so the iframe + its
699
- // `fedcm_session` cookie are first-party with the RP. Shares the
700
- // one-shot guard with the FedCM step.
808
+ // 3) First-party silent iframe at the PER-APEX IdP — the DURABLE
809
+ // cross-domain reload-restore path. The durable session lives as a
810
+ // first-party `fedcm_session` cookie on `auth.<rp-apex>` (e.g.
811
+ // `auth.mention.earth`), established during the `/sso` bounce's
812
+ // `/sso/establish` hop. That host is SAME-SITE to the RP page, so
813
+ // the cookie is first-party under Safari ITP / Firefox TCP — and
814
+ // an iframe read is NOT a top-level navigation, so it restores on
815
+ // reload with NO flash and works in a backgrounded tab. This is the
816
+ // step that prevents the re-bounce loop: when it finds a session,
817
+ // the terminal `sso-bounce` never fires.
818
+ //
819
+ // The instance is configured with `authWebUrl=auth.oxy.so` (central,
820
+ // for the bounce + FedCM), so we explicitly point the iframe at the
821
+ // per-apex host via `autoDetectAuthWebUrl()` and `silentSignIn`'s
822
+ // `authWebUrlOverride`. On a `*.oxy.so` RP the per-apex host IS the
823
+ // central host (`auth.oxy.so`), so this is a same-host no-op-
824
+ // equivalent. When auto-detection bails (localhost/IP/single-label)
825
+ // there is no per-apex IdP and the step skips. Web only; on native
826
+ // `isWebBrowser()` gates it off, so native never runs an iframe.
701
827
  id: 'silent-iframe',
702
- enabled: () => isWebBrowser() && oxyServices.isFedCMSupported?.() !== true && !servicesSilentAttempted.has(silentKey),
828
+ enabled: () => isWebBrowser(),
703
829
  run: async () => {
704
- servicesSilentAttempted.add(silentKey);
705
- const session = await oxyServices.silentSignIn?.();
706
- if (!session || !commitWebSession) {
830
+ const perApexAuthUrl = autoDetectAuthWebUrl();
831
+ if (!perApexAuthUrl || !commitWebSession) {
832
+ return {
833
+ kind: 'skip'
834
+ };
835
+ }
836
+ const session = await oxyServices.silentSignIn?.({
837
+ authWebUrlOverride: perApexAuthUrl
838
+ });
839
+ if (!session?.user || !session?.sessionId) {
707
840
  return {
708
841
  kind: 'skip'
709
842
  };
@@ -715,12 +848,12 @@ export const OxyProvider = ({
715
848
  };
716
849
  }
717
850
  }, {
718
- // 4) Refresh-cookie restore (same-site only). On `*.oxy.so` the
851
+ // 4) Refresh-cookie restore (first-party only). On `*.oxy.so` the
719
852
  // httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
720
853
  // device-local slot. On a cross-domain RP (mention.earth, …) the
721
854
  // cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
722
- // `refreshAllSessions` returns `{accounts:[]}` and this skips.
723
- // That is correct; there is deliberately NO `api.<apex>` bridge.
855
+ // `refreshAllSessions` returns `{accounts:[]}` and this skips. That
856
+ // is correct; cross-domain restore is handled by the SSO bounce.
724
857
  id: 'cookie-restore',
725
858
  enabled: () => isWebBrowser(),
726
859
  run: async () => {
@@ -734,8 +867,8 @@ export const OxyProvider = ({
734
867
  }
735
868
  }, {
736
869
  // 5) Stored-session bearer restore. NO `enabled` gate — runs on ALL
737
- // platforms. This is native's ONLY restore path (every web-only
738
- // step above is disabled off-browser).
870
+ // platforms. This is native's ONLY restore path (every web-only step
871
+ // is disabled off-browser, so native reaches exactly this).
739
872
  id: 'stored-session',
740
873
  run: async () => {
741
874
  const restored = await restoreStoredSession();
@@ -746,6 +879,53 @@ export const OxyProvider = ({
746
879
  kind: 'skip'
747
880
  };
748
881
  }
882
+ }, {
883
+ // 6) SSO bounce (TERMINAL, web only, at most once). No local session
884
+ // was found by any step above. Top-level navigate to the central
885
+ // `auth.oxy.so/sso?prompt=none` so the IdP can either mint a session
886
+ // (returning an opaque code we exchange on the callback) or report
887
+ // `none`. This step tears the document down on success — its `skip`
888
+ // result is only observed if `assign` no-ops. Disabled on the IdP
889
+ // itself, once the NO_SESSION flag is set, or while a bounce guard is
890
+ // still active (loop + self-heal protection).
891
+ id: 'sso-bounce',
892
+ enabled: () => {
893
+ if (!isWebBrowser() || window.top !== window.self) {
894
+ return false;
895
+ }
896
+ const origin = window.location.origin;
897
+ if (isCentralIdPOrigin(origin)) {
898
+ return false;
899
+ }
900
+ if (window.sessionStorage.getItem(ssoNoSessionKey(origin)) === '1') {
901
+ return false;
902
+ }
903
+ if (guardActive(window.sessionStorage, origin, Date.now())) {
904
+ return false;
905
+ }
906
+ return true;
907
+ },
908
+ run: async () => {
909
+ const origin = window.location.origin;
910
+ const state = oxyServices.generateSsoState();
911
+ window.sessionStorage.setItem(ssoStateKey(origin), state);
912
+ window.sessionStorage.setItem(ssoGuardKey(origin), String(Date.now()));
913
+ window.sessionStorage.setItem(ssoDestKey(origin), window.location.href);
914
+ const url = new URL('/sso', resolveCentralAuthUrl(oxyServices.config?.authWebUrl));
915
+ url.searchParams.set('prompt', 'none');
916
+ url.searchParams.set('client_id', origin);
917
+ url.searchParams.set('return_to', origin + SSO_CALLBACK_PATH);
918
+ url.searchParams.set('state', state);
919
+
920
+ // TERMINAL: the document is torn down by this navigation. The
921
+ // `skip` below is only reached if `assign` is a no-op (e.g. the
922
+ // navigation is blocked); in that case we fall through
923
+ // unauthenticated, which is correct.
924
+ ssoBounce.ssoNavigate(url.toString());
925
+ return {
926
+ kind: 'skip'
927
+ };
928
+ }
749
929
  }],
750
930
  onStepError: (id, error) => {
751
931
  if (__DEV__) {
@@ -773,7 +953,7 @@ export const OxyProvider = ({
773
953
  } finally {
774
954
  setTokenReady(true);
775
955
  }
776
- }, [oxyServices, storage, restoreViaRefreshCookie, restoreStoredSession]);
956
+ }, [oxyServices, storage, restoreViaRefreshCookie, restoreStoredSession, runSsoReturn]);
777
957
  useEffect(() => {
778
958
  if (!storage || initialized) {
779
959
  return;
@@ -786,6 +966,37 @@ export const OxyProvider = ({
786
966
  });
787
967
  }, [restoreSessionsFromStorage, storage, initialized, logger]);
788
968
 
969
+ // bfcache re-evaluation (web only, registered once). When a page is restored
970
+ // from the back/forward cache (`e.persisted`) NO cold boot re-runs — React
971
+ // state is resurrected as-is — yet the page may have been frozen mid-bounce
972
+ // and resurrected ON the SSO callback with a fresh fragment in the URL. Re-run
973
+ // the `sso-return` parse so the opaque code is still exchanged (and the
974
+ // fragment stripped + NO_SESSION flag maintained) on a bfcache restore. Routed
975
+ // through a ref so the listener registers exactly once and never churns with
976
+ // `runSsoReturn`'s identity.
977
+ const runSsoReturnRef = useRef(runSsoReturn);
978
+ runSsoReturnRef.current = runSsoReturn;
979
+ useEffect(() => {
980
+ if (!isWebBrowser()) {
981
+ return;
982
+ }
983
+ const onPageShow = event => {
984
+ if (!event.persisted) {
985
+ return;
986
+ }
987
+ runSsoReturnRef.current().catch(error => {
988
+ if (__DEV__) {
989
+ loggerUtil.debug('bfcache SSO return re-evaluation failed (non-fatal)', {
990
+ component: 'OxyContext',
991
+ method: 'onPageShow'
992
+ }, error);
993
+ }
994
+ });
995
+ };
996
+ window.addEventListener('pageshow', onPageShow);
997
+ return () => window.removeEventListener('pageshow', onPageShow);
998
+ }, []);
999
+
789
1000
  // Web SSO: Automatically check for cross-domain session on web platforms
790
1001
  // Also used for popup auth - updates all state and persists session
791
1002
  const handleWebSSOSession = useCallback(async session => {