@oxyhq/services 8.3.0 → 8.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.
@@ -14,18 +14,21 @@ 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 { runColdBoot, resolveCentralAuthUrl, parseSsoReturnFragment } from '@oxyhq/core';
18
- import { toast } from '@oxyhq/bloom';
19
17
  import {
20
- SSO_CALLBACK_PATH,
18
+ runColdBoot,
19
+ resolveCentralAuthUrl,
20
+ autoDetectAuthWebUrl,
21
21
  ssoStateKey,
22
22
  ssoGuardKey,
23
23
  ssoDestKey,
24
24
  ssoNoSessionKey,
25
25
  isCentralIdPOrigin,
26
26
  guardActive,
27
- } from '../utils/ssoBounce';
28
- import * as ssoBounce from '../utils/ssoBounce';
27
+ ssoNavigate,
28
+ buildSsoBounceUrl,
29
+ consumeSsoReturn,
30
+ } from '@oxyhq/core';
31
+ import { toast } from '@oxyhq/bloom';
29
32
  import { useAuthStore, type AuthState } from '../stores/authStore';
30
33
  import { useShallow } from 'zustand/react/shallow';
31
34
  import { useSessionSocket } from '../hooks/useSessionSocket';
@@ -745,99 +748,42 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
745
748
  storageKeys.sessionIds,
746
749
  ]);
747
750
 
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.
751
+ // Central cross-domain SSO return handler (web). A THIN wrapper over core's
752
+ // `consumeSsoReturn`, which performs the entire security-critical kernel —
753
+ // parse the IdP redirect fragment, validate the CSRF `state`, strip the
754
+ // fragment FIRST, exchange the opaque single-use code, restore the user's
755
+ // pre-bounce destination (same-origin only), and set the per-origin
756
+ // NO_SESSION loop breaker on every non-ok outcome and RETURNS the exchanged
757
+ // session (or `null`) WITHOUT committing. We preserve services' contract by
758
+ // committing the returned session here via `handleWebSSOSession`. Shared by
759
+ // the `sso-return` cold-boot step AND the bfcache `pageshow` re-evaluation, so
760
+ // the same kernel runs exactly once per delivered fragment regardless of how
761
+ // the page was (re)shown.
754
762
  //
755
763
  // 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).
764
+ // otherwise. Off-browser `consumeSsoReturn` is a no-op returning `null`, so
765
+ // this returns `false` (native never reaches it).
760
766
  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();
767
+ const session = await consumeSsoReturn(oxyServices, {
768
+ isWeb: isWebBrowser,
769
+ onExchangeError: (error) => {
770
+ if (__DEV__) {
771
+ loggerUtil.debug(
772
+ 'SSO code exchange failed (treating as no session)',
773
+ { component: 'OxyContext', method: 'runSsoReturn' },
774
+ error,
775
+ );
776
+ }
777
+ },
778
+ });
779
+ if (!session) {
795
780
  return false;
796
781
  }
797
-
798
782
  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();
783
+ if (!commitWebSession) {
816
784
  return false;
817
785
  }
818
-
819
786
  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
787
  return true;
842
788
  }, [oxyServices]);
843
789
 
@@ -847,9 +793,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
847
793
  // web-only step is gated by `isWebBrowser()`, so on native ONLY
848
794
  // `stored-session` runs.
849
795
  //
