@oxy.so/core 7.16.0 → 7.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -13
- package/dist/cjs/server/OxyServer.js +85 -3
- package/dist/cjs/server/appPermissions.js +14 -92
- package/dist/cjs/server/callerAccess.js +151 -0
- package/dist/cjs/server/callerAuthority.js +206 -0
- package/dist/cjs/server/capabilityTicket.js +39 -6
- package/dist/cjs/server/cors.js +41 -2
- package/dist/cjs/server/index.js +33 -5
- package/dist/cjs/server/liveCapabilityTicket.js +4 -2
- package/dist/cjs/server/middleware.js +104 -2
- package/dist/cjs/server/namespaces.js +15 -4
- package/dist/cjs/server/safeFetch.js +29 -1
- package/dist/cjs/server/userClient.js +127 -0
- package/dist/cjs/utils/cache.js +58 -22
- package/dist/esm/server/OxyServer.js +85 -3
- package/dist/esm/server/appPermissions.js +12 -89
- package/dist/esm/server/callerAccess.js +145 -0
- package/dist/esm/server/callerAuthority.js +194 -0
- package/dist/esm/server/capabilityTicket.js +38 -6
- package/dist/esm/server/cors.js +39 -2
- package/dist/esm/server/index.js +26 -3
- package/dist/esm/server/liveCapabilityTicket.js +5 -3
- package/dist/esm/server/middleware.js +104 -2
- package/dist/esm/server/namespaces.js +15 -4
- package/dist/esm/server/safeFetch.js +29 -1
- package/dist/esm/server/userClient.js +121 -0
- package/dist/esm/utils/cache.js +57 -22
- package/dist/types/api/accounts.d.ts +8 -1
- package/dist/types/api/apps.d.ts +22 -4
- package/dist/types/index.d.ts +1 -1
- package/dist/types/server/OxyServer.d.ts +74 -7
- package/dist/types/server/appPermissions.d.ts +3 -13
- package/dist/types/server/auth.d.ts +5 -0
- package/dist/types/server/callerAccess.d.ts +86 -0
- package/dist/types/server/callerAuthority.d.ts +127 -0
- package/dist/types/server/capabilityTicket.d.ts +23 -1
- package/dist/types/server/cors.d.ts +39 -0
- package/dist/types/server/index.d.ts +31 -7
- package/dist/types/server/liveCapabilityTicket.d.ts +6 -2
- package/dist/types/server/middleware.d.ts +34 -2
- package/dist/types/server/namespaces.d.ts +18 -2
- package/dist/types/server/safeFetch.d.ts +10 -0
- package/dist/types/server/userClient.d.ts +71 -0
- package/dist/types/utils/cache.d.ts +13 -0
- package/package.json +1 -1
- package/src/api/accounts.ts +8 -1
- package/src/api/apps.ts +23 -4
- package/src/index.ts +1 -0
- package/src/server/OxyServer.ts +126 -7
- package/src/server/appPermissions.ts +28 -105
- package/src/server/auth.ts +5 -0
- package/src/server/callerAccess.ts +232 -0
- package/src/server/callerAuthority.ts +267 -0
- package/src/server/capabilityTicket.ts +76 -17
- package/src/server/cors.ts +63 -2
- package/src/server/index.ts +38 -4
- package/src/server/liveCapabilityTicket.ts +12 -5
- package/src/server/middleware.ts +171 -4
- package/src/server/namespaces.ts +36 -5
- package/src/server/safeFetch.ts +31 -1
- package/src/server/userClient.ts +147 -0
- package/src/utils/cache.ts +56 -18
|
@@ -29,6 +29,16 @@
|
|
|
29
29
|
* response; without one, a server that sends its headers and stalls holds
|
|
30
30
|
* any other reader open.
|
|
31
31
|
*
|
|
32
|
+
* NOT FOR OXY'S OWN HOSTS
|
|
33
|
+
* -----------------------
|
|
34
|
+
* `safeFetch` is for UNTRUSTED or caller-influenced URLs only. Never use it to
|
|
35
|
+
* reach a first-party host (`api.oxy.so`, any `*.oxy.so`, an Oxy product's
|
|
36
|
+
* API): inside the VPC those names resolve to the internal load balancer
|
|
37
|
+
* (split-horizon DNS), a private address this module refuses by design. Call
|
|
38
|
+
* Oxy through `OxyServer` — its namespaces, `serviceRequest`, or
|
|
39
|
+
* `createServiceClient({ baseURL, audience }).fetch` for another Oxy product.
|
|
40
|
+
* A refusal of such a host says so in its message.
|
|
41
|
+
*
|
|
32
42
|
* Node-only: this module imports `node:http`/`node:https`/`node:dns` and is
|
|
33
43
|
* exported solely from `@oxy.so/core/server`. It MUST NOT be reachable from the
|
|
34
44
|
* browser `@oxy.so/core` entry.
|
|
@@ -46,6 +56,7 @@ const node_https_1 = __importDefault(require("node:https"));
|
|
|
46
56
|
const promises_1 = require("node:dns/promises");
|
|
47
57
|
const node_net_1 = require("node:net");
|
|
48
58
|
const node_url_1 = require("node:url");
|
|
59
|
+
const officialOrigins_1 = require("../utils/officialOrigins");
|
|
49
60
|
const deadline_1 = require("./deadline");
|
|
50
61
|
const ipCidr_1 = require("./ipCidr");
|
|
51
62
|
/** Maximum accepted length of an input URL (DoS guard). */
|
|
@@ -255,6 +266,21 @@ async function assertSafePublicUrl(rawUrl, options = {}) {
|
|
|
255
266
|
family: chosen.family === 4 ? 4 : 6,
|
|
256
267
|
};
|
|
257
268
|
}
|
|
269
|
+
/** What a refusal of a first-party Oxy host adds to its reason. */
|
|
270
|
+
const FIRST_PARTY_HOST_HINT = 'first-party Oxy host: inside the VPC it resolves to a private address, which safeFetch refuses by design; call it through OxyServer (its namespaces, serviceRequest, or createServiceClient().fetch), not safeFetch';
|
|
271
|
+
/** `reason`, plus the first-party hint when `rawUrl` names an official Oxy host. */
|
|
272
|
+
function rejectionReason(rawUrl, reason) {
|
|
273
|
+
let origin;
|
|
274
|
+
try {
|
|
275
|
+
origin = new node_url_1.URL(rawUrl).origin;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
return reason;
|
|
279
|
+
}
|
|
280
|
+
return (0, officialOrigins_1.isOfficialWebOrigin)(origin) && !(0, officialOrigins_1.isLoopbackOrigin)(origin)
|
|
281
|
+
? `${reason} (${FIRST_PARTY_HOST_HINT})`
|
|
282
|
+
: reason;
|
|
283
|
+
}
|
|
258
284
|
/** Marker error for a blocked SSRF target (map to 403 at the route layer). */
|
|
259
285
|
class SsrfRejection extends Error {
|
|
260
286
|
constructor(reason) {
|
|
@@ -353,7 +379,9 @@ async function safeFetch(rawUrl, options = {}) {
|
|
|
353
379
|
signal,
|
|
354
380
|
});
|
|
355
381
|
if (!guard.ok) {
|
|
356
|
-
throw guard.incomplete
|
|
382
|
+
throw guard.incomplete
|
|
383
|
+
? new UpstreamError(guard.reason)
|
|
384
|
+
: new SsrfRejection(rejectionReason(currentUrl, guard.reason));
|
|
357
385
|
}
|
|
358
386
|
const target = new node_url_1.URL(currentUrl);
|
|
359
387
|
const requestOptions = buildRequestOptions(target, guard.ip, guard.family, method, baseHeaders, signal);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Calling Oxy AS the person behind a request.
|
|
4
|
+
*
|
|
5
|
+
* A backend's `OxyServer` is a process singleton authenticated as the backend
|
|
6
|
+
* itself; a user's bearer must never be planted on it (`setTokens`,
|
|
7
|
+
* `session.setAccessToken`), because every concurrent request would then speak
|
|
8
|
+
* as that user. A read that must be answered for the person — their own
|
|
9
|
+
* account graph, an application they manage, their profile — goes through an
|
|
10
|
+
* {@link OxyUserClient}: every namespace of `OxyServices`, bound to that one
|
|
11
|
+
* bearer, sharing no mutable state with the server.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* // Express, after oxy.middleware.auth():
|
|
15
|
+
* const node = await oxy.withUser(req, (user) => user.accounts.get(accountId));
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* What makes it safe to create one per request:
|
|
19
|
+
*
|
|
20
|
+
* - **No response cache.** The client is built with `enableCache: false`, so
|
|
21
|
+
* one person's answers are never served to anyone, including the same
|
|
22
|
+
* person's next request, and nothing is registered with the module-wide
|
|
23
|
+
* cache sweep.
|
|
24
|
+
* - **Exactly this bearer, every time.** Every request carries it as an
|
|
25
|
+
* explicit `Authorization` header: never refreshed (that is the user's own
|
|
26
|
+
* client's job), never dropped for an expired or opaque token, never
|
|
27
|
+
* replaced by the server's service token. Oxy answers a revoked or expired
|
|
28
|
+
* bearer 401, which the access readers map to `ACCESS_SESSION_REQUIRED`.
|
|
29
|
+
* - **Deterministic end.** `withUser` disposes the client when the callback
|
|
30
|
+
* settles, so a reference that escapes the callback holds no bearer. A
|
|
31
|
+
* client from `asUser` that is never disposed holds no global resource
|
|
32
|
+
* either; it is collected with its last reference.
|
|
33
|
+
*
|
|
34
|
+
* Node only; reachable solely from `@oxy.so/core/server`.
|
|
35
|
+
*/
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
exports.OxyUserClient = void 0;
|
|
38
|
+
exports.userClientConfig = userClientConfig;
|
|
39
|
+
exports.userBearerOf = userBearerOf;
|
|
40
|
+
const OxyServices_1 = require("../OxyServices");
|
|
41
|
+
const callerAuthority_1 = require("./callerAuthority");
|
|
42
|
+
/** The transport settings a user client inherits from its server; never auth, cache or app identity. */
|
|
43
|
+
const INHERITED_TRANSPORT_KEYS = [
|
|
44
|
+
'baseURL',
|
|
45
|
+
'cloudURL',
|
|
46
|
+
'requestTimeout',
|
|
47
|
+
'enableRetry',
|
|
48
|
+
'maxRetries',
|
|
49
|
+
'retryDelay',
|
|
50
|
+
'maxConcurrentRequests',
|
|
51
|
+
'requestQueueSize',
|
|
52
|
+
'enableLogging',
|
|
53
|
+
'logLevel',
|
|
54
|
+
'onRequestStart',
|
|
55
|
+
'onRequestEnd',
|
|
56
|
+
'onRequestError',
|
|
57
|
+
];
|
|
58
|
+
/** The transport half of a server config, for a user client. */
|
|
59
|
+
function userClientConfig(config) {
|
|
60
|
+
const transport = {};
|
|
61
|
+
for (const key of INHERITED_TRANSPORT_KEYS) {
|
|
62
|
+
if (config[key] !== undefined)
|
|
63
|
+
transport[key] = config[key];
|
|
64
|
+
}
|
|
65
|
+
return { ...transport, enableCache: false };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The user bearer `source` carries. Throws `OxyAccessError`
|
|
69
|
+
* (`ACCESS_SESSION_REQUIRED`) for a request with no user session — anonymous,
|
|
70
|
+
* or authenticated by a SERVICE token, whose bearer is the calling service's
|
|
71
|
+
* and never a person's.
|
|
72
|
+
*/
|
|
73
|
+
function userBearerOf(source) {
|
|
74
|
+
if (typeof source === 'string') {
|
|
75
|
+
if (!(0, callerAuthority_1.isBearerToken)(source)) {
|
|
76
|
+
throw new callerAuthority_1.OxyAccessError('ACCESS_SESSION_REQUIRED', 'A user client needs a bearer token');
|
|
77
|
+
}
|
|
78
|
+
return source;
|
|
79
|
+
}
|
|
80
|
+
if (source.serviceApp) {
|
|
81
|
+
throw new callerAuthority_1.OxyAccessError('ACCESS_SESSION_REQUIRED', 'This request is authenticated by a service token; a user client needs a person’s session');
|
|
82
|
+
}
|
|
83
|
+
const token = source.accessToken;
|
|
84
|
+
if (typeof token !== 'string' || !(0, callerAuthority_1.isBearerToken)(token)) {
|
|
85
|
+
throw new callerAuthority_1.OxyAccessError('ACCESS_SESSION_REQUIRED', 'No verified user session on this request; mount middleware.auth() first');
|
|
86
|
+
}
|
|
87
|
+
return token;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Every `OxyServices` namespace, authenticated as one person. Create it with
|
|
91
|
+
* `OxyServer.withUser` (disposed for you) or `OxyServer.asUser` (dispose it
|
|
92
|
+
* yourself); the constructor is not part of the public contract.
|
|
93
|
+
*/
|
|
94
|
+
class OxyUserClient extends OxyServices_1.OxyServices {
|
|
95
|
+
/** @internal Use `OxyServer.asUser` / `OxyServer.withUser`. */
|
|
96
|
+
constructor(config, accessToken) {
|
|
97
|
+
super(config);
|
|
98
|
+
this.bearer = accessToken;
|
|
99
|
+
// For the namespaces that read the session's identity (`session.userId`).
|
|
100
|
+
this.http.setTokens(accessToken);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Every namespace call goes through here: it carries this client's bearer
|
|
104
|
+
* explicitly, which also keeps the response out of any cache and out of
|
|
105
|
+
* request de-duplication. Rejects once the client is disposed.
|
|
106
|
+
*/
|
|
107
|
+
async request(method, url, data, options = {}) {
|
|
108
|
+
const bearer = this.bearer;
|
|
109
|
+
if (!bearer)
|
|
110
|
+
throw new Error('This Oxy user client has been disposed');
|
|
111
|
+
// This client speaks as its person only: no caller-supplied credential,
|
|
112
|
+
// in any letter case, rides along.
|
|
113
|
+
const headers = {};
|
|
114
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
115
|
+
if (name.toLowerCase() !== 'authorization')
|
|
116
|
+
headers[name] = value;
|
|
117
|
+
}
|
|
118
|
+
headers.Authorization = `Bearer ${bearer}`;
|
|
119
|
+
return super.request(method, url, data, { ...options, headers });
|
|
120
|
+
}
|
|
121
|
+
/** Forget the bearer: every later call rejects. */
|
|
122
|
+
dispose() {
|
|
123
|
+
this.bearer = null;
|
|
124
|
+
super.dispose();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
exports.OxyUserClient = OxyUserClient;
|
package/dist/cjs/utils/cache.js
CHANGED
|
@@ -11,6 +11,7 @@ exports.createCache = createCache;
|
|
|
11
11
|
exports.registerCacheForCleanup = registerCacheForCleanup;
|
|
12
12
|
exports.unregisterCacheFromCleanup = unregisterCacheFromCleanup;
|
|
13
13
|
exports.stopAllCleanupIntervals = stopAllCleanupIntervals;
|
|
14
|
+
exports.sweptCacheCount = sweptCacheCount;
|
|
14
15
|
/**
|
|
15
16
|
* TTL-based cache implementation
|
|
16
17
|
*
|
|
@@ -48,6 +49,10 @@ class TTLCache {
|
|
|
48
49
|
this.cache = new Map();
|
|
49
50
|
this.hits = 0;
|
|
50
51
|
this.misses = 0;
|
|
52
|
+
/** Registered for the shared expiry sweep (see {@link registerCacheForCleanup}). */
|
|
53
|
+
this.swept = false;
|
|
54
|
+
/** The sweep generation this cache registered in; {@link stopAllCleanupIntervals} ends it. */
|
|
55
|
+
this.sweptGeneration = -1;
|
|
51
56
|
this.defaultTTL = defaultTTL;
|
|
52
57
|
this.maxEntries = maxEntries;
|
|
53
58
|
}
|
|
@@ -66,6 +71,7 @@ class TTLCache {
|
|
|
66
71
|
if (now >= entry.expiresAt) {
|
|
67
72
|
this.cache.delete(key);
|
|
68
73
|
this.misses++;
|
|
74
|
+
this.syncSweep();
|
|
69
75
|
return null;
|
|
70
76
|
}
|
|
71
77
|
// Re-insert so Map order is recency order and eviction drops the LRU.
|
|
@@ -97,8 +103,7 @@ class TTLCache {
|
|
|
97
103
|
if (oldest !== undefined)
|
|
98
104
|
this.cache.delete(oldest);
|
|
99
105
|
}
|
|
100
|
-
|
|
101
|
-
updateCleanupInterval();
|
|
106
|
+
this.syncSweep();
|
|
102
107
|
}
|
|
103
108
|
/**
|
|
104
109
|
* Delete a specific cache entry
|
|
@@ -107,10 +112,8 @@ class TTLCache {
|
|
|
107
112
|
*/
|
|
108
113
|
delete(key) {
|
|
109
114
|
const deleted = this.cache.delete(key);
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (deleted && this.cache.size === 0 && activeCaches.has(this))
|
|
113
|
-
updateCleanupInterval();
|
|
115
|
+
if (deleted)
|
|
116
|
+
this.syncSweep();
|
|
114
117
|
return deleted;
|
|
115
118
|
}
|
|
116
119
|
/**
|
|
@@ -120,8 +123,7 @@ class TTLCache {
|
|
|
120
123
|
this.cache.clear();
|
|
121
124
|
this.hits = 0;
|
|
122
125
|
this.misses = 0;
|
|
123
|
-
|
|
124
|
-
updateCleanupInterval();
|
|
126
|
+
this.syncSweep();
|
|
125
127
|
}
|
|
126
128
|
/**
|
|
127
129
|
* Delete every entry whose key matches, in ONE pass.
|
|
@@ -135,8 +137,8 @@ class TTLCache {
|
|
|
135
137
|
removed++;
|
|
136
138
|
}
|
|
137
139
|
}
|
|
138
|
-
if (removed > 0
|
|
139
|
-
|
|
140
|
+
if (removed > 0)
|
|
141
|
+
this.syncSweep();
|
|
140
142
|
return removed;
|
|
141
143
|
}
|
|
142
144
|
/**
|
|
@@ -150,6 +152,7 @@ class TTLCache {
|
|
|
150
152
|
return false;
|
|
151
153
|
if (Date.now() >= entry.expiresAt) {
|
|
152
154
|
this.cache.delete(key);
|
|
155
|
+
this.syncSweep();
|
|
153
156
|
return false;
|
|
154
157
|
}
|
|
155
158
|
return true;
|
|
@@ -169,6 +172,7 @@ class TTLCache {
|
|
|
169
172
|
this.cache.delete(key);
|
|
170
173
|
}
|
|
171
174
|
}
|
|
175
|
+
this.syncSweep();
|
|
172
176
|
return validKeys;
|
|
173
177
|
}
|
|
174
178
|
/**
|
|
@@ -184,8 +188,29 @@ class TTLCache {
|
|
|
184
188
|
removed++;
|
|
185
189
|
}
|
|
186
190
|
}
|
|
191
|
+
if (removed > 0)
|
|
192
|
+
this.syncSweep();
|
|
187
193
|
return removed;
|
|
188
194
|
}
|
|
195
|
+
/**
|
|
196
|
+
* Start or stop being swept: a registered cache is held by the shared sweep
|
|
197
|
+
* ONLY while it has entries to expire. An empty cache is referenced by
|
|
198
|
+
* nothing global, so a client nobody disposed is collected with its cache.
|
|
199
|
+
*/
|
|
200
|
+
setSwept(swept) {
|
|
201
|
+
this.swept = swept;
|
|
202
|
+
this.sweptGeneration = sweepGeneration;
|
|
203
|
+
this.syncSweep();
|
|
204
|
+
}
|
|
205
|
+
syncSweep() {
|
|
206
|
+
if (this.swept && this.sweptGeneration === sweepGeneration && this.cache.size > 0) {
|
|
207
|
+
sweptCaches.add(this);
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
sweptCaches.delete(this);
|
|
211
|
+
}
|
|
212
|
+
updateCleanupInterval();
|
|
213
|
+
}
|
|
189
214
|
/**
|
|
190
215
|
* Get cache size (number of entries)
|
|
191
216
|
*/
|
|
@@ -225,23 +250,30 @@ function createCache(ttl = 5 * 60 * 1000) {
|
|
|
225
250
|
return new TTLCache(ttl);
|
|
226
251
|
}
|
|
227
252
|
/**
|
|
228
|
-
*
|
|
229
|
-
*
|
|
253
|
+
* The shared expiry sweep: one unref'd interval, running only while some
|
|
254
|
+
* registered cache holds entries.
|
|
255
|
+
*
|
|
256
|
+
* `sweptCaches` holds exactly the registered caches that are NON-EMPTY. A
|
|
257
|
+
* cache joins when it gains its first entry and leaves when it is emptied — by
|
|
258
|
+
* the sweep itself once its entries expire — so the module never pins a cache
|
|
259
|
+
* that has nothing to expire. That is what keeps a short-lived client that was
|
|
260
|
+
* never disposed (one per request, say) collectable: the most it can be held
|
|
261
|
+
* for is its entries' TTL.
|
|
230
262
|
*/
|
|
231
263
|
let cleanupInterval = null;
|
|
232
|
-
const
|
|
264
|
+
const sweptCaches = new Set();
|
|
265
|
+
/** Bumped by {@link stopAllCleanupIntervals}, which ends every registration. */
|
|
266
|
+
let sweepGeneration = 0;
|
|
233
267
|
/**
|
|
234
268
|
* Register a cache for automatic cleanup
|
|
235
269
|
* @param cache Cache instance to register
|
|
236
270
|
*/
|
|
237
271
|
function registerCacheForCleanup(cache) {
|
|
238
|
-
|
|
239
|
-
updateCleanupInterval();
|
|
272
|
+
cache.setSwept(true);
|
|
240
273
|
}
|
|
241
274
|
/** Empty registered caches need no timer; importing a client must stay inert. */
|
|
242
275
|
function updateCleanupInterval() {
|
|
243
|
-
|
|
244
|
-
if (!hasEntries) {
|
|
276
|
+
if (sweptCaches.size === 0) {
|
|
245
277
|
if (cleanupInterval)
|
|
246
278
|
clearInterval(cleanupInterval);
|
|
247
279
|
cleanupInterval = null;
|
|
@@ -250,25 +282,29 @@ function updateCleanupInterval() {
|
|
|
250
282
|
if (cleanupInterval)
|
|
251
283
|
return;
|
|
252
284
|
cleanupInterval = setInterval(() => {
|
|
253
|
-
|
|
285
|
+
// `cleanup()` removes an emptied cache from the set as it goes.
|
|
286
|
+
for (const cache of [...sweptCaches])
|
|
254
287
|
cache.cleanup();
|
|
255
|
-
updateCleanupInterval();
|
|
256
288
|
}, 60000);
|
|
257
289
|
cleanupInterval.unref?.();
|
|
258
290
|
}
|
|
259
291
|
/** Unregister a cache without changing its entries or TTL behavior. */
|
|
260
292
|
function unregisterCacheFromCleanup(cache) {
|
|
261
|
-
|
|
262
|
-
updateCleanupInterval();
|
|
293
|
+
cache.setSwept(false);
|
|
263
294
|
}
|
|
264
295
|
/**
|
|
265
296
|
* Stop all cleanup intervals (useful for testing)
|
|
266
297
|
* This will clear the interval and unregister all caches
|
|
267
298
|
*/
|
|
268
299
|
function stopAllCleanupIntervals() {
|
|
300
|
+
sweepGeneration += 1;
|
|
301
|
+
sweptCaches.clear();
|
|
269
302
|
if (cleanupInterval) {
|
|
270
303
|
clearInterval(cleanupInterval);
|
|
271
304
|
cleanupInterval = null;
|
|
272
305
|
}
|
|
273
|
-
|
|
306
|
+
}
|
|
307
|
+
/** How many caches the shared sweep currently holds (tests). @internal */
|
|
308
|
+
function sweptCacheCount() {
|
|
309
|
+
return sweptCaches.size;
|
|
274
310
|
}
|
|
@@ -52,6 +52,8 @@ import { canAttestWorkloadIdentity, requestWorkloadServiceToken, } from './workl
|
|
|
52
52
|
import { createOxyMiddleware, OXY_ACCOUNT_DELETED_EVENT_URI, OXY_JWT_ISSUER, OxyAccountEventError, parseJsonSegment, resolveServiceTokenPublicKey, } from './middleware.js';
|
|
53
53
|
import { APP_PERMISSIONS_CACHE_TTL_MS, OxyAppPermissionsResolver, } from './appPermissions.js';
|
|
54
54
|
import { ServerAccountsApi, ServerAppsApi, ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi, } from './namespaces.js';
|
|
55
|
+
import { OxyCallerAccessResolver, } from './callerAccess.js';
|
|
56
|
+
import { OxyUserClient, userBearerOf, userClientConfig } from './userClient.js';
|
|
55
57
|
import { ServerSafetyApi } from './safety.js';
|
|
56
58
|
import { SESSION_VALIDATION_CACHE_TTL_MS, ServerSessionApi } from './session.js';
|
|
57
59
|
import { OxyServiceEventFeed } from './serviceEvents.js';
|
|
@@ -166,15 +168,24 @@ export class OxyServer extends OxyServices {
|
|
|
166
168
|
defaultApplicationId: () => this.defaultApplicationId(),
|
|
167
169
|
fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken),
|
|
168
170
|
});
|
|
171
|
+
this.callerAccess = new OxyCallerAccessResolver({
|
|
172
|
+
ttlMs: ttl,
|
|
173
|
+
readAccount: (accountId, accessToken) => this.withUser(accessToken, (user) => user.accounts.get(accountId)),
|
|
174
|
+
readApplication: (applicationId, accessToken) => this.withUser(accessToken, (user) => user.apps.get(applicationId)),
|
|
175
|
+
});
|
|
176
|
+
const defaultAudience = config.expectedAudience?.trim() || undefined;
|
|
169
177
|
this.middleware = createOxyMiddleware({
|
|
170
178
|
get baseURL() {
|
|
171
179
|
return server.baseURL;
|
|
172
180
|
},
|
|
181
|
+
defaultAudience,
|
|
173
182
|
jwksCache: this.jwksCache,
|
|
174
183
|
validateSession: (sessionId, options) => this.session.validate(sessionId, options),
|
|
175
184
|
verifyActingAs: (appId, userId, options) => this.verifyActingAs(appId, userId, options),
|
|
176
185
|
introspectServiceToken: (token) => this.introspectServiceToken(token),
|
|
177
186
|
appPermissionsFor: (req, options) => this.appPermissionsFor(req, options),
|
|
187
|
+
accountAccessFor: (req, accountId, options) => this.accountAccessFor(req, accountId, options),
|
|
188
|
+
applicationAccessFor: (req, applicationId, options) => this.applicationAccessFor(req, applicationId, options),
|
|
178
189
|
});
|
|
179
190
|
}
|
|
180
191
|
/** A product SDK transport authenticated only by this process's workload. */
|
|
@@ -601,12 +612,83 @@ export class OxyServer extends OxyServices {
|
|
|
601
612
|
return this.appPermissions.resolve(req, options);
|
|
602
613
|
}
|
|
603
614
|
/**
|
|
604
|
-
* Drop cached
|
|
605
|
-
*
|
|
606
|
-
*
|
|
615
|
+
* Drop cached caller-authority answers — application permissions and
|
|
616
|
+
* account / application access — all of them, or one person's (as the
|
|
617
|
+
* session user or the resolved operator). For a backend that learns of a
|
|
618
|
+
* revocation out of band and will not wait out the TTL.
|
|
607
619
|
*/
|
|
608
620
|
invalidateAppPermissions(filter = {}) {
|
|
609
621
|
this.appPermissions.invalidate(filter);
|
|
622
|
+
this.callerAccess.invalidate(filter);
|
|
623
|
+
}
|
|
624
|
+
// ── The requesting person ────────────────────────────────────────────────
|
|
625
|
+
/**
|
|
626
|
+
* An isolated Oxy client authenticated as the person behind `source` — a
|
|
627
|
+
* request `middleware.auth()` resolved to a user session, or a bearer you
|
|
628
|
+
* already verified. Every `OxyServices` namespace, with no response cache,
|
|
629
|
+
* no token refresh and no fallback to this server's service identity; this
|
|
630
|
+
* server's own session is never touched.
|
|
631
|
+
*
|
|
632
|
+
* Prefer {@link withUser}, which disposes it for you. A client from here
|
|
633
|
+
* should be disposed when the request ends (`client.dispose()`) so a stray
|
|
634
|
+
* reference cannot keep using the bearer; one that is not holds no global
|
|
635
|
+
* resource and is collected normally.
|
|
636
|
+
*
|
|
637
|
+
* Throws `OxyAccessError` (`ACCESS_SESSION_REQUIRED`) for a request with no
|
|
638
|
+
* user session, including one authenticated by a service token.
|
|
639
|
+
*/
|
|
640
|
+
asUser(source) {
|
|
641
|
+
const config = userClientConfig(this.config);
|
|
642
|
+
return new OxyUserClient({ ...config, baseURL: this.baseURL, cloudURL: this.cloudURL }, userBearerOf(source));
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Run `use` with a client authenticated as the person behind `source`
|
|
646
|
+
* ({@link asUser}), and dispose it when `use` settles — success or failure.
|
|
647
|
+
*
|
|
648
|
+
* ```ts
|
|
649
|
+
* const accounts = await oxy.withUser(req, (user) => user.accounts.list());
|
|
650
|
+
* ```
|
|
651
|
+
*/
|
|
652
|
+
async withUser(source, use) {
|
|
653
|
+
const user = this.asUser(source);
|
|
654
|
+
try {
|
|
655
|
+
return await use(user);
|
|
656
|
+
}
|
|
657
|
+
finally {
|
|
658
|
+
user.dispose();
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* What the person behind `req` may do in Oxy account `accountId` — an
|
|
663
|
+
* organization, store, channel or their own personal account: relationship,
|
|
664
|
+
* role, source and the EFFECTIVE permissions (inheritance, grants and
|
|
665
|
+
* revokes applied). Read with the request's own verified bearer.
|
|
666
|
+
*
|
|
667
|
+
* Cached per session for `appPermissionsCacheTtlMs` (30 s), positive
|
|
668
|
+
* answers only; pass `{ fresh: true }` before a write. Throws
|
|
669
|
+
* `OxyAccessError`: `ACCESS_SESSION_REQUIRED` (no session, a service token,
|
|
670
|
+
* or Oxy answered 401), `ACCESS_DENIED` (Oxy answered 403/404 — no access,
|
|
671
|
+
* or no such account), `ACCESS_UNAVAILABLE` (anything else; never a "no").
|
|
672
|
+
* Map it with `accessErrorStatus`. Gate on `has(permission)`, never on
|
|
673
|
+
* `role`.
|
|
674
|
+
*/
|
|
675
|
+
accountAccessFor(req, accountId, options = {}) {
|
|
676
|
+
return this.callerAccess.account(req, accountId, options);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* What the person behind `req` may do to Oxy application `applicationId`,
|
|
680
|
+
* through their membership of the account that owns it (developer RBAC:
|
|
681
|
+
* `app:read`, `app:update`, `webhooks:update`, `billing:manage`, …), plus
|
|
682
|
+
* its `ownerAccountId` and whether it is `official`. Read with the
|
|
683
|
+
* request's own verified bearer.
|
|
684
|
+
*
|
|
685
|
+
* Not {@link appPermissionsFor}: that one answers which of THIS backend's
|
|
686
|
+
* own declared operator keys the person holds. Same cache, options and
|
|
687
|
+
* errors as {@link accountAccessFor}; Oxy refuses (403) a caller with no
|
|
688
|
+
* `app:read`, so a successful answer always includes it.
|
|
689
|
+
*/
|
|
690
|
+
applicationAccessFor(req, applicationId, options = {}) {
|
|
691
|
+
return this.callerAccess.application(req, applicationId, options);
|
|
610
692
|
}
|
|
611
693
|
/**
|
|
612
694
|
* Drop cached session validations — all of them, one user's or one
|
|
@@ -45,10 +45,9 @@
|
|
|
45
45
|
*/
|
|
46
46
|
import { applicationRolesMeSchema, isApplicationPermissionKey } from '@oxy.so/contracts';
|
|
47
47
|
import { logger } from '../logger/index.js';
|
|
48
|
+
import { authorityErrorBody, CallerAuthorityCache, errorStatus, permissionSet, readCallerSession, } from './callerAuthority.js';
|
|
48
49
|
/** Default cache lifetime of one answer. */
|
|
49
50
|
export const APP_PERMISSIONS_CACHE_TTL_MS = 30000;
|
|
50
|
-
/** Entries kept per server; the oldest goes first. */
|
|
51
|
-
const APP_PERMISSIONS_CACHE_MAX = 1000;
|
|
52
51
|
/** The typed failure of {@link OxyAppPermissionsResolver.resolve} and the middleware. */
|
|
53
52
|
export class OxyAppPermissionError extends Error {
|
|
54
53
|
constructor(code, message, options = {}) {
|
|
@@ -73,20 +72,7 @@ export function appPermissionErrorStatus(error, deny = 404) {
|
|
|
73
72
|
}
|
|
74
73
|
}
|
|
75
74
|
/** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
|
|
76
|
-
export
|
|
77
|
-
switch (status) {
|
|
78
|
-
case 401:
|
|
79
|
-
return { error: { code: 'unauthorized', message: 'Authentication required' } };
|
|
80
|
-
case 403:
|
|
81
|
-
return { error: { code: 'forbidden', message: 'Not permitted' } };
|
|
82
|
-
case 404:
|
|
83
|
-
return { error: { code: 'not_found', message: 'Not found' } };
|
|
84
|
-
case 503:
|
|
85
|
-
return { error: { code: 'service_unavailable', message: 'Temporarily unavailable' } };
|
|
86
|
-
default:
|
|
87
|
-
return { error: { code: 'internal_error', message: 'Internal error' } };
|
|
88
|
-
}
|
|
89
|
-
}
|
|
75
|
+
export const appPermissionErrorBody = authorityErrorBody;
|
|
90
76
|
/** Throws at MOUNT time on a key that could never be held — a typo is a deploy error. */
|
|
91
77
|
export function assertApplicationPermissionKey(permission) {
|
|
92
78
|
if (!isApplicationPermissionKey(permission)) {
|
|
@@ -94,18 +80,7 @@ export function assertApplicationPermissionKey(permission) {
|
|
|
94
80
|
}
|
|
95
81
|
}
|
|
96
82
|
function heldPermissions(applicationId, operatorId, permissions) {
|
|
97
|
-
|
|
98
|
-
const set = new Set(sorted);
|
|
99
|
-
return Object.freeze({
|
|
100
|
-
applicationId,
|
|
101
|
-
operatorId,
|
|
102
|
-
permissions: sorted,
|
|
103
|
-
has: (permission) => set.has(permission),
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
function errorStatus(error) {
|
|
107
|
-
const status = error?.status;
|
|
108
|
-
return typeof status === 'number' ? status : undefined;
|
|
83
|
+
return Object.freeze({ applicationId, operatorId, ...permissionSet(permissions) });
|
|
109
84
|
}
|
|
110
85
|
/**
|
|
111
86
|
* The cache and the lookup behind `OxyServer.appPermissionsFor` and
|
|
@@ -114,22 +89,18 @@ function errorStatus(error) {
|
|
|
114
89
|
export class OxyAppPermissionsResolver {
|
|
115
90
|
constructor(host) {
|
|
116
91
|
this.host = host;
|
|
117
|
-
this.cache = new
|
|
118
|
-
this.pending = new Map();
|
|
92
|
+
this.cache = new CallerAuthorityCache(host.ttlMs, (value, userId) => value.operatorId === userId);
|
|
119
93
|
}
|
|
120
94
|
/**
|
|
121
95
|
* What the person behind `req` holds. Throws {@link OxyAppPermissionError};
|
|
122
96
|
* never answers "nothing held" for a failure.
|
|
123
97
|
*/
|
|
124
98
|
async resolve(req, options = {}) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const accessToken = typeof req.accessToken === 'string' && req.accessToken.length > 0 ? req.accessToken : null;
|
|
131
|
-
if (!userId || !sessionId || !accessToken) {
|
|
132
|
-
throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'No verified user session on this request; mount middleware.auth() first');
|
|
99
|
+
const caller = readCallerSession(req);
|
|
100
|
+
if (!caller.ok) {
|
|
101
|
+
throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', caller.reason === 'service'
|
|
102
|
+
? 'Application permissions are held by a person in a session; this request is authenticated by a service token'
|
|
103
|
+
: 'No verified user session on this request; mount middleware.auth() first');
|
|
133
104
|
}
|
|
134
105
|
let applicationId = options.applicationId ?? null;
|
|
135
106
|
if (!applicationId) {
|
|
@@ -143,63 +114,15 @@ export class OxyAppPermissionsResolver {
|
|
|
143
114
|
if (!applicationId) {
|
|
144
115
|
throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }');
|
|
145
116
|
}
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
if (options.fresh !== true && ttl > 0) {
|
|
149
|
-
const cached = this.cache.get(key);
|
|
150
|
-
if (cached && cached.expiresAt > Date.now()) {
|
|
151
|
-
return cached.value;
|
|
152
|
-
}
|
|
153
|
-
const inFlight = this.pending.get(key);
|
|
154
|
-
if (inFlight)
|
|
155
|
-
return inFlight;
|
|
156
|
-
}
|
|
157
|
-
const startedAt = Date.now();
|
|
158
|
-
const lookup = this.lookup(applicationId, accessToken).then((value) => {
|
|
159
|
-
if (ttl > 0)
|
|
160
|
-
this.remember(key, value, startedAt, ttl);
|
|
161
|
-
return value;
|
|
162
|
-
});
|
|
163
|
-
if (options.fresh === true || ttl <= 0)
|
|
164
|
-
return lookup;
|
|
165
|
-
this.pending.set(key, lookup);
|
|
166
|
-
try {
|
|
167
|
-
return await lookup;
|
|
168
|
-
}
|
|
169
|
-
finally {
|
|
170
|
-
if (this.pending.get(key) === lookup)
|
|
171
|
-
this.pending.delete(key);
|
|
172
|
-
}
|
|
117
|
+
const appId = applicationId;
|
|
118
|
+
return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
|
|
173
119
|
}
|
|
174
120
|
/**
|
|
175
121
|
* Forget cached answers: every one, or those about one person (matched as
|
|
176
122
|
* either the session's user or the resolved operator).
|
|
177
123
|
*/
|
|
178
124
|
invalidate(filter = {}) {
|
|
179
|
-
|
|
180
|
-
this.cache.clear();
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
for (const [key, entry] of this.cache) {
|
|
184
|
-
const [, , userId] = JSON.parse(key);
|
|
185
|
-
if (userId === filter.userId || entry.value.operatorId === filter.userId) {
|
|
186
|
-
this.cache.delete(key);
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
remember(key, value, startedAt, ttl) {
|
|
191
|
-
// A slower, OLDER read must not overwrite a newer one — the newer one may
|
|
192
|
-
// be the fresh read that saw a revocation.
|
|
193
|
-
const existing = this.cache.get(key);
|
|
194
|
-
if (existing && existing.startedAt > startedAt)
|
|
195
|
-
return;
|
|
196
|
-
this.cache.delete(key);
|
|
197
|
-
this.cache.set(key, { value, startedAt, expiresAt: startedAt + ttl });
|
|
198
|
-
if (this.cache.size > APP_PERMISSIONS_CACHE_MAX) {
|
|
199
|
-
const oldest = this.cache.keys().next().value;
|
|
200
|
-
if (oldest !== undefined)
|
|
201
|
-
this.cache.delete(oldest);
|
|
202
|
-
}
|
|
125
|
+
this.cache.invalidate(filter);
|
|
203
126
|
}
|
|
204
127
|
async lookup(applicationId, accessToken) {
|
|
205
128
|
let answer;
|