@oxyhq/core 3.9.1 → 3.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- 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 +2 -3
- package/dist/cjs/mixins/OxyServices.assets.js +29 -6
- package/dist/cjs/mixins/OxyServices.utility.js +52 -23
- package/dist/cjs/server/cors.js +155 -0
- package/dist/cjs/server/index.js +21 -1
- package/dist/cjs/server/safeFetch.js +458 -0
- package/dist/cjs/server/verifySecret.js +50 -0
- package/dist/cjs/utils/fapiAutoDetect.js +12 -42
- package/dist/esm/.tsbuildinfo +1 -1
- 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 +1 -1
- package/dist/esm/mixins/OxyServices.assets.js +29 -6
- package/dist/esm/mixins/OxyServices.utility.js +52 -23
- package/dist/esm/server/cors.js +152 -0
- package/dist/esm/server/index.js +6 -0
- package/dist/esm/server/safeFetch.js +447 -0
- package/dist/esm/server/verifySecret.js +47 -0
- package/dist/esm/utils/fapiAutoDetect.js +12 -41
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/mixins/OxyServices.assets.d.ts +6 -1
- package/dist/types/mixins/OxyServices.utility.d.ts +3 -3
- package/dist/types/server/cors.d.ts +57 -0
- package/dist/types/server/index.d.ts +5 -0
- package/dist/types/server/safeFetch.d.ts +135 -0
- package/dist/types/server/verifySecret.d.ts +29 -0
- package/dist/types/utils/fapiAutoDetect.d.ts +6 -23
- package/package.json +2 -1
- package/src/__tests__/authSocket.test.ts +96 -0
- package/src/i18n/locales/en-US.json +9 -0
- package/src/i18n/locales/es-ES.json +9 -0
- package/src/index.ts +1 -1
- package/src/mixins/OxyServices.assets.ts +40 -6
- package/src/mixins/OxyServices.utility.ts +57 -23
- package/src/mixins/__tests__/assetUpload.test.ts +191 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +13 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +30 -2
- package/src/server/__tests__/cors.test.ts +144 -0
- package/src/server/__tests__/safeFetch.test.ts +232 -0
- package/src/server/__tests__/verifySecret.test.ts +40 -0
- package/src/server/cors.ts +195 -0
- package/src/server/index.ts +30 -0
- package/src/server/safeFetch.ts +581 -0
- package/src/server/verifySecret.ts +52 -0
- package/src/utils/__tests__/fapiAutoDetect.test.ts +40 -11
- package/src/utils/fapiAutoDetect.ts +12 -39
package/dist/types/index.d.ts
CHANGED
|
@@ -80,7 +80,7 @@ export type { LogContext } from './utils/loggerUtils';
|
|
|
80
80
|
export { updateAvatarVisibility } from './utils/avatarUtils';
|
|
81
81
|
export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccountFallbackHandle, formatPublicKeyHandle, mergeAccountsFromRefreshAll, getAccountColor, } from './utils/accountUtils';
|
|
82
82
|
export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
|
|
83
|
-
export { autoDetectAuthWebUrl, registrableApex
|
|
83
|
+
export { autoDetectAuthWebUrl, registrableApex } from './utils/fapiAutoDetect';
|
|
84
84
|
export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl';
|
|
85
85
|
export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
|
|
86
86
|
export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant } from '../models/interfaces';
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
|
+
interface FileDownloadUrlOptions {
|
|
4
|
+
/** Omit bearer access tokens from generated URLs, even when authenticated. */
|
|
5
|
+
omitToken?: boolean;
|
|
6
|
+
}
|
|
3
7
|
export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
4
8
|
new (...args: any[]): {
|
|
5
9
|
/**
|
|
@@ -32,7 +36,7 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
|
|
|
32
36
|
*
|
|
33
37
|
* For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
|
|
34
38
|
*/
|
|
35
|
-
getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number): string;
|
|
39
|
+
getFileDownloadUrl(fileId: string, variant?: string, expiresIn?: number, options?: FileDownloadUrlOptions): string;
|
|
36
40
|
/**
|
|
37
41
|
* Get file download URL asynchronously (returns signed URL directly from CDN)
|
|
38
42
|
*/
|
|
@@ -168,3 +172,4 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
|
|
|
168
172
|
}>;
|
|
169
173
|
};
|
|
170
174
|
} & T;
|
|
175
|
+
export {};
|
|
@@ -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.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict CORS allowlist for Oxy backends.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* App backends kept hand-rolling CORS, and the unsafe patterns recurred:
|
|
7
|
+
* - `Access-Control-Allow-Origin: *` together with credentials (which is
|
|
8
|
+
* spec-invalid AND a credential-leak vector), or
|
|
9
|
+
* - a "reflect whatever Origin the request carried" fallback (effectively
|
|
10
|
+
* `*` for credentialed requests — the Allo wildcard-fallback class).
|
|
11
|
+
*
|
|
12
|
+
* `createOxyCors` returns a self-contained Express middleware (no `cors`
|
|
13
|
+
* package dependency) that:
|
|
14
|
+
* - allows the Oxy apex origin family (anything under `*.${CENTRAL_IDP_APEX}`,
|
|
15
|
+
* i.e. `oxy.so` — covering `auth.oxy.so`, `api.oxy.so`, `accounts.oxy.so`,
|
|
16
|
+
* `console.oxy.so`, `inbox.oxy.so`, the marketing site, …) reusing the
|
|
17
|
+
* central-origin constants already in core, NOT a fresh hardcoded list,
|
|
18
|
+
* - allows the caller's explicit `appOrigins`,
|
|
19
|
+
* - DENIES everything else (no reflection, never a wildcard with credentials),
|
|
20
|
+
* - echoes back the EXACT matched origin (so credentialed requests work) and
|
|
21
|
+
* sets `Vary: Origin` for correct caching,
|
|
22
|
+
* - answers CORS preflight (`OPTIONS`) with `204`.
|
|
23
|
+
*
|
|
24
|
+
* Node/Express-only: exported solely from `@oxyhq/core/server`.
|
|
25
|
+
*/
|
|
26
|
+
import type { RequestHandler } from 'express';
|
|
27
|
+
export interface OxyCorsOptions {
|
|
28
|
+
/**
|
|
29
|
+
* Explicit additional allowed origins (exact-origin match, e.g.
|
|
30
|
+
* `https://app.example.com`, `http://localhost:3000`). These are allowed IN
|
|
31
|
+
* ADDITION TO the Oxy apex origin family. Each is normalized via `new URL().origin`.
|
|
32
|
+
*/
|
|
33
|
+
appOrigins?: string[];
|
|
34
|
+
/**
|
|
35
|
+
* Whether to emit `Access-Control-Allow-Credentials: true`. Default `true`
|
|
36
|
+
* (the Oxy ecosystem uses cookie/bearer credentials). Even when `true`, the
|
|
37
|
+
* helper NEVER emits a wildcard origin — only an exact matched origin.
|
|
38
|
+
*/
|
|
39
|
+
allowCredentials?: boolean;
|
|
40
|
+
/** HTTP methods to allow. Defaults to the full standard set. */
|
|
41
|
+
methods?: string[];
|
|
42
|
+
/** Request headers to allow. Defaults to the common Oxy set. */
|
|
43
|
+
allowedHeaders?: string[];
|
|
44
|
+
/** Response headers to expose to the browser. Defaults to none. */
|
|
45
|
+
exposedHeaders?: string[];
|
|
46
|
+
/** Preflight cache lifetime in seconds. Default 86400 (24h). */
|
|
47
|
+
maxAgeSeconds?: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Create a strict Oxy CORS middleware. See module docs.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* app.use(createOxyCors({ appOrigins: ['https://app.example.com'] }));
|
|
55
|
+
* ```
|
|
56
|
+
*/
|
|
57
|
+
export declare function createOxyCors(options?: OxyCorsOptions): RequestHandler;
|
|
@@ -18,3 +18,8 @@ export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyUserId, getRequir
|
|
|
18
18
|
export type { OxyActingAsContext, OxyAuthenticatedRequest, OxyAuthMiddlewareOptions, OxyAuthRequest, OxyRequestUser, OxyServiceActingAsContext, OxyServiceAppContext, } from './auth';
|
|
19
19
|
export { createOxyRateLimit } from './rateLimit';
|
|
20
20
|
export type { OxyRateLimitOptions } from './rateLimit';
|
|
21
|
+
export { assertSafePublicUrl, isBlockedIp, safeFetch, SsrfRejection, UpstreamError, ALLOWED_PORTS, ALLOWED_PROTOCOLS, BLOCKED_HOSTNAMES, DEFAULT_USER_AGENT, MAX_REDIRECTS, MAX_URL_LENGTH, UPSTREAM_HEADERS_TIMEOUT_MS, } from './safeFetch';
|
|
22
|
+
export type { SafeFetchOptions, SafeFetchResult, SsrfCheckFail, SsrfCheckOk, SsrfCheckResult, } from './safeFetch';
|
|
23
|
+
export { createOxyCors } from './cors';
|
|
24
|
+
export type { OxyCorsOptions } from './cors';
|
|
25
|
+
export { verifySecret } from './verifySecret';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSRF-safe upstream HTTP fetch for Oxy backends.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* Every Oxy backend that contacts a caller-influenced URL — media proxies,
|
|
7
|
+
* the website MCP image upload/debug tools, federated fetches, link
|
|
8
|
+
* unfurlers — needs the exact same Server-Side Request Forgery (SSRF)
|
|
9
|
+
* defence. Apps were re-implementing it (or worse, omitting it), so the
|
|
10
|
+
* gold-standard primitive (originally `packages/backend/src/utils` in
|
|
11
|
+
* Mention) lives here ONCE.
|
|
12
|
+
*
|
|
13
|
+
* THE CONTRACT
|
|
14
|
+
* ------------
|
|
15
|
+
* - Every URL — including each redirect hop — is validated by
|
|
16
|
+
* {@link assertSafePublicUrl}: a real DNS resolution plus a denylist of
|
|
17
|
+
* private/reserved/metadata ranges (10/8, 127/8, 169.254.169.254, ::1, …).
|
|
18
|
+
* - The TCP connection is PINNED to the validated IP via a custom `lookup`,
|
|
19
|
+
* closing the DNS-rebind TOCTOU window — DNS is NOT re-resolved at connect
|
|
20
|
+
* time, so the address we validated is exactly the address Node connects to.
|
|
21
|
+
* - Redirects are followed manually (bounded) so every hop is re-validated and
|
|
22
|
+
* redirect bodies (potentially unbounded) are destroyed, not drained.
|
|
23
|
+
*
|
|
24
|
+
* Node-only: this module imports `node:http`/`node:https`/`node:dns` and is
|
|
25
|
+
* exported solely from `@oxyhq/core/server`. It MUST NOT be reachable from the
|
|
26
|
+
* browser `@oxyhq/core` entry.
|
|
27
|
+
*/
|
|
28
|
+
import { type IncomingMessage, type IncomingHttpHeaders } from 'node:http';
|
|
29
|
+
/** Maximum accepted length of an input URL (DoS guard). */
|
|
30
|
+
export declare const MAX_URL_LENGTH = 2048;
|
|
31
|
+
/** The only network ports a safe fetch is allowed to reach upstream. */
|
|
32
|
+
export declare const ALLOWED_PORTS: ReadonlySet<number>;
|
|
33
|
+
/** Protocols a safe fetch is allowed to contact. */
|
|
34
|
+
export declare const ALLOWED_PROTOCOLS: ReadonlySet<string>;
|
|
35
|
+
/**
|
|
36
|
+
* Time-to-first-byte deadline: how long to wait for the upstream to establish
|
|
37
|
+
* the connection and send its RESPONSE HEADERS before aborting. Enforced via
|
|
38
|
+
* `req.setTimeout` on the `ClientRequest`; once headers arrive, the caller owns
|
|
39
|
+
* the (longer) streaming lifetime of the response body.
|
|
40
|
+
*/
|
|
41
|
+
export declare const UPSTREAM_HEADERS_TIMEOUT_MS = 8000;
|
|
42
|
+
/** Maximum number of HTTP redirects to follow; each hop is re-validated. */
|
|
43
|
+
export declare const MAX_REDIRECTS = 5;
|
|
44
|
+
/** Default User-Agent presented to upstreams when the caller does not set one. */
|
|
45
|
+
export declare const DEFAULT_USER_AGENT = "OxyServices/1.0 (+https://oxy.so)";
|
|
46
|
+
/** Hostnames that must never be resolved or contacted, regardless of DNS. */
|
|
47
|
+
export declare const BLOCKED_HOSTNAMES: ReadonlySet<string>;
|
|
48
|
+
export interface SsrfCheckOk {
|
|
49
|
+
ok: true;
|
|
50
|
+
/** The validated literal IP the caller MUST connect to. */
|
|
51
|
+
ip: string;
|
|
52
|
+
/** IP family of the validated address (4 or 6). */
|
|
53
|
+
family: 4 | 6;
|
|
54
|
+
}
|
|
55
|
+
export interface SsrfCheckFail {
|
|
56
|
+
ok: false;
|
|
57
|
+
/** Human-readable, non-sensitive reason (safe to log; not echoed to clients). */
|
|
58
|
+
reason: string;
|
|
59
|
+
}
|
|
60
|
+
export type SsrfCheckResult = SsrfCheckOk | SsrfCheckFail;
|
|
61
|
+
/**
|
|
62
|
+
* Return true if a literal IP address is private/loopback/link-local/reserved/
|
|
63
|
+
* multicast/metadata and therefore must NOT be contacted.
|
|
64
|
+
*/
|
|
65
|
+
export declare function isBlockedIp(rawIp: string): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Validate that a URL is syntactically a public http(s) URL and that its
|
|
68
|
+
* hostname resolves ONLY to non-blocked, public IP addresses.
|
|
69
|
+
*
|
|
70
|
+
* On success, returns the single validated IP (the first allowed record) that
|
|
71
|
+
* the HTTP client MUST pin its connection to. Every resolved address is checked;
|
|
72
|
+
* if ANY resolves into a blocked range the URL is rejected (an attacker
|
|
73
|
+
* controlling a multi-record DNS response cannot smuggle one internal IP past
|
|
74
|
+
* the check).
|
|
75
|
+
*
|
|
76
|
+
* Re-run this on EVERY redirect hop so a public hostname cannot redirect (or
|
|
77
|
+
* DNS-rebind) into an internal address.
|
|
78
|
+
*/
|
|
79
|
+
export declare function assertSafePublicUrl(rawUrl: string): Promise<SsrfCheckResult>;
|
|
80
|
+
/** Marker error for a blocked SSRF target (map to 403 at the route layer). */
|
|
81
|
+
export declare class SsrfRejection extends Error {
|
|
82
|
+
constructor(reason: string);
|
|
83
|
+
}
|
|
84
|
+
/** Marker error for a generic upstream failure (map to 502 at the route layer). */
|
|
85
|
+
export declare class UpstreamError extends Error {
|
|
86
|
+
constructor(reason: string);
|
|
87
|
+
}
|
|
88
|
+
/** Options for {@link safeFetch}. */
|
|
89
|
+
export interface SafeFetchOptions {
|
|
90
|
+
/** HTTP method. Defaults to `GET`. */
|
|
91
|
+
method?: string;
|
|
92
|
+
/** Extra request headers. A `User-Agent` is added if none is provided. */
|
|
93
|
+
headers?: Record<string, string>;
|
|
94
|
+
/**
|
|
95
|
+
* Maximum number of redirects to follow (each re-validated). Defaults to
|
|
96
|
+
* {@link MAX_REDIRECTS}. Set to `0` to disallow redirects.
|
|
97
|
+
*/
|
|
98
|
+
maxRedirects?: number;
|
|
99
|
+
/**
|
|
100
|
+
* Time-to-first-byte deadline in milliseconds (connect + response headers).
|
|
101
|
+
* Defaults to {@link UPSTREAM_HEADERS_TIMEOUT_MS}.
|
|
102
|
+
*/
|
|
103
|
+
headersTimeoutMs?: number;
|
|
104
|
+
/**
|
|
105
|
+
* Optional external abort signal. When it fires the in-flight request is
|
|
106
|
+
* destroyed.
|
|
107
|
+
*/
|
|
108
|
+
signal?: AbortSignal;
|
|
109
|
+
}
|
|
110
|
+
/** The validated, non-redirect response returned by {@link safeFetch}. */
|
|
111
|
+
export interface SafeFetchResult {
|
|
112
|
+
/**
|
|
113
|
+
* The first non-redirect response. The caller OWNS draining/destroying this
|
|
114
|
+
* stream (stream it to the client, or buffer a bounded prefix, then destroy).
|
|
115
|
+
*/
|
|
116
|
+
response: IncomingMessage;
|
|
117
|
+
/** The HTTP status code of the response. */
|
|
118
|
+
status: number;
|
|
119
|
+
/** Response headers. */
|
|
120
|
+
headers: IncomingHttpHeaders;
|
|
121
|
+
/** The final, post-redirect URL that produced the response. */
|
|
122
|
+
finalUrl: string;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* SSRF-safe HTTP(S) fetch. Validates the URL (and every redirect hop) against
|
|
126
|
+
* the private/metadata-range denylist, pins the connection to the validated IP,
|
|
127
|
+
* follows a bounded number of redirects (destroying redirect bodies), and
|
|
128
|
+
* returns the first non-redirect response.
|
|
129
|
+
*
|
|
130
|
+
* The caller owns the returned response stream — drain or destroy it.
|
|
131
|
+
*
|
|
132
|
+
* @throws {SsrfRejection} when any hop targets a blocked address/host/port.
|
|
133
|
+
* @throws {UpstreamError} on redirect-loop / malformed-redirect / timeout.
|
|
134
|
+
*/
|
|
135
|
+
export declare function safeFetch(rawUrl: string, options?: SafeFetchOptions): Promise<SafeFetchResult>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constant-time secret comparison for Oxy backends.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* ---------------
|
|
6
|
+
* Backends kept comparing secrets with `provided !== EXPECTED`. A plain `===`/
|
|
7
|
+
* `!==` short-circuits on the first differing byte, leaking timing information
|
|
8
|
+
* an attacker can use to recover a secret byte-by-byte. This helper performs a
|
|
9
|
+
* constant-time comparison via `crypto.timingSafeEqual`, guarded by a length
|
|
10
|
+
* check, and never throws — replacing the `token !== SECRET` pattern (Alia
|
|
11
|
+
* docker-host / integrations webhook secrets, internal webhook bearers, etc.).
|
|
12
|
+
*
|
|
13
|
+
* Node-only (`node:crypto`); exported solely from `@oxyhq/core/server`.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Compare two secrets in constant time.
|
|
17
|
+
*
|
|
18
|
+
* Returns `true` iff both are non-empty strings of equal byte length with
|
|
19
|
+
* identical contents. Returns `false` — without throwing — when either value is
|
|
20
|
+
* not a string, when lengths differ, or when contents differ.
|
|
21
|
+
*
|
|
22
|
+
* The length-equality guard is required because `crypto.timingSafeEqual` throws
|
|
23
|
+
* on unequal-length buffers; comparing lengths first leaks only the LENGTH of
|
|
24
|
+
* the expected secret (already low-value / often public), never its bytes.
|
|
25
|
+
*
|
|
26
|
+
* @param provided - The untrusted, caller-supplied value (e.g. a request token).
|
|
27
|
+
* @param expected - The trusted secret to compare against.
|
|
28
|
+
*/
|
|
29
|
+
export declare function verifySecret(provided: string, expected: string): boolean;
|
|
@@ -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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/core",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.1",
|
|
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",
|
|
@@ -105,6 +105,7 @@
|
|
|
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": {
|
|
@@ -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
|
+
});
|
|
@@ -129,6 +129,15 @@
|
|
|
129
129
|
"title": "Reputation = Trust & Growth",
|
|
130
130
|
"body": "Oxy Trust is a reputation system that reacts to what you do. Helpful, respectful, constructive actions earn it. Harmful or low‑effort stuff chips it away. More reputation can unlock benefits; low reputation can limit features. It keeps things fair and rewards real contribution."
|
|
131
131
|
},
|
|
132
|
+
"name": {
|
|
133
|
+
"title": "What's your name?",
|
|
134
|
+
"body": "Add your name so people know who you are.",
|
|
135
|
+
"firstLabel": "First name",
|
|
136
|
+
"firstPlaceholder": "Your first name",
|
|
137
|
+
"lastLabel": "Last name",
|
|
138
|
+
"lastPlaceholder": "Your last name",
|
|
139
|
+
"saveFailed": "Could not save your name"
|
|
140
|
+
},
|
|
132
141
|
"avatar": {
|
|
133
142
|
"title": "Make It Yours",
|
|
134
143
|
"body": "Add an avatar so people recognize you. It will show anywhere you show up here. Skip if you want — you can add it later.",
|
|
@@ -849,6 +849,15 @@
|
|
|
849
849
|
"title": "Reputación = Confianza y crecimiento",
|
|
850
850
|
"body": "Oxy Trust es un sistema de reputación que reacciona a lo que haces. Las acciones útiles, respetuosas y constructivas la aumentan. Las acciones dañinas o de poco esfuerzo la reducen. Más reputación puede desbloquear beneficios; poca reputación puede limitar funciones. Mantiene la justicia y recompensa la contribución real."
|
|
851
851
|
},
|
|
852
|
+
"name": {
|
|
853
|
+
"title": "¿Cuál es tu nombre?",
|
|
854
|
+
"body": "Añade tu nombre para que la gente sepa quién eres.",
|
|
855
|
+
"firstLabel": "Nombre",
|
|
856
|
+
"firstPlaceholder": "Tu nombre",
|
|
857
|
+
"lastLabel": "Apellidos",
|
|
858
|
+
"lastPlaceholder": "Tus apellidos",
|
|
859
|
+
"saveFailed": "No se pudo guardar tu nombre"
|
|
860
|
+
},
|
|
852
861
|
"avatar": {
|
|
853
862
|
"title": "Hazlo tuyo",
|
|
854
863
|
"body": "Añade un avatar para que te reconozcan. Se mostrará donde aparezcas aquí. Puedes omitirlo — puedes añadirlo más tarde.",
|
package/src/index.ts
CHANGED
|
@@ -462,7 +462,7 @@ export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
|
|
|
462
462
|
// ---------------------------------------------------------------------------
|
|
463
463
|
// Cross-domain SSO infrastructure
|
|
464
464
|
// ---------------------------------------------------------------------------
|
|
465
|
-
export { autoDetectAuthWebUrl, registrableApex
|
|
465
|
+
export { autoDetectAuthWebUrl, registrableApex } from './utils/fapiAutoDetect';
|
|
466
466
|
|
|
467
467
|
// Central cross-domain SSO (opaque single-use code bounce via auth.oxy.so)
|
|
468
468
|
export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl';
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { AccountStorageUsageResponse, AssetUploadInput, AssetUrlResponse, AssetVariant, RNFileDescriptor } from '../models/interfaces';
|
|
2
2
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
3
|
+
import { isReactNative } from '../utils/platform';
|
|
4
|
+
|
|
5
|
+
interface FileDownloadUrlOptions {
|
|
6
|
+
/** Omit bearer access tokens from generated URLs, even when authenticated. */
|
|
7
|
+
omitToken?: boolean;
|
|
8
|
+
}
|
|
3
9
|
|
|
4
10
|
export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
5
11
|
return class extends Base {
|
|
@@ -44,8 +50,13 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
44
50
|
*
|
|
45
51
|
* For a CDN-signed URL fetched from the API, use {@link getFileDownloadUrlAsync}.
|
|
46
52
|
*/
|
|
47
|
-
getFileDownloadUrl(
|
|
48
|
-
|
|
53
|
+
getFileDownloadUrl(
|
|
54
|
+
fileId: string,
|
|
55
|
+
variant?: string,
|
|
56
|
+
expiresIn?: number,
|
|
57
|
+
options: FileDownloadUrlOptions = {}
|
|
58
|
+
): string {
|
|
59
|
+
const token = options.omitToken ? undefined : this.getClient().getAccessToken();
|
|
49
60
|
|
|
50
61
|
// Public case: no auth token and no expiry requested → clean CDN URL.
|
|
51
62
|
// CloudFront serves the public media origin under `${cloudURL}/<id>`.
|
|
@@ -212,10 +223,33 @@ export function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>(Base: T
|
|
|
212
223
|
} else if (typeof Blob !== 'undefined' && file instanceof Blob) {
|
|
213
224
|
formData.append('file', file, fileName);
|
|
214
225
|
} else if ('uri' in file && typeof (file as RNFileDescriptor).uri === 'string') {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
226
|
+
const descriptor = file as RNFileDescriptor;
|
|
227
|
+
|
|
228
|
+
if (isReactNative()) {
|
|
229
|
+
// React Native file descriptor — RN's FormData handles {uri, type, name} natively.
|
|
230
|
+
// It reads the file from disk during the multipart request — no in-JS Blob
|
|
231
|
+
// conversion (which would fail on Hermes for ArrayBuffer-backed Blobs).
|
|
232
|
+
formData.append('file', descriptor as unknown as Blob, fileName);
|
|
233
|
+
} else {
|
|
234
|
+
// Web (browser/Node): the browser's FormData cannot read bytes from a plain
|
|
235
|
+
// { uri } object — it would serialize "[object Object]" and the server would
|
|
236
|
+
// store a 0-byte asset. Materialize the uri into a real Blob first. `fetch`
|
|
237
|
+
// resolves blob:, data:, and http(s): uris on web, so all picker outputs work.
|
|
238
|
+
const res = await fetch(descriptor.uri);
|
|
239
|
+
if (!res.ok) {
|
|
240
|
+
throw new Error(`Failed to read file from uri (status ${res.status})`);
|
|
241
|
+
}
|
|
242
|
+
const fetched = await res.blob();
|
|
243
|
+
// Preserve the descriptor's declared MIME type when the fetched blob has none.
|
|
244
|
+
const blob =
|
|
245
|
+
fetched.type === '' && descriptor.type
|
|
246
|
+
? new Blob([fetched], { type: descriptor.type })
|
|
247
|
+
: fetched;
|
|
248
|
+
if (blob.size === 0) {
|
|
249
|
+
throw new Error('Cannot upload an empty file');
|
|
250
|
+
}
|
|
251
|
+
formData.append('file', blob, fileName);
|
|
252
|
+
}
|
|
219
253
|
} else {
|
|
220
254
|
throw new Error('Unsupported file input: expected File, Blob, or { uri, type?, name?, size? } descriptor');
|
|
221
255
|
}
|