@oxyhq/core 21.0.0 → 21.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +47 -8
- package/dist/cjs/i18n/locales/en-US.json +7 -2
- package/dist/cjs/i18n/locales/es-ES.json +7 -2
- package/dist/cjs/i18n/locales/locales/en-US.json +7 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +7 -2
- package/dist/cjs/index.js +8 -1
- package/dist/cjs/inference/OxyInferenceClient.js +330 -0
- package/dist/cjs/mixins/OxyServices.accounts.js +5 -72
- package/dist/cjs/mixins/OxyServices.inference.js +59 -0
- package/dist/cjs/mixins/OxyServices.utility.js +18 -6
- package/dist/cjs/mixins/index.js +6 -0
- package/dist/cjs/server/auth.js +76 -0
- package/dist/cjs/server/cors.js +84 -15
- package/dist/cjs/server/index.js +5 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +47 -8
- package/dist/esm/i18n/locales/en-US.json +7 -2
- package/dist/esm/i18n/locales/es-ES.json +7 -2
- package/dist/esm/i18n/locales/locales/en-US.json +7 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +7 -2
- package/dist/esm/index.js +4 -0
- package/dist/esm/inference/OxyInferenceClient.js +325 -0
- package/dist/esm/mixins/OxyServices.accounts.js +5 -72
- package/dist/esm/mixins/OxyServices.inference.js +56 -0
- package/dist/esm/mixins/OxyServices.utility.js +18 -6
- package/dist/esm/mixins/index.js +6 -0
- package/dist/esm/server/auth.js +72 -0
- package/dist/esm/server/cors.js +82 -15
- package/dist/esm/server/index.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +39 -1
- package/dist/types/index.d.ts +3 -1
- package/dist/types/inference/OxyInferenceClient.d.ts +324 -0
- package/dist/types/mixins/OxyServices.accounts.d.ts +73 -95
- package/dist/types/mixins/OxyServices.inference.d.ts +95 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +44 -13
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/server/auth.d.ts +80 -0
- package/dist/types/server/cors.d.ts +41 -0
- package/dist/types/server/index.d.ts +2 -2
- package/package.json +2 -2
- package/src/HttpService.ts +50 -10
- package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +115 -0
- package/src/i18n/locales/en-US.json +7 -2
- package/src/i18n/locales/es-ES.json +7 -2
- package/src/index.ts +19 -7
- package/src/inference/OxyInferenceClient.ts +590 -0
- package/src/inference/__tests__/OxyInferenceClient.test.ts +383 -0
- package/src/mixins/OxyServices.accounts.ts +75 -176
- package/src/mixins/OxyServices.inference.ts +57 -0
- package/src/mixins/OxyServices.utility.ts +58 -14
- package/src/mixins/__tests__/accounts.test.ts +57 -102
- package/src/mixins/__tests__/inferenceFactory.test.ts +58 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +2 -0
- package/src/mixins/index.ts +8 -0
- package/src/server/__tests__/cors.socket.test.ts +225 -0
- package/src/server/__tests__/serviceTokenAttribution.test.ts +396 -0
- package/src/server/auth.ts +118 -0
- package/src/server/cors.ts +87 -12
- package/src/server/index.ts +6 -0
- package/src/session/__tests__/accountDialogShape.test.ts +118 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* The inference API, reached with whatever bearer this session already holds
|
|
4
|
+
* (issue #972, workstream 15).
|
|
5
|
+
*
|
|
6
|
+
* ```typescript
|
|
7
|
+
* const models = await oxyServices.inference().listModels();
|
|
8
|
+
* ```
|
|
9
|
+
*
|
|
10
|
+
* One method, and it is a FACTORY rather than a set of inference methods on
|
|
11
|
+
* `OxyServices`. The calls themselves live once, in
|
|
12
|
+
* {@link OxyInferenceClient} — which an external developer holding only an
|
|
13
|
+
* `oxy_sk_…` machine key constructs directly, with no Oxy session anywhere in
|
|
14
|
+
* the picture. Declaring the same calls a second time here would give the
|
|
15
|
+
* ecosystem two spellings of one request, and only one of them would stay
|
|
16
|
+
* correct.
|
|
17
|
+
*
|
|
18
|
+
* This is the reasoning `createLinkedClient` is already built on: the plumbing
|
|
19
|
+
* that binds an Oxy bearer to a client belongs in core, once, rather than in
|
|
20
|
+
* each app.
|
|
21
|
+
*
|
|
22
|
+
* The credential is a FUNCTION, not the current token: a session bearer rotates
|
|
23
|
+
* on refresh and on account switch, and a client that captured one at
|
|
24
|
+
* construction would start answering 401 an hour into the process's life.
|
|
25
|
+
*/
|
|
26
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
+
exports.OxyServicesInferenceMixin = OxyServicesInferenceMixin;
|
|
28
|
+
const OxyInferenceClient_1 = require("../inference/OxyInferenceClient");
|
|
29
|
+
function OxyServicesInferenceMixin(Base) {
|
|
30
|
+
return class extends Base {
|
|
31
|
+
constructor() {
|
|
32
|
+
super(...arguments);
|
|
33
|
+
/** @internal Memoized so repeated calls return one object identity. */
|
|
34
|
+
this._inferenceClient = null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The inference client for this session.
|
|
38
|
+
*
|
|
39
|
+
* Bound to this instance's base URL and to `getAccessToken()`, so it
|
|
40
|
+
* follows every refresh, sign-in and account switch without being
|
|
41
|
+
* rebuilt.
|
|
42
|
+
*
|
|
43
|
+
* A service-authenticated process wants a different credential and
|
|
44
|
+
* builds {@link OxyInferenceClient} directly:
|
|
45
|
+
* `new OxyInferenceClient({ credential: () => oxy.getServiceToken() })`.
|
|
46
|
+
* The mint is asynchronous and cached, which is exactly what a
|
|
47
|
+
* credential function is for.
|
|
48
|
+
*/
|
|
49
|
+
inference() {
|
|
50
|
+
if (this._inferenceClient === null) {
|
|
51
|
+
this._inferenceClient = new OxyInferenceClient_1.OxyInferenceClient({
|
|
52
|
+
baseURL: this.getBaseURL(),
|
|
53
|
+
credential: () => this.getAccessToken(),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return this._inferenceClient;
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -152,7 +152,7 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
152
152
|
* additionally checked for `aud`, `iss`, and `type` claims to prevent
|
|
153
153
|
* cross-token-type confusion attacks.
|
|
154
154
|
* - The backend's own `authMiddleware` uses `jwt.verify()` because it has
|
|
155
|
-
* direct access to `
|
|
155
|
+
* direct access to `ACCESS_TOKEN_SECRET`.
|
|
156
156
|
*
|
|
157
157
|
* **Why session-less user tokens are refused rather than trusted:**
|
|
158
158
|
* every user access token the Oxy API issues carries a `sessionId` (see
|
|
@@ -184,7 +184,7 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
184
184
|
* const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
|
|
185
185
|
*
|
|
186
186
|
* // Protect all routes under /protected
|
|
187
|
-
* app.use('/protected', oxy.auth({ jwtSecret: process.env.
|
|
187
|
+
* app.use('/protected', oxy.auth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
|
|
188
188
|
*
|
|
189
189
|
* // Access user in route handler
|
|
190
190
|
* app.get('/protected/me', (req, res) => {
|
|
@@ -198,7 +198,7 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
198
198
|
* app.use('/public', oxy.auth({ optional: true }));
|
|
199
199
|
*
|
|
200
200
|
* // Require a specific scope on a service-token-protected route
|
|
201
|
-
* app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.
|
|
201
|
+
* app.use('/internal/files', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }), oxy.requireScope('files:write'));
|
|
202
202
|
* ```
|
|
203
203
|
*
|
|
204
204
|
* @param options Optional configuration
|
|
@@ -363,13 +363,19 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
363
363
|
return onError(error);
|
|
364
364
|
return res.status(401).json(error);
|
|
365
365
|
}
|
|
366
|
-
// Validate required service token fields
|
|
366
|
+
// Validate required service token fields. All of them are
|
|
367
|
+
// required, `ownerAccountId` included: an optional billing
|
|
368
|
+
// principal is one fallback away from being resolved from the
|
|
369
|
+
// delegated user, which is the exact confusion ADR 0007 forbids.
|
|
367
370
|
const appId = decoded.appId;
|
|
368
371
|
const credentialId = decoded.credentialId;
|
|
372
|
+
const ownerAccountId = decoded.ownerAccountId;
|
|
369
373
|
const environment = decoded.environment;
|
|
370
374
|
if (!appId ||
|
|
371
375
|
typeof credentialId !== 'string' ||
|
|
372
376
|
credentialId.length === 0 ||
|
|
377
|
+
typeof ownerAccountId !== 'string' ||
|
|
378
|
+
ownerAccountId.length === 0 ||
|
|
373
379
|
!isOxyServiceEnvironment(environment)) {
|
|
374
380
|
if (optional) {
|
|
375
381
|
req.userId = null;
|
|
@@ -408,6 +414,11 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
408
414
|
return onError(error);
|
|
409
415
|
return res.status(403).json(error);
|
|
410
416
|
}
|
|
417
|
+
// ATTRIBUTION ONLY. `req.userId` answers "on whose behalf", never
|
|
418
|
+
// "who pays": the billing principal stays `req.serviceApp
|
|
419
|
+
// .ownerAccountId`, which this branch does not touch. Read it
|
|
420
|
+
// through `getOxyBillingPrincipal` (`@oxyhq/core/server`), whose
|
|
421
|
+
// return type a user id cannot satisfy (ADR 0007).
|
|
411
422
|
req.userId = oxyUserId;
|
|
412
423
|
req.user = { id: oxyUserId };
|
|
413
424
|
req.serviceActingAs = { userId: oxyUserId, scopes: grant.scopes };
|
|
@@ -422,6 +433,7 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
422
433
|
appId,
|
|
423
434
|
appName: decoded.appName || 'unknown',
|
|
424
435
|
credentialId,
|
|
436
|
+
ownerAccountId,
|
|
425
437
|
scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
|
|
426
438
|
environment,
|
|
427
439
|
};
|
|
@@ -738,7 +750,7 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
738
750
|
* @example
|
|
739
751
|
* ```typescript
|
|
740
752
|
* // Protect internal endpoints
|
|
741
|
-
* app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.
|
|
753
|
+
* app.use('/internal', oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }));
|
|
742
754
|
*
|
|
743
755
|
* app.post('/internal/trigger', (req, res) => {
|
|
744
756
|
* console.log('Service app:', req.serviceApp);
|
|
@@ -776,7 +788,7 @@ function OxyServicesUtilityMixin(Base) {
|
|
|
776
788
|
* ```typescript
|
|
777
789
|
* app.use(
|
|
778
790
|
* '/internal/files',
|
|
779
|
-
* oxy.serviceAuth({ jwtSecret: process.env.
|
|
791
|
+
* oxy.serviceAuth({ jwtSecret: process.env.ACCESS_TOKEN_SECRET }),
|
|
780
792
|
* oxy.requireScope('files:write'),
|
|
781
793
|
* );
|
|
782
794
|
* ```
|
package/dist/cjs/mixins/index.js
CHANGED
|
@@ -36,6 +36,7 @@ const OxyServices_chains_1 = require("./OxyServices.chains");
|
|
|
36
36
|
const OxyServices_nodes_1 = require("./OxyServices.nodes");
|
|
37
37
|
const OxyServices_links_1 = require("./OxyServices.links");
|
|
38
38
|
const OxyServices_followGraph_1 = require("./OxyServices.followGraph");
|
|
39
|
+
const OxyServices_inference_1 = require("./OxyServices.inference");
|
|
39
40
|
const OxyServices_deviceBoot_1 = require("./OxyServices.deviceBoot");
|
|
40
41
|
const OxyServices_deviceTransfer_1 = require("./OxyServices.deviceTransfer");
|
|
41
42
|
/**
|
|
@@ -100,6 +101,11 @@ const MIXIN_PIPELINE = [
|
|
|
100
101
|
// The user-owned follow graph (#809). One relationship per user and target,
|
|
101
102
|
// shared across applications, with per-application context on top.
|
|
102
103
|
OxyServices_followGraph_1.OxyServicesFollowGraphMixin,
|
|
104
|
+
// The inference model catalogue (#972). Reads only, and deliberately no
|
|
105
|
+
// request/stream/receipt methods — the public inference edge those would
|
|
106
|
+
// call is workstream 4 and does not exist yet. See
|
|
107
|
+
// `docs/inference/README.md` for what is and is not built.
|
|
108
|
+
OxyServices_inference_1.OxyServicesInferenceMixin,
|
|
103
109
|
// Device-first token mint: the client half of the zero-cookie transport
|
|
104
110
|
// (`mintFromDeviceSecret` → `POST /session/device/token`).
|
|
105
111
|
OxyServices_deviceBoot_1.OxyServicesDeviceBootMixin,
|
package/dist/cjs/server/auth.js
CHANGED
|
@@ -3,6 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.OXY_SERVICE_ENVIRONMENTS = void 0;
|
|
4
4
|
exports.getOxyUserId = getOxyUserId;
|
|
5
5
|
exports.isOxyAuthenticated = isOxyAuthenticated;
|
|
6
|
+
exports.getOxyBillingPrincipal = getOxyBillingPrincipal;
|
|
7
|
+
exports.getRequiredOxyBillingPrincipal = getRequiredOxyBillingPrincipal;
|
|
8
|
+
exports.getOxyDelegatedUserId = getOxyDelegatedUserId;
|
|
9
|
+
exports.getOxyRequestAttribution = getOxyRequestAttribution;
|
|
6
10
|
exports.getRequiredOxyUserId = getRequiredOxyUserId;
|
|
7
11
|
exports.requireOxyAuth = requireOxyAuth;
|
|
8
12
|
exports.createOptionalOxyAuth = createOptionalOxyAuth;
|
|
@@ -36,6 +40,78 @@ function getOxyUserId(req) {
|
|
|
36
40
|
function isOxyAuthenticated(req) {
|
|
37
41
|
return getOxyUserId(req) !== null;
|
|
38
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The billing principal of a request, or `null` when the request carries no
|
|
45
|
+
* verified service principal (an ordinary user session is not a billable
|
|
46
|
+
* machine principal — its account is resolved from the account graph, not from
|
|
47
|
+
* a token claim).
|
|
48
|
+
*
|
|
49
|
+
* Reads `req.serviceApp` and NOTHING else: not `req.userId`, not `req.user`,
|
|
50
|
+
* not `req.serviceActingAs`. That exclusivity is the invariant this function
|
|
51
|
+
* exists to hold, and `serviceTokenAttribution.test.ts` mutation-tests it.
|
|
52
|
+
*
|
|
53
|
+
* **It answers for the SERVICE-TOKEN lane only.** The API's machine-credential
|
|
54
|
+
* lane (`oxy_sk_*`, issue #972 §2.3) resolves the same five facts into its own
|
|
55
|
+
* `req.machineCredential`, deliberately never `req.serviceApp` — populating the
|
|
56
|
+
* latter would hand a self-serve third-party credential the lane that only
|
|
57
|
+
* platform-trusted applications may enter. So a machine-credential request has
|
|
58
|
+
* no billing principal HERE and resolves `null`, which fails closed: the caller
|
|
59
|
+
* must handle it, and `getRequiredOxyBillingPrincipal` throws rather than
|
|
60
|
+
* charging anyone. One accessor answering for both lanes belongs to the public
|
|
61
|
+
* inference edge that has to admit both, and it needs the machine principal's
|
|
62
|
+
* shape to move into this package first.
|
|
63
|
+
*/
|
|
64
|
+
function getOxyBillingPrincipal(req) {
|
|
65
|
+
const serviceApp = req.serviceApp;
|
|
66
|
+
if (!serviceApp) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const accountId = normalizeId(serviceApp.ownerAccountId);
|
|
70
|
+
const applicationId = normalizeId(serviceApp.appId);
|
|
71
|
+
const credentialId = normalizeId(serviceApp.credentialId);
|
|
72
|
+
if (!accountId || !applicationId || !credentialId) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
accountId,
|
|
77
|
+
applicationId,
|
|
78
|
+
credentialId,
|
|
79
|
+
environment: serviceApp.environment,
|
|
80
|
+
scopes: serviceApp.scopes,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* {@link getOxyBillingPrincipal}, throwing when the request has none. Use on
|
|
85
|
+
* routes that have already required a service token.
|
|
86
|
+
*/
|
|
87
|
+
function getRequiredOxyBillingPrincipal(req) {
|
|
88
|
+
const principal = getOxyBillingPrincipal(req);
|
|
89
|
+
if (!principal) {
|
|
90
|
+
throw new Error('Request has no verified Oxy service principal');
|
|
91
|
+
}
|
|
92
|
+
return principal;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The delegated end user of a service request, or `null`.
|
|
96
|
+
*
|
|
97
|
+
* Deliberately reads `req.serviceActingAs` — the grant-verified delegation —
|
|
98
|
+
* and not `req.userId`, which on a non-service request is the caller's own
|
|
99
|
+
* session identity and is not a delegation at all.
|
|
100
|
+
*/
|
|
101
|
+
function getOxyDelegatedUserId(req) {
|
|
102
|
+
return normalizeId(req.serviceActingAs?.userId);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The whole attribution tuple for a service request: who pays, which
|
|
106
|
+
* application and credential, and optionally on whose behalf.
|
|
107
|
+
*/
|
|
108
|
+
function getOxyRequestAttribution(req) {
|
|
109
|
+
const principal = getOxyBillingPrincipal(req);
|
|
110
|
+
if (!principal) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return { ...principal, delegatedUserId: getOxyDelegatedUserId(req) };
|
|
114
|
+
}
|
|
39
115
|
function getRequiredOxyUserId(req) {
|
|
40
116
|
const userId = getOxyUserId(req);
|
|
41
117
|
if (!userId) {
|
package/dist/cjs/server/cors.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* one-label subdomains such as `auth.oxy.so`, `api.oxy.so`,
|
|
17
17
|
* `accounts.oxy.so`, `console.oxy.so`, and `inbox.oxy.so`,
|
|
18
18
|
* - allows the caller's explicit `appOrigins`,
|
|
19
|
+
* - REFUSES the opaque origin on both sides (see `OPAQUE_ORIGIN`),
|
|
19
20
|
* - DENIES everything else (no reflection, never a wildcard with credentials),
|
|
20
21
|
* - echoes back the EXACT matched origin (so credentialed requests work) and
|
|
21
22
|
* sets `Vary: Origin` for correct caching,
|
|
@@ -24,8 +25,12 @@
|
|
|
24
25
|
* Node/Express-only: exported solely from `@oxyhq/core/server`.
|
|
25
26
|
*/
|
|
26
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.normalizeAppOrigins = normalizeAppOrigins;
|
|
29
|
+
exports.matchesAllowedOrigin = matchesAllowedOrigin;
|
|
27
30
|
exports.createOxyCors = createOxyCors;
|
|
31
|
+
const logger_1 = require("../logger");
|
|
28
32
|
const authWebUrl_1 = require("../utils/authWebUrl");
|
|
33
|
+
const log = (0, logger_1.createLogger)('OxyCors');
|
|
29
34
|
/** Default HTTP methods allowed across origins. */
|
|
30
35
|
const DEFAULT_ALLOWED_METHODS = ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'];
|
|
31
36
|
/** Default request headers a browser may send on a credentialed cross-origin call. */
|
|
@@ -64,6 +69,29 @@ function isOxyFamilyOrigin(candidate) {
|
|
|
64
69
|
return false;
|
|
65
70
|
}
|
|
66
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The URL standard's serialization of an OPAQUE origin: the literal string
|
|
74
|
+
* `"null"`, which `new URL(x).origin` returns for every scheme that has no
|
|
75
|
+
* origin to speak of — `exp:`, `capacitor:`, `chrome-extension:`,
|
|
76
|
+
* `vscode-webview:`, and also `file:`, `data:` and `about:`.
|
|
77
|
+
*
|
|
78
|
+
* This value is why an allowlist may never store it. Every such scheme
|
|
79
|
+
* normalizes to the SAME `"null"`, so a set built by normalization cannot tell
|
|
80
|
+
* them apart: ONE opaque entry admits ALL of them. With credentials on and the
|
|
81
|
+
* raw header echoed back, a single `myapp://` in `appOrigins` turned this
|
|
82
|
+
* helper into "allow any custom-scheme browsing context" — measured live, an
|
|
83
|
+
* `exp://localhost:8150` entry answered `Origin: vscode-webview://…` with
|
|
84
|
+
* `access-control-allow-origin: vscode-webview://…` and
|
|
85
|
+
* `access-control-allow-credentials: true`.
|
|
86
|
+
*
|
|
87
|
+
* There is deliberately no escape hatch that matches such an origin by raw
|
|
88
|
+
* string instead. Admitting a custom-scheme browsing context to a CREDENTIALED
|
|
89
|
+
* allowlist is a distinct decision with its own threat model, and it must not
|
|
90
|
+
* arrive as a side effect of someone adding one line to `appOrigins`. Note
|
|
91
|
+
* also that a native client is not subject to CORS at all — React Native sends
|
|
92
|
+
* no `Origin` header — so a mobile app never needs an entry here.
|
|
93
|
+
*/
|
|
94
|
+
const OPAQUE_ORIGIN = 'null';
|
|
67
95
|
/** Normalize a raw origin string to its canonical `scheme://host[:port]` form. */
|
|
68
96
|
function normalizeOrigin(raw) {
|
|
69
97
|
try {
|
|
@@ -74,24 +102,65 @@ function normalizeOrigin(raw) {
|
|
|
74
102
|
}
|
|
75
103
|
}
|
|
76
104
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
105
|
+
* Normalize the configured `appOrigins` into the exact-match set — the
|
|
106
|
+
* CONFIGURE-SIDE half of the opaque-origin guard.
|
|
107
|
+
*
|
|
108
|
+
* An entry that is not a URL, or whose origin is opaque, is dropped and named
|
|
109
|
+
* in an error log. Dropped rather than thrown on because `appOrigins` is
|
|
110
|
+
* deployment configuration — at least one Oxy backend reads it from the
|
|
111
|
+
* environment — and a typo there must cost that one origin its CORS headers,
|
|
112
|
+
* never the whole service its boot. Both failure modes are equally SAFE (the
|
|
113
|
+
* entry is absent from the set either way), so the choice is purely about
|
|
114
|
+
* blast radius, and dropping keeps it to one origin whose requests then fail
|
|
115
|
+
* visibly in the browser.
|
|
116
|
+
*
|
|
117
|
+
* Exported for `__tests__/cors.socket.test.ts` and NOT re-exported from
|
|
118
|
+
* `server/index.ts`, so it is not part of the package's public surface. The
|
|
119
|
+
* two halves of the guard are separately exported because they are separately
|
|
120
|
+
* testable only that way: with this half in place the match-side half is
|
|
121
|
+
* unreachable through `createOxyCors`, so a test driving the public API alone
|
|
122
|
+
* would measure this function twice and the other one never.
|
|
79
123
|
*/
|
|
80
|
-
function
|
|
124
|
+
function normalizeAppOrigins(appOrigins) {
|
|
81
125
|
const explicit = new Set();
|
|
82
126
|
for (const raw of appOrigins) {
|
|
83
127
|
const normalized = normalizeOrigin(raw);
|
|
84
|
-
if (normalized)
|
|
85
|
-
|
|
128
|
+
if (normalized === null) {
|
|
129
|
+
log.error('CORS allowlist entry ignored: it is not a URL', undefined, { entry: raw });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (normalized === OPAQUE_ORIGIN) {
|
|
133
|
+
log.error('CORS allowlist entry ignored: it has no origin to match against', undefined, {
|
|
134
|
+
entry: raw,
|
|
135
|
+
});
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
explicit.add(normalized);
|
|
86
139
|
}
|
|
87
|
-
return
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
140
|
+
return explicit;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Whether `origin` may be echoed back: it is in the built-in HTTPS Oxy apex
|
|
144
|
+
* family, or it exactly matches one of the configured app origins.
|
|
145
|
+
*
|
|
146
|
+
* The opaque-origin refusal here is the MATCH-SIDE half of the guard, and it
|
|
147
|
+
* is what makes the property hold regardless of how `explicit` was built — a
|
|
148
|
+
* set that somehow contains `"null"` still matches nothing, because no
|
|
149
|
+
* incoming origin ever normalizes past this line. `normalizeAppOrigins` is
|
|
150
|
+
* what stops such a set existing today; this is what stops it mattering.
|
|
151
|
+
*
|
|
152
|
+
* Exported for the same reason as `normalizeAppOrigins`, and likewise absent
|
|
153
|
+
* from `server/index.ts`.
|
|
154
|
+
*/
|
|
155
|
+
function matchesAllowedOrigin(explicit, origin) {
|
|
156
|
+
const normalized = normalizeOrigin(origin);
|
|
157
|
+
if (normalized === null)
|
|
158
|
+
return false;
|
|
159
|
+
if (normalized === OPAQUE_ORIGIN)
|
|
160
|
+
return false;
|
|
161
|
+
if (explicit.has(normalized))
|
|
162
|
+
return true;
|
|
163
|
+
return isOxyFamilyOrigin(normalized);
|
|
95
164
|
}
|
|
96
165
|
/**
|
|
97
166
|
* Create a strict Oxy CORS middleware. See module docs.
|
|
@@ -103,7 +172,7 @@ function buildOriginAllowed(appOrigins) {
|
|
|
103
172
|
*/
|
|
104
173
|
function createOxyCors(options = {}) {
|
|
105
174
|
const { appOrigins = [], allowCredentials = true, methods = DEFAULT_ALLOWED_METHODS, allowedHeaders = DEFAULT_ALLOWED_HEADERS, exposedHeaders = [], maxAgeSeconds = DEFAULT_MAX_AGE_SECONDS, } = options;
|
|
106
|
-
const
|
|
175
|
+
const explicitOrigins = normalizeAppOrigins(appOrigins);
|
|
107
176
|
const methodsHeader = methods.join(', ');
|
|
108
177
|
const allowedHeadersHeader = allowedHeaders.join(', ');
|
|
109
178
|
const exposedHeadersHeader = exposedHeaders.join(', ');
|
|
@@ -121,7 +190,7 @@ function createOxyCors(options = {}) {
|
|
|
121
190
|
}
|
|
122
191
|
// Origin is present. Caching correctness: this response varies by Origin.
|
|
123
192
|
res.setHeader('Vary', 'Origin');
|
|
124
|
-
if (!
|
|
193
|
+
if (!matchesAllowedOrigin(explicitOrigins, origin)) {
|
|
125
194
|
// DENY: do NOT reflect the origin, do NOT emit a wildcard. The browser
|
|
126
195
|
// will block the cross-origin read. Preflights for denied origins get a
|
|
127
196
|
// 204 with no CORS headers (the actual request then fails CORS).
|
package/dist/cjs/server/index.js
CHANGED
|
@@ -16,11 +16,15 @@
|
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
-
exports.isOfficialWebOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.verifySecret = exports.OXY_CSP_BASELINE = exports.formatOxyCspPolicy = exports.createOxySecurityHeaders = exports.buildOxyPagesHeaders = exports.buildOxyCspDirectives = exports.createOxyCors = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.createOxyRateLimit = exports.OXY_SERVICE_ENVIRONMENTS = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getOxyUserId = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
|
|
19
|
+
exports.isOfficialWebOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.verifySecret = exports.OXY_CSP_BASELINE = exports.formatOxyCspPolicy = exports.createOxySecurityHeaders = exports.buildOxyPagesHeaders = exports.buildOxyCspDirectives = exports.createOxyCors = exports.UPSTREAM_HEADERS_TIMEOUT_MS = exports.MAX_URL_LENGTH = exports.MAX_REDIRECTS = exports.DEFAULT_USER_AGENT = exports.BLOCKED_HOSTNAMES = exports.ALLOWED_PROTOCOLS = exports.ALLOWED_PORTS = exports.UpstreamError = exports.SsrfRejection = exports.safeFetch = exports.isBlockedIp = exports.assertSafePublicUrl = exports.createOxyRateLimit = exports.OXY_SERVICE_ENVIRONMENTS = exports.requireOxyAuth = exports.isOxyAuthenticated = exports.getRequiredOxyUserId = exports.getRequiredOxyBillingPrincipal = exports.getOxyUserId = exports.getOxyRequestAttribution = exports.getOxyDelegatedUserId = exports.getOxyBillingPrincipal = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = void 0;
|
|
20
20
|
var auth_1 = require("./auth");
|
|
21
21
|
Object.defineProperty(exports, "createOptionalOxyAuth", { enumerable: true, get: function () { return auth_1.createOptionalOxyAuth; } });
|
|
22
22
|
Object.defineProperty(exports, "createOxyAuthMiddleware", { enumerable: true, get: function () { return auth_1.createOxyAuthMiddleware; } });
|
|
23
|
+
Object.defineProperty(exports, "getOxyBillingPrincipal", { enumerable: true, get: function () { return auth_1.getOxyBillingPrincipal; } });
|
|
24
|
+
Object.defineProperty(exports, "getOxyDelegatedUserId", { enumerable: true, get: function () { return auth_1.getOxyDelegatedUserId; } });
|
|
25
|
+
Object.defineProperty(exports, "getOxyRequestAttribution", { enumerable: true, get: function () { return auth_1.getOxyRequestAttribution; } });
|
|
23
26
|
Object.defineProperty(exports, "getOxyUserId", { enumerable: true, get: function () { return auth_1.getOxyUserId; } });
|
|
27
|
+
Object.defineProperty(exports, "getRequiredOxyBillingPrincipal", { enumerable: true, get: function () { return auth_1.getRequiredOxyBillingPrincipal; } });
|
|
24
28
|
Object.defineProperty(exports, "getRequiredOxyUserId", { enumerable: true, get: function () { return auth_1.getRequiredOxyUserId; } });
|
|
25
29
|
Object.defineProperty(exports, "isOxyAuthenticated", { enumerable: true, get: function () { return auth_1.isOxyAuthenticated; } });
|
|
26
30
|
Object.defineProperty(exports, "requireOxyAuth", { enumerable: true, get: function () { return auth_1.requireOxyAuth; } });
|