@oxyhq/services 8.3.1 → 8.4.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.
Files changed (33) hide show
  1. package/lib/commonjs/ui/context/OxyContext.js +41 -89
  2. package/lib/commonjs/ui/context/OxyContext.js.map +1 -1
  3. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +8 -0
  4. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  5. package/lib/commonjs/ui/utils/activeAuthuser.js +27 -0
  6. package/lib/commonjs/ui/utils/activeAuthuser.js.map +1 -1
  7. package/lib/module/ui/context/OxyContext.js +36 -83
  8. package/lib/module/ui/context/OxyContext.js.map +1 -1
  9. package/lib/module/ui/context/hooks/useAuthOperations.js +9 -1
  10. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  11. package/lib/module/ui/utils/activeAuthuser.js +26 -0
  12. package/lib/module/ui/utils/activeAuthuser.js.map +1 -1
  13. package/lib/typescript/commonjs/ui/context/OxyContext.d.ts.map +1 -1
  14. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  15. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts +9 -0
  16. package/lib/typescript/commonjs/ui/utils/activeAuthuser.d.ts.map +1 -1
  17. package/lib/typescript/module/ui/context/OxyContext.d.ts.map +1 -1
  18. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  19. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts +9 -0
  20. package/lib/typescript/module/ui/utils/activeAuthuser.d.ts.map +1 -1
  21. package/package.json +2 -2
  22. package/src/ui/context/OxyContext.tsx +45 -95
  23. package/src/ui/context/hooks/useAuthOperations.ts +9 -1
  24. package/src/ui/utils/activeAuthuser.ts +34 -0
  25. package/lib/commonjs/ui/utils/ssoBounce.js +0 -158
  26. package/lib/commonjs/ui/utils/ssoBounce.js.map +0 -1
  27. package/lib/module/ui/utils/ssoBounce.js +0 -148
  28. package/lib/module/ui/utils/ssoBounce.js.map +0 -1
  29. package/lib/typescript/commonjs/ui/utils/ssoBounce.d.ts +0 -89
  30. package/lib/typescript/commonjs/ui/utils/ssoBounce.d.ts.map +0 -1
  31. package/lib/typescript/module/ui/utils/ssoBounce.d.ts +0 -89
  32. package/lib/typescript/module/ui/utils/ssoBounce.d.ts.map +0 -1
  33. package/src/ui/utils/ssoBounce.ts +0 -146
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/services",
3
- "version": "8.3.1",
3
+ "version": "8.4.1",
4
4
  "description": "OxyHQ Expo/React Native SDK — UI components, screens, and native features",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -160,7 +160,7 @@
160
160
  "peerDependencies": {
161
161
  "@expo/vector-icons": "^15.0.3",
162
162
  "@oxyhq/bloom": ">=0.5.0",
163
- "@oxyhq/core": "^2.2.1",
163
+ "@oxyhq/core": "^2.3.0",
164
164
  "@react-native-community/netinfo": "^11.4.1",
165
165
  "@tanstack/query-async-storage-persister": "^5.100",
166
166
  "@tanstack/query-sync-storage-persister": "^5.100",
@@ -14,18 +14,22 @@ 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, autoDetectAuthWebUrl } 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
+ ssoAttemptedKey,
25
26
  isCentralIdPOrigin,
26
27
  guardActive,
27
- } from '../utils/ssoBounce';
28
- import * as ssoBounce from '../utils/ssoBounce';
28
+ ssoNavigate,
29
+ buildSsoBounceUrl,
30
+ consumeSsoReturn,
31
+ } from '@oxyhq/core';
32
+ import { toast } from '@oxyhq/bloom';
29
33
  import { useAuthStore, type AuthState } from '../stores/authStore';
30
34
  import { useShallow } from 'zustand/react/shallow';
31
35
  import { useSessionSocket } from '../hooks/useSessionSocket';
@@ -745,99 +749,42 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
745
749
  storageKeys.sessionIds,
