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