850
- // Order (web): redirect callback → SSO return → FedCM silent → cookie restore
851
- // stored session SSO bounce (terminal). Order (native): stored session
852
- // only (every web-only step is disabled off-browser).
796
+ // Order (web): redirect callback → SSO return → FedCM silent (central)
797
+ // silent iframe (per-apex, the durable reload path) cookie restore
798
+ // stored session → SSO bounce (terminal). The per-apex silent iframe is what
799
+ // restores a durable cross-domain session on reload WITHOUT a top-level
800
+ // bounce, so when it wins `sso-bounce` never fires (no flash, no loop).
801
+ // Order (native): stored session only (every web-only step is disabled
802
+ // off-browser).
853
803
  const restoreSessionsFromStorage = useCallback(async (): Promise<void> => {
854
804
  if (!storage) {
855
805
  return;
@@ -916,7 +866,44 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
916
866
  },
917
867
  },
918
868
  {
919
- // 3) Refresh-cookie restore (first-party only). On `*.oxy.so` the
869
+ // 3) First-party silent iframe at the PER-APEX IdP the DURABLE
870
+ // cross-domain reload-restore path. The durable session lives as a
871
+ // first-party `fedcm_session` cookie on `auth.<rp-apex>` (e.g.
872
+ // `auth.mention.earth`), established during the `/sso` bounce's
873
+ // `/sso/establish` hop. That host is SAME-SITE to the RP page, so
874
+ // the cookie is first-party under Safari ITP / Firefox TCP — and
875
+ // an iframe read is NOT a top-level navigation, so it restores on
876
+ // reload with NO flash and works in a backgrounded tab. This is the
877
+ // step that prevents the re-bounce loop: when it finds a session,
878
+ // the terminal `sso-bounce` never fires.
879
+ //
880
+ // The instance is configured with `authWebUrl=auth.oxy.so` (central,
881
+ // for the bounce + FedCM), so we explicitly point the iframe at the
882
+ // per-apex host via `autoDetectAuthWebUrl()` and `silentSignIn`'s
883
+ // `authWebUrlOverride`. On a `*.oxy.so` RP the per-apex host IS the
884
+ // central host (`auth.oxy.so`), so this is a same-host no-op-
885
+ // equivalent. When auto-detection bails (localhost/IP/single-label)
886
+ // there is no per-apex IdP and the step skips. Web only; on native
887
+ // `isWebBrowser()` gates it off, so native never runs an iframe.
888
+ id: 'silent-iframe',
889
+ enabled: () => isWebBrowser(),
890
+ run: async () => {
891
+ const perApexAuthUrl = autoDetectAuthWebUrl();
892
+ if (!perApexAuthUrl || !commitWebSession) {
893
+ return { kind: 'skip' };
894
+ }
895
+ const session = await oxyServices.silentSignIn?.({
896
+ authWebUrlOverride: perApexAuthUrl,
897
+ });
898
+ if (!session?.user || !session?.sessionId) {
899
+ return { kind: 'skip' };
900
+ }
901
+ await commitWebSession(session);
902
+ return { kind: 'session', session: true };
903
+ },
904
+ },
905
+ {
906
+ // 4) Refresh-cookie restore (first-party only). On `*.oxy.so` the
920
907
  // httpOnly `oxy_rt_${n}` cookies ride along and resurrect every
921
908
  // device-local slot. On a cross-domain RP (mention.earth, …) the
922
909
  // cookie is `Domain=oxy.so` so it never reaches `api.<apex>` —
@@ -930,7 +917,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
930
917
  },
931
918
  },
932
919
  {
933
- // 4) Stored-session bearer restore. NO `enabled` gate — runs on ALL
920
+ // 5) Stored-session bearer restore. NO `enabled` gate — runs on ALL
934
921
  // platforms. This is native's ONLY restore path (every web-only step
935
922
  // is disabled off-browser, so native reaches exactly this).
936
923
  id: 'stored-session',
@@ -940,7 +927,7 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
940
927
  },
941
928
  },
942
929
  {
943
- // 5) SSO bounce (TERMINAL, web only, at most once). No local session
930
+ // 6) SSO bounce (TERMINAL, web only, at most once). No local session
944
931
  // was found by any step above. Top-level navigate to the central
945
932
  // `auth.oxy.so/sso?prompt=none` so the IdP can either mint a session
946
933
  // (returning an opaque code we exchange on the callback) or report
@@ -972,17 +959,13 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
972
959
  window.sessionStorage.setItem(ssoGuardKey(origin), String(Date.now()));
973
960
  window.sessionStorage.setItem(ssoDestKey(origin), window.location.href);
974
961
 
975
- const url = new URL('/sso', resolveCentralAuthUrl(oxyServices.config?.authWebUrl));
976
- url.searchParams.set('prompt', 'none');
977
- url.searchParams.set('client_id', origin);
978
- url.searchParams.set('return_to', origin + SSO_CALLBACK_PATH);
979
- url.searchParams.set('state', state);
962
+ const url = buildSsoBounceUrl(origin, state, oxyServices.config?.authWebUrl);
980
963
 
981
964
  // TERMINAL: the document is torn down by this navigation. The
982
965
  // `skip` below is only reached if `assign` is a no-op (e.g. the
983
966
  // navigation is blocked); in that case we fall through
984
967
  // unauthenticated, which is correct.
985
- ssoBounce.ssoNavigate(url.toString());
968
+ ssoNavigate(url);
986
969
  return { kind: 'skip' };
987
970
  },
