@oxyhq/core 5.1.0 → 5.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/AuthManager.js +27 -5
- package/dist/cjs/OxyServices.base.js +71 -0
- package/dist/cjs/index.js +3 -1
- package/dist/cjs/mixins/OxyServices.accounts.js +10 -25
- package/dist/cjs/mixins/OxyServices.assets.js +76 -0
- package/dist/cjs/mixins/OxyServices.user.js +30 -11
- package/dist/cjs/utils/ssoBounce.js +59 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +27 -5
- package/dist/esm/OxyServices.base.js +71 -0
- package/dist/esm/index.js +1 -1
- package/dist/esm/mixins/OxyServices.accounts.js +10 -25
- package/dist/esm/mixins/OxyServices.assets.js +76 -0
- package/dist/esm/mixins/OxyServices.user.js +30 -11
- package/dist/esm/utils/ssoBounce.js +57 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.base.d.ts +38 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/mixins/OxyServices.accounts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.analytics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +1 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +36 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +1 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +1 -0
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +1 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +1 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +1 -0
- package/dist/types/mixins/OxyServices.features.d.ts +1 -0
- package/dist/types/mixins/OxyServices.fedcm.d.ts +1 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +1 -0
- package/dist/types/mixins/OxyServices.language.d.ts +1 -0
- package/dist/types/mixins/OxyServices.links.d.ts +1 -0
- package/dist/types/mixins/OxyServices.location.d.ts +1 -0
- package/dist/types/mixins/OxyServices.nodes.d.ts +1 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +1 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +1 -0
- package/dist/types/mixins/OxyServices.redirect.d.ts +1 -0
- package/dist/types/mixins/OxyServices.reputation.d.ts +1 -0
- package/dist/types/mixins/OxyServices.security.d.ts +1 -0
- package/dist/types/mixins/OxyServices.silent.d.ts +1 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +1 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +1 -0
- package/dist/types/mixins/OxyServices.user.d.ts +34 -10
- package/dist/types/mixins/OxyServices.utility.d.ts +1 -0
- package/dist/types/models/interfaces.d.ts +23 -0
- package/dist/types/utils/ssoBounce.d.ts +47 -0
- package/package.json +1 -1
- package/src/AuthManager.ts +29 -5
- package/src/OxyServices.base.ts +79 -0
- package/src/__tests__/establishDeviceRefreshSlot.test.ts +221 -0
- package/src/index.ts +3 -0
- package/src/mixins/OxyServices.accounts.ts +10 -33
- package/src/mixins/OxyServices.assets.ts +92 -1
- package/src/mixins/OxyServices.user.ts +43 -11
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadataBySha.test.ts +135 -0
- package/src/mixins/__tests__/getUsersByIds.test.ts +149 -0
- package/src/models/interfaces.ts +24 -0
- package/src/utils/__tests__/ssoBounce.test.ts +41 -0
- package/src/utils/ssoBounce.ts +61 -0
package/dist/cjs/AuthManager.js
CHANGED
|
@@ -397,11 +397,33 @@ class AuthManager {
|
|
|
397
397
|
this.currentUser = session.user;
|
|
398
398
|
}
|
|
399
399
|
this.currentAuthMethod = method;
|
|
400
|
-
|
|
401
|
-
|
|
400
|
+
// Register this primary session in the device's first-party multi-account
|
|
401
|
+
// refresh-cookie set (web only). Every web primary commit funnels through
|
|
402
|
+
// here (FedCM, central `/sso` return, credentials), but only a same-apex
|
|
403
|
+
// `/fedcm/exchange` plants an `oxy_rt_<authuser>` slot as a side effect — the
|
|
404
|
+
// cross-origin/credential-less restores (`/sso/exchange`, the IdP
|
|
405
|
+
// `/auth/silent` postMessage) cannot set an `api.oxy.so` cookie. WITHOUT this
|
|
406
|
+
// call those primaries never join the device set, so `refresh-all` returns no
|
|
407
|
+
// accounts and account-switch persistence has no foundation. The shared
|
|
408
|
+
// `POST /auth/session` primitive plants/reuses this user's slot where the
|
|
409
|
+
// cookies ARE visible, re-plants the rotated access token, and returns the
|
|
410
|
+
// AUTHORITATIVE `authuser` slot (the one actually written) — preferred over the
|
|
411
|
+
// JWT-decoded guess, which may not correspond to a real cookie. No-op on native
|
|
412
|
+
// (returns `null`); best-effort on transient failure.
|
|
413
|
+
const establishedAuthuser = await this.oxyServices.establishDeviceRefreshSlot();
|
|
414
|
+
// The slot mint rotated the access token; pick it up so the registry and the
|
|
415
|
+
// refresh authority track the cookie just written. Falls back to the original
|
|
416
|
+
// session token when the slot was a no-op (native) or failed.
|
|
417
|
+
const rotatedToken = (establishedAuthuser !== null ? this.oxyServices.getAccessToken() : null) ??
|
|
418
|
+
session.accessToken;
|
|
419
|
+
if (rotatedToken) {
|
|
420
|
+
this._lastKnownAccessToken = rotatedToken;
|
|
421
|
+
}
|
|
422
|
+
const decodedAuthuser = rotatedToken
|
|
423
|
+
? AuthManager.decodeAuthuserFromAccessToken(rotatedToken)
|
|
402
424
|
: null;
|
|
403
|
-
const authuser = decodedAuthuser ?? 0;
|
|
404
|
-
if (
|
|
425
|
+
const authuser = establishedAuthuser ?? decodedAuthuser ?? 0;
|
|
426
|
+
if (rotatedToken && session.sessionId) {
|
|
405
427
|
this.accounts.set(authuser, {
|
|
406
428
|
authuser,
|
|
407
429
|
sessionId: session.sessionId,
|
|
@@ -411,7 +433,7 @@ class AuthManager {
|
|
|
411
433
|
name: session.user.name,
|
|
412
434
|
avatar: session.user.avatar ?? null,
|
|
413
435
|
},
|
|
414
|
-
accessToken:
|
|
436
|
+
accessToken: rotatedToken,
|
|
415
437
|
expiresAt: session.expiresAt,
|
|
416
438
|
});
|
|
417
439
|
this.activeAuthuser = authuser;
|
|
@@ -11,6 +11,9 @@ const errorUtils_1 = require("./utils/errorUtils");
|
|
|
11
11
|
const HttpService_1 = require("./HttpService");
|
|
12
12
|
const OxyServices_errors_1 = require("./OxyServices.errors");
|
|
13
13
|
const authWebUrl_1 = require("./utils/authWebUrl");
|
|
14
|
+
const platform_1 = require("./utils/platform");
|
|
15
|
+
const fapiAutoDetect_1 = require("./utils/fapiAutoDetect");
|
|
16
|
+
const loggerUtils_1 = require("./utils/loggerUtils");
|
|
14
17
|
/**
|
|
15
18
|
* Base class for OxyServices with core infrastructure
|
|
16
19
|
*/
|
|
@@ -253,6 +256,74 @@ class OxyServicesBase {
|
|
|
253
256
|
getAccessToken() {
|
|
254
257
|
return this.httpService.getAccessToken();
|
|
255
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Register the CURRENTLY-ACTIVE session in the device's first-party
|
|
261
|
+
* multi-account refresh-cookie set by calling `POST /auth/session`.
|
|
262
|
+
*
|
|
263
|
+
* This is the single, shared primitive every web primary-session commit and the
|
|
264
|
+
* account switch use to plant their `oxy_rt_<authuser>` slot. It MUST be a
|
|
265
|
+
* dedicated call to `/auth/session` rather than relying on whichever endpoint
|
|
266
|
+
* established the session: that endpoint is frequently OUTSIDE the cookie's
|
|
267
|
+
* `Path=/auth` scope (`/accounts/:id/switch`) or is a cross-origin/credential-
|
|
268
|
+
* less restore (`/sso/exchange`, the IdP `/auth/silent` postMessage) that cannot
|
|
269
|
+
* set an `api.oxy.so` cookie at all. `/auth/session` runs where the device's
|
|
270
|
+
* existing slots ARE visible, so the server resolves this user's slot (reusing an
|
|
271
|
+
* existing one or allocating a new one) without clobbering a sibling account,
|
|
272
|
+
* mints a fresh access token bound to the same session, and returns the resolved
|
|
273
|
+
* `authuser`.
|
|
274
|
+
*
|
|
275
|
+
* Behaviour:
|
|
276
|
+
* - Requires a planted bearer (the caller must have already installed the
|
|
277
|
+
* session's access token); `/auth/session` derives the session from it.
|
|
278
|
+
* - On success re-plants the rotated access token (so the active token matches
|
|
279
|
+
* the freshly-rotated cookie) and returns the device `authuser` slot.
|
|
280
|
+
* - WEB-ONLY: on native there are no first-party refresh cookies → returns
|
|
281
|
+
* `null`.
|
|
282
|
+
* - FIRST-PARTY-ONLY: the cookie is host-only on the API host with
|
|
283
|
+
* `SameSite=Lax`, so it only sticks when the page is SAME-SITE (same
|
|
284
|
+
* registrable apex) as the API. On a cross-apex RP (`mention.earth` calling
|
|
285
|
+
* `api.oxy.so`) the browser rejects the `Set-Cookie` as a third-party cookie,
|
|
286
|
+
* so a returned slot would be a phantom never enumerated by `refresh-all`.
|
|
287
|
+
* Those RPs durably restore via the per-apex `/auth/silent` iframe + `/sso`
|
|
288
|
+
* bounce, NOT this device set → returns `null` without calling the API.
|
|
289
|
+
* - BEST-EFFORT: a failure (e.g. transient network) never throws — the session
|
|
290
|
+
* stays active in-memory; only its reload durability via the device set is at
|
|
291
|
+
* risk. The caller treats `null` as "not registered in the device set".
|
|
292
|
+
*
|
|
293
|
+
* @returns The resolved device `authuser` slot, or `null` on native / cross-apex
|
|
294
|
+
* / failure.
|
|
295
|
+
*/
|
|
296
|
+
async establishDeviceRefreshSlot() {
|
|
297
|
+
if (!(0, platform_1.isWeb)()) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
if (typeof window !== 'undefined' && window.location?.hostname) {
|
|
301
|
+
const pageApex = (0, fapiAutoDetect_1.registrableApex)(window.location.hostname);
|
|
302
|
+
let apiApex = null;
|
|
303
|
+
try {
|
|
304
|
+
apiApex = (0, fapiAutoDetect_1.registrableApex)(new URL(this.getBaseURL()).hostname);
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
apiApex = null;
|
|
308
|
+
}
|
|
309
|
+
if (!pageApex || !apiApex || pageApex !== apiApex) {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
try {
|
|
314
|
+
const established = await this.makeRequest('POST', '/auth/session', undefined, { cache: false });
|
|
315
|
+
// `/auth/session` mints a fresh access token off the same session; re-plant
|
|
316
|
+
// it so the active token matches the rotated cookie.
|
|
317
|
+
if (established?.accessToken) {
|
|
318
|
+
this.setTokens(established.accessToken);
|
|
319
|
+
}
|
|
320
|
+
return typeof established?.authuser === 'number' ? established.authuser : null;
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
loggerUtils_1.logger.warn('[OxyServices] Failed to establish device refresh cookie via POST /auth/session; the session is active in-session but may not survive a reload as part of the device account set', { component: 'OxyServices', method: 'establishDeviceRefreshSlot' }, error);
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
256
327
|
/**
|
|
257
328
|
* Decode the current access token and return its `exp` claim in SECONDS since
|
|
258
329
|
* the Unix epoch (the raw JWT `exp` unit), or `null` when there is no token,
|
package/dist/cjs/index.js
CHANGED
|
@@ -21,7 +21,7 @@ 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
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.guardActive = exports.isCentralIdPOrigin = exports.buildSsoBounceUrl = exports.getSsoCallbackBootstrapScript = exports.ssoNavigate = exports.ssoCallbackBootstrapKey = 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;
|
|
25
25
|
// Ensure crypto polyfills are loaded before anything else
|
|
26
26
|
require("./crypto/polyfill");
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
@@ -284,12 +284,14 @@ Object.defineProperty(exports, "ssoDestKey", { enumerable: true, get: function (
|
|
|
284
284
|
Object.defineProperty(exports, "ssoNoSessionKey", { enumerable: true, get: function () { return ssoBounce_1.ssoNoSessionKey; } });
|
|
285
285
|
Object.defineProperty(exports, "ssoAttemptedKey", { enumerable: true, get: function () { return ssoBounce_1.ssoAttemptedKey; } });
|
|
286
286
|
Object.defineProperty(exports, "ssoPriorSessionKey", { enumerable: true, get: function () { return ssoBounce_1.ssoPriorSessionKey; } });
|
|
287
|
+
Object.defineProperty(exports, "ssoSignedOutKey", { enumerable: true, get: function () { return ssoBounce_1.ssoSignedOutKey; } });
|
|
287
288
|
Object.defineProperty(exports, "ssoCallbackBootstrapKey", { enumerable: true, get: function () { return ssoBounce_1.ssoCallbackBootstrapKey; } });
|
|
288
289
|
Object.defineProperty(exports, "ssoNavigate", { enumerable: true, get: function () { return ssoBounce_1.ssoNavigate; } });
|
|
289
290
|
Object.defineProperty(exports, "getSsoCallbackBootstrapScript", { enumerable: true, get: function () { return ssoBounce_1.getSsoCallbackBootstrapScript; } });
|
|
290
291
|
Object.defineProperty(exports, "buildSsoBounceUrl", { enumerable: true, get: function () { return ssoBounce_1.buildSsoBounceUrl; } });
|
|
291
292
|
Object.defineProperty(exports, "isCentralIdPOrigin", { enumerable: true, get: function () { return ssoBounce_1.isCentralIdPOrigin; } });
|
|
292
293
|
Object.defineProperty(exports, "guardActive", { enumerable: true, get: function () { return ssoBounce_1.guardActive; } });
|
|
294
|
+
Object.defineProperty(exports, "silentRestoreSuppressed", { enumerable: true, get: function () { return ssoBounce_1.silentRestoreSuppressed; } });
|
|
293
295
|
Object.defineProperty(exports, "allowSsoBounce", { enumerable: true, get: function () { return ssoBounce_1.allowSsoBounce; } });
|
|
294
296
|
var coldBoot_1 = require("./utils/coldBoot");
|
|
295
297
|
Object.defineProperty(exports, "runColdBoot", { enumerable: true, get: function () { return coldBoot_1.runColdBoot; } });
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.OxyServicesAccountsMixin = OxyServicesAccountsMixin;
|
|
4
4
|
const userIdentity_1 = require("../utils/userIdentity");
|
|
5
|
-
const platform_1 = require("../utils/platform");
|
|
6
|
-
const loggerUtils_1 = require("../utils/loggerUtils");
|
|
7
5
|
const mixinHelpers_1 = require("./mixinHelpers");
|
|
8
6
|
function OxyServicesAccountsMixin(Base) {
|
|
9
7
|
return class extends Base {
|
|
@@ -94,29 +92,16 @@ function OxyServicesAccountsMixin(Base) {
|
|
|
94
92
|
}
|
|
95
93
|
// Register the switched session in the device's multi-account set by
|
|
96
94
|
// establishing its first-party refresh cookie. This MUST be a separate
|
|
97
|
-
// call to `POST /auth/session
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (typeof established?.authuser === 'number') {
|
|
108
|
-
authuser = established.authuser;
|
|
109
|
-
}
|
|
110
|
-
// /auth/session mints a fresh access token off the same session;
|
|
111
|
-
// re-plant it so the active token matches the rotated cookie.
|
|
112
|
-
if (established?.accessToken) {
|
|
113
|
-
this.setTokens(established.accessToken);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
catch (error) {
|
|
117
|
-
loggerUtils_1.logger.warn('[OxyServices] Failed to establish device refresh cookie after account switch; the switch is active in-session but may not survive a reload', { component: 'OxyServices', method: 'switchToAccount' }, error);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
95
|
+
// call to `POST /auth/session` (the shared `establishDeviceRefreshSlot`
|
|
96
|
+
// primitive): the switch route is at `/accounts/*`, outside the
|
|
97
|
+
// `oxy_rt_<authuser>` cookie's `Path=/auth` scope, so it can never read
|
|
98
|
+
// the device's existing slots and would overwrite slot 0 (destroying the
|
|
99
|
+
// operator's own session). `/auth/session` runs where the cookies ARE
|
|
100
|
+
// visible, so the server allocates a NEW slot that coexists with the
|
|
101
|
+
// operator's and returns its `authuser`. Web-only; best-effort (the helper
|
|
102
|
+
// re-plants the rotated access token and returns `null` on native/failure).
|
|
103
|
+
const establishedAuthuser = await this.establishDeviceRefreshSlot();
|
|
104
|
+
const authuser = establishedAuthuser ?? res.authuser;
|
|
120
105
|
// Identity changed → drop the entire GET response cache so no entry
|
|
121
106
|
// personalised for the previous identity is reused. Cache keys are
|
|
122
107
|
// identity-scoped, so a different identity could not READ the old
|
|
@@ -11,6 +11,19 @@ const errorUtils_1 = require("../utils/errorUtils");
|
|
|
11
11
|
* `getUsersByIds`'s `USERS_BY_IDS_CHUNK_SIZE`.
|
|
12
12
|
*/
|
|
13
13
|
const SERVICE_ASSET_METADATA_CHUNK_SIZE = 100;
|
|
14
|
+
/**
|
|
15
|
+
* Maximum number of content hashes sent per `POST /assets/service/by-sha256`
|
|
16
|
+
* request. Matches the server-side cap (the route rejects empty or > 100 hash
|
|
17
|
+
* arrays with a 400); larger inputs are chunked and merged. Same ceiling as
|
|
18
|
+
* {@link SERVICE_ASSET_METADATA_CHUNK_SIZE} for the forward id lookup.
|
|
19
|
+
*/
|
|
20
|
+
const SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE = 100;
|
|
21
|
+
/**
|
|
22
|
+
* Lowercase hex SHA-256 digest matcher (exactly 64 hex chars). The reverse
|
|
23
|
+
* lookup drops non-conforming hashes client-side so a single malformed value
|
|
24
|
+
* never 400s an otherwise-valid chunk on the server.
|
|
25
|
+
*/
|
|
26
|
+
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
|
14
27
|
function OxyServicesAssetsMixin(Base) {
|
|
15
28
|
return class extends Base {
|
|
16
29
|
constructor(...args) {
|
|
@@ -213,6 +226,69 @@ function OxyServicesAssetsMixin(Base) {
|
|
|
213
226
|
}));
|
|
214
227
|
return settled.flat();
|
|
215
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Reverse content-address lookup: resolve many content `sha256` digests to
|
|
231
|
+
* the servable Oxy asset holding each, in one round-trip per chunk via
|
|
232
|
+
* `POST /assets/service/by-sha256` (body `{ sha256s }`).
|
|
233
|
+
*
|
|
234
|
+
* This is the INVERSE of {@link getServiceAssetMetadataByIds}: given a
|
|
235
|
+
* record's `blob.sha256`, it returns the asset's `id`, `mime`, byte `size`,
|
|
236
|
+
* `status`, and — for active, public, CDN-reachable assets only — a public
|
|
237
|
+
* `url` (`cloud.oxy.so`). Built for server-to-server callers (e.g. Mention's
|
|
238
|
+
* MTN materializer / node-blob sync) that hold a content hash and need to
|
|
239
|
+
* map it back to a servable asset. Hashes are lowercased, validated against
|
|
240
|
+
* a 64-char hex pattern (malformed entries dropped client-side), and
|
|
241
|
+
* deduplicated before being split into chunks of
|
|
242
|
+
* {@link SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE} (the server-side cap). The
|
|
243
|
+
* server omits unknown/deleted hashes from each chunk's `data`, so the
|
|
244
|
+
* merged result may be shorter than the requested list and the caller is
|
|
245
|
+
* expected to map by `sha256`.
|
|
246
|
+
*
|
|
247
|
+
* **Service-token auth (required).** `/assets/service/by-sha256` is guarded
|
|
248
|
+
* by `serviceAuthMiddleware` + the `files:read` scope and is called via
|
|
249
|
+
* `makeServiceRequest` (`Authorization: Bearer <serviceToken>`, `cache:false`).
|
|
250
|
+
* The calling client MUST be service-configured (`configureServiceAuth`)
|
|
251
|
+
* before invoking; a plain user-session request is rejected by the route's
|
|
252
|
+
* service-auth guard.
|
|
253
|
+
*
|
|
254
|
+
* Resilience: chunks are independent. A failed chunk is logged and skipped —
|
|
255
|
+
* every entry that resolved is still returned. An empty input (or one whose
|
|
256
|
+
* every value is malformed) resolves immediately with `[]` and performs no
|
|
257
|
+
* network call.
|
|
258
|
+
*
|
|
259
|
+
* Not cached at the SDK layer: it's a POST keyed on a multi-hash body (low
|
|
260
|
+
* hit rate), mirroring the sibling service/POST methods which never cache.
|
|
261
|
+
*/
|
|
262
|
+
async getServiceAssetMetadataBySha256(sha256s) {
|
|
263
|
+
const uniqueShas = Array.from(new Set(sha256s
|
|
264
|
+
.filter((sha) => typeof sha === 'string')
|
|
265
|
+
.map((sha) => sha.trim().toLowerCase())
|
|
266
|
+
.filter((sha) => SHA256_HEX_PATTERN.test(sha))));
|
|
267
|
+
if (uniqueShas.length === 0) {
|
|
268
|
+
return [];
|
|
269
|
+
}
|
|
270
|
+
const chunks = [];
|
|
271
|
+
for (let i = 0; i < uniqueShas.length; i += SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE) {
|
|
272
|
+
chunks.push(uniqueShas.slice(i, i + SERVICE_ASSET_METADATA_BY_SHA_CHUNK_SIZE));
|
|
273
|
+
}
|
|
274
|
+
// Run chunks concurrently; a single chunk failure must not sink the rest.
|
|
275
|
+
const settled = await Promise.all(chunks.map(async (chunk) => {
|
|
276
|
+
try {
|
|
277
|
+
const entries = await this.makeServiceRequest('POST', '/assets/service/by-sha256', { sha256s: chunk });
|
|
278
|
+
return Array.isArray(entries) ? entries : [];
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
loggerUtils_1.logger.warn('getServiceAssetMetadataBySha256: chunk failed, continuing with remaining chunks', {
|
|
282
|
+
method: 'getServiceAssetMetadataBySha256',
|
|
283
|
+
chunkSize: chunk.length,
|
|
284
|
+
status: (0, errorUtils_1.extractErrorStatus)(error),
|
|
285
|
+
error: error instanceof Error ? error.message : String(error),
|
|
286
|
+
});
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
}));
|
|
290
|
+
return settled.flat();
|
|
291
|
+
}
|
|
216
292
|
/**
|
|
217
293
|
* Upload raw file data
|
|
218
294
|
*/
|
|
@@ -233,16 +233,28 @@ function OxyServicesUserMixin(Base) {
|
|
|
233
233
|
* by `id`); each is run through `normalizeUserIdentity`, matching
|
|
234
234
|
* `getUserById`.
|
|
235
235
|
*
|
|
236
|
-
* **
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
236
|
+
* **Dual-mode auth.** `/users/by-ids` is `optionalUserOrServiceAuth` on
|
|
237
|
+
* oxy-api: it accepts a service token, a user session, or an anonymous
|
|
238
|
+
* caller, and returns the SAME public `{ data: PublicUserProfile[] }`
|
|
239
|
+
* payload (canonical `name.displayName` + `_count`) in every case — no
|
|
240
|
+
* viewer-specific fields. This method picks the path automatically:
|
|
241
|
+
* - **Service-configured host (backend):** when `configureServiceAuth(apiKey,
|
|
242
|
+
* apiSecret)` has been called, the chunk is fetched via `makeServiceRequest`
|
|
243
|
+
* (attaches `Authorization: Bearer <serviceToken>`). This is the
|
|
244
|
+
* server-to-server feed/notification hydration path (e.g. Mention's
|
|
245
|
+
* `PostHydrationService`) and is unchanged.
|
|
246
|
+
* - **Plain client (browser / React Native with a user session):** when no
|
|
247
|
+
* service credentials are configured, the chunk is fetched via
|
|
248
|
+
* `makeRequest`, which attaches the configured user bearer. oxy-api's CSRF
|
|
249
|
+
* middleware skips bearer-authenticated writes, and `makeRequest` only
|
|
250
|
+
* fetches a CSRF token for cookie-only (no-bearer) state-changing requests,
|
|
251
|
+
* so the user-bearer POST is sent without CSRF and succeeds. Previously
|
|
252
|
+
* this method always used the service path, so every client-side caller
|
|
253
|
+
* silently received `[]` because `getServiceToken()` had no credentials.
|
|
254
|
+
*
|
|
255
|
+
* Both paths run results through `normalizeUserIdentity` and unwrap the
|
|
256
|
+
* API's `{ data }` envelope identically (`makeServiceRequest` is literally
|
|
257
|
+
* `makeRequest` plus a bearer service header).
|
|
246
258
|
*
|
|
247
259
|
* Resilience: chunks are independent. A failed chunk is logged and skipped
|
|
248
260
|
* — the method returns every user that resolved successfully rather than
|
|
@@ -261,15 +273,22 @@ function OxyServicesUserMixin(Base) {
|
|
|
261
273
|
for (let i = 0; i < uniqueIds.length; i += USERS_BY_IDS_CHUNK_SIZE) {
|
|
262
274
|
chunks.push(uniqueIds.slice(i, i + USERS_BY_IDS_CHUNK_SIZE));
|
|
263
275
|
}
|
|
276
|
+
// A backend that called configureServiceAuth() uses the bearer-service
|
|
277
|
+
// path; any other caller (browser / RN with a user session) uses the
|
|
278
|
+
// user-bearer path. See the method doc for why the user path is CSRF-safe.
|
|
279
|
+
const useServiceAuth = Boolean(this._serviceApiKey && this._serviceApiSecret);
|
|
264
280
|
// Run chunks concurrently; a single chunk failure must not sink the rest.
|
|
265
281
|
const settled = await Promise.all(chunks.map(async (chunk) => {
|
|
266
282
|
try {
|
|
267
|
-
const users =
|
|
283
|
+
const users = useServiceAuth
|
|
284
|
+
? await this.makeServiceRequest('POST', '/users/by-ids', { ids: chunk })
|
|
285
|
+
: await this.makeRequest('POST', '/users/by-ids', { ids: chunk }, { cache: false });
|
|
268
286
|
return Array.isArray(users) ? users.map((user) => (0, userIdentity_1.normalizeUserIdentity)(user)) : [];
|
|
269
287
|
}
|
|
270
288
|
catch (error) {
|
|
271
289
|
loggerUtils_1.logger.warn('getUsersByIds: chunk failed, continuing with remaining chunks', {
|
|
272
290
|
method: 'getUsersByIds',
|
|
291
|
+
mode: useServiceAuth ? 'service' : 'user',
|
|
273
292
|
chunkSize: chunk.length,
|
|
274
293
|
status: (0, errorUtils_1.extractErrorStatus)(error),
|
|
275
294
|
error: error instanceof Error ? error.message : String(error),
|
|
@@ -50,12 +50,14 @@ exports.ssoDestKey = ssoDestKey;
|
|
|
50
50
|
exports.ssoNoSessionKey = ssoNoSessionKey;
|
|
51
51
|
exports.ssoAttemptedKey = ssoAttemptedKey;
|
|
52
52
|
exports.ssoPriorSessionKey = ssoPriorSessionKey;
|
|
53
|
+
exports.ssoSignedOutKey = ssoSignedOutKey;
|
|
53
54
|
exports.ssoCallbackBootstrapKey = ssoCallbackBootstrapKey;
|
|
54
55
|
exports.getSsoCallbackBootstrapScript = getSsoCallbackBootstrapScript;
|
|
55
56
|
exports.ssoNavigate = ssoNavigate;
|
|
56
57
|
exports.buildSsoBounceUrl = buildSsoBounceUrl;
|
|
57
58
|
exports.isCentralIdPOrigin = isCentralIdPOrigin;
|
|
58
59
|
exports.guardActive = guardActive;
|
|
60
|
+
exports.silentRestoreSuppressed = silentRestoreSuppressed;
|
|
59
61
|
exports.allowSsoBounce = allowSsoBounce;
|
|
60
62
|
const authWebUrl_1 = require("./authWebUrl");
|
|
61
63
|
/**
|
|
@@ -81,6 +83,7 @@ const NO_SESSION_KEY_PREFIX = 'oxy_sso_no_session:';
|
|
|
81
83
|
const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
|
|
82
84
|
const CALLBACK_BOOTSTRAP_KEY_PREFIX = 'oxy_sso_callback_bootstrap:';
|
|
83
85
|
const PRIOR_SESSION_KEY_PREFIX = 'oxy_sso_prior_session:';
|
|
86
|
+
const SIGNED_OUT_KEY_PREFIX = 'oxy_signed_out:';
|
|
84
87
|
/** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
|
|
85
88
|
function ssoStateKey(origin) {
|
|
86
89
|
return `${STATE_KEY_PREFIX}${origin}`;
|
|
@@ -130,6 +133,36 @@ function ssoAttemptedKey(origin) {
|
|
|
130
133
|
function ssoPriorSessionKey(origin) {
|
|
131
134
|
return `${PRIOR_SESSION_KEY_PREFIX}${origin}`;
|
|
132
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* Per-origin DURABLE "the user DELIBERATELY signed out on this device/origin"
|
|
138
|
+
* flag.
|
|
139
|
+
*
|
|
140
|
+
* Like {@link ssoPriorSessionKey} this lives in DURABLE storage (web
|
|
141
|
+
* `localStorage`; services uses its own `storageKeyPrefix`-scoped key), NOT the
|
|
142
|
+
* per-tab `sessionStorage` the loop-breaker keys use — it must survive a reload.
|
|
143
|
+
*
|
|
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.
|
|
151
|
+
*
|
|
152
|
+
* Lifecycle (mirrors the existing gate machinery — set on a definitive event,
|
|
153
|
+
* cleared on its inverse):
|
|
154
|
+
* - SET on EXPLICIT full sign-out (alongside clearing the prior-session hint
|
|
155
|
+
* and the SSO bounce state).
|
|
156
|
+
* - CLEARED on ANY deliberate sign-in (password, FedCM, account switch, device
|
|
157
|
+
* claim) so a real sign-in fully re-enables silent restore — there is no
|
|
158
|
+
* "stuck signed out" state.
|
|
159
|
+
*
|
|
160
|
+
* NOTE: this gates only AUTOMATIC/silent restore. An INTERACTIVE sign-in always
|
|
161
|
+
* clears it first, so the user can always sign back in.
|
|
162
|
+
*/
|
|
163
|
+
function ssoSignedOutKey(origin) {
|
|
164
|
+
return `${SIGNED_OUT_KEY_PREFIX}${origin}`;
|
|
165
|
+
}
|
|
133
166
|
/**
|
|
134
167
|
* Per-origin marker written by the pre-hydration callback bootstrap.
|
|
135
168
|
*
|
|
@@ -260,6 +293,32 @@ function guardActive(storage, origin, now = Date.now()) {
|
|
|
260
293
|
}
|
|
261
294
|
return now - ts < exports.SSO_GUARD_TTL_MS;
|
|
262
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* Whether AUTOMATIC silent restore is SUPPRESSED for this origin because the
|
|
298
|
+
* user deliberately signed out (the durable {@link ssoSignedOutKey} flag).
|
|
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
|
|
302
|
+
* `/auth/silent` iframe — MUST be skipped, so a user who pressed "Sign out" is
|
|
303
|
+
* not silently signed back in on the next reload. Interactive sign-in clears the
|
|
304
|
+
* flag, so this never blocks a deliberate re-sign-in.
|
|
305
|
+
*
|
|
306
|
+
* Defensive: a `getItem` that throws (locked/disabled storage) is treated as NOT
|
|
307
|
+
* suppressed, so the gate fails toward the normal restore behaviour rather than
|
|
308
|
+
* wedging the user out.
|
|
309
|
+
*
|
|
310
|
+
* @param storage - The DURABLE storage to read (web `localStorage`; injected for
|
|
311
|
+
* testability).
|
|
312
|
+
* @param origin - The page origin whose signed-out flag to evaluate.
|
|
313
|
+
*/
|
|
314
|
+
function silentRestoreSuppressed(storage, origin) {
|
|
315
|
+
try {
|
|
316
|
+
return storage.getItem(ssoSignedOutKey(origin)) === '1';
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
263
322
|
/**
|
|
264
323
|
* Decide whether the terminal `/sso` establish-bounce is ALLOWED for this
|
|
265
324
|
* visitor (the smart `enabled` gate for the `sso-bounce` cold-boot step).
|