@oxyhq/core 2.1.2 → 2.2.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 (38) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/OxyServices.base.js +9 -10
  3. package/dist/cjs/index.js +9 -1
  4. package/dist/cjs/mixins/OxyServices.popup.js +15 -4
  5. package/dist/cjs/mixins/OxyServices.sso.js +142 -0
  6. package/dist/cjs/mixins/index.js +4 -0
  7. package/dist/cjs/utils/authWebUrl.js +37 -0
  8. package/dist/cjs/utils/ssoReturn.js +72 -0
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/OxyServices.base.js +9 -10
  11. package/dist/esm/index.js +4 -0
  12. package/dist/esm/mixins/OxyServices.popup.js +15 -4
  13. package/dist/esm/mixins/OxyServices.sso.js +138 -0
  14. package/dist/esm/mixins/index.js +4 -0
  15. package/dist/esm/utils/authWebUrl.js +33 -0
  16. package/dist/esm/utils/ssoReturn.js +69 -0
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/OxyServices.d.ts +2 -0
  19. package/dist/types/index.d.ts +4 -0
  20. package/dist/types/mixins/OxyServices.assets.d.ts +1 -3
  21. package/dist/types/mixins/OxyServices.popup.d.ts +19 -0
  22. package/dist/types/mixins/OxyServices.sso.d.ts +111 -0
  23. package/dist/types/mixins/index.d.ts +2 -1
  24. package/dist/types/utils/authWebUrl.d.ts +31 -0
  25. package/dist/types/utils/ssoReturn.d.ts +46 -0
  26. package/package.json +1 -1
  27. package/src/OxyServices.base.ts +9 -10
  28. package/src/OxyServices.ts +4 -0
  29. package/src/index.ts +7 -0
  30. package/src/mixins/OxyServices.popup.ts +36 -4
  31. package/src/mixins/OxyServices.sso.ts +172 -0
  32. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +32 -55
  33. package/src/mixins/__tests__/sso.test.ts +146 -0
  34. package/src/mixins/index.ts +6 -0
  35. package/src/utils/__tests__/authWebUrl.test.ts +40 -0
  36. package/src/utils/__tests__/ssoReturn.test.ts +120 -0
  37. package/src/utils/authWebUrl.ts +35 -0
  38. package/src/utils/ssoReturn.ts +94 -0
@@ -10,7 +10,7 @@ const jwt_decode_1 = require("jwt-decode");
10
10
  const errorUtils_1 = require("./utils/errorUtils");
11
11
  const HttpService_1 = require("./HttpService");
12
12
  const OxyServices_errors_1 = require("./OxyServices.errors");
13
- const fapiAutoDetect_1 = require("./utils/fapiAutoDetect");
13
+ const authWebUrl_1 = require("./utils/authWebUrl");
14
14
  /**
15
15
  * Base class for OxyServices with core infrastructure
16
16
  */
