@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.
@@ -14,8 +14,18 @@ import type { User, ApiError, SessionLoginResponse } from '@oxyhq/core';
14
14
  import type { ManagedAccount, CreateManagedAccountInput } from '@oxyhq/core';
15
15
  import { KeyManager } from '@oxyhq/core';
16
16
  import type { ClientSession } from '@oxyhq/core';
17
- import { autoDetectAuthWebUrl, runColdBoot } from '@oxyhq/core';
17
+ import { runColdBoot, resolveCentralAuthUrl, parseSsoReturnFragment, autoDetectAuthWebUrl } from '@oxyhq/core';
18
18
  import { toast } from '@oxyhq/bloom';
19
+ import {
20
+ SSO_CALLBACK_PATH,
21
+ ssoStateKey,
22
+ ssoGuardKey,
23
+ ssoDestKey,
24
+ ssoNoSessionKey,
25
+ isCentralIdPOrigin,
26
+ guardActive,
27
+ } from '../utils/ssoBounce';
28
+ import * as ssoBounce from '../utils/ssoBounce';
19
29
  import { useAuthStore, type AuthState } from '../stores/authStore';
20
30
  import { useShallow } from 'zustand/react/shallow';
21
31
  import { useSessionSocket } from '../hooks/useSessionSocket';
@@ -115,19 +125,19 @@ export interface OxyContextProviderProps {
115
125
  }
116
126
 
