@oxyhq/core 2.2.1 → 2.2.2
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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +17 -1
- package/dist/cjs/mixins/OxyServices.auth.js +45 -0
- package/dist/cjs/mixins/OxyServices.user.js +15 -5
- package/dist/cjs/utils/authWebUrl.js +14 -3
- package/dist/cjs/utils/fapiAutoDetect.js +47 -6
- package/dist/cjs/utils/ssoBounce.js +192 -0
- package/dist/cjs/utils/ssoReturn.js +111 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +5 -3
- package/dist/esm/mixins/OxyServices.auth.js +45 -0
- package/dist/esm/mixins/OxyServices.user.js +15 -5
- package/dist/esm/utils/authWebUrl.js +13 -2
- package/dist/esm/utils/fapiAutoDetect.js +45 -6
- package/dist/esm/utils/ssoBounce.js +181 -0
- package/dist/esm/utils/ssoReturn.js +110 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +5 -4
- package/dist/types/mixins/OxyServices.auth.d.ts +35 -0
- package/dist/types/mixins/OxyServices.user.d.ts +7 -0
- package/dist/types/utils/authWebUrl.d.ts +12 -1
- package/dist/types/utils/fapiAutoDetect.d.ts +36 -0
- package/dist/types/utils/ssoBounce.d.ts +124 -0
- package/dist/types/utils/ssoReturn.d.ts +65 -0
- package/package.json +1 -1
- package/src/index.ts +18 -4
- package/src/mixins/OxyServices.auth.ts +54 -0
- package/src/mixins/OxyServices.user.ts +14 -5
- package/src/mixins/__tests__/serviceAuth.test.ts +92 -0
- package/src/utils/__tests__/authWebUrl.test.ts +11 -1
- package/src/utils/__tests__/consumeSsoReturn.test.ts +401 -0
- package/src/utils/__tests__/fapiAutoDetect.test.ts +62 -1
- package/src/utils/__tests__/ssoBounce.test.ts +148 -0
- package/src/utils/authWebUrl.ts +14 -2
- package/src/utils/fapiAutoDetect.ts +41 -5
- package/src/utils/ssoBounce.ts +198 -0
- package/src/utils/ssoReturn.ts +168 -0
package/dist/esm/index.js
CHANGED
|
@@ -99,11 +99,13 @@ export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccou
|
|
|
99
99
|
// ---------------------------------------------------------------------------
|
|
100
100
|
// Cross-domain SSO infrastructure
|
|
101
101
|
// ---------------------------------------------------------------------------
|
|
102
|
-
export { autoDetectAuthWebUrl } from './utils/fapiAutoDetect.js';
|
|
102
|
+
export { autoDetectAuthWebUrl, registrableApex, MULTIPART_TLDS } from './utils/fapiAutoDetect.js';
|
|
103
103
|
// Central cross-domain SSO (opaque single-use code bounce via auth.oxy.so)
|
|
104
|
-
export { CENTRAL_AUTH_URL, resolveCentralAuthUrl } from './utils/authWebUrl.js';
|
|
105
|
-
export { parseSsoReturnFragment } from './utils/ssoReturn.js';
|
|
104
|
+
export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl.js';
|
|
105
|
+
export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn.js';
|
|
106
106
|
export { generateSsoState } from './mixins/OxyServices.sso.js';
|
|
107
|
+
// SSO bounce — per-origin sessionStorage keys, bounce URL builder, predicates
|
|
108
|
+
export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoNavigate, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce.js';
|
|
107
109
|
export { runColdBoot } from './utils/coldBoot.js';
|
|
108
110
|
// ---------------------------------------------------------------------------
|
|
109
111
|
// Constants
|
|
@@ -133,6 +133,7 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
133
133
|
expiresAt: 0,
|
|
134
134
|
secretBuf: providedSecretBuf,
|
|
135
135
|
pending: null,
|
|
136
|
+
apiKey: key,
|
|
136
137
|
};
|
|
137
138
|
this._serviceTokenCache.set(cacheKey, entry);
|
|
138
139
|
}
|
|
@@ -172,10 +173,54 @@ export function OxyServicesAuthMixin(Base) {
|
|
|
172
173
|
expiresAt,
|
|
173
174
|
secretBuf,
|
|
174
175
|
pending: null,
|
|
176
|
+
apiKey: key,
|
|
175
177
|
});
|
|
176
178
|
}
|
|
177
179
|
return response.token;
|
|
178
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Invalidate cached service token(s), forcing the next `getServiceToken()`
|
|
183
|
+
* call to mint a fresh token from `/auth/service-token`.
|
|
184
|
+
*
|
|
185
|
+
* `getServiceToken()` only refreshes on expiry (with a 60s clock-drift
|
|
186
|
+
* buffer), so a credential that is revoked or rotated mid-run — surfaced as
|
|
187
|
+
* a 401 on a downstream service request — cannot otherwise be recovered
|
|
188
|
+
* within the same process: the still-unexpired cached token keeps being
|
|
189
|
+
* returned. Call this after such a 401 to clear the stale entry; the very
|
|
190
|
+
* next `getServiceToken()` for that credential re-mints.
|
|
191
|
+
*
|
|
192
|
+
* Fully synchronous and deterministic: the call completes before it
|
|
193
|
+
* returns, so a `getServiceToken()` issued immediately afterwards is
|
|
194
|
+
* guaranteed to see the cleared cache and mint anew.
|
|
195
|
+
*
|
|
196
|
+
* @param apiKey - When provided, clears only the cache entry for that
|
|
197
|
+
* specific apiKey. When omitted, clears the entry for the credential set
|
|
198
|
+
* via `configureServiceAuth()`; if neither is available (no key to
|
|
199
|
+
* target), clears the entire cache. Passing no argument is the common
|
|
200
|
+
* case for hosts that configured a single service credential at startup.
|
|
201
|
+
*
|
|
202
|
+
* The cache Map is keyed by an asynchronously-computed `SHA-256(apiKey)`
|
|
203
|
+
* that cannot be reproduced synchronously, so a targeted clear scans the
|
|
204
|
+
* entries and removes the one whose stored raw `apiKey` matches — keeping
|
|
205
|
+
* this method synchronous. The fully-untargeted call (no argument and no
|
|
206
|
+
* configured key) clears every entry, which is safe because each credential
|
|
207
|
+
* pair is independently re-minted on its next request.
|
|
208
|
+
*/
|
|
209
|
+
invalidateServiceToken(apiKey) {
|
|
210
|
+
const targetKey = apiKey ?? this._serviceApiKey;
|
|
211
|
+
// No specific credential to target — clear everything. The next
|
|
212
|
+
// getServiceToken() for any credential re-mints from scratch.
|
|
213
|
+
if (!targetKey) {
|
|
214
|
+
this._serviceTokenCache.clear();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
for (const [cacheKey, entry] of this._serviceTokenCache) {
|
|
218
|
+
if (entry.apiKey === targetKey) {
|
|
219
|
+
this._serviceTokenCache.delete(cacheKey);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
179
224
|
/**
|
|
180
225
|
* Make an authenticated request on behalf of a user using a service token.
|
|
181
226
|
* Automatically obtains/refreshes the service token.
|
|
@@ -93,14 +93,24 @@ export function OxyServicesUserMixin(Base) {
|
|
|
93
93
|
}
|
|
94
94
|
/**
|
|
95
95
|
* Get profile recommendations, optionally filtering out specific user types.
|
|
96
|
+
*
|
|
97
|
+
* Public discovery read — works WITHOUT authentication. The SDK attaches the
|
|
98
|
+
* access token automatically when one is available (personalized via
|
|
99
|
+
* mutual-connection overlap), and falls back to popular public profiles when
|
|
100
|
+
* the caller is logged out. This deliberately does NOT use `withAuthRetry`,
|
|
101
|
+
* which would throw an authentication timeout for logged-out callers before
|
|
102
|
+
* the request is ever sent.
|
|
96
103
|
*/
|
|
97
104
|
async getProfileRecommendations(options) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
105
|
+
try {
|
|
106
|
+
const params = options?.excludeTypes?.length
|
|
107
|
+
? { excludeTypes: options.excludeTypes.join(',') }
|
|
108
|
+
: undefined;
|
|
102
109
|
return await this.makeRequest('GET', '/profiles/recommendations', params, { cache: true });
|
|
103
|
-
}
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
throw this.handleError(error);
|
|
113
|
+
}
|
|
104
114
|
}
|
|
105
115
|
/**
|
|
106
116
|
* Get profiles similar to a given user, based on co-follower overlap.
|
|
@@ -16,11 +16,22 @@
|
|
|
16
16
|
* derivation). The central-SSO path deliberately does NOT auto-detect per-apex
|
|
17
17
|
* IdPs — it is central only. An explicitly-configured `authWebUrl` still wins.
|
|
18
18
|
*/
|
|
19
|
+
/**
|
|
20
|
+
* The registrable apex (eTLD+1) of the Oxy ecosystem's central Identity
|
|
21
|
+
* Provider. The central IdP is reachable at `auth.${CENTRAL_IDP_APEX}` and the
|
|
22
|
+
* ID-token assertion issuer is always `https://auth.${CENTRAL_IDP_APEX}`
|
|
23
|
+
* regardless of which per-apex `auth.<rp>` host served a given request.
|
|
24
|
+
*
|
|
25
|
+
* Kept as a standalone constant so the IdP worker and the SDK derive the same
|
|
26
|
+
* literal from one source of truth (the worker imports it to brand assertions).
|
|
27
|
+
*/
|
|
28
|
+
export const CENTRAL_IDP_APEX = 'oxy.so';
|
|
19
29
|
/**
|
|
20
30
|
* The canonical central Identity Provider origin for the Oxy ecosystem.
|
|
21
|
-
* No trailing slash.
|
|
31
|
+
* No trailing slash. Derived from {@link CENTRAL_IDP_APEX} so the apex and the
|
|
32
|
+
* full origin never drift apart.
|
|
22
33
|
*/
|
|
23
|
-
export const CENTRAL_AUTH_URL =
|
|
34
|
+
export const CENTRAL_AUTH_URL = `https://auth.${CENTRAL_IDP_APEX}`;
|
|
24
35
|
/**
|
|
25
36
|
* Resolve the central IdP origin, honouring an explicit override.
|
|
26
37
|
*
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
* before relying on this helper, otherwise auto-detection silently bails to
|
|
47
47
|
* `undefined` and the consumer must pass `authWebUrl` explicitly.
|
|
48
48
|
*/
|
|
49
|
-
const MULTIPART_TLDS = new Set([
|
|
49
|
+
export const MULTIPART_TLDS = new Set([
|
|
50
50
|
'co.uk',
|
|
51
51
|
'com.au',
|
|
52
52
|
'co.jp',
|
|
@@ -58,6 +58,46 @@ const MULTIPART_TLDS = new Set([
|
|
|
58
58
|
'co.kr',
|
|
59
59
|
'com.sg',
|
|
60
60
|
]);
|
|
61
|
+
/**
|
|
62
|
+
* Compute the bare registrable apex (eTLD+1) of a hostname, guarding against
|
|
63
|
+
* multi-part public suffixes.
|
|
64
|
+
*
|
|
65
|
+
* This is the pure host-handling kernel shared by {@link autoDetectAuthWebUrl}
|
|
66
|
+
* and the IdP worker — it performs NO protocol handling, NO `auth.` prefixing,
|
|
67
|
+
* and builds NO URL. It only answers "what is the registrable domain of this
|
|
68
|
+
* host, or is that undefinable?".
|
|
69
|
+
*
|
|
70
|
+
* Returns `null` (apex undefinable) for:
|
|
71
|
+
* - empty input;
|
|
72
|
+
* - IPv4 literals (`192.168.1.10`);
|
|
73
|
+
* - IPv6 literals or any host carrying a port (`[::1]`, anything with `:`);
|
|
74
|
+
* - single-label hosts (`intranet`, `localhost`);
|
|
75
|
+
* - hosts whose trailing two labels form a known multi-part public suffix
|
|
76
|
+
* (e.g. `foo.co.uk`), where `labels.slice(-2)` would yield an
|
|
77
|
+
* attacker-registrable suffix (`co.uk`) rather than a real registrable
|
|
78
|
+
* domain. Such hosts MUST configure `authWebUrl` explicitly.
|
|
79
|
+
*
|
|
80
|
+
* @param hostname - A bare hostname (no scheme), e.g. `www.mention.earth`.
|
|
81
|
+
* @returns The eTLD+1 (`mention.earth`), or `null` when undefinable.
|
|
82
|
+
*/
|
|
83
|
+
export function registrableApex(hostname) {
|
|
84
|
+
if (!hostname)
|
|
85
|
+
return null;
|
|
86
|
+
const host = hostname.toLowerCase();
|
|
87
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host))
|
|
88
|
+
return null;
|
|
89
|
+
// IPv6 literals are bracketed; any remaining ':' implies a port — neither
|
|
90
|
+
// yields a registrable apex.
|
|
91
|
+
if (host.startsWith('[') || host.includes(':'))
|
|
92
|
+
return null;
|
|
93
|
+
const labels = host.split('.');
|
|
94
|
+
if (labels.length < 2)
|
|
95
|
+
return null;
|
|
96
|
+
const lastTwo = labels.slice(-2).join('.');
|
|
97
|
+
if (MULTIPART_TLDS.has(lastTwo))
|
|
98
|
+
return null;
|
|
99
|
+
return lastTwo;
|
|
100
|
+
}
|
|
61
101
|
export function autoDetectAuthWebUrl(location = typeof window !== 'undefined' ? window.location : undefined) {
|
|
62
102
|
if (!location)
|
|
63
103
|
return undefined;
|
|
@@ -72,14 +112,13 @@ export function autoDetectAuthWebUrl(location = typeof window !== 'undefined' ?
|
|
|
72
112
|
return undefined;
|
|
73
113
|
if (hostname.startsWith('['))
|
|
74
114
|
return undefined;
|
|
115
|
+
// Already ON the IdP — keep everything same-origin instead of hopping to a
|
|
116
|
+
// sibling host.
|
|
75
117
|
if (hostname.startsWith('auth.')) {
|
|
76
118
|
return `${protocol}//${hostname}`;
|
|
77
119
|
}
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
return undefined;
|
|
81
|
-
if (MULTIPART_TLDS.has(labels.slice(-2).join('.')))
|
|
120
|
+
const apex = registrableApex(hostname);
|
|
121
|
+
if (apex === null)
|
|
82
122
|
return undefined;
|
|
83
|
-
const apex = labels.slice(-2).join('.');
|
|
84
123
|
return `${protocol}//auth.${apex}`;
|
|
85
124
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central cross-domain SSO bounce — per-origin sessionStorage keys, the bounce
|
|
3
|
+
* URL builder, and the small pure predicates shared by every consumer's
|
|
4
|
+
* cold-boot `sso-return` / `sso-bounce` steps and bfcache `pageshow`
|
|
5
|
+
* re-evaluation.
|
|
6
|
+
*
|
|
7
|
+
* This is the single source of truth for the SSO bounce wire/storage contract.
|
|
8
|
+
* `@oxyhq/auth` (`WebOxyProvider`) and `@oxyhq/services` (`OxyContext`) both
|
|
9
|
+
* consume these helpers so the two providers behave identically.
|
|
10
|
+
*
|
|
11
|
+
* TRUE central SSO (Google/Meta/Clerk style) works like this for a Relying
|
|
12
|
+
* Party (mention.earth, homiio.com, alia.onl, …) with no local session:
|
|
13
|
+
*
|
|
14
|
+
* 1. `sso-bounce` (terminal, once): a TOP-LEVEL navigation to
|
|
15
|
+
* `auth.oxy.so/sso?prompt=none&client_id=<origin>&return_to=<origin>{@link SSO_CALLBACK_PATH}&state=<s>`.
|
|
16
|
+
* Before navigating it records, in this origin's `sessionStorage`, the
|
|
17
|
+
* CSRF `state` ({@link ssoStateKey}), a guard timestamp ({@link ssoGuardKey},
|
|
18
|
+
* the loop breaker), and the real destination URL ({@link ssoDestKey}) to
|
|
19
|
+
* restore after the callback.
|
|
20
|
+
* 2. The central IdP worker reads its first-party `fedcm_session`, mints a
|
|
21
|
+
* session, stores it under an opaque single-use `code`, and 303-redirects
|
|
22
|
+
* back to `<origin>{@link SSO_CALLBACK_PATH}#oxy_sso=ok&code=<code>&state=<s>`
|
|
23
|
+
* (or `#oxy_sso=none` / `#oxy_sso=error`).
|
|
24
|
+
* 3. `sso-return` parses the fragment (`parseSsoReturnFragment`), validates
|
|
25
|
+
* `state`, exchanges the `code` via `oxyServices.exchangeSsoCode`, commits
|
|
26
|
+
* the session, then restores the original destination.
|
|
27
|
+
*
|
|
28
|
+
* Loop proof (logged-out): first load all steps skip → `sso-bounce` sets
|
|
29
|
+
* guard/state/dest and navigates; the IdP (no central session) returns
|
|
30
|
+
* `#oxy_sso=none`; the callback load's `sso-return` sees `none`, sets the
|
|
31
|
+
* NO_SESSION flag ({@link ssoNoSessionKey}), and `sso-bounce` is then disabled.
|
|
32
|
+
* Exactly ONE bounce, no loop. An interrupted bounce (user hit back
|
|
33
|
+
* mid-redirect) self-heals once the {@link SSO_GUARD_TTL_MS} guard TTL lapses.
|
|
34
|
+
*
|
|
35
|
+
* All state lives in `sessionStorage` (per tab, cleared on tab close) and is
|
|
36
|
+
* keyed per-origin so two RPs hosted in the same browser never collide. The
|
|
37
|
+
* key strings and the 30s TTL are a wire/storage contract — they MUST match
|
|
38
|
+
* the values the IdP and every consumer expect and must not change lightly.
|
|
39
|
+
*/
|
|
40
|
+
import { CENTRAL_AUTH_URL, resolveCentralAuthUrl } from './authWebUrl.js';
|
|
41
|
+
/**
|
|
42
|
+
* The RP callback path the central IdP redirects back to after a bounce. The
|
|
43
|
+
* SSO result is delivered in the fragment of this URL; the `sso-return` step
|
|
44
|
+
* consumes it and then restores the user's real destination (stored under
|
|
45
|
+
* {@link ssoDestKey}), so the user never lingers on this internal path.
|
|
46
|
+
*/
|
|
47
|
+
export const SSO_CALLBACK_PATH = '/__oxy/sso-callback';
|
|
48
|
+
/**
|
|
49
|
+
* Self-healing TTL (ms) for the bounce guard. An in-flight bounce sets a
|
|
50
|
+
* timestamp guard; if the bounce is interrupted before the callback lands
|
|
51
|
+
* (e.g. the user navigates back mid-redirect), the guard would otherwise pin
|
|
52
|
+
* the RP signed-out forever. After this window the guard is treated as stale
|
|
53
|
+
* and a fresh single bounce is permitted. 30s comfortably exceeds a real
|
|
54
|
+
* redirect round-trip while keeping a crash short-lived.
|
|
55
|
+
*/
|
|
56
|
+
export const SSO_GUARD_TTL_MS = 30000;
|
|
57
|
+
const STATE_KEY_PREFIX = 'oxy_sso_state:';
|
|
58
|
+
const GUARD_KEY_PREFIX = 'oxy_sso_guard:';
|
|
59
|
+
const DEST_KEY_PREFIX = 'oxy_sso_dest:';
|
|
60
|
+
const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
|
|
61
|
+
/** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
|
|
62
|
+
export function ssoStateKey(origin) {
|
|
63
|
+
return `${STATE_KEY_PREFIX}${origin}`;
|
|
64
|
+
}
|
|
65
|
+
/** Per-origin bounce guard key (a timestamp; loop breaker + self-heal TTL). */
|
|
66
|
+
export function ssoGuardKey(origin) {
|
|
67
|
+
return `${GUARD_KEY_PREFIX}${origin}`;
|
|
68
|
+
}
|
|
69
|
+
/** Per-origin destination key (the real URL to restore after the callback). */
|
|
70
|
+
export function ssoDestKey(origin) {
|
|
71
|
+
return `${DEST_KEY_PREFIX}${origin}`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Per-origin "the central IdP has no session for me" key. Set after a
|
|
75
|
+
* `none`/`error` return (or a failed/forged exchange) so `sso-bounce` does not
|
|
76
|
+
* fire again this tab — the definitive loop breaker.
|
|
77
|
+
*/
|
|
78
|
+
export function ssoNoSessionKey(origin) {
|
|
79
|
+
return `${NO_SESSION_KEY_PREFIX}${origin}`;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Perform the terminal top-level SSO bounce navigation.
|
|
83
|
+
*
|
|
84
|
+
* A thin wrapper over `window.location.assign(url)` so the single navigation
|
|
85
|
+
* seam lives in one place (and stays mockable in tests, where jsdom's
|
|
86
|
+
* `Location.assign` is a non-configurable native method). In production this is
|
|
87
|
+
* exactly `window.location.assign` — the document is torn down and replaced by
|
|
88
|
+
* the central IdP page. Off-browser (SSR / native) it is a no-op: native never
|
|
89
|
+
* bounces.
|
|
90
|
+
*/
|
|
91
|
+
export function ssoNavigate(url) {
|
|
92
|
+
if (typeof window === 'undefined' || typeof window.location === 'undefined') {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
window.location.assign(url);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Build the central IdP `/sso` bounce URL for an RP.
|
|
99
|
+
*
|
|
100
|
+
* Pure (no DOM access) so it is unit-testable and shared by every consumer's
|
|
101
|
+
* terminal `sso-bounce` step. The IdP reads `client_id` (the RP origin) and
|
|
102
|
+
* `return_to` to mint an origin-bound opaque code and 303-redirect back.
|
|
103
|
+
*
|
|
104
|
+
* The IdP base is resolved via {@link resolveCentralAuthUrl} so an explicit
|
|
105
|
+
* `authWebUrl` override (e.g. a staging IdP) drives the SSO bounce exactly the
|
|
106
|
+
* way it drives FedCM. When omitted, the central default {@link CENTRAL_AUTH_URL}
|
|
107
|
+
* is used.
|
|
108
|
+
*
|
|
109
|
+
* @param origin - The RP origin (`window.location.origin`).
|
|
110
|
+
* @param state - The CSRF state minted for this bounce.
|
|
111
|
+
* @param authWebUrl - Optional explicit IdP base URL override. Falls back to
|
|
112
|
+
* the central default when `undefined`/empty.
|
|
113
|
+
* @returns The absolute `<idp-origin>/sso?...` URL string.
|
|
114
|
+
*/
|
|
115
|
+
export function buildSsoBounceUrl(origin, state, authWebUrl) {
|
|
116
|
+
const url = new URL('/sso', resolveCentralAuthUrl(authWebUrl));
|
|
117
|
+
url.searchParams.set('prompt', 'none');
|
|
118
|
+
url.searchParams.set('client_id', origin);
|
|
119
|
+
url.searchParams.set('return_to', origin + SSO_CALLBACK_PATH);
|
|
120
|
+
url.searchParams.set('state', state);
|
|
121
|
+
return url.toString();
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Whether `origin` IS the central IdP origin. The RP must NEVER bounce while
|
|
125
|
+
* sitting on `auth.oxy.so` itself — doing so would loop the IdP against itself.
|
|
126
|
+
*
|
|
127
|
+
* Both sides are normalised via `new URL(...).origin` so a trailing-slash or
|
|
128
|
+
* path difference never defeats the guard. Returns `false` on any parse
|
|
129
|
+
* failure (an unparseable candidate is, by definition, not the central IdP).
|
|
130
|
+
*/
|
|
131
|
+
export function isCentralIdPOrigin(origin) {
|
|
132
|
+
let centralOrigin;
|
|
133
|
+
try {
|
|
134
|
+
centralOrigin = new URL(CENTRAL_AUTH_URL).origin;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
let candidateOrigin;
|
|
140
|
+
try {
|
|
141
|
+
candidateOrigin = new URL(origin).origin;
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
return candidateOrigin === centralOrigin;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Read the bounce guard and decide whether it is still ACTIVE.
|
|
150
|
+
*
|
|
151
|
+
* Active means: a guard value is present AND it parses to a finite timestamp
|
|
152
|
+
* AND less than {@link SSO_GUARD_TTL_MS} has elapsed since it was set. An active
|
|
153
|
+
* guard disables `sso-bounce` (a bounce is already in flight this tab). A
|
|
154
|
+
* missing, malformed, or expired guard is NOT active, so a fresh bounce may
|
|
155
|
+
* proceed (this is the 30s self-heal for an interrupted bounce).
|
|
156
|
+
*
|
|
157
|
+
* Defensive: a `getItem` that throws (e.g. a locked/disabled storage) is
|
|
158
|
+
* treated as "not active" so the guard never wedges the flow.
|
|
159
|
+
*
|
|
160
|
+
* @param storage - The session storage to read (injected for testability).
|
|
161
|
+
* @param origin - The page origin whose guard to evaluate.
|
|
162
|
+
* @param now - Current epoch ms (injected for deterministic tests). Defaults to
|
|
163
|
+
* `Date.now()`.
|
|
164
|
+
*/
|
|
165
|
+
export function guardActive(storage, origin, now = Date.now()) {
|
|
166
|
+
let raw;
|
|
167
|
+
try {
|
|
168
|
+
raw = storage.getItem(ssoGuardKey(origin));
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
if (raw === null || raw.length === 0) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
const ts = Number(raw);
|
|
177
|
+
if (!Number.isFinite(ts)) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
return now - ts < SSO_GUARD_TTL_MS;
|
|
181
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* an oxy_sso fragment at all (i.e. `oxy_sso` is absent or an unrecognised
|
|
21
21
|
* value), so the caller can ignore unrelated fragments without special-casing.
|
|
22
22
|
*/
|
|
23
|
+
import { SSO_CALLBACK_PATH, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, } from './ssoBounce.js';
|
|
23
24
|
const VALID_KINDS = new Set(['ok', 'none', 'error']);
|
|
24
25
|
/**
|
|
25
26
|
* Parse an SSO return fragment.
|
|
@@ -67,3 +68,112 @@ export function parseSsoReturnFragment(hash) {
|
|
|
67
68
|
}
|
|
68
69
|
return result;
|
|
69
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Consume an SSO return: the commit-free, security-critical kernel of the
|
|
73
|
+
* cross-domain SSO `sso-return` cold-boot step.
|
|
74
|
+
*
|
|
75
|
+
* This performs the CSRF/fragment/exchange/dest-restore/loop-breaker sequence
|
|
76
|
+
* and RETURNS the exchanged session (or `null`). It deliberately does NOT
|
|
77
|
+
* commit any UI/auth state — each provider commits its own way AROUND this
|
|
78
|
+
* (e.g. `@oxyhq/services` `OxyContext` calls its `handleWebSSOSession`,
|
|
79
|
+
* `@oxyhq/auth` `WebOxyProvider` updates its React state). Hoisting the kernel
|
|
80
|
+
* here keeps the two providers byte-for-byte identical on the parts that matter
|
|
81
|
+
* for security (state validation, fragment stripping order, loop prevention).
|
|
82
|
+
*
|
|
83
|
+
* Security/loop invariants (preserved exactly from both former copies):
|
|
84
|
+
* - The fragment is stripped via `history.replaceState` FIRST — before the
|
|
85
|
+
* exchange — so the opaque code never lingers in the URL, browser history,
|
|
86
|
+
* or a `Referer` header even if a later step throws.
|
|
87
|
+
* - `state` must match (CSRF). A mismatch or a missing code sets the
|
|
88
|
+
* NO_SESSION flag so `sso-bounce` is disabled (no rebounce loop).
|
|
89
|
+
* - `none`/`error` outcomes set the NO_SESSION flag (the load2 half of the
|
|
90
|
+
* loop proof).
|
|
91
|
+
* - A throwing exchange is caught, reported via `onExchangeError`, and
|
|
92
|
+
* treated exactly like "no session" (never loops, never rethrows).
|
|
93
|
+
* - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
|
|
94
|
+
* destination is restored from the DEST key — same-origin only (an
|
|
95
|
+
* attacker-planted cross-origin or relative-evil dest is rejected). The
|
|
96
|
+
* DEST key is removed unconditionally.
|
|
97
|
+
*
|
|
98
|
+
* Total: this function NEVER throws. Off-web it is a no-op returning `null`.
|
|
99
|
+
*
|
|
100
|
+
* @param oxy - The exchange surface (`oxyServices.exchangeSsoCode`).
|
|
101
|
+
* @param deps - Injectable web seams; see {@link ConsumeSsoReturnDeps}.
|
|
102
|
+
* @returns The exchanged session on success, otherwise `null`.
|
|
103
|
+
*/
|
|
104
|
+
export async function consumeSsoReturn(oxy, deps = {}) {
|
|
105
|
+
const isWeb = deps.isWeb ??
|
|
106
|
+
(() => typeof window !== 'undefined' &&
|
|
107
|
+
typeof window.sessionStorage !== 'undefined');
|
|
108
|
+
if (!isWeb()) {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
const storage = deps.storage ?? window.sessionStorage;
|
|
112
|
+
const location = deps.location ?? window.location;
|
|
113
|
+
const history = deps.history ?? window.history;
|
|
114
|
+
const onExchangeError = deps.onExchangeError;
|
|
115
|
+
const ret = parseSsoReturnFragment(location.hash);
|
|
116
|
+
if (!ret) {
|
|
117
|
+
// Not an oxy_sso fragment — nothing to do (do NOT touch any flags).
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const origin = location.origin;
|
|
121
|
+
const expectedState = storage.getItem(ssoStateKey(origin));
|
|
122
|
+
const stateOk = !!ret.state && !!expectedState && ret.state === expectedState;
|
|
123
|
+
// Strip the fragment FIRST so the opaque code never lingers in the address
|
|
124
|
+
// bar, history, or a `Referer` — even if a later step throws.
|
|
125
|
+
history.replaceState(null, '', location.pathname + location.search);
|
|
126
|
+
storage.removeItem(ssoStateKey(origin));
|
|
127
|
+
// The in-flight bounce is now resolved — drop its guard so a later cold boot
|
|
128
|
+
// (e.g. after sign-out) can bounce again.
|
|
129
|
+
storage.removeItem(ssoGuardKey(origin));
|
|
130
|
+
const markNoSession = () => {
|
|
131
|
+
storage.setItem(ssoNoSessionKey(origin), '1');
|
|
132
|
+
};
|
|
133
|
+
if (ret.kind === 'none' || ret.kind === 'error') {
|
|
134
|
+
// The central IdP had no session (or the bounce failed). Record it so we do
|
|
135
|
+
// not bounce again this tab — the definitive loop breaker.
|
|
136
|
+
markNoSession();
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
if (!stateOk || !ret.code) {
|
|
140
|
+
// Forged / replayed / stale fragment, or a malformed ok with no code. Treat
|
|
141
|
+
// exactly like "no session": never exchange, never loop.
|
|
142
|
+
markNoSession();
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
let session;
|
|
146
|
+
try {
|
|
147
|
+
session = await oxy.exchangeSsoCode(ret.code);
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
onExchangeError?.(error);
|
|
151
|
+
markNoSession();
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
if (!session?.sessionId) {
|
|
155
|
+
markNoSession();
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
// If we landed on the internal callback path, restore the user's real
|
|
159
|
+
// destination (captured at bounce time). Same-origin only — never honour a
|
|
160
|
+
// cross-origin destination that could have been planted to redirect the
|
|
161
|
+
// freshly signed-in user. `new URL(dest, origin)` tolerates relative dests
|
|
162
|
+
// and is still re-checked against the page origin.
|
|
163
|
+
if (location.pathname === SSO_CALLBACK_PATH) {
|
|
164
|
+
const dest = storage.getItem(ssoDestKey(origin));
|
|
165
|
+
if (dest) {
|
|
166
|
+
try {
|
|
167
|
+
const destUrl = new URL(dest, origin);
|
|
168
|
+
if (destUrl.origin === origin) {
|
|
169
|
+
history.replaceState(null, '', destUrl.pathname + destUrl.search + destUrl.hash);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
// Malformed stored destination — leave the URL on the callback path.
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
storage.removeItem(ssoDestKey(origin));
|
|
178
|
+
return session;
|
|
179
|
+
}
|