988
971
  },
@@ -1,158 +0,0 @@
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
@@ -1 +0,0 @@
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":[]}
@@ -1,148 +0,0 @@
1
- "use strict";
2
-
3
- /**
4
- * Central cross-domain SSO bounce — per-origin sessionStorage keys and small
5
- * pure predicates shared by the cold-boot `sso-return` / `sso-bounce` steps and
6
- * the bfcache `pageshow` re-evaluation.
7
- *
8
- * TRUE central SSO (Google/Meta/Clerk style) works like this for a Relying
9
- * Party (mention.earth, homiio.com, alia.onl, …) with no local session:
10
- *
11
- * 1. `sso-bounce` (terminal, once): top-level navigate to
12
- * `auth.oxy.so/sso?prompt=none&client_id=<origin>&return_to=<origin>/__oxy/sso-callback&state=<s>`.
13
- * Before navigating it records, in this origin's `sessionStorage`, the CSRF
14
- * `state`, a guard timestamp (loop breaker), and the real destination URL
15
- * to restore after the callback.
16
- * 2. The central IdP worker reads its first-party `fedcm_session`, mints a
17
- * session, stores it under an opaque single-use `code`, and 303-redirects
18
- * back to `<origin>/__oxy/sso-callback#oxy_sso=ok&code=<code>&state=<s>`
19
- * (or `#oxy_sso=none` / `#oxy_sso=error`).
20
- * 3. `sso-return` parses the fragment (`parseSsoReturnFragment` from core),
21
- * validates `state`, exchanges the `code` via `oxyServices.exchangeSsoCode`,
22
- * and commits the session — then restores the original destination.
23
- *
24
- * Loop proof (logged-out): first load all steps skip → `sso-bounce` sets
25
- * guard/state/dest and navigates; the IdP (no central session) returns
26
- * `#oxy_sso=none`; the callback load's `sso-return` sees `none`, sets the
27
- * no-session flag, and `sso-bounce` is then disabled. Exactly ONE bounce, no
28
- * loop. An interrupted bounce (user hit back mid-redirect) self-heals once the
29
- * 30s guard TTL lapses.
30
- *
31
- * All state lives in `sessionStorage` (per tab, cleared on tab close) and is
32
- * keyed per-origin so two RPs hosted in the same browser never collide. This
33
- * module is pure with respect to navigation: it only reads/writes
34
- * `sessionStorage` and parses URLs; it performs no redirects itself.
35
- */
36
-
37
- import { CENTRAL_AUTH_URL } from '@oxyhq/core';
38
-
39
- /**
40
- * The RP callback path the central IdP redirects back to. The SSO result is
41
- * delivered in the fragment of this URL; `sso-return` consumes it and then
42
- * restores the user's real destination.
43
- */
44
- export const SSO_CALLBACK_PATH = '/__oxy/sso-callback';
45
-
46
- /**
47
- * Self-healing TTL (ms) for the bounce guard. If a bounce is interrupted before
48
- * the callback lands (e.g. the user navigates back mid-redirect), the guard
49
- * would otherwise pin the RP signed-out forever. After this window the guard is
50
- * treated as stale and a fresh single bounce is permitted.
51
- */
52
- export const SSO_GUARD_TTL_MS = 30_000;
53
- const STATE_KEY_PREFIX = 'oxy_sso_state:';
54
- const GUARD_KEY_PREFIX = 'oxy_sso_guard:';
55
- const DEST_KEY_PREFIX = 'oxy_sso_dest:';
56
- const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
57
-
58
- /**
59
- * Perform the terminal top-level SSO bounce navigation.
60
- *
61
- * A thin wrapper over `window.location.assign(url)` so the single navigation
62
- * seam lives in one place (and stays mockable in tests, where jsdom's
63
- * `Location.assign` is a non-configurable native method). In production this is
64
- * exactly `window.location.assign` — the document is torn down and replaced by
65
- * the central IdP page. Off-browser it is a no-op (native never bounces).
66
- */
67
- export function ssoNavigate(url) {
68
- if (typeof window === 'undefined' || typeof window.location === 'undefined') {
69
- return;
70
- }
71
- window.location.assign(url);
72
- }
73
-
74
- /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
75
- export function ssoStateKey(origin) {
76
- return `${STATE_KEY_PREFIX}${origin}`;
77
- }
78
-
79
- /** Per-origin bounce guard key (a timestamp; loop breaker + self-heal TTL). */
80
- export function ssoGuardKey(origin) {
81
- return `${GUARD_KEY_PREFIX}${origin}`;
82
- }
83
-
84
- /** Per-origin destination key (the real URL to restore after the callback). */
85
- export function ssoDestKey(origin) {
86
- return `${DEST_KEY_PREFIX}${origin}`;
87
- }
88
-
89
- /**
90
- * Per-origin "the central IdP has no session for me" key. Set after a
91
- * `none`/`error` return (or a failed/forged exchange) so `sso-bounce` does not
92
- * fire again this tab — the definitive loop breaker.
93
- */
94
- export function ssoNoSessionKey(origin) {
95
- return `${NO_SESSION_KEY_PREFIX}${origin}`;
96
- }
97
-
98
- /**
99
- * Whether `origin` IS the central IdP origin. We must never bounce while on
100
- * `auth.oxy.so` itself (it would bounce to itself). Compared by URL origin so a
101
- * trailing-slash / path difference never defeats the guard.
102
- */
103
- export function isCentralIdPOrigin(origin) {
104
- let centralOrigin;
105
- try {
106
- centralOrigin = new URL(CENTRAL_AUTH_URL).origin;
107
- } catch {
108
- return false;
109
- }
110
- let candidateOrigin;
111
- try {
112
- candidateOrigin = new URL(origin).origin;
113
- } catch {
114
- return false;
115
- }
116
- return candidateOrigin === centralOrigin;
117
- }
118
-
119
- /**
120
- * Read the bounce guard and decide whether it is still ACTIVE.
121
- *
122
- * Active means: a guard value is present AND it parses to a finite timestamp AND
123
- * less than {@link SSO_GUARD_TTL_MS} has elapsed since it was set. An active
124
- * guard disables `sso-bounce` (a bounce is already in flight this tab). A
125
- * missing, malformed, or expired guard is NOT active, so a fresh bounce may
126
- * proceed (this is the 30s self-heal for an interrupted bounce).
127
- *
128
- * @param storage - The session storage to read (injected for testability).
129
- * @param origin - The page origin whose guard to evaluate.
130
- * @param now - Current epoch ms (injected for deterministic tests).
131
- */
132
- export function guardActive(storage, origin, now) {
133
- let raw;
134
- try {
135
- raw = storage.getItem(ssoGuardKey(origin));
136
- } catch {
137
- return false;
138
- }
139
- if (raw === null || raw.length === 0) {
140
- return false;
141
- }
142
- const stamp = Number(raw);
143
- if (!Number.isFinite(stamp)) {
144
- return false;
145
- }
146
- return now - stamp < SSO_GUARD_TTL_MS;
147
- }
148
- //# sourceMappingURL=ssoBounce.js.map
@@ -1 +0,0 @@
1
- {"version":3,"names":["CENTRAL_AUTH_URL","SSO_CALLBACK_PATH","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","candidateOrigin","guardActive","storage","now","raw","getItem","length","stamp","Number","isFinite"],"sourceRoot":"../../../../src","sources":["ui/utils/ssoBounce.ts"],"mappings":";;AAAA;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;;AAEA,SAASA,gBAAgB,QAAQ,aAAa;;AAE9C;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMC,iBAAiB,GAAG,qBAAqB;;AAEtD;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,MAAMC,gBAAgB,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;AACA,OAAO,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;AACA,OAAO,SAASI,WAAWA,CAACC,MAAc,EAAU;EAClD,OAAO,GAAGV,gBAAgB,GAAGU,MAAM,EAAE;AACvC;;AAEA;AACA,OAAO,SAASC,WAAWA,CAACD,MAAc,EAAU;EAClD,OAAO,GAAGT,gBAAgB,GAAGS,MAAM,EAAE;AACvC;;AAEA;AACA,OAAO,SAASE,UAAUA,CAACF,MAAc,EAAU;EACjD,OAAO,GAAGR,eAAe,GAAGQ,MAAM,EAAE;AACtC;;AAEA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASG,eAAeA,CAACH,MAAc,EAAU;EACtD,OAAO,GAAGP,qBAAqB,GAAGO,MAAM,EAAE;AAC5C;;AAEA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASI,kBAAkBA,CAACJ,MAAc,EAAW;EAC1D,IAAIK,aAAqB;EACzB,IAAI;IACFA,aAAa,GAAG,IAAIC,GAAG,CAACnB,gBAAgB,CAAC,CAACa,MAAM;EAClD,CAAC,CAAC,MAAM;IACN,OAAO,KAAK;EACd;EACA,IAAIO,eAAuB;EAC3B,IAAI;IACFA,eAAe,GAAG,IAAID,GAAG,CAACN,MAAM,CAAC,CAACA,MAAM;EAC1C,CAAC,CAAC,MAAM;IACN,OAAO,KAAK;EACd;EACA,OAAOO,eAAe,KAAKF,aAAa;AAC1C;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASG,WAAWA,CAACC,OAAgB,EAAET,MAAc,EAAEU,GAAW,EAAW;EAClF,IAAIC,GAAkB;EACtB,IAAI;IACFA,GAAG,GAAGF,OAAO,CAACG,OAAO,CAACX,WAAW,CAACD,MAAM,CAAC,CAAC;EAC5C,CAAC,CAAC,MAAM;IACN,OAAO,KAAK;EACd;EACA,IAAIW,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,GAAGzB,gBAAgB;AACvC","ignoreList":[]}
@@ -1,89 +0,0 @@
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
- * The RP callback path the central IdP redirects back to. The SSO result is
36
- * delivered in the fragment of this URL; `sso-return` consumes it and then
37
- * restores the user's real destination.
38
- */
39
- export declare const SSO_CALLBACK_PATH = "/__oxy/sso-callback";
40
- /**
41
- * Self-healing TTL (ms) for the bounce guard. If a bounce is interrupted before
42
- * the callback lands (e.g. the user navigates back mid-redirect), the guard
43
- * would otherwise pin the RP signed-out forever. After this window the guard is
44
- * treated as stale and a fresh single bounce is permitted.
45
- */
46
- export declare const SSO_GUARD_TTL_MS = 30000;
47
- /**
48
- * Perform the terminal top-level SSO bounce navigation.
49
- *
50
- * A thin wrapper over `window.location.assign(url)` so the single navigation
51
- * seam lives in one place (and stays mockable in tests, where jsdom's
52
- * `Location.assign` is a non-configurable native method). In production this is
53
- * exactly `window.location.assign` — the document is torn down and replaced by
54
- * the central IdP page. Off-browser it is a no-op (native never bounces).
55
- */
56
- export declare function ssoNavigate(url: string): void;
57
- /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
58
- export declare function ssoStateKey(origin: string): string;
59
- /** Per-origin bounce guard key (a timestamp; loop breaker + self-heal TTL). */
60
- export declare function ssoGuardKey(origin: string): string;
61
- /** Per-origin destination key (the real URL to restore after the callback). */
62
- export declare function ssoDestKey(origin: string): string;
63
- /**
64
- * Per-origin "the central IdP has no session for me" key. Set after a
65
- * `none`/`error` return (or a failed/forged exchange) so `sso-bounce` does not
66
- * fire again this tab — the definitive loop breaker.
67
- */
68
- export declare function ssoNoSessionKey(origin: string): string;
69
- /**
70
- * Whether `origin` IS the central IdP origin. We must never bounce while on
71
- * `auth.oxy.so` itself (it would bounce to itself). Compared by URL origin so a
72
- * trailing-slash / path difference never defeats the guard.
73
- */
74
- export declare function isCentralIdPOrigin(origin: string): boolean;
75
- /**
76
- * Read the bounce guard and decide whether it is still ACTIVE.
77
- *
78
- * Active means: a guard value is present AND it parses to a finite timestamp AND
79
- * less than {@link SSO_GUARD_TTL_MS} has elapsed since it was set. An active
80
- * guard disables `sso-bounce` (a bounce is already in flight this tab). A
81
- * missing, malformed, or expired guard is NOT active, so a fresh bounce may
82
- * proceed (this is the 30s self-heal for an interrupted bounce).
83
- *
84
- * @param storage - The session storage to read (injected for testability).
85
- * @param origin - The page origin whose guard to evaluate.
86
- * @param now - Current epoch ms (injected for deterministic tests).
87
- */
88
- export declare function guardActive(storage: Storage, origin: string, now: number): boolean;
89
- //# sourceMappingURL=ssoBounce.d.ts.map