117
127
  /**
118
- * Module-level run-once guard for the cold-boot silent SSO steps
119
- * (`fedcm-silent` and `silent-iframe`).
128
+ * Module-level run-once guard for the cold-boot `fedcm-silent` step.
120
129
  *
121
- * Both steps trigger a one-shot browser credential / iframe handshake that must
122
- * fire AT MOST ONCE per page load — otherwise a provider remount storm (route
123
- * churn, StrictMode double-invoke, error-boundary recovery) becomes a credential
124
- * request storm. A per-instance ref resets on every remount, so the guard must
125
- * live at module scope. Keyed on `origin|baseURL` so two providers pointed at
126
- * the same API from the same origin share one attempt; never cleared because
127
- * only a fresh page load can change the IdP session state, and a fresh page load
128
- * starts a fresh module scope.
130
+ * The FedCM silent step triggers a one-shot `navigator.credentials.get`
131
+ * handshake that must fire AT MOST ONCE per page load — otherwise a provider
132
+ * remount storm (route churn, StrictMode double-invoke, error-boundary
133
+ * recovery) becomes a credential request storm. A per-instance ref resets on
134
+ * every remount, so the guard must live at module scope. Keyed on
135
+ * `origin|baseURL` so two providers pointed at the same API from the same
136
+ * origin share one attempt; never cleared because only a fresh page load can
137
+ * change the central IdP session state, and a fresh page load starts a fresh
138
+ * module scope.
129
139
  *
130
- * This is a NEW, dedicated set — distinct from `useWebSSO`'s `silentSSOAttempted`
140
+ * This is a dedicated set — distinct from `useWebSSO`'s `silentSSOAttempted`
131
141
  * (which guards the post-boot INTERACTIVE button path) and never a core
132
142
  * module-level singleton (that re-evaluates under Metro web bundling and the
133
143
  * guard would not hold).
@@ -226,19 +236,19 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
226
236
  if (providedOxyServices) {
227
237
  oxyServicesRef.current = providedOxyServices;
228
238
  } else if (baseURL) {
229
- // Auto-detect the FAPI (IdP) origin from the current browser hostname so
230
- // a consuming RP (mention.earth, homiio.com, alia.onl, …) targets
231
- // `auth.<rp-apex>` for FedCM + the silent iframe WITHOUT passing
232
- // `authWebUrl` explicitly that is what makes both the FedCM config and
233
- // the `/auth/silent` iframe first-party with the RP (Safari ITP /
234
- // Firefox TCP need first-party). An explicit `authWebUrl` prop still
235
- // wins. On native `autoDetectAuthWebUrl()` returns `undefined`
236
- // (off-browser), leaving the value unchanged. We only auto-detect on the
237
- // baseURL-only path a consumer-provided `OxyServices` instance is
238
- // never mutated.
239
+ // Target the CENTRAL IdP for TRUE cross-domain SSO. Every RP
240
+ // (mention.earth, homiio.com, alia.onl, …) delegates to the one central
241
+ // `auth.oxy.so` it owns the host-only `fedcm_session` cookie and the
242
+ // central session store reached via `api.oxy.so`, so a single sign-in
243
+ // there is observed by all RPs through the opaque-code `/sso` bounce.
244
+ // `resolveCentralAuthUrl(authWebUrl)` returns the explicit `authWebUrl`
245
+ // prop when provided (explicit always wins) and the central default
246
+ // otherwise. This is NOT per-apex auto-detection central SSO is
247
+ // deliberately central. A consumer-provided `OxyServices` instance is
248
+ // never mutated; only the baseURL-only construction path applies this.
239
249
  oxyServicesRef.current = new OxyServices({
240
250
  baseURL,
241
- authWebUrl: authWebUrl ?? autoDetectAuthWebUrl(),
251
+ authWebUrl: resolveCentralAuthUrl(authWebUrl),
242
252
  authRedirectUri,
243
253
  });
244
254
  } else {
@@ -735,14 +745,115 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
735
745
  storageKeys.sessionIds,
736
746
  ]);
737
747
 
748
+ // Central cross-domain SSO return handler (web). Parses the IdP redirect
749
+ // fragment, validates the CSRF `state`, exchanges the opaque single-use code
750
+ // for the real session, commits it, and restores the user's pre-bounce
751
+ // destination. Shared by the `sso-return` cold-boot step AND the bfcache
752
+ // `pageshow` re-evaluation, so the same security-critical logic runs exactly
753
+ // once per delivered fragment regardless of how the page was (re)shown.
754
+ //
755
+ // Returns `true` when a session was committed (caller short-circuits), `false`
756
+ // otherwise. On ANY non-ok outcome — `none`/`error`, state mismatch, missing
757
+ // code, or a failed/forged exchange — it sets the per-origin NO_SESSION flag
758
+ // so `sso-bounce` is disabled and the page cannot loop. Off-browser it is a
759
+ // no-op returning `false` (native never reaches it).
760
+ const runSsoReturn = useCallback(async (): Promise<boolean> => {
761
+ if (!isWebBrowser()) {
762
+ return false;
763
+ }
764
+
765
+ const ret = parseSsoReturnFragment(window.location.hash);
766
+ if (!ret) {
767
+ // Not an oxy_sso fragment — nothing to do (do NOT touch any flags).
768
+ return false;
769
+ }
770
+
771
+ const origin = window.location.origin;
772
+ const expectedState = window.sessionStorage.getItem(ssoStateKey(origin));
773
+ const stateOk = !!ret.state && !!expectedState && ret.state === expectedState;
774
+
775
+ // Strip the fragment FIRST so the opaque code never lingers in the address
776
+ // bar, history, or a copy-paste — even if a later step throws.
777
+ window.history.replaceState(null, '', window.location.pathname + window.location.search);
778
+ window.sessionStorage.removeItem(ssoStateKey(origin));
779
+
780
+ const markNoSession = () => {
781
+ window.sessionStorage.setItem(ssoNoSessionKey(origin), '1');
782
+ };
783
+
784
+ if (ret.kind === 'none' || ret.kind === 'error') {
785
+ // The central IdP had no session (or the bounce failed). Record it so we
786
+ // do not bounce again this tab — the definitive loop breaker.
787
+ markNoSession();
788
+ return false;
789
+ }
790
+
791
+ if (!stateOk || !ret.code) {
792
+ // Forged / replayed / stale fragment, or a malformed ok with no code.
793
+ // Treat exactly like "no session": never exchange, never loop.
794
+ markNoSession();
795
+ return false;
796
+ }
797
+
798
+ const commitWebSession = handleWebSSOSessionRef.current;
799
+ let session: SessionLoginResponse;
800
+ try {
801
+ session = await oxyServices.exchangeSsoCode(ret.code);
802
+ } catch (error) {
803
+ if (__DEV__) {
804
+ loggerUtil.debug(
805
+ 'SSO code exchange failed (treating as no session)',
806
+ { component: 'OxyContext', method: 'runSsoReturn' },
807
+ error,
808
+ );
809
+ }
810
+ markNoSession();
811
+ return false;
812
+ }
813
+
814
+ if (!session?.sessionId || !commitWebSession) {
815
+ markNoSession();
816
+ return false;
817
+ }
818
+
819
+ await commitWebSession(session);
820
+
821
+ // Restore the user's real destination captured before the bounce. We only
822
+ // rewrite the URL when we are sitting on the callback path — otherwise the
823
+ // current URL is already the destination.
824
+ if (window.location.pathname === SSO_CALLBACK_PATH) {
825
+ const dest = window.sessionStorage.getItem(ssoDestKey(origin));
826
+ if (dest) {
827
+ try {
828
+ const destUrl = new URL(dest);
829
+ // Same-origin only — never honour a cross-origin destination that
830
+ // could have been planted to redirect the freshly signed-in user.
831
+ if (destUrl.origin === origin) {
832
+ window.history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
833
+ }
834
+ } catch {
835
+ // Malformed stored destination — leave the URL on the callback path.
836
+ }
837
+ }
838
+ }
839
+ window.sessionStorage.removeItem(ssoDestKey(origin));
840
+
841
+ return true;
842
+ }, [oxyServices]);
843
+
738
844
  // Cold boot — the single, ordered, short-circuit session-recovery sequence,
739
845
  // consuming the SAME `runColdBoot` core primitive as `WebOxyProvider`. The
740
846
  // FIRST step that yields a session wins; every later step is skipped. Each
741
847
  // web-only step is gated by `isWebBrowser()`, so on native ONLY
742
848
  // `stored-session` runs.
743
849
  //
744
- // Order (web): redirect callback → FedCM silent → silent iframe refresh
745
- // cookie stored session. Order (native): stored session only.
850
+ // Order (web): redirect callback → SSO returnFedCM silent (central)
851
+ // silent iframe (per-apex, the durable reload path) cookie restore →
852
+ // stored session → SSO bounce (terminal). The per-apex silent iframe is what
853
+ // restores a durable cross-domain session on reload WITHOUT a top-level
854
+ // bounce, so when it wins `sso-bounce` never fires (no flash, no loop).
855
+ // Order (native): stored session only (every web-only step is disabled
856
+ // off-browser).
746
857
  const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
747
858
  if (!storage) {
748
859
  return;
@@ -758,7 +869,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
758
869
  const outcome = await runColdBoot<true>({
759
870
  steps: [
760
871
  {
761
- // 1) Redirect callback wins: a popup/redirect sign-in just landed
872
+ // 0) Redirect callback wins: a popup/redirect sign-in just landed
762
873
  // back on this page with `access_token`/`session_id` query params.
763
874
  // `handleAuthCallback` plants the token but returns a PLACEHOLDER
764
875
  // user (empty id), so we hydrate the REAL user via `getCurrentUser`
@@ -777,10 +888,25 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
777
888
  },
778
889
  },
779
890
  {
780
- // 2) FedCM silent reauthn (Chrome). `silentSignInWithFedCM` plants
781
- // the access token internally; we commit the returned session via
891
+ // 1) Central SSO return: we are landing back from an `auth.oxy.so/sso`
892
+ // bounce with the result in the URL fragment. Parse it, validate the
893
+ // CSRF state, exchange the opaque code, and commit. On any non-ok
894
+ // outcome `runSsoReturn` sets the per-origin NO_SESSION flag so the
895
+ // terminal `sso-bounce` step is disabled — the loop breaker.
896
+ id: 'sso-return',
897
+ enabled: () => isWebBrowser(),
898
+ run: async () => {
899
+ const committed = await runSsoReturn();
900
+ return committed ? { kind: 'session', session: true } : { kind: 'skip' };
901
+ },
902
+ },
903
+ {
904
+ // 2) FedCM silent reauthn (Chrome) against the CENTRAL IdP
905
+ // (auth.oxy.so). `silentSignInWithFedCM` plants the access token
906
+ // internally; we commit the returned session via
782
907
  // `handleWebSSOSession`. Guarded so it fires at most once per page
783
- // load across remounts.
908
+ // load across remounts. This is an enhancement layered above the
909
+ // opaque-code bounce: when it succeeds the bounce never fires.
784
910
  id: 'fedcm-silent',
785
911
  enabled: () => fedcmSupported && !servicesSilentAttempted.has(silentKey),
786
912
  run: async () => {
@@ -794,20 +920,36 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
794
920
  },
795
921
  },
796
922
  {
797
- // 3) Silent first-party iframe ({authWebUrl}/auth/silent) for
798
- // browsers without FedCM (Safari / Firefox). After auto-detection
799
- // `authWebUrl` is `auth.<rp-apex>`, so the iframe + its
800
- // `fedcm_session` cookie are first-party with the RP. Shares the
801
- // one-shot guard with the FedCM step.
923
+ // 3) First-party silent iframe at the PER-APEX IdP — the DURABLE
924
+ // cross-domain reload-restore path. The durable session lives as a
925
+ // first-party `fedcm_session` cookie on `auth.<rp-apex>` (e.g.
926
+ // `auth.mention.earth`), established during the `/sso` bounce's
927
+ // `/sso/establish` hop. That host is SAME-SITE to the RP page, so
928
+ // the cookie is first-party under Safari ITP / Firefox TCP — and
929
+ // an iframe read is NOT a top-level navigation, so it restores on
930
+ // reload with NO flash and works in a backgrounded tab. This is the
931
+ // step that prevents the re-bounce loop: when it finds a session,
932
+ // the terminal `sso-bounce` never fires.
933
+ //
934
+ // The instance is configured with `authWebUrl=auth.oxy.so` (central,
935
+ // for the bounce + FedCM), so we explicitly point the iframe at the
936
+ // per-apex host via `autoDetectAuthWebUrl()` and `silentSignIn`'s
937
+ // `authWebUrlOverride`. On a `*.oxy.so` RP the per-apex host IS the
938
+ // central host (`auth.oxy.so`), so this is a same-host no-op-
939
+ // equivalent. When auto-detection bails (localhost/IP/single-label)
940
+ // there is no per-apex IdP and the step skips. Web only; on native
941
+ // `isWebBrowser()` gates it off, so native never runs an iframe.
802
942
  id: 'silent-iframe',
803
- enabled: () =>
804
- isWebBrowser() &&
805
- oxyServices.isFedCMSupported?.() !== true &&
806
- !servicesSilentAttempted.has(silentKey),
943
+ enabled: () => isWebBrowser(),
807
944
  run: async () => {
808
- servicesSilentAttempted.add(silentKey);
809
- const session = await oxyServices.silentSignIn?.();
810
- if (!session || !commitWebSession) {
945
+ const perApexAuthUrl = autoDetectAuthWebUrl();
946
+ if (!perApexAuthUrl || !commitWebSession) {
947
+ return { kind: 'skip' };
948
+ }
949
+ const session = await oxyServices.silentSignIn?.({
950
+ authWebUrlOverride: perApexAuthUrl,
951
+ });
952
+ if (!session?.user || !session?.sessionId) {
811
953
  return { kind: 'skip' };
812
954
  }
813
955
  await commitWebSession(session);
@@ -815,12 +957,12 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
815
957
  },
816
958
  },
817
959
  {
818
- // 4) Refresh-cookie restore (same-site only). On `*.oxy.so` the
960
+ // 4) Refresh-cookie restore (first-party only). On `*.oxy.so` the
819
961
  // httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
820
962
  // device-local slot. On a cross-domain RP (mention.earth, …) the
821
963
  // cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
822
- // `refreshAllSessions` returns `{accounts:[]}` and this skips.
823
- // That is correct; there is deliberately NO `api.<apex>` bridge.
964
+ // `refreshAllSessions` returns `{accounts:[]}` and this skips. That
965
+ // is correct; cross-domain restore is handled by the SSO bounce.
824
966
  id: 'cookie-restore',
825
967
  enabled: () => isWebBrowser(),
826
968
  run: async () => {
@@ -830,14 +972,61 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
830
972
  },
831
973
  {
832
974
  // 5) Stored-session bearer restore. NO `enabled` gate — runs on ALL
833
- // platforms. This is native's ONLY restore path (every web-only
834
- // step above is disabled off-browser).
975
+ // platforms. This is native's ONLY restore path (every web-only step
976
+ // is disabled off-browser, so native reaches exactly this).
835
977
  id: 'stored-session',
836
978
  run: async () => {
837
979
  const restored = await restoreStoredSession();
838
980
  return restored ? { kind: 'session', session: true } : { kind: 'skip' };
839
981
  },
840
982
  },
983
+ {
984
+ // 6) SSO bounce (TERMINAL, web only, at most once). No local session
985
+ // was found by any step above. Top-level navigate to the central
986
+ // `auth.oxy.so/sso?prompt=none` so the IdP can either mint a session
987
+ // (returning an opaque code we exchange on the callback) or report
988
+ // `none`. This step tears the document down on success — its `skip`
989
+ // result is only observed if `assign` no-ops. Disabled on the IdP
990
+ // itself, once the NO_SESSION flag is set, or while a bounce guard is
991
+ // still active (loop + self-heal protection).
992
+ id: 'sso-bounce',
993
+ enabled: () => {
994
+ if (!isWebBrowser() || window.top !== window.self) {
995
+ return false;
996
+ }
997
+ const origin = window.location.origin;
998
+ if (isCentralIdPOrigin(origin)) {
999
+ return false;
1000
+ }
1001
+ if (window.sessionStorage.getItem(ssoNoSessionKey(origin)) === '1') {
1002
+ return false;
1003
+ }
1004
+ if (guardActive(window.sessionStorage, origin, Date.now())) {
1005
+ return false;
1006
+ }
1007
+ return true;
1008
+ },
1009
+ run: async () => {
1010
+ const origin = window.location.origin;
1011
+ const state = oxyServices.generateSsoState();
1012
+ window.sessionStorage.setItem(ssoStateKey(origin), state);
1013
+ window.sessionStorage.setItem(ssoGuardKey(origin), String(Date.now()));
1014
+ window.sessionStorage.setItem(ssoDestKey(origin), window.location.href);
1015
+
1016
+ const url = new URL('/sso', resolveCentralAuthUrl(oxyServices.config?.authWebUrl));
1017
+ url.searchParams.set('prompt', 'none');
1018
+ url.searchParams.set('client_id', origin);
1019
+ url.searchParams.set('return_to', origin + SSO_CALLBACK_PATH);
1020
+ url.searchParams.set('state', state);
1021
+
1022
+ // TERMINAL: the document is torn down by this navigation. The
1023
+ // `skip` below is only reached if `assign` is a no-op (e.g. the
1024
+ // navigation is blocked); in that case we fall through
1025
+ // unauthenticated, which is correct.
1026
+ ssoBounce.ssoNavigate(url.toString());
1027
+ return { kind: 'skip' };
1028
+ },
1029
+ },
841
1030
  ],
842
1031
  onStepError: (id, error) => {
843
1032
  if (__DEV__) {
@@ -869,6 +1058,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
869
1058
  storage,
870
1059
  restoreViaRefreshCookie,
871
1060
  restoreStoredSession,
1061
+ runSsoReturn,
872
1062
  ]);
873
1063
 
874
1064
  useEffect(() => {
@@ -884,6 +1074,39 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
884
1074
  });
885
1075
  }, [restoreSessionsFromStorage, storage, initialized, logger]);
886
1076
 
1077
+ // bfcache re-evaluation (web only, registered once). When a page is restored
1078
+ // from the back/forward cache (`e.persisted`) NO cold boot re-runs — React
1079
+ // state is resurrected as-is — yet the page may have been frozen mid-bounce
1080
+ // and resurrected ON the SSO callback with a fresh fragment in the URL. Re-run
1081
+ // the `sso-return` parse so the opaque code is still exchanged (and the
1082
+ // fragment stripped + NO_SESSION flag maintained) on a bfcache restore. Routed
1083
+ // through a ref so the listener registers exactly once and never churns with
1084
+ // `runSsoReturn`'s identity.
1085
+ const runSsoReturnRef = useRef(runSsoReturn);
1086
+ runSsoReturnRef.current = runSsoReturn;
1087
+
1088
+ useEffect(() => {
1089
+ if (!isWebBrowser()) {
1090
+ return;
1091
+ }
1092
+ const onPageShow = (event: PageTransitionEvent) => {
1093
+ if (!event.persisted) {
1094
+ return;
1095
+ }
1096
+ runSsoReturnRef.current().catch((error) => {
1097
+ if (__DEV__) {
1098
+ loggerUtil.debug(
1099
+ 'bfcache SSO return re-evaluation failed (non-fatal)',
1100
+ { component: 'OxyContext', method: 'onPageShow' },
1101
+ error,
1102
+ );
1103
+ }
1104
+ });
1105
+ };
1106
+ window.addEventListener('pageshow', onPageShow);
1107
+ return () => window.removeEventListener('pageshow', onPageShow);
1108
+ }, []);
1109
+
887
1110
  // Web SSO: Automatically check for cross-domain session on web platforms
888
1111
  // Also used for popup auth - updates all state and persists session
889
1112
  const handleWebSSOSession = useCallback(async (session: SessionLoginResponse) => {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Central cross-domain SSO bounce — per-origin sessionStorage keys and small
3
+ * pure predicates shared by the cold-boot `sso-return` / `sso-bounce` steps and
4
+ * the bfcache `pageshow` re-evaluation.
5
+ *
6
+ * TRUE central SSO (Google/Meta/Clerk style) works like this for a Relying
7
+ * Party (mention.earth, homiio.com, alia.onl, …) with no local session:
8
+ *
9
+ * 1. `sso-bounce` (terminal, once): top-level navigate to
10
+ * `auth.oxy.so/sso?prompt=none&client_id=<origin>&return_to=<origin>/__oxy/sso-callback&state=<s>`.
11
+ * Before navigating it records, in this origin's `sessionStorage`, the CSRF
12
+ * `state`, a guard timestamp (loop breaker), and the real destination URL
13
+ * to restore after the callback.
14
+ * 2. The central IdP worker reads its first-party `fedcm_session`, mints a
15
+ * session, stores it under an opaque single-use `code`, and 303-redirects
16
+ * back to `<origin>/__oxy/sso-callback#oxy_sso=ok&code=<code>&state=<s>`
17
+ * (or `#oxy_sso=none` / `#oxy_sso=error`).
18
+ * 3. `sso-return` parses the fragment (`parseSsoReturnFragment` from core),
19
+ * validates `state`, exchanges the `code` via `oxyServices.exchangeSsoCode`,
20
+ * and commits the session — then restores the original destination.
21
+ *
22
+ * Loop proof (logged-out): first load all steps skip → `sso-bounce` sets
23
+ * guard/state/dest and navigates; the IdP (no central session) returns
24
+ * `#oxy_sso=none`; the callback load's `sso-return` sees `none`, sets the
25
+ * no-session flag, and `sso-bounce` is then disabled. Exactly ONE bounce, no
26
+ * loop. An interrupted bounce (user hit back mid-redirect) self-heals once the
27
+ * 30s guard TTL lapses.
28
+ *
29
+ * All state lives in `sessionStorage` (per tab, cleared on tab close) and is
30
+ * keyed per-origin so two RPs hosted in the same browser never collide. This
31
+ * module is pure with respect to navigation: it only reads/writes
32
+ * `sessionStorage` and parses URLs; it performs no redirects itself.
33
+ */
34
+
35
+ import { CENTRAL_AUTH_URL } from '@oxyhq/core';
36
+
37
+ /**
38
+ * The RP callback path the central IdP redirects back to. The SSO result is
39
+ * delivered in the fragment of this URL; `sso-return` consumes it and then
40
+ * restores the user's real destination.
41
+ */
42
+ export const SSO_CALLBACK_PATH = '/__oxy/sso-callback';
43
+
44
+ /**
45
+ * Self-healing TTL (ms) for the bounce guard. If a bounce is interrupted before
46
+ * the callback lands (e.g. the user navigates back mid-redirect), the guard
47
+ * would otherwise pin the RP signed-out forever. After this window the guard is
48
+ * treated as stale and a fresh single bounce is permitted.
49
+ */
50
+ export const SSO_GUARD_TTL_MS = 30_000;
51
+
52
+ const STATE_KEY_PREFIX = 'oxy_sso_state:';
53
+ const GUARD_KEY_PREFIX = 'oxy_sso_guard:';
54
+ const DEST_KEY_PREFIX = 'oxy_sso_dest:';
55
+ const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
56
+
57
+ /**
58
+ * Perform the terminal top-level SSO bounce navigation.
59
+ *
60
+ * A thin wrapper over `window.location.assign(url)` so the single navigation
61
+ * seam lives in one place (and stays mockable in tests, where jsdom's
62
+ * `Location.assign` is a non-configurable native method). In production this is
63
+ * exactly `window.location.assign` — the document is torn down and replaced by
64
+ * the central IdP page. Off-browser it is a no-op (native never bounces).
65
+ */
66
+ export function ssoNavigate(url: string): void {
67
+ if (typeof window === 'undefined' || typeof window.location === 'undefined') {
68
+ return;
69
+ }
70
+ window.location.assign(url);
71
+ }
72
+
73
+ /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
74
+ export function ssoStateKey(origin: string): string {
75
+ return `${STATE_KEY_PREFIX}${origin}`;
76
+ }
77
+
78
+ /** Per-origin bounce guard key (a timestamp; loop breaker + self-heal TTL). */
79
+ export function ssoGuardKey(origin: string): string {
80
+ return `${GUARD_KEY_PREFIX}${origin}`;
81
+ }
82
+
83
+ /** Per-origin destination key (the real URL to restore after the callback). */
84
+ export function ssoDestKey(origin: string): string {
85
+ return `${DEST_KEY_PREFIX}${origin}`;
86
+ }
87
+
88
+ /**
89
+ * Per-origin "the central IdP has no session for me" key. Set after a
90
+ * `none`/`error` return (or a failed/forged exchange) so `sso-bounce` does not
91
+ * fire again this tab — the definitive loop breaker.
92
+ */
93
+ export function ssoNoSessionKey(origin: string): string {
94
+ return `${NO_SESSION_KEY_PREFIX}${origin}`;
95
+ }
96
+
97
+ /**
98
+ * Whether `origin` IS the central IdP origin. We must never bounce while on
99
+ * `auth.oxy.so` itself (it would bounce to itself). Compared by URL origin so a
100
+ * trailing-slash / path difference never defeats the guard.
101
+ */
102
+ export function isCentralIdPOrigin(origin: string): boolean {
103
+ let centralOrigin: string;
104
+ try {
105
+ centralOrigin = new URL(CENTRAL_AUTH_URL).origin;
106
+ } catch {
107
+ return false;
108
+ }
109
+ let candidateOrigin: string;
110
+ try {
111
+ candidateOrigin = new URL(origin).origin;
112
+ } catch {
113
+ return false;
114
+ }
115
+ return candidateOrigin === centralOrigin;
116
+ }
117
+
118
+ /**
119
+ * Read the bounce guard and decide whether it is still ACTIVE.
120
+ *
121
+ * Active means: a guard value is present AND it parses to a finite timestamp AND
122
+ * less than {@link SSO_GUARD_TTL_MS} has elapsed since it was set. An active
123
+ * guard disables `sso-bounce` (a bounce is already in flight this tab). A
124
+ * missing, malformed, or expired guard is NOT active, so a fresh bounce may
125
+ * proceed (this is the 30s self-heal for an interrupted bounce).
126
+ *
127
+ * @param storage - The session storage to read (injected for testability).
128
+ * @param origin - The page origin whose guard to evaluate.
129
+ * @param now - Current epoch ms (injected for deterministic tests).
130
+ */
131
+ export function guardActive(storage: Storage, origin: string, now: number): boolean {
132
+ let raw: string | null;
133
+ try {
134
+ raw = storage.getItem(ssoGuardKey(origin));
135
+ } catch {
136
+ return false;
137
+ }
138
+ if (raw === null || raw.length === 0) {
139
+ return false;
140
+ }
141
+ const stamp = Number(raw);
142
+ if (!Number.isFinite(stamp)) {
143
+ return false;
144
+ }
145
+ return now - stamp < SSO_GUARD_TTL_MS;
146
+ }