@oxyhq/core 5.2.1 → 5.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.
Files changed (59) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/CrossDomainAuth.js +32 -42
  3. package/dist/cjs/index.js +24 -2
  4. package/dist/cjs/mixins/OxyServices.sso.js +36 -0
  5. package/dist/cjs/mixins/OxyServices.user.js +24 -0
  6. package/dist/cjs/server/index.js +13 -1
  7. package/dist/cjs/session/SessionClient.js +142 -0
  8. package/dist/cjs/session/createSessionClient.js +26 -0
  9. package/dist/cjs/session/projectSessionState.js +75 -0
  10. package/dist/cjs/session/sessionClientHost.js +30 -0
  11. package/dist/cjs/session/socketLoader.js +55 -0
  12. package/dist/cjs/utils/ssoBounce.js +9 -9
  13. package/dist/cjs/utils/ssoEstablish.js +110 -0
  14. package/dist/esm/.tsbuildinfo +1 -1
  15. package/dist/esm/CrossDomainAuth.js +32 -42
  16. package/dist/esm/index.js +14 -0
  17. package/dist/esm/mixins/OxyServices.sso.js +36 -0
  18. package/dist/esm/mixins/OxyServices.user.js +24 -0
  19. package/dist/esm/server/index.js +10 -0
  20. package/dist/esm/session/SessionClient.js +138 -0
  21. package/dist/esm/session/createSessionClient.js +23 -0
  22. package/dist/esm/session/projectSessionState.js +69 -0
  23. package/dist/esm/session/sessionClientHost.js +27 -0
  24. package/dist/esm/session/socketLoader.js +19 -0
  25. package/dist/esm/utils/ssoBounce.js +9 -9
  26. package/dist/esm/utils/ssoEstablish.js +107 -0
  27. package/dist/types/.tsbuildinfo +1 -1
  28. package/dist/types/CrossDomainAuth.d.ts +33 -13
  29. package/dist/types/index.d.ts +7 -0
  30. package/dist/types/mixins/OxyServices.sso.d.ts +24 -0
  31. package/dist/types/mixins/OxyServices.user.d.ts +14 -0
  32. package/dist/types/server/index.d.ts +2 -0
  33. package/dist/types/session/SessionClient.d.ts +55 -0
  34. package/dist/types/session/createSessionClient.d.ts +23 -0
  35. package/dist/types/session/projectSessionState.d.ts +43 -0
  36. package/dist/types/session/sessionClientHost.d.ts +18 -0
  37. package/dist/types/session/socketLoader.d.ts +9 -0
  38. package/dist/types/utils/ssoBounce.d.ts +9 -9
  39. package/dist/types/utils/ssoEstablish.d.ts +85 -0
  40. package/package.json +1 -1
  41. package/src/CrossDomainAuth.ts +33 -44
  42. package/src/__tests__/crossDomainAuth.test.ts +33 -16
  43. package/src/index.ts +24 -0
  44. package/src/mixins/OxyServices.sso.ts +55 -0
  45. package/src/mixins/OxyServices.user.ts +26 -0
  46. package/src/mixins/__tests__/sso.test.ts +41 -0
  47. package/src/server/index.ts +12 -0
  48. package/src/session/SessionClient.ts +175 -0
  49. package/src/session/__tests__/SessionClient.rest.test.ts +79 -0
  50. package/src/session/__tests__/SessionClient.socket.test.ts +132 -0
  51. package/src/session/__tests__/SessionClient.state.test.ts +64 -0
  52. package/src/session/__tests__/sessionIntegration.test.ts +202 -0
  53. package/src/session/createSessionClient.ts +31 -0
  54. package/src/session/projectSessionState.ts +83 -0
  55. package/src/session/sessionClientHost.ts +32 -0
  56. package/src/session/socketLoader.ts +28 -0
  57. package/src/utils/__tests__/ssoEstablish.test.ts +204 -0
  58. package/src/utils/ssoBounce.ts +9 -9
  59. package/src/utils/ssoEstablish.ts +174 -0