746
750
  ]);
747
751
 
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.
752
+ // Central cross-domain SSO return handler (web). A THIN wrapper over core's
753
+ // `consumeSsoReturn`, which performs the entire security-critical kernel —
754
+ // parse the IdP redirect fragment, validate the CSRF `state`, strip the
755
+ // fragment FIRST, exchange the opaque single-use code, restore the user's
756
+ // pre-bounce destination (same-origin only), and set the per-origin
757
+ // NO_SESSION loop breaker on every non-ok outcome and RETURNS the exchanged
758
+ // session (or `null`) WITHOUT committing. We preserve services' contract by
759
+ // committing the returned session here via `handleWebSSOSession`. Shared by
760
+ // the `sso-return` cold-boot step AND the bfcache `pageshow` re-evaluation, so
761
+ // the same kernel runs exactly once per delivered fragment regardless of how
762
+ // the page was (re)shown.
754
763
  //
755
764
  // 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).
765
+ // otherwise. Off-browser `consumeSsoReturn` is a no-op returning `null`, so
766
+ // this returns `false` (native never reaches it).
760
767
  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();
768
+ const session = await consumeSsoReturn(oxyServices, {
769
+ isWeb: isWebBrowser,
770
+ onExchangeError: (error) => {
771
+ if (__DEV__) {
772
+ loggerUtil.debug(
773
+ 'SSO code exchange failed (treating as no session)',
774
+ { component: 'OxyContext', method: 'runSsoReturn' },
775
+ error,
776
+ );
777
+ }
778
+ },
779
+ });
780
+ if (!session) {
795
781
  return false;
796
782
  }
797
-
798
783
  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();
784
+ if (!commitWebSession) {
816
785
  return false;
817
786
  }
818
-
819
787
  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
788
  return true;
842
789
  }, [oxyServices]);
843
790
 
@@ -1001,6 +948,9 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1001
948
  if (window.sessionStorage.getItem(ssoNoSessionKey(origin)) === '1') {
1002
949
  return false;
1003
950
  }
951
+ if (window.sessionStorage.getItem(ssoAttemptedKey(origin)) === '1') {
952
+ return false;
953
+ }
1004
954
  if (guardActive(window.sessionStorage, origin, Date.now())) {
1005
955
  return false;
1006
956
  }
@@ -1012,18 +962,18 @@ export const OxyProvider: React.FC<OxyContextProviderProps> = ({
1012
962
  window.sessionStorage.setItem(ssoStateKey(origin), state);
1013
963
  window.sessionStorage.setItem(ssoGuardKey(origin), String(Date.now()));
1014
964
  window.sessionStorage.setItem(ssoDestKey(origin), window.location.href);
965
+ // OUTCOME-INDEPENDENT once-guard: mark the probe attempted the instant we
966
+ // commit to the bounce, so even if the callback never lands cleanly no
967
+ // second bounce can ever fire this tab (the definitive loop breaker).
968
+ window.sessionStorage.setItem(ssoAttemptedKey(origin), '1');
1015
969
 
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);
970
+ const url = buildSsoBounceUrl(origin, state, oxyServices.config?.authWebUrl);
1021
971
 
1022
972
  // TERMINAL: the document is torn down by this navigation. The
1023
973
  // `skip` below is only reached if `assign` is a no-op (e.g. the
1024
974
  // navigation is blocked); in that case we fall through
1025
975
  // unauthenticated, which is correct.
1026
- ssoBounce.ssoNavigate(url.toString());
976
+ ssoNavigate(url);
1027
977
  return { kind: 'skip' };
1028
978
  },
1029
979
  },
@@ -9,7 +9,7 @@ import type { StorageInterface } from '../../utils/storageHelpers';
9
9
  import type { OxyServices } from '@oxyhq/core';
10
10
  import { SignatureService } from '@oxyhq/core';
11
11
  import { isWebBrowser } from '../../hooks/useWebSSO';
