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