@oxy.so/core 4.8.0 → 4.9.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/HttpService.js +34 -15
- package/dist/cjs/server/OxyServer.js +43 -16
- package/dist/cjs/server/index.js +3 -2
- package/dist/cjs/server/workloadIdentity.js +6 -1
- package/dist/cjs/utils/corsPreflight.js +48 -0
- package/dist/esm/HttpService.js +34 -15
- package/dist/esm/server/OxyServer.js +42 -15
- package/dist/esm/server/index.js +1 -1
- package/dist/esm/server/workloadIdentity.js +6 -1
- package/dist/esm/utils/corsPreflight.js +45 -0
- package/dist/types/api/safety.d.ts +1 -1
- package/dist/types/server/OxyServer.d.ts +17 -1
- package/dist/types/server/index.d.ts +2 -2
- package/dist/types/server/requesterAssertion.d.ts +2 -2
- package/dist/types/server/workloadIdentity.d.ts +5 -0
- package/dist/types/utils/corsPreflight.d.ts +19 -0
- package/package.json +1 -1
- package/src/HttpService.ts +31 -12
- package/src/server/OxyServer.ts +56 -15
- package/src/server/index.ts +2 -1
- package/src/server/workloadIdentity.ts +11 -1
- package/src/utils/corsPreflight.ts +49 -0
package/dist/cjs/HttpService.js
CHANGED
|
@@ -26,6 +26,7 @@ const redactUrl_1 = require("./utils/redactUrl");
|
|
|
26
26
|
const OxyServices_errors_1 = require("./OxyServices.errors");
|
|
27
27
|
const edgeRegion_1 = require("./utils/edgeRegion");
|
|
28
28
|
const activityId_1 = require("./utils/activityId");
|
|
29
|
+
const corsPreflight_1 = require("./utils/corsPreflight");
|
|
29
30
|
const slashes_1 = require("./utils/slashes");
|
|
30
31
|
/** Comparison only fences local async work; it never validates token authority. */
|
|
31
32
|
function sameTokenSession(left, right) {
|
|
@@ -508,12 +509,17 @@ class HttpService {
|
|
|
508
509
|
const headers = {
|
|
509
510
|
Accept: 'application/json',
|
|
510
511
|
};
|
|
511
|
-
//
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
512
|
+
// Content-Type describes a body, so a GET (which never sends one) gets
|
|
513
|
+
// none: `application/json` is not a CORS-safelisted type, and on a GET it
|
|
514
|
+
// only made the browser preflight a request with nothing to describe.
|
|
515
|
+
// FormData sets its own multipart type with the boundary.
|
|
516
|
+
if (method !== 'GET') {
|
|
517
|
+
if (isUrlEncoded) {
|
|
518
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
|
519
|
+
}
|
|
520
|
+
else if (!isFormData) {
|
|
521
|
+
headers['Content-Type'] = 'application/json';
|
|
522
|
+
}
|
|
517
523
|
}
|
|
518
524
|
// Add authorization header if available
|
|
519
525
|
if (authHeader) {
|
|
@@ -533,9 +539,14 @@ class HttpService {
|
|
|
533
539
|
}
|
|
534
540
|
// This is Cloudflare's coarse serving PoP (for example `mad`), never an
|
|
535
541
|
// IP or coordinate. Set it after caller headers so request code cannot
|
|
536
|
-
// accidentally or deliberately substitute a different origin.
|
|
537
|
-
|
|
538
|
-
|
|
542
|
+
// accidentally or deliberately substitute a different origin. Both are
|
|
543
|
+
// advisory telemetry, so they ride only on a request the browser
|
|
544
|
+
// preflights anyway: on an anonymous GET they were the only thing that
|
|
545
|
+
// made it non-simple, and every distinct URL paid an OPTIONS round trip.
|
|
546
|
+
if ((0, corsPreflight_1.requiresCorsPreflight)(method, Object.entries(headers))) {
|
|
547
|
+
Object.assign(headers, edgeRegionHeader);
|
|
548
|
+
Object.assign(headers, activityIdHeader);
|
|
549
|
+
}
|
|
539
550
|
// `URLSearchParams` is serialised explicitly rather than handed to
|
|
540
551
|
// `fetch` as-is: RN's fetch does not consistently encode it, and doing
|
|
541
552
|
// it here keeps the body identical across every platform.
|
|
@@ -1307,12 +1318,20 @@ class HttpService {
|
|
|
1307
1318
|
headers.delete(name);
|
|
1308
1319
|
if (authHeader)
|
|
1309
1320
|
headers.set('Authorization', authHeader);
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1321
|
+
// Telemetry rides only on a request the browser preflights anyway — see
|
|
1322
|
+
// `request()`.
|
|
1323
|
+
const sentHeaders = [];
|
|
1324
|
+
headers.forEach((value, name) => {
|
|
1325
|
+
sentHeaders.push([name, value]);
|
|
1326
|
+
});
|
|
1327
|
+
if ((0, corsPreflight_1.requiresCorsPreflight)(config.method, sentHeaders)) {
|
|
1328
|
+
const edgeRegionHeader = (0, edgeRegion_1.peekBrowserEdgeRegionHeader)();
|
|
1329
|
+
for (const [name, value] of Object.entries(edgeRegionHeader))
|
|
1330
|
+
headers.set(name, value);
|
|
1331
|
+
const activityIdHeader = (0, activityId_1.getBrowserActivityIdHeader)();
|
|
1332
|
+
for (const [name, value] of Object.entries(activityIdHeader))
|
|
1333
|
+
headers.set(name, value);
|
|
1334
|
+
}
|
|
1316
1335
|
try {
|
|
1317
1336
|
const body = config.method === 'GET' ? undefined : config.body;
|
|
1318
1337
|
const init = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.OxyServer = exports.ServiceCredentialMismatchError = exports.ANONYMOUS_SERVICE_TOKEN_RETRY_MS = void 0;
|
|
3
|
+
exports.OxyServer = exports.DEFAULT_SERVICE_TOKEN_AUDIENCE = exports.ServiceCredentialMismatchError = exports.ANONYMOUS_SERVICE_TOKEN_RETRY_MS = void 0;
|
|
4
4
|
const contracts_1 = require("@oxy.so/contracts");
|
|
5
5
|
/**
|
|
6
6
|
* `OxyServer` — the Oxy client for a backend.
|
|
@@ -67,6 +67,12 @@ class ServiceCredentialMismatchError extends Error {
|
|
|
67
67
|
}
|
|
68
68
|
exports.ServiceCredentialMismatchError = ServiceCredentialMismatchError;
|
|
69
69
|
const WORKLOAD_CACHE_KEY = 'workload-identity';
|
|
70
|
+
/** What `serviceToken()` mints, and what Oxy assumes when no audience is sent. */
|
|
71
|
+
exports.DEFAULT_SERVICE_TOKEN_AUDIENCE = 'oxy-api';
|
|
72
|
+
/** Tokens of different audiences never share a cache entry; the default keeps its old key. */
|
|
73
|
+
function audienceCacheKey(audience, key) {
|
|
74
|
+
return audience === exports.DEFAULT_SERVICE_TOKEN_AUDIENCE ? key : `${audience}\u0000${key}`;
|
|
75
|
+
}
|
|
70
76
|
/** A token is reused until 60s before expiry (clock drift). */
|
|
71
77
|
const TOKEN_REUSE_MARGIN_MS = 60000;
|
|
72
78
|
/** Bound on remembered `appId:userId` delegation answers. */
|
|
@@ -202,19 +208,28 @@ class OxyServer extends OxyServices_1.OxyServices {
|
|
|
202
208
|
* {@link ServiceCredentialMismatchError} rather than return the cached token.
|
|
203
209
|
*/
|
|
204
210
|
async serviceToken(apiKey, apiSecret) {
|
|
211
|
+
return this.serviceTokenFor(exports.DEFAULT_SERVICE_TOKEN_AUDIENCE, apiKey, apiSecret);
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* A service token for `audience` — e.g. `kaana.ai` for https://kaana.ai/v1.
|
|
215
|
+
* Minted, cached and refreshed exactly as {@link serviceToken} (which is this
|
|
216
|
+
* with `oxy-api`), from the same credential or attested workload identity;
|
|
217
|
+
* each audience has its own cache entry, so one never answers for another.
|
|
218
|
+
*/
|
|
219
|
+
async serviceTokenFor(audience, apiKey, apiSecret) {
|
|
205
220
|
const key = apiKey || this.serviceApiKey;
|
|
206
221
|
const secret = apiSecret || this.serviceApiSecret;
|
|
207
222
|
if (!key || !secret) {
|
|
208
223
|
// No credential — prove what this process IS instead. The fallback, not the
|
|
209
224
|
// preference: a deployment that still has a credential keeps using it.
|
|
210
225
|
if ((0, workloadIdentity_1.canAttestWorkloadIdentity)()) {
|
|
211
|
-
return this.workloadServiceToken();
|
|
226
|
+
return this.workloadServiceToken(audience);
|
|
212
227
|
}
|
|
213
228
|
throw new Error('Service credentials not provided. Pass serviceAuth, call configureServiceAuth(), or pass apiKey and apiSecret.');
|
|
214
229
|
}
|
|
215
230
|
// The apiKey is the credential's PUBLIC id (`oxy_dk_…`, the OAuth
|
|
216
231
|
// client_id), so it keys the in-memory cache as is; the secret never does.
|
|
217
|
-
const cacheKey = key;
|
|
232
|
+
const cacheKey = audienceCacheKey(audience, key);
|
|
218
233
|
const now = Date.now();
|
|
219
234
|
const providedSecretBuf = Buffer.from(secret, 'utf8');
|
|
220
235
|
let entry = this.serviceTokens.get(cacheKey);
|
|
@@ -247,7 +262,7 @@ class OxyServer extends OxyServices_1.OxyServices {
|
|
|
247
262
|
entry = { token: '', expiresAt: 0, secretBuf: providedSecretBuf, pending: null, apiKey: key };
|
|
248
263
|
this.serviceTokens.set(cacheKey, entry);
|
|
249
264
|
}
|
|
250
|
-
const pending = this.mintServiceToken(key, secret, cacheKey, providedSecretBuf);
|
|
265
|
+
const pending = this.mintServiceToken(key, secret, cacheKey, providedSecretBuf, audience);
|
|
251
266
|
entry.pending = pending;
|
|
252
267
|
try {
|
|
253
268
|
return await pending;
|
|
@@ -280,7 +295,11 @@ class OxyServer extends OxyServices_1.OxyServices {
|
|
|
280
295
|
this.serviceTokens.clear();
|
|
281
296
|
return;
|
|
282
297
|
}
|
|
283
|
-
|
|
298
|
+
// Every audience minted from that credential goes with it.
|
|
299
|
+
for (const [cacheKey, entry] of this.serviceTokens) {
|
|
300
|
+
if (entry.apiKey === targetKey)
|
|
301
|
+
this.serviceTokens.delete(cacheKey);
|
|
302
|
+
}
|
|
284
303
|
}
|
|
285
304
|
/**
|
|
286
305
|
* One request with this service's token. `actAs` sends `X-Oxy-User-Id`: the
|
|
@@ -406,8 +425,12 @@ class OxyServer extends OxyServices_1.OxyServices {
|
|
|
406
425
|
canMintServiceToken() {
|
|
407
426
|
return Boolean(this.serviceApiKey && this.serviceApiSecret) || (0, workloadIdentity_1.canAttestWorkloadIdentity)();
|
|
408
427
|
}
|
|
409
|
-
async mintServiceToken(key, secret, cacheKey, secretBuf) {
|
|
410
|
-
const response = await this.request('POST', '/auth/service-token', {
|
|
428
|
+
async mintServiceToken(key, secret, cacheKey, secretBuf, audience) {
|
|
429
|
+
const response = await this.request('POST', '/auth/service-token', {
|
|
430
|
+
apiKey: key,
|
|
431
|
+
apiSecret: secret,
|
|
432
|
+
...(audience === exports.DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }),
|
|
433
|
+
}, { cache: false, retry: false, skipAuth: true });
|
|
411
434
|
const expiresAt = Date.now() + response.expiresIn * 1000;
|
|
412
435
|
const entry = this.serviceTokens.get(cacheKey);
|
|
413
436
|
if (entry) {
|
|
@@ -426,9 +449,10 @@ class OxyServer extends OxyServices_1.OxyServices {
|
|
|
426
449
|
}
|
|
427
450
|
return response.token;
|
|
428
451
|
}
|
|
429
|
-
/** A token obtained by attestation, cached like a credential's (one identity per process). */
|
|
430
|
-
async workloadServiceToken() {
|
|
431
|
-
const
|
|
452
|
+
/** A token obtained by attestation, cached like a credential's (one identity per process, one entry per audience). */
|
|
453
|
+
async workloadServiceToken(audience) {
|
|
454
|
+
const cacheKey = audienceCacheKey(audience, WORKLOAD_CACHE_KEY);
|
|
455
|
+
const entry = this.serviceTokens.get(cacheKey);
|
|
432
456
|
if (entry?.token &&
|
|
433
457
|
reusableServiceToken(entry.token) &&
|
|
434
458
|
entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS)
|
|
@@ -442,10 +466,13 @@ class OxyServer extends OxyServices_1.OxyServices {
|
|
|
442
466
|
pending: null,
|
|
443
467
|
apiKey: WORKLOAD_CACHE_KEY,
|
|
444
468
|
};
|
|
445
|
-
this.serviceTokens.set(
|
|
469
|
+
this.serviceTokens.set(cacheKey, seeded);
|
|
446
470
|
const pending = (async () => {
|
|
447
|
-
const granted = await (0, workloadIdentity_1.requestWorkloadServiceToken)({
|
|
448
|
-
|
|
471
|
+
const granted = await (0, workloadIdentity_1.requestWorkloadServiceToken)({
|
|
472
|
+
baseUrl: this.baseURL,
|
|
473
|
+
...(audience === exports.DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }),
|
|
474
|
+
});
|
|
475
|
+
const current = this.serviceTokens.get(cacheKey);
|
|
449
476
|
if (current) {
|
|
450
477
|
current.token = granted.token;
|
|
451
478
|
current.expiresAt = Date.now() + granted.expiresIn * 1000;
|
|
@@ -457,13 +484,13 @@ class OxyServer extends OxyServices_1.OxyServices {
|
|
|
457
484
|
return await pending;
|
|
458
485
|
}
|
|
459
486
|
catch (error) {
|
|
460
|
-
const failed = this.serviceTokens.get(
|
|
487
|
+
const failed = this.serviceTokens.get(cacheKey);
|
|
461
488
|
if (failed?.pending === pending && !failed.token)
|
|
462
|
-
this.serviceTokens.delete(
|
|
489
|
+
this.serviceTokens.delete(cacheKey);
|
|
463
490
|
throw error;
|
|
464
491
|
}
|
|
465
492
|
finally {
|
|
466
|
-
const settled = this.serviceTokens.get(
|
|
493
|
+
const settled = this.serviceTokens.get(cacheKey);
|
|
467
494
|
if (settled?.pending === pending)
|
|
468
495
|
settled.pending = null;
|
|
469
496
|
}
|
package/dist/cjs/server/index.js
CHANGED
|
@@ -16,14 +16,15 @@
|
|
|
16
16
|
* ```
|
|
17
17
|
*/
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
-
exports.
|
|
20
|
-
exports.signInAgentAccount = exports.hasBoundedServiceTokenLifetime = exports.OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = exports.requestWorkloadServiceToken = exports.canAttestWorkloadIdentity = exports.attestWorkloadIdentity = exports.withoutNodeHttpObservation = exports.observeNodeHttp = exports.createEcosystemTraffic = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.createLiveCapabilityTicketVerifier = exports.verifySecret = exports.verifyOxyRequesterAssertion = exports.signOxyRequesterAssertion = exports.readOxyRequesterAssertionKeyId = exports.oxyRequesterAssertionClaimsSchema = exports.OxyRequesterAssertionError = exports.OXY_REQUESTER_ASSERTION_TYPE = exports.OXY_REQUESTER_ASSERTION_MAX_TTL_SECONDS = exports.OXY_REQUESTER_ASSERTION_HEADER = exports.OXY_REQUESTER_ASSERTION_DEFAULT_ISSUER = exports.createOxyRequesterAssertionAuth = exports.createOxyJwksKeyResolver = exports.verifyCapabilityTicket = exports.readCapabilityAuthorization = exports.issueCapabilityTicket = exports.inputSatisfiesCapabilityLimits = exports.createCapabilityTicketMiddleware = exports.CapabilityTicketError = exports.OXY_CSP_BASELINE = void 0;
|
|
19
|
+
exports.formatOxyCspPolicy = exports.extractInlineScripts = exports.cspSourcesFor = 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.getOxyAuthRefusal = exports.getOxyActor = exports.createOxyAuthMiddleware = exports.createOptionalOxyAuth = exports.OXY_ACCOUNT_DELETED_EVENT_URI = exports.OxyAccountEventError = exports.OXY_ALIA_MACHINE_SCOPES = exports.OXY_ALIA_RESOURCE_APPLICATION_ID = exports.isOxyAliaMachinePrincipal = exports.getOxyAliaMachineCredentialBearer = exports.createOxyAliaMachineCredentialAuth = exports.ServerReputationApi = exports.ServerNotificationsApi = exports.ServerLinkedAccountsApi = exports.ServerAssetsApi = exports.ServerAgencyApi = exports.ServerAppsApi = exports.DEFAULT_SERVICE_TOKEN_AUDIENCE = exports.ANONYMOUS_SERVICE_TOKEN_RETRY_MS = exports.ServiceCredentialMismatchError = exports.OxyServer = void 0;
|
|
20
|
+
exports.signInAgentAccount = exports.hasBoundedServiceTokenLifetime = exports.OXY_SERVICE_TOKEN_MAX_LIFETIME_SECONDS = exports.requestWorkloadServiceToken = exports.canAttestWorkloadIdentity = exports.attestWorkloadIdentity = exports.withoutNodeHttpObservation = exports.observeNodeHttp = exports.createEcosystemTraffic = exports.isAllowedDeviceJoinOrigin = exports.isOfficialWebOrigin = exports.isLoopbackOrigin = exports.registrableApex = exports.OXY_IDENTITY_CACHE_PREFIXES = exports.oxyUserByIdCacheKey = exports.evictOxyIdentityCache = exports.publishOxyUserInvalidation = exports.createOxyUserInvalidationHandler = exports.createLiveCapabilityTicketVerifier = exports.verifySecret = exports.verifyOxyRequesterAssertion = exports.signOxyRequesterAssertion = exports.readOxyRequesterAssertionKeyId = exports.oxyRequesterAssertionClaimsSchema = exports.OxyRequesterAssertionError = exports.OXY_REQUESTER_ASSERTION_TYPE = exports.OXY_REQUESTER_ASSERTION_MAX_TTL_SECONDS = exports.OXY_REQUESTER_ASSERTION_HEADER = exports.OXY_REQUESTER_ASSERTION_DEFAULT_ISSUER = exports.createOxyRequesterAssertionAuth = exports.createOxyJwksKeyResolver = exports.verifyCapabilityTicket = exports.readCapabilityAuthorization = exports.issueCapabilityTicket = exports.inputSatisfiesCapabilityLimits = exports.createCapabilityTicketMiddleware = exports.CapabilityTicketError = exports.OXY_CSP_BASELINE = exports.inlineScriptCspHash = void 0;
|
|
21
21
|
// The backend client: OxyServices plus the service-token lane, middleware,
|
|
22
22
|
// account events and the service-only namespace methods.
|
|
23
23
|
var OxyServer_1 = require("./OxyServer");
|
|
24
24
|
Object.defineProperty(exports, "OxyServer", { enumerable: true, get: function () { return OxyServer_1.OxyServer; } });
|
|
25
25
|
Object.defineProperty(exports, "ServiceCredentialMismatchError", { enumerable: true, get: function () { return OxyServer_1.ServiceCredentialMismatchError; } });
|
|
26
26
|
Object.defineProperty(exports, "ANONYMOUS_SERVICE_TOKEN_RETRY_MS", { enumerable: true, get: function () { return OxyServer_1.ANONYMOUS_SERVICE_TOKEN_RETRY_MS; } });
|
|
27
|
+
Object.defineProperty(exports, "DEFAULT_SERVICE_TOKEN_AUDIENCE", { enumerable: true, get: function () { return OxyServer_1.DEFAULT_SERVICE_TOKEN_AUDIENCE; } });
|
|
27
28
|
var namespaces_1 = require("./namespaces");
|
|
28
29
|
Object.defineProperty(exports, "ServerAppsApi", { enumerable: true, get: function () { return namespaces_1.ServerAppsApi; } });
|
|
29
30
|
Object.defineProperty(exports, "ServerAgencyApi", { enumerable: true, get: function () { return namespaces_1.ServerAgencyApi; } });
|
|
@@ -176,7 +176,12 @@ async function requestWorkloadServiceToken(options) {
|
|
|
176
176
|
const tokenResponse = await fetchImpl(`${base}/auth/service-token/workload`, {
|
|
177
177
|
method: 'POST',
|
|
178
178
|
headers: { 'content-type': 'application/json' },
|
|
179
|
-
body: JSON.stringify({
|
|
179
|
+
body: JSON.stringify({
|
|
180
|
+
provider: 'aws-iam',
|
|
181
|
+
nonce: challenge.nonce,
|
|
182
|
+
attestation,
|
|
183
|
+
...(options.audience === undefined ? {} : { audience: options.audience }),
|
|
184
|
+
}),
|
|
180
185
|
});
|
|
181
186
|
if (!tokenResponse.ok) {
|
|
182
187
|
// The body carries a bounded `reason`; the token never travels on a failure,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a browser has to send a CORS preflight (`OPTIONS`) before this
|
|
4
|
+
* cross-origin request goes out, per the Fetch standard's CORS-safelisted
|
|
5
|
+
* methods and request headers.
|
|
6
|
+
*
|
|
7
|
+
* The SDK uses it for one invariant: its anonymous telemetry headers
|
|
8
|
+
* (`X-Oxy-Activity-Id`, `X-Oxy-Edge-Region`) ride only on requests that are
|
|
9
|
+
* preflighted anyway, so they never turn a request that would go straight out
|
|
10
|
+
* into one that first waits a round trip for an `OPTIONS`. A browser caches a
|
|
11
|
+
* preflight per URL, so without this every distinct anonymous read (a post's
|
|
12
|
+
* replies, a profile, a public app record) paid a round trip of its own.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately conservative: a header it does not recognise as safelisted
|
|
15
|
+
* counts as needing a preflight, which only means the telemetry headers ride
|
|
16
|
+
* along exactly as they always did. Value-level rules (for example `Accept`'s
|
|
17
|
+
* 128-byte limit) are not modelled; a misjudgement in that direction drops two
|
|
18
|
+
* advisory headers from one request and nothing else.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.requiresCorsPreflight = requiresCorsPreflight;
|
|
22
|
+
const SAFELISTED_METHODS = new Set(['GET', 'HEAD', 'POST']);
|
|
23
|
+
const SAFELISTED_HEADERS = new Set([
|
|
24
|
+
'accept',
|
|
25
|
+
'accept-language',
|
|
26
|
+
'content-language',
|
|
27
|
+
'content-type',
|
|
28
|
+
]);
|
|
29
|
+
const SAFELISTED_CONTENT_TYPES = new Set([
|
|
30
|
+
'application/x-www-form-urlencoded',
|
|
31
|
+
'multipart/form-data',
|
|
32
|
+
'text/plain',
|
|
33
|
+
]);
|
|
34
|
+
function requiresCorsPreflight(method, headers) {
|
|
35
|
+
if (!SAFELISTED_METHODS.has(method.toUpperCase()))
|
|
36
|
+
return true;
|
|
37
|
+
for (const [rawName, value] of headers) {
|
|
38
|
+
const name = rawName.toLowerCase();
|
|
39
|
+
if (!SAFELISTED_HEADERS.has(name))
|
|
40
|
+
return true;
|
|
41
|
+
if (name === 'content-type') {
|
|
42
|
+
const essence = value.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
43
|
+
if (!SAFELISTED_CONTENT_TYPES.has(essence))
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
package/dist/esm/HttpService.js
CHANGED
|
@@ -23,6 +23,7 @@ import { redactUrlQuery } from './utils/redactUrl.js';
|
|
|
23
23
|
import { OxyAuthenticationError } from './OxyServices.errors.js';
|
|
24
24
|
import { peekBrowserEdgeRegionHeader } from './utils/edgeRegion.js';
|
|
25
25
|
import { getBrowserActivityIdHeader } from './utils/activityId.js';
|
|
26
|
+
import { requiresCorsPreflight } from './utils/corsPreflight.js';
|
|
26
27
|
import { trimLeadingSlashes, trimTrailingSlashes } from './utils/slashes.js';
|
|
27
28
|
/** Comparison only fences local async work; it never validates token authority. */
|
|
28
29
|
function sameTokenSession(left, right) {
|
|
@@ -505,12 +506,17 @@ export class HttpService {
|
|
|
505
506
|
const headers = {
|
|
506
507
|
Accept: 'application/json',
|
|
507
508
|
};
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
509
|
+
// Content-Type describes a body, so a GET (which never sends one) gets
|
|
510
|
+
// none: `application/json` is not a CORS-safelisted type, and on a GET it
|
|
511
|
+
// only made the browser preflight a request with nothing to describe.
|
|
512
|
+
// FormData sets its own multipart type with the boundary.
|
|
513
|
+
if (method !== 'GET') {
|
|
514
|
+
if (isUrlEncoded) {
|
|
515
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
|
516
|
+
}
|
|
517
|
+
else if (!isFormData) {
|
|
518
|
+
headers['Content-Type'] = 'application/json';
|
|
519
|
+
}
|
|
514
520
|
}
|
|
515
521
|
// Add authorization header if available
|
|
516
522
|
if (authHeader) {
|
|
@@ -530,9 +536,14 @@ export class HttpService {
|
|
|
530
536
|
}
|
|
531
537
|
// This is Cloudflare's coarse serving PoP (for example `mad`), never an
|
|
532
538
|
// IP or coordinate. Set it after caller headers so request code cannot
|
|
533
|
-
// accidentally or deliberately substitute a different origin.
|
|
534
|
-
|
|
535
|
-
|
|
539
|
+
// accidentally or deliberately substitute a different origin. Both are
|
|
540
|
+
// advisory telemetry, so they ride only on a request the browser
|
|
541
|
+
// preflights anyway: on an anonymous GET they were the only thing that
|
|
542
|
+
// made it non-simple, and every distinct URL paid an OPTIONS round trip.
|
|
543
|
+
if (requiresCorsPreflight(method, Object.entries(headers))) {
|
|
544
|
+
Object.assign(headers, edgeRegionHeader);
|
|
545
|
+
Object.assign(headers, activityIdHeader);
|
|
546
|
+
}
|
|
536
547
|
// `URLSearchParams` is serialised explicitly rather than handed to
|
|
537
548
|
// `fetch` as-is: RN's fetch does not consistently encode it, and doing
|
|
538
549
|
// it here keeps the body identical across every platform.
|
|
@@ -1304,12 +1315,20 @@ export class HttpService {
|
|
|
1304
1315
|
headers.delete(name);
|
|
1305
1316
|
if (authHeader)
|
|
1306
1317
|
headers.set('Authorization', authHeader);
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1318
|
+
// Telemetry rides only on a request the browser preflights anyway — see
|
|
1319
|
+
// `request()`.
|
|
1320
|
+
const sentHeaders = [];
|
|
1321
|
+
headers.forEach((value, name) => {
|
|
1322
|
+
sentHeaders.push([name, value]);
|
|
1323
|
+
});
|
|
1324
|
+
if (requiresCorsPreflight(config.method, sentHeaders)) {
|
|
1325
|
+
const edgeRegionHeader = peekBrowserEdgeRegionHeader();
|
|
1326
|
+
for (const [name, value] of Object.entries(edgeRegionHeader))
|
|
1327
|
+
headers.set(name, value);
|
|
1328
|
+
const activityIdHeader = getBrowserActivityIdHeader();
|
|
1329
|
+
for (const [name, value] of Object.entries(activityIdHeader))
|
|
1330
|
+
headers.set(name, value);
|
|
1331
|
+
}
|
|
1313
1332
|
try {
|
|
1314
1333
|
const body = config.method === 'GET' ? undefined : config.body;
|
|
1315
1334
|
const init = {
|
|
@@ -63,6 +63,12 @@ export class ServiceCredentialMismatchError extends Error {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
const WORKLOAD_CACHE_KEY = 'workload-identity';
|
|
66
|
+
/** What `serviceToken()` mints, and what Oxy assumes when no audience is sent. */
|
|
67
|
+
export const DEFAULT_SERVICE_TOKEN_AUDIENCE = 'oxy-api';
|
|
68
|
+
/** Tokens of different audiences never share a cache entry; the default keeps its old key. */
|
|
69
|
+
function audienceCacheKey(audience, key) {
|
|
70
|
+
return audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? key : `${audience}\u0000${key}`;
|
|
71
|
+
}
|
|
66
72
|
/** A token is reused until 60s before expiry (clock drift). */
|
|
67
73
|
const TOKEN_REUSE_MARGIN_MS = 60000;
|
|
68
74
|
/** Bound on remembered `appId:userId` delegation answers. */
|
|
@@ -198,19 +204,28 @@ export class OxyServer extends OxyServices {
|
|
|
198
204
|
* {@link ServiceCredentialMismatchError} rather than return the cached token.
|
|
199
205
|
*/
|
|
200
206
|
async serviceToken(apiKey, apiSecret) {
|
|
207
|
+
return this.serviceTokenFor(DEFAULT_SERVICE_TOKEN_AUDIENCE, apiKey, apiSecret);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* A service token for `audience` — e.g. `kaana.ai` for https://kaana.ai/v1.
|
|
211
|
+
* Minted, cached and refreshed exactly as {@link serviceToken} (which is this
|
|
212
|
+
* with `oxy-api`), from the same credential or attested workload identity;
|
|
213
|
+
* each audience has its own cache entry, so one never answers for another.
|
|
214
|
+
*/
|
|
215
|
+
async serviceTokenFor(audience, apiKey, apiSecret) {
|
|
201
216
|
const key = apiKey || this.serviceApiKey;
|
|
202
217
|
const secret = apiSecret || this.serviceApiSecret;
|
|
203
218
|
if (!key || !secret) {
|
|
204
219
|
// No credential — prove what this process IS instead. The fallback, not the
|
|
205
220
|
// preference: a deployment that still has a credential keeps using it.
|
|
206
221
|
if (canAttestWorkloadIdentity()) {
|
|
207
|
-
return this.workloadServiceToken();
|
|
222
|
+
return this.workloadServiceToken(audience);
|
|
208
223
|
}
|
|
209
224
|
throw new Error('Service credentials not provided. Pass serviceAuth, call configureServiceAuth(), or pass apiKey and apiSecret.');
|
|
210
225
|
}
|
|
211
226
|
// The apiKey is the credential's PUBLIC id (`oxy_dk_…`, the OAuth
|
|
212
227
|
// client_id), so it keys the in-memory cache as is; the secret never does.
|
|
213
|
-
const cacheKey = key;
|
|
228
|
+
const cacheKey = audienceCacheKey(audience, key);
|
|
214
229
|
const now = Date.now();
|
|
215
230
|
const providedSecretBuf = Buffer.from(secret, 'utf8');
|
|
216
231
|
let entry = this.serviceTokens.get(cacheKey);
|
|
@@ -243,7 +258,7 @@ export class OxyServer extends OxyServices {
|
|
|
243
258
|
entry = { token: '', expiresAt: 0, secretBuf: providedSecretBuf, pending: null, apiKey: key };
|
|
244
259
|
this.serviceTokens.set(cacheKey, entry);
|
|
245
260
|
}
|
|
246
|
-
const pending = this.mintServiceToken(key, secret, cacheKey, providedSecretBuf);
|
|
261
|
+
const pending = this.mintServiceToken(key, secret, cacheKey, providedSecretBuf, audience);
|
|
247
262
|
entry.pending = pending;
|
|
248
263
|
try {
|
|
249
264
|
return await pending;
|
|
@@ -276,7 +291,11 @@ export class OxyServer extends OxyServices {
|
|
|
276
291
|
this.serviceTokens.clear();
|
|
277
292
|
return;
|
|
278
293
|
}
|
|
279
|
-
|
|
294
|
+
// Every audience minted from that credential goes with it.
|
|
295
|
+
for (const [cacheKey, entry] of this.serviceTokens) {
|
|
296
|
+
if (entry.apiKey === targetKey)
|
|
297
|
+
this.serviceTokens.delete(cacheKey);
|
|
298
|
+
}
|
|
280
299
|
}
|
|
281
300
|
/**
|
|
282
301
|
* One request with this service's token. `actAs` sends `X-Oxy-User-Id`: the
|
|
@@ -402,8 +421,12 @@ export class OxyServer extends OxyServices {
|
|
|
402
421
|
canMintServiceToken() {
|
|
403
422
|
return Boolean(this.serviceApiKey && this.serviceApiSecret) || canAttestWorkloadIdentity();
|
|
404
423
|
}
|
|
405
|
-
async mintServiceToken(key, secret, cacheKey, secretBuf) {
|
|
406
|
-
const response = await this.request('POST', '/auth/service-token', {
|
|
424
|
+
async mintServiceToken(key, secret, cacheKey, secretBuf, audience) {
|
|
425
|
+
const response = await this.request('POST', '/auth/service-token', {
|
|
426
|
+
apiKey: key,
|
|
427
|
+
apiSecret: secret,
|
|
428
|
+
...(audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }),
|
|
429
|
+
}, { cache: false, retry: false, skipAuth: true });
|
|
407
430
|
const expiresAt = Date.now() + response.expiresIn * 1000;
|
|
408
431
|
const entry = this.serviceTokens.get(cacheKey);
|
|
409
432
|
if (entry) {
|
|
@@ -422,9 +445,10 @@ export class OxyServer extends OxyServices {
|
|
|
422
445
|
}
|
|
423
446
|
return response.token;
|
|
424
447
|
}
|
|
425
|
-
/** A token obtained by attestation, cached like a credential's (one identity per process). */
|
|
426
|
-
async workloadServiceToken() {
|
|
427
|
-
const
|
|
448
|
+
/** A token obtained by attestation, cached like a credential's (one identity per process, one entry per audience). */
|
|
449
|
+
async workloadServiceToken(audience) {
|
|
450
|
+
const cacheKey = audienceCacheKey(audience, WORKLOAD_CACHE_KEY);
|
|
451
|
+
const entry = this.serviceTokens.get(cacheKey);
|
|
428
452
|
if (entry?.token &&
|
|
429
453
|
reusableServiceToken(entry.token) &&
|
|
430
454
|
entry.expiresAt > Date.now() + TOKEN_REUSE_MARGIN_MS)
|
|
@@ -438,10 +462,13 @@ export class OxyServer extends OxyServices {
|
|
|
438
462
|
pending: null,
|
|
439
463
|
apiKey: WORKLOAD_CACHE_KEY,
|
|
440
464
|
};
|
|
441
|
-
this.serviceTokens.set(
|
|
465
|
+
this.serviceTokens.set(cacheKey, seeded);
|
|
442
466
|
const pending = (async () => {
|
|
443
|
-
const granted = await requestWorkloadServiceToken({
|
|
444
|
-
|
|
467
|
+
const granted = await requestWorkloadServiceToken({
|
|
468
|
+
baseUrl: this.baseURL,
|
|
469
|
+
...(audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }),
|
|
470
|
+
});
|
|
471
|
+
const current = this.serviceTokens.get(cacheKey);
|
|
445
472
|
if (current) {
|
|
446
473
|
current.token = granted.token;
|
|
447
474
|
current.expiresAt = Date.now() + granted.expiresIn * 1000;
|
|
@@ -453,13 +480,13 @@ export class OxyServer extends OxyServices {
|
|
|
453
480
|
return await pending;
|
|
454
481
|
}
|
|
455
482
|
catch (error) {
|
|
456
|
-
const failed = this.serviceTokens.get(
|
|
483
|
+
const failed = this.serviceTokens.get(cacheKey);
|
|
457
484
|
if (failed?.pending === pending && !failed.token)
|
|
458
|
-
this.serviceTokens.delete(
|
|
485
|
+
this.serviceTokens.delete(cacheKey);
|
|
459
486
|
throw error;
|
|
460
487
|
}
|
|
461
488
|
finally {
|
|
462
|
-
const settled = this.serviceTokens.get(
|
|
489
|
+
const settled = this.serviceTokens.get(cacheKey);
|
|
463
490
|
if (settled?.pending === pending)
|
|
464
491
|
settled.pending = null;
|
|
465
492
|
}
|
package/dist/esm/server/index.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
// The backend client: OxyServices plus the service-token lane, middleware,
|
|
18
18
|
// account events and the service-only namespace methods.
|
|
19
|
-
export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS, } from './OxyServer.js';
|
|
19
|
+
export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS, DEFAULT_SERVICE_TOKEN_AUDIENCE, } from './OxyServer.js';
|
|
20
20
|
export { ServerAppsApi, ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi, } from './namespaces.js';
|
|
21
21
|
export { createOxyAliaMachineCredentialAuth, getOxyAliaMachineCredentialBearer, isOxyAliaMachinePrincipal, OXY_ALIA_RESOURCE_APPLICATION_ID, OXY_ALIA_MACHINE_SCOPES, } from './aliaMachineCredential.js';
|
|
22
22
|
export { OxyAccountEventError, OXY_ACCOUNT_DELETED_EVENT_URI } from './middleware.js';
|
|
@@ -171,7 +171,12 @@ export async function requestWorkloadServiceToken(options) {
|
|
|
171
171
|
const tokenResponse = await fetchImpl(`${base}/auth/service-token/workload`, {
|
|
172
172
|
method: 'POST',
|
|
173
173
|
headers: { 'content-type': 'application/json' },
|
|
174
|
-
body: JSON.stringify({
|
|
174
|
+
body: JSON.stringify({
|
|
175
|
+
provider: 'aws-iam',
|
|
176
|
+
nonce: challenge.nonce,
|
|
177
|
+
attestation,
|
|
178
|
+
...(options.audience === undefined ? {} : { audience: options.audience }),
|
|
179
|
+
}),
|
|
175
180
|
});
|
|
176
181
|
if (!tokenResponse.ok) {
|
|
177
182
|
// The body carries a bounded `reason`; the token never travels on a failure,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a browser has to send a CORS preflight (`OPTIONS`) before this
|
|
3
|
+
* cross-origin request goes out, per the Fetch standard's CORS-safelisted
|
|
4
|
+
* methods and request headers.
|
|
5
|
+
*
|
|
6
|
+
* The SDK uses it for one invariant: its anonymous telemetry headers
|
|
7
|
+
* (`X-Oxy-Activity-Id`, `X-Oxy-Edge-Region`) ride only on requests that are
|
|
8
|
+
* preflighted anyway, so they never turn a request that would go straight out
|
|
9
|
+
* into one that first waits a round trip for an `OPTIONS`. A browser caches a
|
|
10
|
+
* preflight per URL, so without this every distinct anonymous read (a post's
|
|
11
|
+
* replies, a profile, a public app record) paid a round trip of its own.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately conservative: a header it does not recognise as safelisted
|
|
14
|
+
* counts as needing a preflight, which only means the telemetry headers ride
|
|
15
|
+
* along exactly as they always did. Value-level rules (for example `Accept`'s
|
|
16
|
+
* 128-byte limit) are not modelled; a misjudgement in that direction drops two
|
|
17
|
+
* advisory headers from one request and nothing else.
|
|
18
|
+
*/
|
|
19
|
+
const SAFELISTED_METHODS = new Set(['GET', 'HEAD', 'POST']);
|
|
20
|
+
const SAFELISTED_HEADERS = new Set([
|
|
21
|
+
'accept',
|
|
22
|
+
'accept-language',
|
|
23
|
+
'content-language',
|
|
24
|
+
'content-type',
|
|
25
|
+
]);
|
|
26
|
+
const SAFELISTED_CONTENT_TYPES = new Set([
|
|
27
|
+
'application/x-www-form-urlencoded',
|
|
28
|
+
'multipart/form-data',
|
|
29
|
+
'text/plain',
|
|
30
|
+
]);
|
|
31
|
+
export function requiresCorsPreflight(method, headers) {
|
|
32
|
+
if (!SAFELISTED_METHODS.has(method.toUpperCase()))
|
|
33
|
+
return true;
|
|
34
|
+
for (const [rawName, value] of headers) {
|
|
35
|
+
const name = rawName.toLowerCase();
|
|
36
|
+
if (!SAFELISTED_HEADERS.has(name))
|
|
37
|
+
return true;
|
|
38
|
+
if (name === 'content-type') {
|
|
39
|
+
const essence = value.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
40
|
+
if (!SAFELISTED_CONTENT_TYPES.has(essence))
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
@@ -8,7 +8,7 @@ export declare class SafetyApi {
|
|
|
8
8
|
accessToken: string | null;
|
|
9
9
|
}): Promise<{
|
|
10
10
|
allowed: boolean;
|
|
11
|
-
reason: "
|
|
11
|
+
reason: "unavailable" | "owner" | "public" | "private" | "blocked";
|
|
12
12
|
policyVersion: "1";
|
|
13
13
|
}>;
|
|
14
14
|
}
|
|
@@ -58,6 +58,15 @@ export interface ServiceTokenResponse {
|
|
|
58
58
|
export declare class ServiceCredentialMismatchError extends Error {
|
|
59
59
|
constructor();
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* The resource server a service token is minted for (Oxy's closed set,
|
|
63
|
+
* `packages/api/src/config/serviceTokenAudiences.ts`). A token is only ever
|
|
64
|
+
* accepted by its own audience: Oxy's API refuses a `kaana.ai` token and
|
|
65
|
+
* https://kaana.ai/v1 refuses an `oxy-api` one (Kaana ADR 0002 decision 6).
|
|
66
|
+
*/
|
|
67
|
+
export type ServiceTokenAudience = 'oxy-api' | 'kaana.ai' | 'oxy-api:kaana-control';
|
|
68
|
+
/** What `serviceToken()` mints, and what Oxy assumes when no audience is sent. */
|
|
69
|
+
export declare const DEFAULT_SERVICE_TOKEN_AUDIENCE: ServiceTokenAudience;
|
|
61
70
|
export declare class OxyServer extends OxyServices {
|
|
62
71
|
private readonly serviceTokens;
|
|
63
72
|
private serviceApiKey;
|
|
@@ -120,6 +129,13 @@ export declare class OxyServer extends OxyServices {
|
|
|
120
129
|
* {@link ServiceCredentialMismatchError} rather than return the cached token.
|
|
121
130
|
*/
|
|
122
131
|
serviceToken(apiKey?: string, apiSecret?: string): Promise<string>;
|
|
132
|
+
/**
|
|
133
|
+
* A service token for `audience` — e.g. `kaana.ai` for https://kaana.ai/v1.
|
|
134
|
+
* Minted, cached and refreshed exactly as {@link serviceToken} (which is this
|
|
135
|
+
* with `oxy-api`), from the same credential or attested workload identity;
|
|
136
|
+
* each audience has its own cache entry, so one never answers for another.
|
|
137
|
+
*/
|
|
138
|
+
serviceTokenFor(audience: ServiceTokenAudience, apiKey?: string, apiSecret?: string): Promise<string>;
|
|
123
139
|
/**
|
|
124
140
|
* Forget cached service token(s) so the next `serviceToken()` mints anew —
|
|
125
141
|
* the way back from a 401 after a credential was revoked or rotated.
|
|
@@ -187,7 +203,7 @@ export declare class OxyServer extends OxyServices {
|
|
|
187
203
|
private serviceTokenForAnonymousRequest;
|
|
188
204
|
private canMintServiceToken;
|
|
189
205
|
private mintServiceToken;
|
|
190
|
-
/** A token obtained by attestation, cached like a credential's (one identity per process). */
|
|
206
|
+
/** A token obtained by attestation, cached like a credential's (one identity per process, one entry per audience). */
|
|
191
207
|
private workloadServiceToken;
|
|
192
208
|
private lookupActingAs;
|
|
193
209
|
private clearActingAsPair;
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* app.use('/api', createOxyAuthMiddleware(oxy));
|
|
15
15
|
* ```
|
|
16
16
|
*/
|
|
17
|
-
export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS, } from './OxyServer';
|
|
18
|
-
export type { OxyServerConfig, ServiceTokenResponse } from './OxyServer';
|
|
17
|
+
export { OxyServer, ServiceCredentialMismatchError, ANONYMOUS_SERVICE_TOKEN_RETRY_MS, DEFAULT_SERVICE_TOKEN_AUDIENCE, } from './OxyServer';
|
|
18
|
+
export type { OxyServerConfig, ServiceTokenAudience, ServiceTokenResponse } from './OxyServer';
|
|
19
19
|
export { ServerAppsApi, ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi, } from './namespaces';
|
|
20
20
|
export { createOxyAliaMachineCredentialAuth, getOxyAliaMachineCredentialBearer, isOxyAliaMachinePrincipal, OXY_ALIA_RESOURCE_APPLICATION_ID, OXY_ALIA_MACHINE_SCOPES, } from './aliaMachineCredential';
|
|
21
21
|
export type { AliaMachineCredentialIntrospection, OxyAliaMachinePrincipal, OxyAliaMachineRequest, OxyAliaMachineCredentialHost, } from './aliaMachineCredential';
|
|
@@ -36,8 +36,8 @@ export declare const oxyRequesterAssertionClaimsSchema: z.ZodObject<{
|
|
|
36
36
|
/** The one native agent this requester may reach with it. */
|
|
37
37
|
agentId: z.ZodString;
|
|
38
38
|
}, "strict", z.ZodTypeAny, {
|
|
39
|
-
sub: string;
|
|
40
39
|
exp: number;
|
|
40
|
+
sub: string;
|
|
41
41
|
aud: string;
|
|
42
42
|
iss: string;
|
|
43
43
|
jti: string;
|
|
@@ -46,8 +46,8 @@ export declare const oxyRequesterAssertionClaimsSchema: z.ZodObject<{
|
|
|
46
46
|
cid: string;
|
|
47
47
|
agentId: string;
|
|
48
48
|
}, {
|
|
49
|
-
sub: string;
|
|
50
49
|
exp: number;
|
|
50
|
+
sub: string;
|
|
51
51
|
aud: string;
|
|
52
52
|
iss: string;
|
|
53
53
|
jti: string;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export interface WorkloadServiceTokenOptions {
|
|
2
2
|
/** Oxy's base URL, e.g. `https://api.oxy.so`. */
|
|
3
3
|
baseUrl: string;
|
|
4
|
+
/**
|
|
5
|
+
* The resource server the token is for (e.g. `kaana.ai`). Omitted, Oxy mints
|
|
6
|
+
* for `oxy-api`, as it always has.
|
|
7
|
+
*/
|
|
8
|
+
audience?: string;
|
|
4
9
|
/** Injected in tests; defaults to the global. */
|
|
5
10
|
fetch?: typeof fetch;
|
|
6
11
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a browser has to send a CORS preflight (`OPTIONS`) before this
|
|
3
|
+
* cross-origin request goes out, per the Fetch standard's CORS-safelisted
|
|
4
|
+
* methods and request headers.
|
|
5
|
+
*
|
|
6
|
+
* The SDK uses it for one invariant: its anonymous telemetry headers
|
|
7
|
+
* (`X-Oxy-Activity-Id`, `X-Oxy-Edge-Region`) ride only on requests that are
|
|
8
|
+
* preflighted anyway, so they never turn a request that would go straight out
|
|
9
|
+
* into one that first waits a round trip for an `OPTIONS`. A browser caches a
|
|
10
|
+
* preflight per URL, so without this every distinct anonymous read (a post's
|
|
11
|
+
* replies, a profile, a public app record) paid a round trip of its own.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately conservative: a header it does not recognise as safelisted
|
|
14
|
+
* counts as needing a preflight, which only means the telemetry headers ride
|
|
15
|
+
* along exactly as they always did. Value-level rules (for example `Accept`'s
|
|
16
|
+
* 128-byte limit) are not modelled; a misjudgement in that direction drops two
|
|
17
|
+
* advisory headers from one request and nothing else.
|
|
18
|
+
*/
|
|
19
|
+
export declare function requiresCorsPreflight(method: string, headers: Iterable<readonly [string, string]>): boolean;
|
package/package.json
CHANGED
package/src/HttpService.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type { DeviceSecretMintOutcome } from './session/refresh';
|
|
|
32
32
|
import { OxyAuthenticationError } from './OxyServices.errors';
|
|
33
33
|
import { peekBrowserEdgeRegionHeader } from './utils/edgeRegion';
|
|
34
34
|
import { getBrowserActivityIdHeader } from './utils/activityId';
|
|
35
|
+
import { requiresCorsPreflight } from './utils/corsPreflight';
|
|
35
36
|
import { trimLeadingSlashes, trimTrailingSlashes } from './utils/slashes';
|
|
36
37
|
|
|
37
38
|
export type AuthRefreshReason = 'preflight' | 'response-401';
|
|
@@ -712,11 +713,16 @@ export class HttpService {
|
|
|
712
713
|
Accept: 'application/json',
|
|
713
714
|
};
|
|
714
715
|
|
|
715
|
-
//
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
716
|
+
// Content-Type describes a body, so a GET (which never sends one) gets
|
|
717
|
+
// none: `application/json` is not a CORS-safelisted type, and on a GET it
|
|
718
|
+
// only made the browser preflight a request with nothing to describe.
|
|
719
|
+
// FormData sets its own multipart type with the boundary.
|
|
720
|
+
if (method !== 'GET') {
|
|
721
|
+
if (isUrlEncoded) {
|
|
722
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
|
723
|
+
} else if (!isFormData) {
|
|
724
|
+
headers['Content-Type'] = 'application/json';
|
|
725
|
+
}
|
|
720
726
|
}
|
|
721
727
|
|
|
722
728
|
// Add authorization header if available
|
|
@@ -741,9 +747,14 @@ export class HttpService {
|
|
|
741
747
|
|
|
742
748
|
// This is Cloudflare's coarse serving PoP (for example `mad`), never an
|
|
743
749
|
// IP or coordinate. Set it after caller headers so request code cannot
|
|
744
|
-
// accidentally or deliberately substitute a different origin.
|
|
745
|
-
|
|
746
|
-
|
|
750
|
+
// accidentally or deliberately substitute a different origin. Both are
|
|
751
|
+
// advisory telemetry, so they ride only on a request the browser
|
|
752
|
+
// preflights anyway: on an anonymous GET they were the only thing that
|
|
753
|
+
// made it non-simple, and every distinct URL paid an OPTIONS round trip.
|
|
754
|
+
if (requiresCorsPreflight(method, Object.entries(headers))) {
|
|
755
|
+
Object.assign(headers, edgeRegionHeader);
|
|
756
|
+
Object.assign(headers, activityIdHeader);
|
|
757
|
+
}
|
|
747
758
|
|
|
748
759
|
// `URLSearchParams` is serialised explicitly rather than handed to
|
|
749
760
|
// `fetch` as-is: RN's fetch does not consistently encode it, and doing
|
|
@@ -1637,10 +1648,18 @@ export class HttpService {
|
|
|
1637
1648
|
for (const name of ['Authorization', 'Cookie', 'Cookie2', 'Host', 'Proxy-Authorization'])
|
|
1638
1649
|
headers.delete(name);
|
|
1639
1650
|
if (authHeader) headers.set('Authorization', authHeader);
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
const
|
|
1643
|
-
|
|
1651
|
+
// Telemetry rides only on a request the browser preflights anyway — see
|
|
1652
|
+
// `request()`.
|
|
1653
|
+
const sentHeaders: Array<[string, string]> = [];
|
|
1654
|
+
headers.forEach((value, name) => {
|
|
1655
|
+
sentHeaders.push([name, value]);
|
|
1656
|
+
});
|
|
1657
|
+
if (requiresCorsPreflight(config.method, sentHeaders)) {
|
|
1658
|
+
const edgeRegionHeader = peekBrowserEdgeRegionHeader();
|
|
1659
|
+
for (const [name, value] of Object.entries(edgeRegionHeader)) headers.set(name, value);
|
|
1660
|
+
const activityIdHeader = getBrowserActivityIdHeader();
|
|
1661
|
+
for (const [name, value] of Object.entries(activityIdHeader)) headers.set(name, value);
|
|
1662
|
+
}
|
|
1644
1663
|
|
|
1645
1664
|
try {
|
|
1646
1665
|
const body = config.method === 'GET' ? undefined : config.body;
|
package/src/server/OxyServer.ts
CHANGED
|
@@ -162,6 +162,22 @@ interface ServiceTokenCacheEntry {
|
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
const WORKLOAD_CACHE_KEY = 'workload-identity';
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The resource server a service token is minted for (Oxy's closed set,
|
|
168
|
+
* `packages/api/src/config/serviceTokenAudiences.ts`). A token is only ever
|
|
169
|
+
* accepted by its own audience: Oxy's API refuses a `kaana.ai` token and
|
|
170
|
+
* https://kaana.ai/v1 refuses an `oxy-api` one (Kaana ADR 0002 decision 6).
|
|
171
|
+
*/
|
|
172
|
+
export type ServiceTokenAudience = 'oxy-api' | 'kaana.ai' | 'oxy-api:kaana-control';
|
|
173
|
+
|
|
174
|
+
/** What `serviceToken()` mints, and what Oxy assumes when no audience is sent. */
|
|
175
|
+
export const DEFAULT_SERVICE_TOKEN_AUDIENCE: ServiceTokenAudience = 'oxy-api';
|
|
176
|
+
|
|
177
|
+
/** Tokens of different audiences never share a cache entry; the default keeps its old key. */
|
|
178
|
+
function audienceCacheKey(audience: ServiceTokenAudience, key: string): string {
|
|
179
|
+
return audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? key : `${audience}\u0000${key}`;
|
|
180
|
+
}
|
|
165
181
|
/** A token is reused until 60s before expiry (clock drift). */
|
|
166
182
|
const TOKEN_REUSE_MARGIN_MS = 60_000;
|
|
167
183
|
|
|
@@ -298,6 +314,20 @@ export class OxyServer extends OxyServices {
|
|
|
298
314
|
* {@link ServiceCredentialMismatchError} rather than return the cached token.
|
|
299
315
|
*/
|
|
300
316
|
async serviceToken(apiKey?: string, apiSecret?: string): Promise<string> {
|
|
317
|
+
return this.serviceTokenFor(DEFAULT_SERVICE_TOKEN_AUDIENCE, apiKey, apiSecret);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A service token for `audience` — e.g. `kaana.ai` for https://kaana.ai/v1.
|
|
322
|
+
* Minted, cached and refreshed exactly as {@link serviceToken} (which is this
|
|
323
|
+
* with `oxy-api`), from the same credential or attested workload identity;
|
|
324
|
+
* each audience has its own cache entry, so one never answers for another.
|
|
325
|
+
*/
|
|
326
|
+
async serviceTokenFor(
|
|
327
|
+
audience: ServiceTokenAudience,
|
|
328
|
+
apiKey?: string,
|
|
329
|
+
apiSecret?: string,
|
|
330
|
+
): Promise<string> {
|
|
301
331
|
const key = apiKey || this.serviceApiKey;
|
|
302
332
|
const secret = apiSecret || this.serviceApiSecret;
|
|
303
333
|
|
|
@@ -305,7 +335,7 @@ export class OxyServer extends OxyServices {
|
|
|
305
335
|
// No credential — prove what this process IS instead. The fallback, not the
|
|
306
336
|
// preference: a deployment that still has a credential keeps using it.
|
|
307
337
|
if (canAttestWorkloadIdentity()) {
|
|
308
|
-
return this.workloadServiceToken();
|
|
338
|
+
return this.workloadServiceToken(audience);
|
|
309
339
|
}
|
|
310
340
|
throw new Error(
|
|
311
341
|
'Service credentials not provided. Pass serviceAuth, call configureServiceAuth(), or pass apiKey and apiSecret.',
|
|
@@ -314,7 +344,7 @@ export class OxyServer extends OxyServices {
|
|
|
314
344
|
|
|
315
345
|
// The apiKey is the credential's PUBLIC id (`oxy_dk_…`, the OAuth
|
|
316
346
|
// client_id), so it keys the in-memory cache as is; the secret never does.
|
|
317
|
-
const cacheKey = key;
|
|
347
|
+
const cacheKey = audienceCacheKey(audience, key);
|
|
318
348
|
const now = Date.now();
|
|
319
349
|
const providedSecretBuf = Buffer.from(secret, 'utf8');
|
|
320
350
|
|
|
@@ -351,7 +381,7 @@ export class OxyServer extends OxyServices {
|
|
|
351
381
|
this.serviceTokens.set(cacheKey, entry);
|
|
352
382
|
}
|
|
353
383
|
|
|
354
|
-
const pending = this.mintServiceToken(key, secret, cacheKey, providedSecretBuf);
|
|
384
|
+
const pending = this.mintServiceToken(key, secret, cacheKey, providedSecretBuf, audience);
|
|
355
385
|
entry.pending = pending;
|
|
356
386
|
try {
|
|
357
387
|
return await pending;
|
|
@@ -382,7 +412,10 @@ export class OxyServer extends OxyServices {
|
|
|
382
412
|
this.serviceTokens.clear();
|
|
383
413
|
return;
|
|
384
414
|
}
|
|
385
|
-
|
|
415
|
+
// Every audience minted from that credential goes with it.
|
|
416
|
+
for (const [cacheKey, entry] of this.serviceTokens) {
|
|
417
|
+
if (entry.apiKey === targetKey) this.serviceTokens.delete(cacheKey);
|
|
418
|
+
}
|
|
386
419
|
}
|
|
387
420
|
|
|
388
421
|
/**
|
|
@@ -580,11 +613,16 @@ export class OxyServer extends OxyServices {
|
|
|
580
613
|
secret: string,
|
|
581
614
|
cacheKey: string,
|
|
582
615
|
secretBuf: Buffer,
|
|
616
|
+
audience: ServiceTokenAudience,
|
|
583
617
|
): Promise<string> {
|
|
584
618
|
const response = await this.request<ServiceTokenResponse>(
|
|
585
619
|
'POST',
|
|
586
620
|
'/auth/service-token',
|
|
587
|
-
{
|
|
621
|
+
{
|
|
622
|
+
apiKey: key,
|
|
623
|
+
apiSecret: secret,
|
|
624
|
+
...(audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }),
|
|
625
|
+
},
|
|
588
626
|
{ cache: false, retry: false, skipAuth: true },
|
|
589
627
|
);
|
|
590
628
|
const expiresAt = Date.now() + response.expiresIn * 1000;
|
|
@@ -605,9 +643,10 @@ export class OxyServer extends OxyServices {
|
|
|
605
643
|
return response.token;
|
|
606
644
|
}
|
|
607
645
|
|
|
608
|
-
/** A token obtained by attestation, cached like a credential's (one identity per process). */
|
|
609
|
-
private async workloadServiceToken(): Promise<string> {
|
|
610
|
-
const
|
|
646
|
+
/** A token obtained by attestation, cached like a credential's (one identity per process, one entry per audience). */
|
|
647
|
+
private async workloadServiceToken(audience: ServiceTokenAudience): Promise<string> {
|
|
648
|
+
const cacheKey = audienceCacheKey(audience, WORKLOAD_CACHE_KEY);
|
|
649
|
+
const entry = this.serviceTokens.get(cacheKey);
|
|
611
650
|
if (
|
|
612
651
|
entry?.token &&
|
|
613
652
|
reusableServiceToken(entry.token) &&
|
|
@@ -623,11 +662,14 @@ export class OxyServer extends OxyServices {
|
|
|
623
662
|
pending: null,
|
|
624
663
|
apiKey: WORKLOAD_CACHE_KEY,
|
|
625
664
|
};
|
|
626
|
-
this.serviceTokens.set(
|
|
665
|
+
this.serviceTokens.set(cacheKey, seeded);
|
|
627
666
|
|
|
628
667
|
const pending = (async () => {
|
|
629
|
-
const granted = await requestWorkloadServiceToken({
|
|
630
|
-
|
|
668
|
+
const granted = await requestWorkloadServiceToken({
|
|
669
|
+
baseUrl: this.baseURL,
|
|
670
|
+
...(audience === DEFAULT_SERVICE_TOKEN_AUDIENCE ? {} : { audience }),
|
|
671
|
+
});
|
|
672
|
+
const current = this.serviceTokens.get(cacheKey);
|
|
631
673
|
if (current) {
|
|
632
674
|
current.token = granted.token;
|
|
633
675
|
current.expiresAt = Date.now() + granted.expiresIn * 1000;
|
|
@@ -639,12 +681,11 @@ export class OxyServer extends OxyServices {
|
|
|
639
681
|
try {
|
|
640
682
|
return await pending;
|
|
641
683
|
} catch (error) {
|
|
642
|
-
const failed = this.serviceTokens.get(
|
|
643
|
-
if (failed?.pending === pending && !failed.token)
|
|
644
|
-
this.serviceTokens.delete(WORKLOAD_CACHE_KEY);
|
|
684
|
+
const failed = this.serviceTokens.get(cacheKey);
|
|
685
|
+
if (failed?.pending === pending && !failed.token) this.serviceTokens.delete(cacheKey);
|
|
645
686
|
throw error;
|
|
646
687
|
} finally {
|
|
647
|
-
const settled = this.serviceTokens.get(
|
|
688
|
+
const settled = this.serviceTokens.get(cacheKey);
|
|
648
689
|
if (settled?.pending === pending) settled.pending = null;
|
|
649
690
|
}
|
|
650
691
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -21,8 +21,9 @@ export {
|
|
|
21
21
|
OxyServer,
|
|
22
22
|
ServiceCredentialMismatchError,
|
|
23
23
|
ANONYMOUS_SERVICE_TOKEN_RETRY_MS,
|
|
24
|
+
DEFAULT_SERVICE_TOKEN_AUDIENCE,
|
|
24
25
|
} from './OxyServer';
|
|
25
|
-
export type { OxyServerConfig, ServiceTokenResponse } from './OxyServer';
|
|
26
|
+
export type { OxyServerConfig, ServiceTokenAudience, ServiceTokenResponse } from './OxyServer';
|
|
26
27
|
export {
|
|
27
28
|
ServerAppsApi,
|
|
28
29
|
ServerAgencyApi,
|
|
@@ -41,6 +41,11 @@ const STS_BODY = 'Action=GetCallerIdentity&Version=2011-06-15';
|
|
|
41
41
|
export interface WorkloadServiceTokenOptions {
|
|
42
42
|
/** Oxy's base URL, e.g. `https://api.oxy.so`. */
|
|
43
43
|
baseUrl: string;
|
|
44
|
+
/**
|
|
45
|
+
* The resource server the token is for (e.g. `kaana.ai`). Omitted, Oxy mints
|
|
46
|
+
* for `oxy-api`, as it always has.
|
|
47
|
+
*/
|
|
48
|
+
audience?: string;
|
|
44
49
|
/** Injected in tests; defaults to the global. */
|
|
45
50
|
fetch?: typeof fetch;
|
|
46
51
|
}
|
|
@@ -233,7 +238,12 @@ export async function requestWorkloadServiceToken(
|
|
|
233
238
|
const tokenResponse = await fetchImpl(`${base}/auth/service-token/workload`, {
|
|
234
239
|
method: 'POST',
|
|
235
240
|
headers: { 'content-type': 'application/json' },
|
|
236
|
-
body: JSON.stringify({
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
provider: 'aws-iam',
|
|
243
|
+
nonce: challenge.nonce,
|
|
244
|
+
attestation,
|
|
245
|
+
...(options.audience === undefined ? {} : { audience: options.audience }),
|
|
246
|
+
}),
|
|
237
247
|
});
|
|
238
248
|
if (!tokenResponse.ok) {
|
|
239
249
|
// The body carries a bounded `reason`; the token never travels on a failure,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a browser has to send a CORS preflight (`OPTIONS`) before this
|
|
3
|
+
* cross-origin request goes out, per the Fetch standard's CORS-safelisted
|
|
4
|
+
* methods and request headers.
|
|
5
|
+
*
|
|
6
|
+
* The SDK uses it for one invariant: its anonymous telemetry headers
|
|
7
|
+
* (`X-Oxy-Activity-Id`, `X-Oxy-Edge-Region`) ride only on requests that are
|
|
8
|
+
* preflighted anyway, so they never turn a request that would go straight out
|
|
9
|
+
* into one that first waits a round trip for an `OPTIONS`. A browser caches a
|
|
10
|
+
* preflight per URL, so without this every distinct anonymous read (a post's
|
|
11
|
+
* replies, a profile, a public app record) paid a round trip of its own.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately conservative: a header it does not recognise as safelisted
|
|
14
|
+
* counts as needing a preflight, which only means the telemetry headers ride
|
|
15
|
+
* along exactly as they always did. Value-level rules (for example `Accept`'s
|
|
16
|
+
* 128-byte limit) are not modelled; a misjudgement in that direction drops two
|
|
17
|
+
* advisory headers from one request and nothing else.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const SAFELISTED_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD', 'POST']);
|
|
21
|
+
|
|
22
|
+
const SAFELISTED_HEADERS: ReadonlySet<string> = new Set([
|
|
23
|
+
'accept',
|
|
24
|
+
'accept-language',
|
|
25
|
+
'content-language',
|
|
26
|
+
'content-type',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const SAFELISTED_CONTENT_TYPES: ReadonlySet<string> = new Set([
|
|
30
|
+
'application/x-www-form-urlencoded',
|
|
31
|
+
'multipart/form-data',
|
|
32
|
+
'text/plain',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
export function requiresCorsPreflight(
|
|
36
|
+
method: string,
|
|
37
|
+
headers: Iterable<readonly [string, string]>,
|
|
38
|
+
): boolean {
|
|
39
|
+
if (!SAFELISTED_METHODS.has(method.toUpperCase())) return true;
|
|
40
|
+
for (const [rawName, value] of headers) {
|
|
41
|
+
const name = rawName.toLowerCase();
|
|
42
|
+
if (!SAFELISTED_HEADERS.has(name)) return true;
|
|
43
|
+
if (name === 'content-type') {
|
|
44
|
+
const essence = value.split(';')[0]?.trim().toLowerCase() ?? '';
|
|
45
|
+
if (!SAFELISTED_CONTENT_TYPES.has(essence)) return true;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|