12
- import { clearActiveAuthuser } from '../../utils/activeAuthuser';
12
+ import { clearActiveAuthuser, clearSsoBounceState } from '../../utils/activeAuthuser';
13
13
 
14
14
  /** Type guard for error objects with optional code and status properties */
15
15
  function isErrorWithCodeOrStatus(error: unknown): error is { code?: string; status?: number; message?: string } {
@@ -323,6 +323,9 @@ export const useAuthOperations = ({
323
323
  if (filteredSessions.length > 0) {
324
324
  await switchSession(filteredSessions[0].sessionId);
325
325
  } else {
326
+ // Genuine FULL sign-out (no sessions remain): clear the per-origin
327
+ // SSO bounce state so a fresh deliberate sign-in can re-probe.
328
+ clearSsoBounceState();
326
329
  await clearSessionState();
327
330
  return;
328
331
  }
@@ -331,6 +334,8 @@ export const useAuthOperations = ({
331
334
  const isInvalid = isInvalidSessionError(error);
332
335
 
333
336
  if (isInvalid && targetSessionId === activeSessionId) {
337
+ // The active session is invalid → full sign-out; clear SSO state too.
338
+ clearSsoBounceState();
334
339
  await clearSessionState();
335
340
  return;
336
341
  }
@@ -390,6 +395,9 @@ export const useAuthOperations = ({
390
395
  } else {
391
396
  await oxyServices.logoutAllSessions(activeSessionId);
392
397
  }
398
+ // logoutAll is ALWAYS a full sign-out: clear the per-origin SSO bounce
399
+ // state (web-guarded internally) so a fresh sign-in can re-probe.
400
+ clearSsoBounceState();
393
401
  await clearSessionState();
394
402
  } catch (error) {
395
403
  handleAuthError(error, {
@@ -18,12 +18,24 @@
18
18
  * outside the browser.
19
19
  */
20
20
 
21
+ import {
22
+ ssoAttemptedKey,
23
+ ssoNoSessionKey,
24
+ ssoGuardKey,
25
+ ssoStateKey,
26
+ ssoDestKey,
27
+ } from '@oxyhq/core';
28
+
21
29
  const ACTIVE_AUTHUSER_KEY = 'oxy_active_authuser';
22
30
 
23
31
  function hasLocalStorage(): boolean {
24
32
  return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
25
33
  }
26
34
 
35
+ function hasSessionStorage(): boolean {
36
+ return typeof window !== 'undefined' && typeof window.sessionStorage !== 'undefined';
37
+ }
38
+
27
39
  /**
28
40
  * Read the persisted active `authuser` slot index.
29
41
  *
@@ -75,4 +87,26 @@ export function clearActiveAuthuser(): void {
75
87
  }
76
88
  }
77
89
 
90
+ /**
91
+ * Clear all per-origin SSO bounce sessionStorage keys. Called ONLY on EXPLICIT
92
+ * user sign-out (logout / logoutAll) — never on a cold-boot failure path — so a
93
+ * fresh deliberate sign-in can re-probe the central IdP. Clearing on cold-boot
94
+ * failure would reintroduce the redirect loop.
95
+ *
96
+ * No-ops on native and on any storage failure (best-effort).
97
+ */
98
+ export function clearSsoBounceState(): void {
99
+ if (!hasSessionStorage()) return;
100
+ const origin = window.location.origin;
101
+ try {
102
+ window.sessionStorage.removeItem(ssoAttemptedKey(origin));
103
+ window.sessionStorage.removeItem(ssoNoSessionKey(origin));
104
+ window.sessionStorage.removeItem(ssoGuardKey(origin));
105
+ window.sessionStorage.removeItem(ssoStateKey(origin));
106
+ window.sessionStorage.removeItem(ssoDestKey(origin));
107
+ } catch {
108
+ // Best-effort; swallow SecurityError (e.g. Safari private mode).
109
+ }
110
+ }
111
+
78
112
  export { ACTIVE_AUTHUSER_KEY };
@@ -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":[]}