@oxyhq/core 3.10.0 → 3.11.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 +9 -2
- package/dist/cjs/HttpService.js +27 -9
- package/dist/cjs/OxyServices.base.js +3 -2
- package/dist/cjs/crypto/canonicalJson.js +107 -0
- package/dist/cjs/crypto/keyManager.js +67 -8
- package/dist/cjs/crypto/signatureService.js +103 -0
- package/dist/cjs/i18n/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/es-ES.json +9 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +9 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +9 -0
- package/dist/cjs/index.js +15 -5
- package/dist/cjs/mixins/OxyServices.assets.js +45 -7
- package/dist/cjs/mixins/OxyServices.auth.js +190 -1
- package/dist/cjs/mixins/OxyServices.identity.js +291 -0
- package/dist/cjs/mixins/OxyServices.sso.js +28 -1
- package/dist/cjs/mixins/OxyServices.user.js +1 -0
- package/dist/cjs/mixins/OxyServices.utility.js +52 -23
- package/dist/cjs/mixins/index.js +3 -0
- package/dist/cjs/server/cors.js +20 -21
- package/dist/cjs/server/rateLimit.js +32 -8
- package/dist/cjs/utils/fapiAutoDetect.js +12 -42
- package/dist/cjs/utils/ssoReturn.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/AuthManager.js +9 -2
- package/dist/esm/HttpService.js +27 -9
- package/dist/esm/OxyServices.base.js +3 -2
- package/dist/esm/crypto/canonicalJson.js +104 -0
- package/dist/esm/crypto/keyManager.js +67 -8
- package/dist/esm/crypto/signatureService.js +102 -0
- package/dist/esm/i18n/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/es-ES.json +9 -0
- package/dist/esm/i18n/locales/locales/en-US.json +9 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +9 -0
- package/dist/esm/index.js +10 -2
- package/dist/esm/mixins/OxyServices.assets.js +45 -7
- package/dist/esm/mixins/OxyServices.auth.js +190 -1
- package/dist/esm/mixins/OxyServices.identity.js +287 -0
- package/dist/esm/mixins/OxyServices.sso.js +28 -1
- package/dist/esm/mixins/OxyServices.user.js +1 -0
- package/dist/esm/mixins/OxyServices.utility.js +52 -23
- package/dist/esm/mixins/index.js +3 -0
- package/dist/esm/server/cors.js +20 -21
- package/dist/esm/server/rateLimit.js +32 -8
- package/dist/esm/utils/fapiAutoDetect.js +12 -41
- package/dist/esm/utils/ssoReturn.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +3 -0
- package/dist/types/OxyServices.d.ts +2 -2
- package/dist/types/crypto/canonicalJson.d.ts +44 -0
- package/dist/types/crypto/keyManager.d.ts +7 -0
- package/dist/types/crypto/signatureService.d.ts +61 -0
- package/dist/types/index.d.ts +7 -3
- package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
- package/dist/types/mixins/OxyServices.auth.d.ts +136 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +249 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +4 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/models/interfaces.d.ts +3 -0
- package/dist/types/server/cors.d.ts +5 -5
- package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
- package/dist/types/utils/ssoReturn.d.ts +1 -1
- package/package.json +3 -2
- package/src/AuthManager.ts +8 -2
- package/src/HttpService.ts +36 -8
- package/src/OxyServices.base.ts +3 -2
- package/src/OxyServices.ts +1 -1
- package/src/__tests__/authManager.security.test.ts +31 -0
- package/src/__tests__/authSocket.test.ts +96 -0
- package/src/__tests__/httpServiceCsrf.test.ts +75 -0
- package/src/crypto/__tests__/canonicalJson.test.ts +116 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +41 -2
- package/src/crypto/__tests__/signChallengeShared.test.ts +64 -0
- package/src/crypto/__tests__/signedRecord.test.ts +125 -0
- package/src/crypto/canonicalJson.ts +120 -0
- package/src/crypto/keyManager.ts +62 -12
- package/src/crypto/signatureService.ts +126 -0
- package/src/i18n/locales/en-US.json +9 -0
- package/src/i18n/locales/es-ES.json +9 -0
- package/src/index.ts +28 -3
- package/src/mixins/OxyServices.assets.ts +56 -7
- package/src/mixins/OxyServices.auth.ts +309 -1
- package/src/mixins/OxyServices.identity.ts +445 -0
- package/src/mixins/OxyServices.sso.ts +30 -1
- package/src/mixins/OxyServices.user.ts +1 -0
- package/src/mixins/OxyServices.utility.ts +57 -23
- package/src/mixins/__tests__/OxyServices.identity.test.ts +364 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/assetUpload.test.ts +191 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +277 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +49 -2
- package/src/mixins/__tests__/sso.test.ts +31 -0
- package/src/mixins/index.ts +4 -0
- package/src/models/interfaces.ts +3 -0
- package/src/server/__tests__/cors.test.ts +5 -1
- package/src/server/__tests__/rateLimit.test.ts +116 -0
- package/src/server/cors.ts +25 -20
- package/src/server/rateLimit.ts +39 -8
- package/src/utils/__tests__/consumeSsoReturn.test.ts +1 -1
- package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
- package/src/utils/__tests__/ssoReturn.test.ts +1 -1
- package/src/utils/fapiAutoDetect.ts +12 -39
- package/src/utils/ssoReturn.ts +2 -2
|
@@ -225,9 +225,9 @@ export declare function OxyServicesUtilityMixin<T extends typeof OxyServicesBase
|
|
|
225
225
|
* Express.js middleware that enforces a specific service-token scope.
|
|
226
226
|
*
|
|
227
227
|
* Mount AFTER `auth()` / `serviceAuth()` — relies on `req.serviceApp` and
|
|
228
|
-
* (when delegation is in effect) `req.serviceActingAs.scopes`.
|
|
229
|
-
*
|
|
230
|
-
* the app
|
|
228
|
+
* (when delegation is in effect) `req.serviceActingAs.scopes`. App-only
|
|
229
|
+
* service requests require the app scope. Delegated user requests require
|
|
230
|
+
* BOTH the app scope and the per-user delegation scope.
|
|
231
231
|
*
|
|
232
232
|
* Requests authenticated as a regular user (no service token) are rejected
|
|
233
233
|
* with 403 — scope-protected endpoints are service-to-service by design.
|
|
@@ -11,6 +11,7 @@ import { OxyServicesSilentAuthMixin } from './OxyServices.silent';
|
|
|
11
11
|
import { OxyServicesRedirectAuthMixin } from './OxyServices.redirect';
|
|
12
12
|
import { OxyServicesSsoMixin } from './OxyServices.sso';
|
|
13
13
|
import { OxyServicesUserMixin } from './OxyServices.user';
|
|
14
|
+
import { OxyServicesIdentityMixin } from './OxyServices.identity';
|
|
14
15
|
import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
|
|
15
16
|
import { OxyServicesLanguageMixin } from './OxyServices.language';
|
|
16
17
|
import { OxyServicesPaymentMixin } from './OxyServices.payment';
|
|
@@ -37,7 +38,7 @@ import { OxyServicesAppDataMixin } from './OxyServices.appData';
|
|
|
37
38
|
* If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
|
|
38
39
|
* are visible without a cast.
|
|
39
40
|
*/
|
|
40
|
-
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSilentAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesApplicationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesWorkspacesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
41
|
+
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSilentAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesIdentityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesReputationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesApplicationsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesWorkspacesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
41
42
|
/**
|
|
42
43
|
* Constructor type for the fully composed mixin pipeline. Each mixin returns
|
|
43
44
|
* a new constructor that augments its input; reducing across the pipeline
|
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* `createOxyCors` returns a self-contained Express middleware (no `cors`
|
|
13
13
|
* package dependency) that:
|
|
14
|
-
* - allows the Oxy apex origin family
|
|
15
|
-
*
|
|
16
|
-
* `console.oxy.so`, `inbox.oxy.so`,
|
|
17
|
-
* central-origin constants already in core, NOT a fresh hardcoded list,
|
|
14
|
+
* - allows the Oxy apex origin family over HTTPS only: the apex plus
|
|
15
|
+
* one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
|
|
16
|
+
* `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
|
|
18
17
|
* - allows the caller's explicit `appOrigins`,
|
|
19
18
|
* - DENIES everything else (no reflection, never a wildcard with credentials),
|
|
20
19
|
* - echoes back the EXACT matched origin (so credentialed requests work) and
|
|
@@ -28,7 +27,8 @@ export interface OxyCorsOptions {
|
|
|
28
27
|
/**
|
|
29
28
|
* Explicit additional allowed origins (exact-origin match, e.g.
|
|
30
29
|
* `https://app.example.com`, `http://localhost:3000`). These are allowed IN
|
|
31
|
-
* ADDITION TO the Oxy apex origin family. Each is normalized
|
|
30
|
+
* ADDITION TO the built-in HTTPS Oxy apex origin family. Each is normalized
|
|
31
|
+
* via `new URL().origin`.
|
|
32
32
|
*/
|
|
33
33
|
appOrigins?: string[];
|
|
34
34
|
/**
|
|
@@ -20,10 +20,9 @@
|
|
|
20
20
|
* - SSR / non-browser (no `window`).
|
|
21
21
|
* - `localhost`, `127.0.0.1`, IPv4/IPv6 literals.
|
|
22
22
|
* - Hostnames with fewer than two labels.
|
|
23
|
-
* - Hostnames
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* registrable domain.
|
|
23
|
+
* - Hostnames where a registrable domain cannot be determined from the
|
|
24
|
+
* Public Suffix List, including private hosted suffixes such as
|
|
25
|
+
* `github.io`, `pages.dev`, and `netlify.app`.
|
|
27
26
|
*
|
|
28
27
|
* When the page is already loaded ON the IdP itself (`auth.<anything>`),
|
|
29
28
|
* the helper returns the current origin so the SDK keeps everything
|
|
@@ -35,21 +34,8 @@
|
|
|
35
34
|
* is required for end-to-end FedCM correctness — no per-RP config.
|
|
36
35
|
*/
|
|
37
36
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* of these would yield an attacker-registrable suffix (e.g. `auth.co.uk`),
|
|
41
|
-
* so we bail out instead.
|
|
42
|
-
*
|
|
43
|
-
* This is intentionally a small, explicit allow-list rather than the full
|
|
44
|
-
* Public Suffix List — it covers the suffixes the Oxy ecosystem's RPs use.
|
|
45
|
-
* Any multi-part-TLD RP MUST extend this set (or wire in a proper PSL check)
|
|
46
|
-
* before relying on this helper, otherwise auto-detection silently bails to
|
|
47
|
-
* `undefined` and the consumer must pass `authWebUrl` explicitly.
|
|
48
|
-
*/
|
|
49
|
-
export declare const MULTIPART_TLDS: ReadonlySet<string>;
|
|
50
|
-
/**
|
|
51
|
-
* Compute the bare registrable apex (eTLD+1) of a hostname, guarding against
|
|
52
|
-
* multi-part public suffixes.
|
|
37
|
+
* Compute the bare registrable apex (eTLD+1) of a hostname using the Public
|
|
38
|
+
* Suffix List, including private hosted suffixes.
|
|
53
39
|
*
|
|
54
40
|
* This is the pure host-handling kernel shared by {@link autoDetectAuthWebUrl}
|
|
55
41
|
* and the IdP worker — it performs NO protocol handling, NO `auth.` prefixing,
|
|
@@ -61,10 +47,7 @@ export declare const MULTIPART_TLDS: ReadonlySet<string>;
|
|
|
61
47
|
* - IPv4 literals (`192.168.1.10`);
|
|
62
48
|
* - IPv6 literals or any host carrying a port (`[::1]`, anything with `:`);
|
|
63
49
|
* - single-label hosts (`intranet`, `localhost`);
|
|
64
|
-
* -
|
|
65
|
-
* (e.g. `foo.co.uk`), where `labels.slice(-2)` would yield an
|
|
66
|
-
* attacker-registrable suffix (`co.uk`) rather than a real registrable
|
|
67
|
-
* domain. Such hosts MUST configure `authWebUrl` explicitly.
|
|
50
|
+
* - public suffixes without a registrable label (e.g. `co.uk`, `github.io`).
|
|
68
51
|
*
|
|
69
52
|
* @param hostname - A bare hostname (no scheme), e.g. `www.mention.earth`.
|
|
70
53
|
* @returns The eTLD+1 (`mention.earth`), or `null` when undefinable.
|
|
@@ -142,5 +142,5 @@ export interface ConsumeSsoReturnDeps {
|
|
|
142
142
|
* @returns The exchanged session on success, otherwise `null`.
|
|
143
143
|
*/
|
|
144
144
|
export declare function consumeSsoReturn(oxy: {
|
|
145
|
-
exchangeSsoCode: (code: string) => Promise<SessionLoginResponse>;
|
|
145
|
+
exchangeSsoCode: (code: string, state?: string) => Promise<SessionLoginResponse>;
|
|
146
146
|
}, deps?: ConsumeSsoReturnDeps): Promise<SessionLoginResponse | null>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0",
|
|
4
4
|
"description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
|
|
5
5
|
"main": "dist/cjs/index.js",
|
|
6
6
|
"module": "dist/esm/index.js",
|
|
@@ -98,13 +98,14 @@
|
|
|
98
98
|
}
|
|
99
99
|
},
|
|
100
100
|
"dependencies": {
|
|
101
|
-
"@oxyhq/contracts": "^0.
|
|
101
|
+
"@oxyhq/contracts": "^0.3.0",
|
|
102
102
|
"bip39": "^3.1.0",
|
|
103
103
|
"buffer": "^6.0.3",
|
|
104
104
|
"elliptic": "^6.6.1",
|
|
105
105
|
"invariant": "^2.2.4",
|
|
106
106
|
"jwt-decode": "^4.0.0",
|
|
107
107
|
"socket.io-client": "^4.8.1",
|
|
108
|
+
"tldts": "^7.0.22",
|
|
108
109
|
"zod": "^3.25.64"
|
|
109
110
|
},
|
|
110
111
|
"peerDependencies": {
|
package/src/AuthManager.ts
CHANGED
|
@@ -445,8 +445,14 @@ export class AuthManager {
|
|
|
445
445
|
* Get default storage based on environment.
|
|
446
446
|
*/
|
|
447
447
|
private getDefaultStorage(): StorageAdapter {
|
|
448
|
-
|
|
449
|
-
|
|
448
|
+
try {
|
|
449
|
+
if (typeof window !== 'undefined' && window.localStorage) {
|
|
450
|
+
return new LocalStorageAdapter();
|
|
451
|
+
}
|
|
452
|
+
} catch {
|
|
453
|
+
// Accessing window.localStorage can throw in opaque-origin/sandboxed
|
|
454
|
+
// browser contexts or when storage is disabled. Fall back to memory so
|
|
455
|
+
// AuthManager construction remains safe during provider render.
|
|
450
456
|
}
|
|
451
457
|
return new MemoryStorage();
|
|
452
458
|
}
|
package/src/HttpService.ts
CHANGED
|
@@ -68,6 +68,7 @@ export interface RequestOptions {
|
|
|
68
68
|
timeout?: number;
|
|
69
69
|
signal?: AbortSignal;
|
|
70
70
|
headers?: Record<string, string>;
|
|
71
|
+
responseType?: 'blob';
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
interface RequestConfig extends RequestOptions {
|
|
@@ -496,13 +497,21 @@ export class HttpService {
|
|
|
496
497
|
const useXhrForUpload = isFormData && isReactNative() && typeof XMLHttpRequest !== 'undefined';
|
|
497
498
|
|
|
498
499
|
const response = useXhrForUpload
|
|
499
|
-
? await this.uploadViaXHR(
|
|
500
|
+
? await this.uploadViaXHR(
|
|
501
|
+
fullUrl,
|
|
502
|
+
method,
|
|
503
|
+
headers,
|
|
504
|
+
bodyValue as FormData,
|
|
505
|
+
controller.signal,
|
|
506
|
+
timeout,
|
|
507
|
+
this.shouldSendCredentials(fullUrl),
|
|
508
|
+
)
|
|
500
509
|
: await fetch(fullUrl, {
|
|
501
510
|
method,
|
|
502
511
|
headers,
|
|
503
512
|
body: bodyValue as BodyInit | null | undefined,
|
|
504
513
|
signal: controller.signal,
|
|
505
|
-
credentials:
|
|
514
|
+
credentials: this.getCredentialsMode(fullUrl),
|
|
506
515
|
});
|
|
507
516
|
|
|
508
517
|
if (timeoutId) clearTimeout(timeoutId);
|
|
@@ -530,7 +539,7 @@ export class HttpService {
|
|
|
530
539
|
const errBody = await clonedResponse.json() as { code?: string } | null;
|
|
531
540
|
if (errBody?.code === 'CSRF_TOKEN_INVALID' || errBody?.code === 'CSRF_TOKEN_MISSING') {
|
|
532
541
|
this.tokenStore.clearCsrfToken();
|
|
533
|
-
return this.request<T>({ ...config, _isCsrfRetry: true, retry: false });
|
|
542
|
+
return this.request<T>({ ...config, _isCsrfRetry: true, retry: false, deduplicate: false });
|
|
534
543
|
}
|
|
535
544
|
} catch {
|
|
536
545
|
// Failed to parse error body — not a CSRF error
|
|
@@ -568,7 +577,9 @@ export class HttpService {
|
|
|
568
577
|
const contentType = response.headers.get('content-type');
|
|
569
578
|
let responseData: unknown;
|
|
570
579
|
|
|
571
|
-
if (
|
|
580
|
+
if (config.responseType === 'blob') {
|
|
581
|
+
responseData = await response.blob();
|
|
582
|
+
} else if (contentType && contentType.includes('application/json')) {
|
|
572
583
|
// Use response.json() directly for better performance
|
|
573
584
|
try {
|
|
574
585
|
responseData = await response.json();
|
|
@@ -694,13 +705,15 @@ export class HttpService {
|
|
|
694
705
|
body: FormData,
|
|
695
706
|
abortSignal: AbortSignal,
|
|
696
707
|
timeout: number,
|
|
708
|
+
withCredentials: boolean,
|
|
697
709
|
): Promise<Response> {
|
|
698
710
|
return new Promise<Response>((resolve, reject) => {
|
|
699
711
|
const xhr = new XMLHttpRequest();
|
|
700
712
|
xhr.open(method, url, true);
|
|
701
|
-
//
|
|
702
|
-
//
|
|
703
|
-
|
|
713
|
+
// Only send ambient cookies to the configured API origin. Absolute
|
|
714
|
+
// caller-supplied URLs can target arbitrary origins, so they must not
|
|
715
|
+
// receive credential-bearing requests by default.
|
|
716
|
+
xhr.withCredentials = withCredentials;
|
|
704
717
|
|
|
705
718
|
// Forward headers but skip Content-Type — XHR sets the multipart
|
|
706
719
|
// boundary automatically and overriding it breaks the upload.
|
|
@@ -874,6 +887,18 @@ export class HttpService {
|
|
|
874
887
|
return queryString ? `${base}${base.includes('?') ? '&' : '?'}${queryString}` : base;
|
|
875
888
|
}
|
|
876
889
|
|
|
890
|
+
private getCredentialsMode(url: string): RequestCredentials {
|
|
891
|
+
return this.shouldSendCredentials(url) ? 'include' : 'omit';
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
private shouldSendCredentials(url: string): boolean {
|
|
895
|
+
try {
|
|
896
|
+
return new URL(url).origin === new URL(this.baseURL).origin;
|
|
897
|
+
} catch {
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
877
902
|
/**
|
|
878
903
|
* Fetch CSRF token from server (with deduplication)
|
|
879
904
|
* Required for state-changing requests (POST, PUT, PATCH, DELETE)
|
|
@@ -915,8 +940,11 @@ export class HttpService {
|
|
|
915
940
|
|
|
916
941
|
if (response.ok) {
|
|
917
942
|
const data = await response.json() as { csrfToken?: string };
|
|
918
|
-
this.logger.debug('CSRF response data:', data);
|
|
919
943
|
const token = data.csrfToken || null;
|
|
944
|
+
this.logger.debug('CSRF response data:', {
|
|
945
|
+
hasCsrfToken: typeof token === 'string' && token.length > 0,
|
|
946
|
+
csrfTokenLength: token?.length,
|
|
947
|
+
});
|
|
920
948
|
this.tokenStore.setCsrfToken(token);
|
|
921
949
|
this.logger.debug('CSRF token fetched');
|
|
922
950
|
return token;
|
package/src/OxyServices.base.ts
CHANGED
|
@@ -287,8 +287,9 @@ export class OxyServicesBase {
|
|
|
287
287
|
|
|
288
288
|
try {
|
|
289
289
|
const decoded = jwtDecode<JwtPayload>(accessToken);
|
|
290
|
-
|
|
291
|
-
|
|
290
|
+
const userId = decoded.userId || decoded.id || null;
|
|
291
|
+
this._cachedUserId = userId;
|
|
292
|
+
return userId;
|
|
292
293
|
} catch {
|
|
293
294
|
this._cachedUserId = null;
|
|
294
295
|
return null;
|
package/src/OxyServices.ts
CHANGED
|
@@ -153,7 +153,7 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
153
153
|
signUpWithRedirect(options?: RedirectAuthOptions): void;
|
|
154
154
|
|
|
155
155
|
// Central cross-domain SSO (opaque single-use code exchange)
|
|
156
|
-
exchangeSsoCode(code: string): Promise<SessionLoginResponse>;
|
|
156
|
+
exchangeSsoCode(code: string, state?: string): Promise<SessionLoginResponse>;
|
|
157
157
|
generateSsoState(): string;
|
|
158
158
|
|
|
159
159
|
// Express.js middleware
|
|
@@ -164,6 +164,37 @@ describe('AuthManager.switchAuthuser — concurrency lock', () => {
|
|
|
164
164
|
});
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
describe('AuthManager default storage selection', () => {
|
|
168
|
+
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
|
169
|
+
|
|
170
|
+
afterEach(() => {
|
|
171
|
+
if (originalWindow) {
|
|
172
|
+
Object.defineProperty(globalThis, 'window', originalWindow);
|
|
173
|
+
} else {
|
|
174
|
+
Reflect.deleteProperty(globalThis, 'window');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('falls back to memory storage when localStorage access throws', () => {
|
|
179
|
+
const blockedWindow = {};
|
|
180
|
+
Object.defineProperty(blockedWindow, 'localStorage', {
|
|
181
|
+
configurable: true,
|
|
182
|
+
get() {
|
|
183
|
+
throw new DOMException('Blocked localStorage', 'SecurityError');
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
Object.defineProperty(globalThis, 'window', {
|
|
187
|
+
configurable: true,
|
|
188
|
+
value: blockedWindow,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
expect(() => new AuthManager(makeMockServices() as unknown as OxyServices, {
|
|
192
|
+
autoRefresh: false,
|
|
193
|
+
crossTabSync: false,
|
|
194
|
+
})).not.toThrow();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
167
198
|
describe('AuthManager.switchAuthuser — hydration of unknown slots', () => {
|
|
168
199
|
it('hydrates a slot with no prior user metadata via getCurrentUser()', async () => {
|
|
169
200
|
const services = makeMockServices();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { OxyServices } from '../OxyServices';
|
|
2
|
+
|
|
3
|
+
function jsonResponse(data: unknown): Response {
|
|
4
|
+
return new Response(JSON.stringify({ data }), {
|
|
5
|
+
status: 200,
|
|
6
|
+
headers: { 'content-type': 'application/json' },
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function createJwt(payload: Record<string, unknown>): string {
|
|
11
|
+
const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
12
|
+
return `${encode({ alg: 'HS256', typ: 'JWT' })}.${encode(payload)}.forged-signature`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function runAuthSocket(oxy: OxyServices, token: string) {
|
|
16
|
+
const socket: {
|
|
17
|
+
handshake: { auth: { token: string } };
|
|
18
|
+
data?: Record<string, unknown>;
|
|
19
|
+
user?: { id: string; userId: string; sessionId?: string | null };
|
|
20
|
+
} = { handshake: { auth: { token } } };
|
|
21
|
+
let nextError: Error | undefined;
|
|
22
|
+
|
|
23
|
+
await oxy.authSocket()(socket, (err?: Error) => {
|
|
24
|
+
nextError = err;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return { socket, nextError };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('authSocket', () => {
|
|
31
|
+
const originalFetch = globalThis.fetch;
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
globalThis.fetch = originalFetch;
|
|
35
|
+
jest.restoreAllMocks();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects decoded JWT payloads that do not include a server-validated session', async () => {
|
|
39
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
40
|
+
const fetchMock = jest.fn();
|
|
41
|
+
globalThis.fetch = fetchMock;
|
|
42
|
+
|
|
43
|
+
const { socket, nextError } = await runAuthSocket(oxy, createJwt({
|
|
44
|
+
userId: 'victimUserId',
|
|
45
|
+
exp: 4102444800,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
expect(nextError?.message).toBe('Session required');
|
|
49
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
50
|
+
expect(socket.data?.userId).toBeUndefined();
|
|
51
|
+
expect(socket.user).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('rejects tokens whose decoded user does not match the validated session user', async () => {
|
|
55
|
+
globalThis.fetch = async () =>
|
|
56
|
+
jsonResponse({
|
|
57
|
+
valid: true,
|
|
58
|
+
expiresAt: '2099-01-01T00:00:00.000Z',
|
|
59
|
+
lastActivity: '2026-06-24T00:00:00.000Z',
|
|
60
|
+
user: { id: 'realUserId', username: 'real', publicKey: 'pub_1' },
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
64
|
+
const { socket, nextError } = await runAuthSocket(oxy, createJwt({
|
|
65
|
+
userId: 'victimUserId',
|
|
66
|
+
sessionId: 'session_1',
|
|
67
|
+
exp: 4102444800,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
expect(nextError?.message).toBe('Session user mismatch');
|
|
71
|
+
expect(socket.data?.userId).toBeUndefined();
|
|
72
|
+
expect(socket.user).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('attaches the validated session user when the decoded user matches', async () => {
|
|
76
|
+
globalThis.fetch = async () =>
|
|
77
|
+
jsonResponse({
|
|
78
|
+
valid: true,
|
|
79
|
+
expiresAt: '2099-01-01T00:00:00.000Z',
|
|
80
|
+
lastActivity: '2026-06-24T00:00:00.000Z',
|
|
81
|
+
user: { id: 'user_1', username: 'nate', publicKey: 'pub_1' },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
85
|
+
const { socket, nextError } = await runAuthSocket(oxy, createJwt({
|
|
86
|
+
userId: 'user_1',
|
|
87
|
+
sessionId: 'session_1',
|
|
88
|
+
exp: 4102444800,
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
expect(nextError).toBeUndefined();
|
|
92
|
+
expect(socket.data?.userId).toBe('user_1');
|
|
93
|
+
expect(socket.data?.sessionId).toBe('session_1');
|
|
94
|
+
expect(socket.user).toEqual({ id: 'user_1', userId: 'user_1', sessionId: 'session_1' });
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -157,4 +157,79 @@ describe('HttpService CSRF behavior', () => {
|
|
|
157
157
|
expect(headers.Authorization).toBeUndefined();
|
|
158
158
|
expect(headers['X-CSRF-Token']).toBe('csrf_1');
|
|
159
159
|
});
|
|
160
|
+
|
|
161
|
+
it('includes credentials for configured API origin requests', async () => {
|
|
162
|
+
const calls: FetchCall[] = [];
|
|
163
|
+
globalThis.fetch = async (input, init) => {
|
|
164
|
+
calls.push({ url: String(input), init });
|
|
165
|
+
return jsonResponse({ ok: true });
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
|
|
169
|
+
|
|
170
|
+
await http.get('/users/me');
|
|
171
|
+
|
|
172
|
+
expect(calls).toHaveLength(1);
|
|
173
|
+
expect(calls[0].url).toBe('https://api.oxy.so/users/me');
|
|
174
|
+
expect(calls[0].init?.credentials).toBe('include');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('omits credentials for caller-supplied absolute URLs outside the configured API origin', async () => {
|
|
178
|
+
const calls: FetchCall[] = [];
|
|
179
|
+
globalThis.fetch = async (input, init) => {
|
|
180
|
+
calls.push({ url: String(input), init });
|
|
181
|
+
return jsonResponse({ ok: true });
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const http = new HttpService({ baseURL: 'https://api.oxy.so', enableRetry: false });
|
|
185
|
+
|
|
186
|
+
await http.get('https://attacker.oxy.so/collect');
|
|
187
|
+
|
|
188
|
+
expect(calls).toHaveLength(1);
|
|
189
|
+
expect(calls[0].url).toBe('https://attacker.oxy.so/collect');
|
|
190
|
+
expect(calls[0].init?.credentials).toBe('omit');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('bypasses request deduplication for the internal CSRF retry', async () => {
|
|
194
|
+
const calls: FetchCall[] = [];
|
|
195
|
+
let csrfFetches = 0;
|
|
196
|
+
let postFetches = 0;
|
|
197
|
+
|
|
198
|
+
globalThis.fetch = async (input, init) => {
|
|
199
|
+
const url = String(input);
|
|
200
|
+
calls.push({ url, init });
|
|
201
|
+
|
|
202
|
+
if (url.endsWith('/csrf-token')) {
|
|
203
|
+
csrfFetches += 1;
|
|
204
|
+
return new Response(JSON.stringify({ csrfToken: `csrf_${csrfFetches}` }), {
|
|
205
|
+
status: 200,
|
|
206
|
+
headers: { 'content-type': 'application/json' },
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
postFetches += 1;
|
|
211
|
+
if (postFetches === 1) {
|
|
212
|
+
return new Response(JSON.stringify({ code: 'CSRF_TOKEN_INVALID' }), {
|
|
213
|
+
status: 403,
|
|
214
|
+
statusText: 'Forbidden',
|
|
215
|
+
headers: { 'content-type': 'application/json' },
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return jsonResponse({ ok: true });
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const http = new HttpService({ baseURL: 'https://api.mention.earth', enableRetry: false });
|
|
223
|
+
|
|
224
|
+
await expect(http.post('/posts', { text: 'hello' })).resolves.toEqual({ ok: true });
|
|
225
|
+
expect(calls.map((call) => call.url)).toEqual([
|
|
226
|
+
'https://api.mention.earth/csrf-token',
|
|
227
|
+
'https://api.mention.earth/posts',
|
|
228
|
+
'https://api.mention.earth/csrf-token',
|
|
229
|
+
'https://api.mention.earth/posts',
|
|
230
|
+
]);
|
|
231
|
+
|
|
232
|
+
expect(readHeaders(calls[1].init)['X-CSRF-Token']).toBe('csrf_1');
|
|
233
|
+
expect(readHeaders(calls[3].init)['X-CSRF-Token']).toBe('csrf_2');
|
|
234
|
+
});
|
|
160
235
|
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON tests.
|
|
3
|
+
*
|
|
4
|
+
* The whole point of `canonicalize` is that two structurally-equal values
|
|
5
|
+
* produce identical strings regardless of how their keys were ordered, so a
|
|
6
|
+
* client which signs and a server which verifies agree on the signing input.
|
|
7
|
+
* These tests pin that determinism, the array-order guarantee, the nesting
|
|
8
|
+
* behaviour, and the JSON value/omit semantics.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { canonicalize } from '../canonicalJson';
|
|
12
|
+
|
|
13
|
+
describe('canonicalize', () => {
|
|
14
|
+
describe('object key ordering', () => {
|
|
15
|
+
it('produces identical output regardless of insertion order', () => {
|
|
16
|
+
const a = canonicalize({ b: 1, a: 2, c: 3 });
|
|
17
|
+
const b = canonicalize({ c: 3, a: 2, b: 1 });
|
|
18
|
+
const c = canonicalize({ a: 2, b: 1, c: 3 });
|
|
19
|
+
expect(a).toBe(b);
|
|
20
|
+
expect(b).toBe(c);
|
|
21
|
+
expect(a).toBe('{"a":2,"b":1,"c":3}');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('sorts keys recursively at every level', () => {
|
|
25
|
+
const value = {
|
|
26
|
+
z: { y: 1, x: 2 },
|
|
27
|
+
a: { c: 3, b: { e: 5, d: 4 } },
|
|
28
|
+
};
|
|
29
|
+
expect(canonicalize(value)).toBe(
|
|
30
|
+
'{"a":{"b":{"d":4,"e":5},"c":3},"z":{"x":2,"y":1}}',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('is order-insensitive across deep nesting', () => {
|
|
35
|
+
const first = canonicalize({
|
|
36
|
+
outer: { inner: { p: 1, q: 2 }, lead: 'x' },
|
|
37
|
+
meta: { issuedAt: 10, version: 1 },
|
|
38
|
+
});
|
|
39
|
+
const second = canonicalize({
|
|
40
|
+
meta: { version: 1, issuedAt: 10 },
|
|
41
|
+
outer: { lead: 'x', inner: { q: 2, p: 1 } },
|
|
42
|
+
});
|
|
43
|
+
expect(first).toBe(second);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('array ordering', () => {
|
|
48
|
+
it('preserves array element order (never sorts arrays)', () => {
|
|
49
|
+
expect(canonicalize([3, 1, 2])).toBe('[3,1,2]');
|
|
50
|
+
expect(canonicalize(['b', 'a', 'c'])).toBe('["b","a","c"]');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('distinguishes arrays that differ only in order', () => {
|
|
54
|
+
expect(canonicalize([1, 2])).not.toBe(canonicalize([2, 1]));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('canonicalizes objects inside arrays without reordering the array', () => {
|
|
58
|
+
const value = [
|
|
59
|
+
{ b: 1, a: 2 },
|
|
60
|
+
{ d: 3, c: 4 },
|
|
61
|
+
];
|
|
62
|
+
expect(canonicalize(value)).toBe('[{"a":2,"b":1},{"c":4,"d":3}]');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('primitives', () => {
|
|
67
|
+
it('serializes null, booleans, strings and numbers as JSON', () => {
|
|
68
|
+
expect(canonicalize(null)).toBe('null');
|
|
69
|
+
expect(canonicalize(true)).toBe('true');
|
|
70
|
+
expect(canonicalize(false)).toBe('false');
|
|
71
|
+
expect(canonicalize('hi')).toBe('"hi"');
|
|
72
|
+
expect(canonicalize(42)).toBe('42');
|
|
73
|
+
expect(canonicalize(-1.5)).toBe('-1.5');
|
|
74
|
+
expect(canonicalize(0)).toBe('0');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('escapes strings the same way JSON does', () => {
|
|
78
|
+
expect(canonicalize('a"b\\c\n')).toBe(JSON.stringify('a"b\\c\n'));
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('JSON value/omit semantics', () => {
|
|
83
|
+
it('omits object properties whose value is undefined', () => {
|
|
84
|
+
expect(canonicalize({ a: 1, b: undefined, c: 3 })).toBe('{"a":1,"c":3}');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('renders undefined array elements as null (preserving length)', () => {
|
|
88
|
+
expect(canonicalize([1, undefined, 3])).toBe('[1,null,3]');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('respects toJSON (Date and its ISO string canonicalize identically)', () => {
|
|
92
|
+
const date = new Date('2026-06-26T00:00:00.000Z');
|
|
93
|
+
expect(canonicalize(date)).toBe(JSON.stringify(date.toISOString()));
|
|
94
|
+
expect(canonicalize({ at: date })).toBe(
|
|
95
|
+
canonicalize({ at: '2026-06-26T00:00:00.000Z' }),
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('rejects values outside the JSON data model', () => {
|
|
101
|
+
it('throws on non-finite numbers', () => {
|
|
102
|
+
expect(() => canonicalize(NaN)).toThrow();
|
|
103
|
+
expect(() => canonicalize(Infinity)).toThrow();
|
|
104
|
+
expect(() => canonicalize({ x: Infinity })).toThrow();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('throws on bigint', () => {
|
|
108
|
+
expect(() => canonicalize(BigInt(1))).toThrow();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('throws on a bare undefined / function at the top level', () => {
|
|
112
|
+
expect(() => canonicalize(undefined)).toThrow();
|
|
113
|
+
expect(() => canonicalize(() => 1)).toThrow();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
});
|