@@ -2,11 +2,19 @@
2
2
  /**
3
3
  * Cross-Domain Authentication Helper
4
4
  *
5
- * Provides a simplified API for cross-domain SSO authentication that automatically
6
- * selects the best authentication method based on browser capabilities:
5
+ * Provides a simplified API for cross-domain SSO authentication. The
6
+ * automatic sign-in path uses a full-page redirect through the central IdP
7
+ * (`auth.oxy.so`) — a tokenless, universal mechanism that works in every
8
+ * browser.
7
9
  *
8
- * 1. FedCM (if supported) - Modern, Google-style browser-native auth
9
- * 2. Redirect (fallback) - Tokenless central SSO full-page redirect
10
+ * FedCM (`signInWithFedCM`) is intentionally NOT part of the automatic
11
+ * (`'auto'`) path: it is a Chrome-only browser API, and a misconfigured or
12
+ * unreachable FedCM endpoint fails fast and silently, which — combined with a
13
+ * caller's auth-guard effect re-invoking `signIn()` whenever the user is still
14
+ * unauthenticated — produced a real production incident (an accelerating
15
+ * `autoSignIn` → FedCM-fails → redirect retry loop). `signInWithFedCM` remains
16
+ * available for callers that want to opt into it EXPLICITLY
17
+ * (`signIn({ method: 'fedcm' })`).
10
18
  *
11
19
  * Usage:
12
20
  * ```typescript
@@ -14,7 +22,7 @@
14
22
  *
15
23
  * const auth = new CrossDomainAuth(oxyServices);
16
24
  *
17
- * // Automatic method selection
25
+ * // Automatic method selection (always redirect)
18
26
  * const session = await auth.signIn();
19
27
  *
20
28
  * // Or use a specific method
@@ -30,11 +38,11 @@ class CrossDomainAuth {
30
38
  this.oxyServices = oxyServices;
31
39
  }
32
40
  /**
33
- * Sign in with automatic method selection
41
+ * Sign in with automatic method selection.
34
42
  *
35
- * Tries methods in this order:
36
- * 1. FedCM (if supported and not in private browsing)
37
- * 2. Redirect (always works)
43
+ * Auto mode always uses the full-page redirect (see the class doc comment
44
+ * for why FedCM was removed from this path). Pass `{ method: 'fedcm' }` to
45
+ * opt into FedCM explicitly.
38
46
  *
39
47
  * @param options - Authentication options
40
48
  * @returns Session with user data and access token
@@ -52,22 +60,18 @@ class CrossDomainAuth {
52
60
  return this.autoSignIn(options);
53
61
  }
54
62
  /**
55
- * Automatic sign-in with progressive enhancement
63
+ * Automatic sign-in.
64
+ *
65
+ * Goes straight to the full-page redirect — the sole automatic method.
66
+ * FedCM is deliberately NOT attempted here (see the class doc comment):
67
+ * it is Chrome-only, and its fast/silent failure mode combined with a
68
+ * caller's auth-guard effect re-invoking `signIn()` produced a real
69
+ * production sign-in loop. Use `signIn({ method: 'fedcm' })` to opt in
70
+ * explicitly.
56
71
  *
57
72
  * @private
58
73
  */