@@ -22,18 +22,17 @@ class OxyServicesBase {
22
22
  if (!config || typeof config !== 'object') {
23
23
  throw new Error('OxyConfig is required');
24
24
  }
25
- // Auto-detect the first-party IdP (`auth.<rp-apex>`) when the caller did not
26
- // pin `authWebUrl` explicitly. This mirrors the provider-`baseURL` path in
27
- // `@oxyhq/services` (OxyContext), so apps that construct their OWN
28
- // `OxyServices` instance and pass it to `<OxyProvider oxyServices={...} />`
29
- // get the same same-site IdP resolution. On web at `https://mention.earth`
30
- // this yields `https://auth.mention.earth`; on native/SSR (no `window`)
31
- // `autoDetectAuthWebUrl()` returns `undefined`, leaving the auth mixins'
32
- // `DEFAULT_AUTH_URL` fallback in effect — native behavior is unchanged.
25
+ // Default `authWebUrl` to the CENTRAL IdP (`auth.oxy.so`) when the caller
26
+ // did not pin it explicitly. TRUE central cross-domain SSO (Google/Meta/
27
+ // Clerk style) routes every RP through the one central IdP — it owns the
28
+ // host-only `fedcm_session` cookie and the central session store so the
29
+ // SDK no longer derives a per-apex `auth.<rp-apex>` IdP by default.
30
+ // `autoDetectAuthWebUrl` is still exported for any call site that opts into
31
+ // per-apex resolution, but it is NOT the constructor default anymore.
33
32
  // An explicit `authWebUrl` always wins (we only fill it when absent).
34
33
  const resolvedConfig = config.authWebUrl
35
34
  ? config
36
- : { ...config, authWebUrl: (0, fapiAutoDetect_1.autoDetectAuthWebUrl)() };
35
+ : { ...config, authWebUrl: (0, authWebUrl_1.resolveCentralAuthUrl)(config.authWebUrl) };
37
36
  this.config = resolvedConfig;
38
37
  this.cloudURL = resolvedConfig.cloudURL || 'https://cloud.oxy.so';
39
38
  // Initialize unified HTTP service (handles auth, caching, deduplication, queuing, retry)
package/dist/cjs/index.js CHANGED
@@ -20,7 +20,7 @@
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
21
  exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isAndroid = exports.isIOS = exports.isNative = exports.isWeb = exports.setPlatformOS = exports.getPlatformOS = exports.isRTLLocale = exports.normalizeLanguageCode = exports.getNativeLanguageName = exports.getLanguageName = exports.getLanguageMetadata = exports.SUPPORTED_LANGUAGES = exports.TopicSource = exports.TopicType = exports.SECURITY_EVENT_SEVERITY_MAP = exports.DeviceManager = exports.RecoveryPhraseService = exports.SignatureService = exports.IdentityPersistError = exports.IdentityAlreadyExistsError = exports.KeyManager = exports.sessionsArraysEqual = exports.normalizeAndSortSessions = exports.mergeSessions = exports.authenticatedApiCall = exports.withAuthErrorHandling = exports.isAuthenticationError = exports.ensureValidToken = exports.AuthenticationFailedError = exports.SessionSyncRequiredError = exports.OxyAppDataIdentifierError = exports.ServiceCredentialMismatchError = exports.createCrossDomainAuth = exports.CrossDomainAuth = exports.createAuthManager = exports.AuthManager = exports.oxyClient = exports.OXY_CLOUD_URL = exports.OxyAuthenticationTimeoutError = exports.OxyAuthenticationError = exports.OxyServices = void 0;
22
22
  exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = exports.PASSWORD_REGEX = exports.USERNAME_REGEX = exports.EMAIL_REGEX = exports.retryAsync = exports.validateRequiredFields = exports.handleHttpError = exports.createApiError = exports.ErrorCodes = exports.safeJsonParse = exports.buildPaginationParams = exports.buildUrl = exports.buildSearchParams = exports.translate = exports.createDebugLogger = exports.debugError = exports.debugWarn = exports.debugLog = exports.isDev = exports.withRetry = exports.delay = exports.shouldAllowRequest = exports.recordSuccess = exports.recordFailure = exports.calculateBackoffInterval = exports.createCircuitBreakerState = exports.DEFAULT_CIRCUIT_BREAKER_CONFIG = exports.isRetryableError = exports.isNetworkError = exports.isServerError = exports.isRateLimitError = exports.isNotFoundError = exports.isForbiddenError = exports.isUnauthorizedError = exports.isAlreadyRegisteredError = exports.getErrorMessage = exports.getErrorStatus = exports.HttpStatus = exports.getSystemColorScheme = exports.systemPrefersDarkMode = exports.getOppositeTheme = void 0;
23
- exports.packageInfo = exports.runColdBoot = exports.autoDetectAuthWebUrl = exports.getAccountColor = exports.mergeAccountsFromRefreshAll = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = void 0;
23
+ exports.packageInfo = exports.runColdBoot = exports.generateSsoState = exports.parseSsoReturnFragment = exports.resolveCentralAuthUrl = exports.CENTRAL_AUTH_URL = exports.autoDetectAuthWebUrl = exports.getAccountColor = exports.mergeAccountsFromRefreshAll = exports.formatPublicKeyHandle = exports.getAccountFallbackHandle = exports.getAccountDisplayName = exports.createQuickAccount = exports.buildAccountsArray = exports.updateAvatarVisibility = exports.logPerformance = exports.logPayment = exports.logDevice = exports.logUser = exports.logSession = exports.logApi = exports.logAuth = exports.LogLevel = exports.logger = exports.validateAndSanitizeUserInput = exports.isValidObjectId = exports.sanitizeHTML = exports.sanitizeString = exports.isValidFileType = exports.isValidFileSize = exports.isValidDate = void 0;
24
24
  // Ensure crypto polyfills are loaded before anything else
25
25
  require("./crypto/polyfill");
26
26
  // ---------------------------------------------------------------------------
@@ -230,6 +230,14 @@ Object.defineProperty(exports, "getAccountColor", { enumerable: true, get: funct
230
230
  // ---------------------------------------------------------------------------
231
231
  var fapiAutoDetect_1 = require("./utils/fapiAutoDetect");
232
232
  Object.defineProperty(exports, "autoDetectAuthWebUrl", { enumerable: true, get: function () { return fapiAutoDetect_1.autoDetectAuthWebUrl; } });
233
+ // Central cross-domain SSO (opaque single-use code bounce via auth.oxy.so)
234
+ var authWebUrl_1 = require("./utils/authWebUrl");
235
+ Object.defineProperty(exports, "CENTRAL_AUTH_URL", { enumerable: true, get: function () { return authWebUrl_1.CENTRAL_AUTH_URL; } });
236
+ Object.defineProperty(exports, "resolveCentralAuthUrl", { enumerable: true, get: function () { return authWebUrl_1.resolveCentralAuthUrl; } });
237
+ var ssoReturn_1 = require("./utils/ssoReturn");
238
+ Object.defineProperty(exports, "parseSsoReturnFragment", { enumerable: true, get: function () { return ssoReturn_1.parseSsoReturnFragment; } });
239
+ var OxyServices_sso_1 = require("./mixins/OxyServices.sso");
240
+ Object.defineProperty(exports, "generateSsoState", { enumerable: true, get: function () { return OxyServices_sso_1.generateSsoState; } });
233
241
  var coldBoot_1 = require("./utils/coldBoot");
234
242
  Object.defineProperty(exports, "runColdBoot", { enumerable: true, get: function () { return coldBoot_1.runColdBoot; } });
235
243
  // ---------------------------------------------------------------------------
@@ -191,17 +191,25 @@ function OxyServicesPopupAuthMixin(Base) {
191
191
  const timeout = options.timeout || this.constructor.SILENT_TIMEOUT;
192
192
  const nonce = this.generateNonce();
193
193
  const clientId = window.location.origin;
194
+ // Resolve the IdP origin for the iframe. An explicit per-apex override (the
195
+ // durable cross-domain reload path — see `SilentAuthOptions.authWebUrlOverride`)
196
+ // wins over the instance's configured central auth URL. The SAME origin is
197
+ // handed to `waitForIframeAuth` so the postMessage origin check matches the
198
+ // exact host the iframe was loaded from.
199
+ const authOrigin = options.authWebUrlOverride && options.authWebUrlOverride.length > 0
200
+ ? options.authWebUrlOverride
201
+ : this.resolveAuthUrl();
194
202
  const iframe = document.createElement('iframe');
195
203
  iframe.style.display = 'none';
196
204
  iframe.style.position = 'absolute';
197
205
  iframe.style.width = '0';
198
206
  iframe.style.height = '0';
199
207
  iframe.style.border = 'none';
200
- const silentUrl = `${this.resolveAuthUrl()}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
208
+ const silentUrl = `${authOrigin}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
201
209
  iframe.src = silentUrl;
202
210
  document.body.appendChild(iframe);
203
211
  try {
204
- const session = await this.waitForIframeAuth(iframe, timeout, clientId);
212
+ const session = await this.waitForIframeAuth(iframe, timeout, authOrigin);
205
213
  // Bail early on incomplete responses. The iframe contract requires
206
214
  // both an access token and a session id; anything less is unusable.
207
215
  // Returning `null` here (without installing the token) prevents a
@@ -389,8 +397,11 @@ function OxyServicesPopupAuthMixin(Base) {
389
397
  resolve(null); // Silent failure - don't throw
390
398
  }, timeout);
391
399
  const messageHandler = (event) => {
392
- // Verify origin
393
- if (event.origin !== this.resolveAuthUrl()) {
400
+ // Verify origin against the EXACT host the iframe was loaded from
401
+ // (`expectedOrigin`). For the per-apex durable-restore path this is
402
+ // `auth.<rp-apex>`, not the instance's central `resolveAuthUrl()` — so
403
+ // we must honour the caller-supplied origin, never re-derive it here.
404
+ if (event.origin !== expectedOrigin) {
394
405
  return;
395
406
  }
396
407
  const { type, session } = event.data;
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ /**
3
+ * Central Cross-Domain SSO (opaque-code) Mixin
4
+ *
5
+ * Implements the Relying-Party half of TRUE central cross-domain SSO
6
+ * (Google/Meta/Clerk style). The central IdP at `auth.oxy.so` owns the session;
7
+ * an RP bounces a top-level redirect (prompt=none) to `auth.oxy.so/sso`, which
8
+ * returns an OPAQUE single-use code in the redirect fragment. The RP then
9
+ * exchanges that code here for the real session.
10
+ *
11
+ * Security properties:
12
+ * - NO token/JWT ever travels in a URL — only the opaque code does. The real
13
+ * `accessToken` is delivered exclusively in this exchange response body.
14
+ * - The exchange is a CORS POST with NO credentials/cookies — the opaque code
15
+ * is the only bearer of authority, and the central store burns it atomically
16
+ * (single-use). Sending no cookies keeps the request a clean, ambient-
17
+ * authority-free bearer exchange that the central `POST /sso/exchange`
18
+ * endpoint validates by `Origin` against the code's bound `clientOrigin`.
19
+ * - The code is minted server-side bound to the RP origin and expires in
20
+ * seconds, so a leaked code is useless cross-origin and short-lived.
21
+ *
22
+ * On success the mixin plants the returned access token via
23
+ * `httpService.setTokens(...)` — mirroring `exchangeIdTokenForSession` /
24
+ * `verifyChallenge` — so callers do NOT need to plant tokens manually.
25
+ */
26
+ Object.defineProperty(exports, "__esModule", { value: true });
27
+ exports.generateSsoState = generateSsoState;
28
+ exports.OxyServicesSsoMixin = OxyServicesSsoMixin;
29
+ const debugUtils_1 = require("../shared/utils/debugUtils");
30
+ const debug = (0, debugUtils_1.createDebugLogger)('SSO');
31
+ /**
32
+ * Generate a cryptographically secure state value for the SSO bounce.
33
+ *
34
+ * Exposed as a module-level helper (in addition to the instance method below)
35
+ * so consumers that do not yet hold an `OxyServices` instance can still mint a
36
+ * bounce state. Reuses the same `crypto.randomUUID`-based generator the popup
37
+ * mixin uses for its CSRF state, with a `getRandomValues` fallback.
38
+ */
39
+ function generateSsoState() {
40
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
41
+ return crypto.randomUUID();
42
+ }
43
+ if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
44
+ const bytes = new Uint8Array(16);
45
+ crypto.getRandomValues(bytes);
46
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
47
+ }
48
+ throw new Error('No secure random source available for SSO state generation');
49
+ }
50
+ function OxyServicesSsoMixin(Base) {
51
+ return class extends Base {
52
+ constructor(...args) {
53
+ super(...args);
54
+ }
55
+ /**
56
+ * Generate cryptographically secure state for the SSO bounce (CSRF
57
+ * protection). Delegates to the module-level {@link generateSsoState}
58
+ * helper, which uses the same `crypto.randomUUID`-based generator the popup
59
+ * mixin's `generateState()` uses — one shared secure-random implementation.
60
+ */
61
+ generateSsoState() {
62
+ return generateSsoState();
63
+ }
64
+ /**
65
+ * Exchange an opaque single-use SSO code for the real Oxy session.
66
+ *
67
+ * POSTs `{ code }` to `${getSessionBaseUrl()}/sso/exchange` as a CORS
68
+ * request with NO credentials/cookies. On success the returned access token
69
+ * is planted via `httpService.setTokens(...)` (matching
70
+ * `exchangeIdTokenForSession` / `verifyChallenge`), so callers do not need
71
+ * to plant tokens manually.
72
+ *
73
+ * @param code - The opaque single-use code delivered in the SSO return
74
+ * fragment (see {@link parseSsoReturnFragment}). The central store burns
75
+ * it atomically on exchange.
76
+ * @returns The resolved {@link SessionLoginResponse}.
77
+ */
78
+ async exchangeSsoCode(code) {
79
+ if (typeof code !== 'string' || code.length === 0) {
80
+ throw this.handleError(new Error('exchangeSsoCode requires a non-empty code'));
81
+ }
82
+ const url = `${this.getSessionBaseUrl().replace(/\/$/, '')}/sso/exchange`;
83
+ debug.log('Exchanging SSO code for session...');
84
+ let response;
85
+ try {
86
+ response = await fetch(url, {
87
+ method: 'POST',
88
+ // No cookies: the opaque code is the sole bearer of authority and the
89
+ // server validates by Origin against the code's bound clientOrigin.
90
+ credentials: 'omit',
91
+ headers: {
92
+ 'Content-Type': 'application/json',
93
+ Accept: 'application/json',
94
+ },
95
+ body: JSON.stringify({ code }),
96
+ });
97
+ }
98
+ catch (error) {
99
+ debug.error('SSO exchange request failed:', error instanceof Error ? error.message : String(error));
100
+ throw this.handleError(error);
101
+ }
102
+ if (!response.ok) {
103
+ throw this.handleError(new Error(`SSO exchange failed with HTTP ${response.status}`));
104
+ }
105
+ let payload;
106
+ try {
107
+ payload = (await response.json());
108
+ }
109
+ catch (error) {
110
+ throw this.handleError(error);
111
+ }
112
+ if (!payload || typeof payload.accessToken !== 'string' || payload.accessToken.length === 0) {
113
+ throw this.handleError(new Error('SSO exchange returned no access token'));
114
+ }
115
+ if (typeof payload.sessionId !== 'string' || payload.sessionId.length === 0) {
116
+ throw this.handleError(new Error('SSO exchange returned no sessionId'));
117
+ }
118
+ const userId = payload.user?.id ?? payload.user?._id;
119
+ if (!userId || typeof payload.user?.username !== 'string') {
120
+ throw this.handleError(new Error('SSO exchange returned an invalid user'));
121
+ }
122
+ const user = {
123
+ id: userId,
124
+ username: payload.user.username,
125
+ avatar: payload.user.avatar,
126
+ };
127
+ // Plant the access token exactly like exchangeIdTokenForSession does.
128
+ // The SSO exchange does not return a refresh token (the central store
129
+ // holds the refresh credential), so default it to an empty string.
130
+ this.httpService.setTokens(payload.accessToken, '');
131
+ debug.log('SSO exchange complete:', { hasSession: !!payload.sessionId });
132
+ const session = {
133
+ sessionId: payload.sessionId,
134
+ deviceId: '',
135
+ expiresAt: payload.expiresAt ?? '',
136
+ user,
137
+ accessToken: payload.accessToken,
138
+ };
139
+ return session;
140
+ }
141
+ };
142
+ }
@@ -13,6 +13,7 @@ const OxyServices_auth_1 = require("./OxyServices.auth");
13
13
  const OxyServices_fedcm_1 = require("./OxyServices.fedcm");
14
14
  const OxyServices_popup_1 = require("./OxyServices.popup");
15
15
  const OxyServices_redirect_1 = require("./OxyServices.redirect");
16
+ const OxyServices_sso_1 = require("./OxyServices.sso");
16
17
  const OxyServices_user_1 = require("./OxyServices.user");
17
18
  const OxyServices_privacy_1 = require("./OxyServices.privacy");
18
19
  const OxyServices_language_1 = require("./OxyServices.language");
@@ -52,6 +53,9 @@ const MIXIN_PIPELINE = [
52
53
  OxyServices_fedcm_1.OxyServicesFedCMMixin,
53
54
  OxyServices_popup_1.OxyServicesPopupAuthMixin,
54
55
  OxyServices_redirect_1.OxyServicesRedirectAuthMixin,
56
+ // Central cross-domain SSO (opaque-code exchange). After Popup so it can
57
+ // reuse the popup mixin's secure-random `generateState()`.
58
+ OxyServices_sso_1.OxyServicesSsoMixin,
55
59
  // User management (requires auth)
56
60
  OxyServices_user_1.OxyServicesUserMixin,
57
61
  OxyServices_privacy_1.OxyServicesPrivacyMixin,
@@ -0,0 +1,37 @@
1
+ "use strict";
2
+ /**
3
+ * Central IdP (auth web) URL resolution for cross-domain SSO.
4
+ *
5
+ * The Oxy ecosystem runs a single, central Identity Provider at
6
+ * `auth.oxy.so`. For TRUE central cross-domain SSO (Google/Meta/Clerk style),
7
+ * FedCM and the opaque-code SSO bounce always target this one origin — it owns
8
+ * the host-only `fedcm_session` cookie and the central session store reachable
9
+ * via `api.oxy.so`. Relying Parties (mention.earth, homiio.com, alia.onl, …)
10
+ * delegate to it rather than standing up a per-apex IdP.
11
+ *
12
+ * This module is intentionally pure: it performs no DOM access, reads no
13
+ * `window`/`location`, and has no side effects. It is the single source of
14
+ * truth for the central IdP origin so call sites never hardcode the literal.
15
+ *
16
+ * Note: this is distinct from `autoDetectAuthWebUrl` (per-apex `auth.<rp-apex>`
17
+ * derivation). The central-SSO path deliberately does NOT auto-detect per-apex
18
+ * IdPs — it is central only. An explicitly-configured `authWebUrl` still wins.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.CENTRAL_AUTH_URL = void 0;
22
+ exports.resolveCentralAuthUrl = resolveCentralAuthUrl;
23
+ /**
24
+ * The canonical central Identity Provider origin for the Oxy ecosystem.
25
+ * No trailing slash.
26
+ */
27
+ exports.CENTRAL_AUTH_URL = 'https://auth.oxy.so';
28
+ /**
29
+ * Resolve the central IdP origin, honouring an explicit override.
30
+ *
31
+ * @param explicit - A caller-supplied auth web URL, or `undefined`/empty to use
32
+ * the central default. An explicit non-empty value always wins.
33
+ * @returns The explicit value when provided, otherwise {@link CENTRAL_AUTH_URL}.
34
+ */
35
+ function resolveCentralAuthUrl(explicit) {
36
+ return explicit ?? exports.CENTRAL_AUTH_URL;
37
+ }
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ /**
3
+ * Parse the SSO return fragment delivered by the central IdP.
4
+ *
5
+ * After a top-level redirect bounce to `auth.oxy.so/sso` (prompt=none), the
6
+ * central IdP returns the Relying Party to its `redirect_uri` with the result
7
+ * encoded in the URL fragment (the `#…` part). The fragment is used — not a
8
+ * query string — so the opaque single-use code never reaches a server access
9
+ * log, a `Referer` header, or browser history in a recoverable form.
10
+ *
11
+ * Three outcomes are possible:
12
+ * - `#oxy_sso=ok&code=<opaque>&state=<state>` — the IdP had a session; the RP
13
+ * exchanges `code` (via `oxy.exchangeSsoCode`) for the real session. NO
14
+ * token/JWT ever appears in the URL — only the opaque code.
15
+ * - `#oxy_sso=none&state=<state>` — the IdP had no session (prompt=none, user
16
+ * not signed in centrally). The RP shows its own signed-out UI.
17
+ * - `#oxy_sso=error&state=<state>` — the bounce failed. The RP recovers.
18
+ *
19
+ * This parser is pure and defensive: it never throws, and `kind` is strictly
20
+ * one of `'ok' | 'none' | 'error'`. It returns `null` when the fragment is not
21
+ * an oxy_sso fragment at all (i.e. `oxy_sso` is absent or an unrecognised
22
+ * value), so the caller can ignore unrelated fragments without special-casing.
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.parseSsoReturnFragment = parseSsoReturnFragment;
26
+ const VALID_KINDS = new Set(['ok', 'none', 'error']);
27
+ /**
28
+ * Parse an SSO return fragment.
29
+ *
30
+ * @param hash - The URL fragment, with or without the leading `#`
31
+ * (e.g. `location.hash`). May be `undefined`/empty.
32
+ * @returns The parsed result when `hash` is a recognised oxy_sso fragment,
33
+ * otherwise `null`. Never throws.
34
+ */
35
+ function parseSsoReturnFragment(hash) {
36
+ if (typeof hash !== 'string' || hash.length === 0) {
37
+ return null;
38
+ }
39
+ // Strip a single leading '#'. A bare '#' (empty fragment) yields no params.
40
+ const raw = hash.startsWith('#') ? hash.slice(1) : hash;
41
+ if (raw.length === 0) {
42
+ return null;
43
+ }
44
+ let params;
45
+ try {
46
+ params = new URLSearchParams(raw);
47
+ }
48
+ catch {
49
+ // URLSearchParams does not throw for malformed input in practice, but guard
50
+ // against any environment/polyfill that might so this stays total.
51
+ return null;
52
+ }
53
+ const kind = params.get('oxy_sso');
54
+ if (kind === null || !VALID_KINDS.has(kind)) {
55
+ // Not an oxy_sso fragment (absent or unrecognised value) — ignore it.
56
+ return null;
57
+ }
58
+ const result = { kind: kind };
59
+ const state = params.get('state');
60
+ if (state !== null && state.length > 0) {
61
+ result.state = state;
62
+ }
63
+ // The opaque code is only meaningful on success; ignore any stray `code` on
64
+ // none/error so callers never attempt an exchange for a non-ok outcome.
65
+ if (result.kind === 'ok') {
66
+ const code = params.get('code');
67
+ if (code !== null && code.length > 0) {
68
+ result.code = code;
69
+ }
70
+ }
71
+ return result;
72
+ }