59
74
  async autoSignIn(options) {
60
- // 1. Try FedCM first (best UX, most modern)
61
- if (this.isFedCMSupported()) {
62
- try {
63
- options.onMethodSelected?.('fedcm');
64
- return await this.signInWithFedCM(options);
65
- }
66
- catch (error) {
67
- loggerUtils_1.logger.warn('FedCM failed, falling back to redirect', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
68
- }
69
- }
70
- // 2. Fallback to redirect (always works)
71
75
  options.onMethodSelected?.('redirect');
72
76
  this.signInWithRedirect(options);
73
77
  return null;
@@ -104,25 +108,13 @@ class CrossDomainAuth {
104
108
  /**
105
109
  * Silent sign-in (check for existing session)
106
110
  *
107
- * Tries to automatically sign in without user interaction.
108
- * Works with FedCM and iframe-based silent auth.
111
+ * Tries to automatically sign in without user interaction, via the
112
+ * iframe-based silent auth against the per-apex `/auth/silent` IdP host.
113
+ * FedCM is deliberately NOT attempted here (see the class doc comment).
109
114
  *
110
115
  * @returns Session if user is already signed in, null otherwise
111
116
  */
112
117
  async silentSignIn() {
113
- // Try FedCM silent sign-in first (if supported)
114
- if (this.isFedCMSupported()) {
115
- try {
116
- const session = await this.oxyServices.silentSignInWithFedCM();
117
- if (session) {
118
- return session;
119
- }
120
- }
121
- catch (error) {
122
- loggerUtils_1.logger.debug('FedCM silent sign-in did not resolve', { component: 'CrossDomainAuth', method: 'silentSignIn' }, error);
123
- }
124
- }
125
- // Fallback to iframe-based silent auth
126
118
  try {
127
119
  return await this.oxyServices.silentSignIn();
128
120
  }
@@ -151,15 +143,13 @@ class CrossDomainAuth {
151
143
  /**
152
144
  * Get recommended authentication method for current environment
153
145
  *
146
+ * Redirect is the sole recommended automatic method — it works in every
147
+ * browser, unlike FedCM (Chrome-only). Callers that want FedCM must opt in
148
+ * explicitly via `signIn({ method: 'fedcm' })`.
149
+ *
154
150
  * @returns Recommended method name and reason
155
151
  */
156
152
  getRecommendedMethod() {
157
- if (this.isFedCMSupported()) {
158
- return {
159
- method: 'fedcm',
160
- reason: 'FedCM is supported - provides best UX with browser-native auth',
161
- };
162
- }
163
153
  if (typeof window !== 'undefined') {
164
154
  return {
165
155
  method: 'redirect',
package/dist/cjs/index.js CHANGED
@@ -20,8 +20,8 @@
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
21
  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.verifyPublicCardAttestation = exports.parseAttestPayload = exports.parseIdPayload = exports.buildUserDid = exports.normalizeProfileLinks = exports.getNormalizedUserHandle = exports.getCanonicalUserHandle = exports.normalizeUserIdentityOrNull = exports.normalizeUserIdentity = exports.getNormalizedUserId = 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.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 = exports.normalizeColorScheme = exports.normalizeTheme = exports.getContrastTextColor = exports.isLightColor = exports.withOpacity = exports.rgbToHex = exports.hexToRgb = exports.lightenColor = exports.darkenColor = exports.isAndroid = void 0;
23
- exports.ssoPriorSessionKey = exports.ssoAttemptedKey = 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.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 = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = void 0;
24
- exports.packageInfo = exports.runColdBoot = exports.allowSsoBounce = exports.silentRestoreSuppressed = exports.guardActive = exports.isCentralIdPOrigin = exports.buildSsoBounceUrl = exports.getSsoCallbackBootstrapScript = exports.ssoNavigate = exports.ssoCallbackBootstrapKey = exports.ssoSignedOutKey = void 0;
23
+ exports.ssoAttemptedKey = exports.ssoNoSessionKey = exports.ssoDestKey = exports.ssoGuardKey = exports.ssoStateKey = exports.SSO_GUARD_TTL_MS = exports.SSO_CALLBACK_PATH = exports.establishIdpSessionAfterClaim = exports.generateSsoState = exports.consumeSsoReturn = exports.parseSsoReturnFragment = exports.resolveCentralAuthUrl = exports.CENTRAL_IDP_APEX = exports.CENTRAL_AUTH_URL = 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 = exports.isValidURL = exports.isValidUUID = exports.isValidObject = exports.isValidArray = exports.isRequiredBoolean = exports.isRequiredNumber = exports.isRequiredString = exports.isValidPassword = exports.isValidUsername = exports.isValidEmail = void 0;
24
+ exports.packageInfo = exports.accountIdsOf = exports.activeUserOf = exports.activeSessionIdOf = exports.deviceStateToClientSessions = exports.createSessionClient = exports.createSessionClientHost = exports.SessionClient = exports.runColdBoot = exports.allowSsoBounce = exports.silentRestoreSuppressed = exports.guardActive = exports.isCentralIdPOrigin = exports.buildSsoBounceUrl = exports.getSsoCallbackBootstrapScript = exports.ssoNavigate = exports.ssoCallbackBootstrapKey = exports.ssoSignedOutKey = exports.ssoPriorSessionKey = void 0;
25
25
  // Ensure crypto polyfills are loaded before anything else
26
26
  require("./crypto/polyfill");
27
27
  // ---------------------------------------------------------------------------
@@ -274,6 +274,9 @@ Object.defineProperty(exports, "parseSsoReturnFragment", { enumerable: true, get
274
274
  Object.defineProperty(exports, "consumeSsoReturn", { enumerable: true, get: function () { return ssoReturn_1.consumeSsoReturn; } });
275
275
  var OxyServices_sso_1 = require("./mixins/OxyServices.sso");
276
276
  Object.defineProperty(exports, "generateSsoState", { enumerable: true, get: function () { return OxyServices_sso_1.generateSsoState; } });
277
+ // Post-claim durable-session establish hop (web device-flow / QR sign-in).
278
+ var ssoEstablish_1 = require("./utils/ssoEstablish");
279
+ Object.defineProperty(exports, "establishIdpSessionAfterClaim", { enumerable: true, get: function () { return ssoEstablish_1.establishIdpSessionAfterClaim; } });
277
280
  // SSO bounce — per-origin sessionStorage keys, bounce URL builder, predicates
278
281
  var ssoBounce_1 = require("./utils/ssoBounce");
279
282
  Object.defineProperty(exports, "SSO_CALLBACK_PATH", { enumerable: true, get: function () { return ssoBounce_1.SSO_CALLBACK_PATH; } });
@@ -295,6 +298,25 @@ Object.defineProperty(exports, "silentRestoreSuppressed", { enumerable: true, ge
295
298
  Object.defineProperty(exports, "allowSsoBounce", { enumerable: true, get: function () { return ssoBounce_1.allowSsoBounce; } });
296
299
  var coldBoot_1 = require("./utils/coldBoot");
297
300
  Object.defineProperty(exports, "runColdBoot", { enumerable: true, get: function () { return coldBoot_1.runColdBoot; } });
301
+ // ---------------------------------------------------------------------------
302
+ // Session sync (device-scoped multi-account session client)
303
+ // ---------------------------------------------------------------------------
304
+ var SessionClient_1 = require("./session/SessionClient");
305
+ Object.defineProperty(exports, "SessionClient", { enumerable: true, get: function () { return SessionClient_1.SessionClient; } });
306
+ // Shared SessionClient integration layer: the host adapter, the pure
307
+ // DeviceSessionState projection helpers, and the client factory are defined
308
+ // ONCE here so `@oxyhq/services` and `@oxyhq/auth` both reuse them instead of
309
+ // duplicating a local copy. Each consumer supplies its own `TokenTransport`
310
+ // (native vs. web mint strategies differ) to `createSessionClient`.
311
+ var sessionClientHost_1 = require("./session/sessionClientHost");
312
+ Object.defineProperty(exports, "createSessionClientHost", { enumerable: true, get: function () { return sessionClientHost_1.createSessionClientHost; } });
313
+ var createSessionClient_1 = require("./session/createSessionClient");
314
+ Object.defineProperty(exports, "createSessionClient", { enumerable: true, get: function () { return createSessionClient_1.createSessionClient; } });
315
+ var projectSessionState_1 = require("./session/projectSessionState");
316
+ Object.defineProperty(exports, "deviceStateToClientSessions", { enumerable: true, get: function () { return projectSessionState_1.deviceStateToClientSessions; } });
317
+ Object.defineProperty(exports, "activeSessionIdOf", { enumerable: true, get: function () { return projectSessionState_1.activeSessionIdOf; } });
318
+ Object.defineProperty(exports, "activeUserOf", { enumerable: true, get: function () { return projectSessionState_1.activeUserOf; } });
319
+ Object.defineProperty(exports, "accountIdsOf", { enumerable: true, get: function () { return projectSessionState_1.accountIdsOf; } });
298
320
  // API response contracts (request/response Zod schemas + inferred types) live in
299
321
  // `@oxyhq/contracts` — the single source of truth shared by the backend and every
300
322
  // client SDK. Import them directly from `@oxyhq/contracts`; `@oxyhq/core` does NOT
@@ -168,5 +168,41 @@ function OxyServicesSsoMixin(Base) {
168
168
  };
169
169
  return session;
170
170
  }
171
+ /**
172
+ * Mint a server-formed `/sso/establish` URL for the caller's OWN session,
173
+ * bound to an approved RP `origin`.
174
+ *
175
+ * Bearer-authenticated (the session id is taken from the caller's own
176
+ * bearer, server-side — never from any argument). The server validates that
177
+ * `origin` is an approved client origin (and matches the request `Origin`),
178
+ * derives the per-apex IdP host (`auth.<apex>`), mints a short-lived HS256
179
+ * establish-token, and returns a fully-formed
180
+ * `https://<auth-host>/sso/establish?et=…&return_to=<origin>/__oxy/sso-callback&state=<state>`.
181
+ *
182
+ * Used AFTER a web device-flow claim to plant the durable first-party
183
+ * `fedcm_session` cookie so a reload can re-mint a token (see
184
+ * {@link establishIdpSessionAfterClaim}). Cache-free (a POST is never
185
+ * cached, but `cache: false` is explicit).
186
+ *
187
+ * @param origin - The RP origin (`window.location.origin`) to establish for.
188
+ * @param state - The CSRF state echoed back in the callback fragment; the
189
+ * caller persists the SAME value under `ssoStateKey(origin)` so the
190
+ * post-bounce `sso-return` step validates it.
191
+ */
192
+ async requestSsoEstablishUrl(origin, state) {
193
+ if (typeof origin !== 'string' || origin.length === 0) {
194
+ throw this.handleError(new Error('requestSsoEstablishUrl requires a non-empty origin'));
195
+ }
196
+ if (typeof state !== 'string' || state.length === 0) {
197
+ throw this.handleError(new Error('requestSsoEstablishUrl requires a non-empty state'));
198
+ }
199
+ const response = await this.makeRequest('POST', '/sso/establish-token', { origin, state }, { cache: false });
200
+ if (!response ||
201
+ typeof response.establishUrl !== 'string' ||
202
+ response.establishUrl.length === 0) {
203
+ throw this.handleError(new Error('SSO establish-token returned no establishUrl'));
204
+ }
205
+ return { establishUrl: response.establishUrl };
206
+ }
171
207
  };
172
208
  }
@@ -633,6 +633,30 @@ function OxyServicesUserMixin(Base) {
633
633
  throw this.handleError(error);
634
634
  }
635
635
  }
636
+ /**
637
+ * Get the authenticated VIEWER's OWN mutual-follow user ids — the accounts the
638
+ * viewer follows that ALSO follow the viewer back (a bidirectional follow
639
+ * edge). The viewer is derived server-side from the SDK's auth token (never a
640
+ * param), so there is no target id to pass.
641
+ *
642
+ * Returns a bounded, lean list of ids meant to SEED a "Mutuals" feed (the
643
+ * consumer hydrates/ranks the posts itself) — distinct from
644
+ * {@link getUserMutuals}, which returns hydrated "followers you know" DTOs
645
+ * about ANOTHER profile. An anonymous caller resolves to an empty array.
646
+ */
647
+ async getMutualUserIds(params) {
648
+ try {
649
+ const query = (0, apiUtils_1.buildPaginationParams)(params || {});
650
+ const response = await this.makeRequest('GET', '/users/mutual-ids', query, {
651
+ cache: true,
652
+ cacheTTL: 2 * 60 * 1000, // 2 minutes cache
653
+ });
654
+ return response.data || [];
655
+ }
656
+ catch (error) {
657
+ throw this.handleError(error);
658
+ }
659
+ }
636
660
  /**
637
661
  * Get notifications
638
662
  */
@@ -16,7 +16,7 @@
16
16
  * ```
17
17
  */
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
- exports.verifySecret = exports.createOxyCors = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.createOxyRateLimit = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getOxyUserId = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
19
+ exports.SSO_CALLBACK_PATH = exports.registrableApex = exports.verifySecret = exports.createOxyCors = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.createOxyRateLimit = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getOxyUserId = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
20
20
  var auth_1 = require("./auth");
21
21
  Object.defineProperty(exports, "createOptionalOxyAuth", { enumerable: true, get: function () { return auth_1.createOptionalOxyAuth; } });
22
22
  Object.defineProperty(exports, "createOxyAuthMiddleware", { enumerable: true, get: function () { return auth_1.createOxyAuthMiddleware; } });
@@ -46,3 +46,15 @@ Object.defineProperty(exports, "createOxyCors", { enumerable: true, get: functio
46
46
  // Constant-time secret comparison.
47
47
  var verifySecret_1 = require("./verifySecret");
48
48
  Object.defineProperty(exports, "verifySecret", { enumerable: true, get: function () { return verifySecret_1.verifySecret; } });
49
+ // Registrable-apex (eTLD+1) derivation via the Public Suffix List — the SINGLE
50
+ // SOURCE OF TRUTH shared with the IdP worker and the client FAPI auto-detect.
51
+ // Pure host handling (no browser deps), so it is safe on the server subpath and
52
+ // lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
53
+ var fapiAutoDetect_1 = require("../utils/fapiAutoDetect");
54
+ Object.defineProperty(exports, "registrableApex", { enumerable: true, get: function () { return fapiAutoDetect_1.registrableApex; } });
55
+ // The single RP callback path the IdP redirects back to. A pure wire-contract
56
+ // constant (no browser deps at module top level), re-used server-side so the
57
+ // `/sso/establish-token` `return_to` cannot drift from what `/sso/establish`
58
+ // validates.
59
+ var ssoBounce_1 = require("../utils/ssoBounce");
60
+ Object.defineProperty(exports, "SSO_CALLBACK_PATH", { enumerable: true, get: function () { return ssoBounce_1.SSO_CALLBACK_PATH; } });
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SessionClient = void 0;
4
+ const contracts_1 = require("@oxyhq/contracts");
5
+ const loggerUtils_1 = require("../utils/loggerUtils");
6
+ const socketLoader_1 = require("./socketLoader");
7
+ class SessionClient {
8
+ constructor(host, options = {}) {
9
+ this.host = host;
10
+ this.options = options;
11
+ this.state = null;
12
+ this.listeners = new Set();
13
+ this.socket = null;
14
+ this.tokenUnsub = null;
15
+ this.started = false;
16
+ }
17
+ getState() {
18
+ return this.state;
19
+ }
20
+ subscribe(listener) {
21
+ this.listeners.add(listener);
22
+ return () => {
23
+ this.listeners.delete(listener);
24
+ };
25
+ }
26
+ notify() {
27
+ for (const listener of this.listeners) {
28
+ try {
29
+ listener(this.state);
30
+ }
31
+ catch (error) {
32
+ loggerUtils_1.logger.error('[SessionClient] subscriber threw', error);
33
+ }
34
+ }
35
+ }
36
+ /** Validate + last-writer-wins by revision. Returns true if applied. */
37
+ applyState(raw) {
38
+ const next = (0, contracts_1.safeParseContract)(contracts_1.deviceSessionStateSchema, raw);
39
+ if (!next) {
40
+ loggerUtils_1.logger.warn('[SessionClient] discarded invalid session state');
41
+ return false;
42
+ }
43
+ if (this.state && next.revision <= this.state.revision) {
44
+ return false;
45
+ }
46
+ this.state = next;
47
+ this.notify();
48
+ if (this.options.transport) {
49
+ void this.options.transport.ensureActiveToken(next).catch((error) => {
50
+ loggerUtils_1.logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
51
+ });
52
+ }
53
+ return true;
54
+ }
55
+ /**
56
+ * Validate `{ state, activeToken }`, apply the state, and plant the active token host-side.
57
+ * Token-planting is decoupled from whether `applyState` advanced the revision: a socket push
58
+ * followed by this same `GET /state` fetch returns the SAME revision (applyState no-ops), but
59
+ * the token still needs to be planted. The account-match guard rejects a stale response for an
60
+ * account that is no longer active.
61
+ */
62
+ applySync(raw) {
63
+ const sync = (0, contracts_1.safeParseContract)(contracts_1.deviceSessionSyncSchema, raw);
64
+ if (!sync) {
65
+ loggerUtils_1.logger.warn('[SessionClient] discarded invalid session sync');
66
+ return;
67
+ }
68
+ this.applyState(sync.state);
69
+ if (sync.activeToken && this.state && sync.state.activeAccountId === this.state.activeAccountId) {
70
+ this.host.setTokens(sync.activeToken.accessToken);
71
+ }
72
+ }
73
+ async bootstrap() {
74
+ const res = await this.host.makeRequest('GET', '/session/device/state', undefined, { cache: false });
75
+ this.applySync(res?.data);
76
+ }
77
+ async switchAccount(accountId) {
78
+ const res = await this.host.makeRequest('POST', '/session/device/switch', { accountId }, { cache: false });
79
+ this.applySync(res?.data);
80
+ }
81
+ async signOut(target) {
82
+ const res = await this.host.makeRequest('POST', '/session/device/signout', target, { cache: false });
83
+ this.applySync(res?.data);
84
+ }
85
+ async addCurrentAccount() {
86
+ const res = await this.host.makeRequest('POST', '/session/device/add', undefined, { cache: false });
87
+ this.applySync(res?.data);
88
+ }
89
+ async start() {
90
+ if (this.started)
91
+ return;
92
+ this.started = true;
93
+ this.tokenUnsub = this.host.onTokensChanged((token) => {
94
+ if (token && this.socket && !this.socket.connected) {
95
+ this.socket.connect();
96
+ }
97
+ });
98
+ await this.bootstrap();
99
+ await this.connectSocket();
100
+ }
101
+ stop() {
102
+ this.started = false;
103
+ if (this.tokenUnsub) {
104
+ this.tokenUnsub();
105
+ this.tokenUnsub = null;
106
+ }
107
+ if (this.socket) {
108
+ this.socket.disconnect();
109
+ this.socket = null;
110
+ }
111
+ }
112
+ async connectSocket() {
113
+ const io = await (0, socketLoader_1.getSocketIO)();
114
+ if (!io) {
115
+ loggerUtils_1.logger.warn('[SessionClient] no socket.io-client; running REST-only (no realtime sync)', { component: 'SessionClient' });
116
+ return;
117
+ }
118
+ if (!this.started)
119
+ return; // stopped while the dynamic import was in flight
120
+ const hasToken = Boolean(this.host.getAccessToken());
121
+ const socket = io(this.host.getBaseURL(), {
122
+ transports: ['websocket'],
123
+ autoConnect: hasToken,
124
+ auth: (cb) => {
125
+ cb({ token: this.host.getAccessToken() ?? '' });
126
+ },
127
+ });
128
+ socket.on('session_state', (payload) => {
129
+ const applied = this.applyState(payload);
130
+ if (applied) {
131
+ const active = this.state?.activeAccountId ?? null;
132
+ if (active && active !== this.host.getCurrentAccountId()) {
133
+ void this.bootstrap().catch((error) => {
134
+ loggerUtils_1.logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
135
+ });
136
+ }
137
+ }
138
+ });
139
+ this.socket = socket;
140
+ }
141
+ }
142
+ exports.SessionClient = SessionClient;
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSessionClient = createSessionClient;
4
+ const SessionClient_1 = require("./SessionClient");
5
+ const sessionClientHost_1 = require("./sessionClientHost");
6
+ /**
7
+ * Wires a `SessionClient` over the given `OxyServices` instance: builds the
8
+ * `SessionClientHost` adapter and passes it through together with a
9
+ * caller-supplied `TokenTransport`.
10
+ *
11
+ * The transport is a required parameter (not constructed here) because it is
12
+ * the one piece of this integration that is NOT platform-agnostic: `services`
13
+ * branches native (shared-keychain sign-in) vs. web (silent sign-in), while
14
+ * `auth-sdk` is web-only. Each consumer builds its own transport and passes
15
+ * it in; this factory only wires the platform-agnostic parts (host + client)
16
+ * so neither consumer re-implements them.
17
+ *
18
+ * The host is returned alongside the client (not just the client) so the
19
+ * caller can call `host.setCurrentAccountId(...)` as the active account
20
+ * changes.
21
+ */
22
+ function createSessionClient(oxyServices, transport) {
23
+ const host = (0, sessionClientHost_1.createSessionClientHost)(oxyServices);
24
+ const client = new SessionClient_1.SessionClient(host, { transport });
25
+ return { client, host };
26
+ }
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deviceStateToClientSessions = deviceStateToClientSessions;
4
+ exports.activeSessionIdOf = activeSessionIdOf;
5
+ exports.activeUserOf = activeUserOf;
6
+ exports.accountIdsOf = accountIdsOf;
7
+ /**
8
+ * Pure projection helpers: `DeviceSessionState` (the device-scoped
9
+ * multi-account session-sync state produced by `SessionClient`) -> the
10
+ * shapes consumers (`@oxyhq/services`, `@oxyhq/auth`) render today
11
+ * (`ClientSession[]`, an active session id, an active `User`).
12
+ *
13
+ * No I/O. The caller fetches profiles via
14
+ * `oxyServices.getUsersByIds(accountIdsOf(state))` and builds `usersById`
15
+ * from the result before calling `deviceStateToClientSessions` /
16
+ * `activeUserOf`.
17
+ */
18
+ /**
19
+ * Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
20
+ *
21
+ * `DeviceSessionState` carries no per-account `expiresAt` / `lastActive` —
22
+ * both are set to `state.updatedAt` (converted to an ISO-8601 string; the
23
+ * wire value is an epoch-ms number) as a provisional value.
24
+ *
25
+ * `usersById` is accepted for signature symmetry with `activeUserOf` even
26
+ * though `ClientSession` only stores `userId` — a session is still
27
+ * projected for an account whose id is absent from `usersById` (no
28
+ * placeholder user is fabricated).
29
+ */
30
+ function deviceStateToClientSessions(state, usersById) {
31
+ const provisionalTimestamp = new Date(state.updatedAt).toISOString();
32
+ return state.accounts.map((account) => ({
33
+ sessionId: account.sessionId,
34
+ deviceId: state.deviceId,
35
+ // provisional: expiresAt/lastActive are not carried on DeviceSessionState
36
+ expiresAt: provisionalTimestamp,
37
+ lastActive: provisionalTimestamp,
38
+ userId: account.accountId,
39
+ isCurrent: account.accountId === state.activeAccountId,
40
+ authuser: account.authuser,
41
+ }));
42
+ }
43
+ /**
44
+ * The active account's `sessionId`, or `null` when there is no state or no
45
+ * active account is set.
46
+ */
47
+ function activeSessionIdOf(state) {
48
+ if (state === null || state.activeAccountId === null) {
49
+ return null;
50
+ }
51
+ const activeAccountId = state.activeAccountId;
52
+ const activeAccount = state.accounts.find((account) => account.accountId === activeAccountId);
53
+ return activeAccount?.sessionId ?? null;
54
+ }
55
+ /**
56
+ * The active account's `User`, resolved from `usersById`. `null` when there
57
+ * is no state, no active account is set, or the active account id is absent
58
+ * from `usersById`.
59
+ */
60
+ function activeUserOf(state, usersById) {
61
+ if (state === null || state.activeAccountId === null) {
62
+ return null;
63
+ }
64
+ return usersById.get(state.activeAccountId) ?? null;
65
+ }
66
+ /**
67
+ * All account ids in `state`, suitable for an `oxyServices.getUsersByIds(...)`
68
+ * fetch. `[]` for `null` state.
69
+ */
70
+ function accountIdsOf(state) {
71
+ if (state === null) {
72
+ return [];
73
+ }
74
+ return state.accounts.map((account) => account.accountId);
75
+ }
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createSessionClientHost = createSessionClientHost;
4
+ /**
5
+ * Thin `SessionClientHost` adapter over an `OxyServices` instance.
6
+ *
7
+ * `SessionClient` is host-agnostic: it only needs a REST + token surface.
8
+ * `OxyServices` already exposes all of that except `getCurrentAccountId`,
9
+ * which has no direct equivalent — the adapter holds a mutable ref set by
10
+ * the caller (`OxyContext` in `@oxyhq/services`, `WebOxyProvider` in
11
+ * `@oxyhq/auth`) via `setCurrentAccountId`.
12
+ *
13
+ * Shared here (rather than duplicated per consumer) because it is entirely
14
+ * platform-agnostic: every method it calls exists identically on
15
+ * `OxyServices` regardless of host (web, Expo/RN, Node).
16
+ */
17
+ function createSessionClientHost(oxyServices) {
18
+ let currentAccountId = null;
19
+ return {
20
+ makeRequest: (method, url, data, options) => oxyServices.makeRequest(method, url, data, options),
21
+ getBaseURL: () => oxyServices.getBaseURL(),
22
+ getAccessToken: () => oxyServices.getAccessToken(),
23
+ onTokensChanged: (listener) => oxyServices.onTokensChanged(listener),
24
+ setTokens: (accessToken) => oxyServices.setTokens(accessToken),
25
+ getCurrentAccountId: () => currentAccountId,
26
+ setCurrentAccountId: (id) => {
27
+ currentAccountId = id;
28
+ },
29
+ };
30
+ }
@@ -0,0 +1,55 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getSocketIO = getSocketIO;
37
+ const loggerUtils_1 = require("../utils/loggerUtils");
38
+ let cachedFactory = null;
39
+ let loadAttempted = false;
40
+ async function getSocketIO() {
41
+ if (cachedFactory)
42
+ return cachedFactory;
43
+ if (loadAttempted)
44
+ return null;
45
+ loadAttempted = true;
46
+ try {
47
+ const mod = (await Promise.resolve().then(() => __importStar(require('socket.io-client'))));
48
+ cachedFactory = mod.io ?? mod.default ?? null;
49
+ return cachedFactory;
50
+ }
51
+ catch (error) {
52
+ loggerUtils_1.logger.warn('[SessionClient] socket.io-client import failed; realtime session sync disabled', { component: 'SessionClient' }, error);
53
+ return null;
54
+ }
55
+ }
@@ -142,18 +142,18 @@ function ssoPriorSessionKey(origin) {
142
142
  * per-tab `sessionStorage` the loop-breaker keys use — it must survive a reload.
143
143
  *
144
144
  * It exists purely to suppress AUTOMATIC silent restore after a deliberate
145
- * sign-out: a still-live IdP session (the central `fedcm_session` / the FedCM
146
- * credential association) would otherwise let `fedcm-silent` / the per-apex
147
- * `/auth/silent` iframe re-mint a session on the very next cold boot, so a user
148
- * who pressed "Sign out" gets silently signed back in on reload. With this flag
149
- * set, those silent cold-boot steps are skipped while the Gmail-style
150
- * returning-account fast-path is otherwise preserved.
145
+ * sign-out: a still-live IdP session (the central `fedcm_session`) would
146
+ * otherwise let the per-apex `/auth/silent` iframe re-mint a session on the
147
+ * very next cold boot, so a user who pressed "Sign out" gets silently signed
148
+ * back in on reload. With this flag set, that silent cold-boot step is
149
+ * skipped while the Gmail-style returning-account fast-path is otherwise
150
+ * preserved.
151
151
  *
152
152
  * Lifecycle (mirrors the existing gate machinery — set on a definitive event,
153
153
  * cleared on its inverse):
154
154
  * - SET on EXPLICIT full sign-out (alongside clearing the prior-session hint
155
155
  * and the SSO bounce state).
156
- * - CLEARED on ANY deliberate sign-in (password, FedCM, account switch, device
156
+ * - CLEARED on ANY deliberate sign-in (password, account switch, device
157
157
  * claim) so a real sign-in fully re-enables silent restore — there is no
158
158
  * "stuck signed out" state.
159
159
  *
@@ -297,8 +297,8 @@ function guardActive(storage, origin, now = Date.now()) {
297
297
  * Whether AUTOMATIC silent restore is SUPPRESSED for this origin because the
298
298
  * user deliberately signed out (the durable {@link ssoSignedOutKey} flag).
299
299
  *
300
- * When `true`, the silent cold-boot steps that can re-mint a session from a
301
- * still-live IdP session WITHOUT user intent — `fedcm-silent` and the per-apex
300
+ * When `true`, the silent cold-boot step that can re-mint a session from a
301
+ * still-live IdP session WITHOUT user intent — the per-apex
302
302
  * `/auth/silent` iframe — MUST be skipped, so a user who pressed "Sign out" is
303
303
  * not silently signed back in on the next reload. Interactive sign-in clears the
304
304
  * flag, so this never blocks a deliberate re-sign-in.