@oxy.so/core 7.19.0 → 7.20.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 +6 -3
- package/dist/cjs/HttpService.js +33 -1
- package/dist/cjs/server/OxyServer.js +48 -5
- package/dist/cjs/server/appPermissions.js +99 -15
- package/dist/esm/HttpService.js +33 -1
- package/dist/esm/server/OxyServer.js +49 -6
- package/dist/esm/server/appPermissions.js +100 -16
- package/dist/types/HttpService.d.ts +12 -1
- package/dist/types/server/OxyServer.d.ts +14 -2
- package/dist/types/server/appPermissions.d.ts +60 -8
- package/dist/types/server/callerAuthority.d.ts +11 -1
- package/dist/types/server/index.d.ts +1 -1
- package/package.json +2 -2
- package/src/HttpService.ts +43 -1
- package/src/server/OxyServer.ts +70 -16
- package/src/server/appPermissions.ts +165 -20
- package/src/server/callerAuthority.ts +20 -3
- package/src/server/index.ts +2 -0
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* Application roles, from the application's side: which of THIS application's
|
|
3
3
|
* declared permissions does the person behind a request hold?
|
|
4
4
|
*
|
|
5
|
-
* Oxy decides who holds an app's operator powers (`docs/auth/application-roles.md`)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Oxy decides who holds an app's operator powers (`docs/auth/application-roles.md`),
|
|
6
|
+
* and the backend asks — so no app keeps a `*_OPERATOR_OXY_USER_IDS` allow-list
|
|
7
|
+
* in its environment. One of Oxy's own applications declares its keys
|
|
8
|
+
* (`payments.operate`, …) in Oxy's first-party registry, each held by roles of
|
|
9
|
+
* the owning organization (ADR 0056); a third-party app declares them in the
|
|
10
|
+
* Console and its owners grant them to people. The answer has the same shape
|
|
11
|
+
* either way.
|
|
9
12
|
*
|
|
10
13
|
* ```ts
|
|
11
14
|
* const oxy = new OxyServer({ baseURL: 'https://api.oxy.so', applicationId: process.env.OXY_APPLICATION_ID });
|
|
@@ -31,6 +34,23 @@
|
|
|
31
34
|
* `X-Oxy-User-Id`, is refused — an operator power is exercised by a person in
|
|
32
35
|
* a session, never through delegation.
|
|
33
36
|
*
|
|
37
|
+
* ## An MCP tool call
|
|
38
|
+
*
|
|
39
|
+
* An MCP request carries no session: its bearer is an access token Oxy issued
|
|
40
|
+
* for this app's MCP resource, which `@oxy.so/mcp`'s transport introspected
|
|
41
|
+
* live before the tool ran. Pass the call's `extra` (`context.request` in a
|
|
42
|
+
* catalog handler or `authorize`) the same way:
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* const held = await oxy.appPermissionsFor(context.request, { fresh: true });
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* The SDK presents that token, with THIS process's service token, to
|
|
49
|
+
* `POST /auth/mcp/oauth/app-permissions`. Oxy answers only for a token whose
|
|
50
|
+
* resource this application registered, only about this application, and for
|
|
51
|
+
* the person who authorized the token — whichever account the connection is
|
|
52
|
+
* acting as. Same answer type, cache, `fresh` and errors as a session.
|
|
53
|
+
*
|
|
34
54
|
* ## Caching, and how soon a revocation lands
|
|
35
55
|
*
|
|
36
56
|
* Answers are cached per (application, session, user) for
|
|
@@ -44,6 +64,7 @@
|
|
|
44
64
|
* Node only; reachable solely from `@oxy.so/core/server`.
|
|
45
65
|
*/
|
|
46
66
|
|
|
67
|
+
import { createHash } from 'node:crypto';
|
|
47
68
|
import { applicationRolesMeSchema, isApplicationPermissionKey } from '@oxy.so/contracts';
|
|
48
69
|
import { logger } from '../logger';
|
|
49
70
|
import {
|
|
@@ -51,6 +72,7 @@ import {
|
|
|
51
72
|
CallerAuthorityCache,
|
|
52
73
|
errorStatus,
|
|
53
74
|
permissionSet,
|
|
75
|
+
isBearerToken,
|
|
54
76
|
readCallerSession,
|
|
55
77
|
type OxyCallerRequest,
|
|
56
78
|
} from './callerAuthority';
|
|
@@ -64,7 +86,8 @@ export type OxyAppPermissionDenyStatus = 403 | 404;
|
|
|
64
86
|
/**
|
|
65
87
|
* - `APP_PERMISSION_DENIED` — a person in a session, without the permission.
|
|
66
88
|
* - `APP_PERMISSION_SESSION_REQUIRED` — no user session on the request: anonymous,
|
|
67
|
-
* or authenticated by a service token (delegated or not)
|
|
89
|
+
* or authenticated by a service token (delegated or not); for an MCP call, no
|
|
90
|
+
* access token, or one Oxy refused (revoked, expired, another app's resource).
|
|
68
91
|
* - `APP_PERMISSION_UNAVAILABLE` — Oxy could not be asked or gave no usable
|
|
69
92
|
* answer. Fails closed; answered 503.
|
|
70
93
|
* - `APP_PERMISSION_NOT_CONFIGURED` — this server knows no application id, or
|
|
@@ -104,6 +127,8 @@ export interface OxyAppPermissions {
|
|
|
104
127
|
* The HUMAN Oxy resolved from the session — record THIS as the actor in an
|
|
105
128
|
* audit log. It differs from `req.userId` when a person acts as an
|
|
106
129
|
* organization (the session speaks as the org; the operator is the person).
|
|
130
|
+
* For an MCP call it is the person who authorized the token, which may differ
|
|
131
|
+
* from the account the connection is acting as.
|
|
107
132
|
*/
|
|
108
133
|
readonly operatorId: string;
|
|
109
134
|
/** The held keys, sorted. */
|
|
@@ -114,7 +139,11 @@ export interface OxyAppPermissions {
|
|
|
114
139
|
export interface AppPermissionsOptions {
|
|
115
140
|
/** Skip the cache and ask Oxy (the answer still refreshes the cache). Use on writes. */
|
|
116
141
|
fresh?: boolean;
|
|
117
|
-
/**
|
|
142
|
+
/**
|
|
143
|
+
* Ask about another application than the server's configured one. Not for an
|
|
144
|
+
* MCP call: Oxy answers it only about the application that registered the
|
|
145
|
+
* token's resource, and any other id is `APP_PERMISSION_NOT_CONFIGURED`.
|
|
146
|
+
*/
|
|
118
147
|
applicationId?: string;
|
|
119
148
|
}
|
|
120
149
|
|
|
@@ -140,6 +169,22 @@ export interface AppPermissionsRequest extends OxyCallerRequest {
|
|
|
140
169
|
oxyAppPermissions?: OxyAppPermissions;
|
|
141
170
|
}
|
|
142
171
|
|
|
172
|
+
/**
|
|
173
|
+
* An MCP tool call: the `extra` (`RequestHandlerExtra`) an MCP handler receives,
|
|
174
|
+
* whose `authInfo` `@oxy.so/mcp`'s transport set after Oxy introspected the
|
|
175
|
+
* access token live. `CatalogInvocationContext.request` is one.
|
|
176
|
+
*/
|
|
177
|
+
export interface OxyMcpCallerRequest {
|
|
178
|
+
readonly authInfo?: { readonly token: string };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** What `appPermissionsFor` reads: a request after `middleware.auth()`, or an MCP tool call. */
|
|
182
|
+
export type AppPermissionsSource = AppPermissionsRequest | OxyMcpCallerRequest;
|
|
183
|
+
|
|
184
|
+
function isMcpCallerRequest(source: AppPermissionsSource): source is OxyMcpCallerRequest {
|
|
185
|
+
return (source as OxyMcpCallerRequest).authInfo !== undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
143
188
|
/** The status the middleware answers an error with, under `deny`. */
|
|
144
189
|
export function appPermissionErrorStatus(
|
|
145
190
|
error: OxyAppPermissionError,
|
|
@@ -181,6 +226,11 @@ function heldPermissions(
|
|
|
181
226
|
export interface AppPermissionsHost {
|
|
182
227
|
/** `GET /applications/:appId/roles/me` with this bearer, uncached. Rejects with the API error. */
|
|
183
228
|
fetchRolesMe(applicationId: string, accessToken: string): Promise<unknown>;
|
|
229
|
+
/**
|
|
230
|
+
* `POST /auth/mcp/oauth/app-permissions` with this MCP access token, as this
|
|
231
|
+
* server's service, uncached. Rejects with the API error.
|
|
232
|
+
*/
|
|
233
|
+
fetchMcpAppPermissions(mcpAccessToken: string): Promise<unknown>;
|
|
184
234
|
/** The configured application id, or the one this server's service token names. */
|
|
185
235
|
defaultApplicationId(): Promise<string | null>;
|
|
186
236
|
readonly ttlMs: number;
|
|
@@ -201,14 +251,16 @@ export class OxyAppPermissionsResolver {
|
|
|
201
251
|
}
|
|
202
252
|
|
|
203
253
|
/**
|
|
204
|
-
* What the person behind `
|
|
254
|
+
* What the person behind `source` holds. Throws {@link OxyAppPermissionError};
|
|
205
255
|
* never answers "nothing held" for a failure.
|
|
206
256
|
*/
|
|
207
257
|
async resolve(
|
|
208
|
-
|
|
258
|
+
source: AppPermissionsSource,
|
|
209
259
|
options: AppPermissionsOptions = {},
|
|
210
260
|
): Promise<OxyAppPermissions> {
|
|
211
|
-
|
|
261
|
+
if (isMcpCallerRequest(source)) return this.resolveMcp(source, options);
|
|
262
|
+
|
|
263
|
+
const caller = readCallerSession(source);
|
|
212
264
|
if (!caller.ok) {
|
|
213
265
|
throw new OxyAppPermissionError(
|
|
214
266
|
'APP_PERMISSION_SESSION_REQUIRED',
|
|
@@ -218,6 +270,49 @@ export class OxyAppPermissionsResolver {
|
|
|
218
270
|
);
|
|
219
271
|
}
|
|
220
272
|
|
|
273
|
+
const appId = await this.applicationIdFor(options);
|
|
274
|
+
return this.cache.read([appId], caller.session, options.fresh === true, () =>
|
|
275
|
+
this.lookup(appId, caller.session.accessToken),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Forget cached answers: every one, or those about one person (matched as
|
|
281
|
+
* either the session's user or the resolved operator).
|
|
282
|
+
*/
|
|
283
|
+
invalidate(filter: { userId?: string } = {}): void {
|
|
284
|
+
this.cache.invalidate(filter);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* An MCP call: Oxy names the application (the one that registered the token's
|
|
289
|
+
* resource) and the person (who authorized the token); the answer is refused
|
|
290
|
+
* unless the application is the one this server expects.
|
|
291
|
+
*/
|
|
292
|
+
private async resolveMcp(
|
|
293
|
+
source: OxyMcpCallerRequest,
|
|
294
|
+
options: AppPermissionsOptions,
|
|
295
|
+
): Promise<OxyAppPermissions> {
|
|
296
|
+
const token = source.authInfo?.token;
|
|
297
|
+
if (typeof token !== 'string' || !isBearerToken(token)) {
|
|
298
|
+
throw new OxyAppPermissionError(
|
|
299
|
+
'APP_PERMISSION_SESSION_REQUIRED',
|
|
300
|
+
'No verified MCP access token on this call',
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const appId = await this.applicationIdFor(options);
|
|
304
|
+
// The person is known only once Oxy answers; the token's digest keys the
|
|
305
|
+
// entry, so the bearer itself is never held as a cache key.
|
|
306
|
+
const cacheKey = {
|
|
307
|
+
sessionId: `mcp:${createHash('sha256').update(token).digest('base64url')}`,
|
|
308
|
+
userId: null,
|
|
309
|
+
};
|
|
310
|
+
return this.cache.read([appId], cacheKey, options.fresh === true, () =>
|
|
311
|
+
this.lookupMcp(appId, token),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private async applicationIdFor(options: AppPermissionsOptions): Promise<string> {
|
|
221
316
|
let applicationId = options.applicationId ?? null;
|
|
222
317
|
if (!applicationId) {
|
|
223
318
|
try {
|
|
@@ -236,19 +331,69 @@ export class OxyAppPermissionsResolver {
|
|
|
236
331
|
'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }',
|
|
237
332
|
);
|
|
238
333
|
}
|
|
239
|
-
|
|
240
|
-
const appId = applicationId;
|
|
241
|
-
return this.cache.read([appId], caller.session, options.fresh === true, () =>
|
|
242
|
-
this.lookup(appId, caller.session.accessToken),
|
|
243
|
-
);
|
|
334
|
+
return applicationId;
|
|
244
335
|
}
|
|
245
336
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
337
|
+
private async lookupMcp(
|
|
338
|
+
applicationId: string,
|
|
339
|
+
mcpAccessToken: string,
|
|
340
|
+
): Promise<OxyAppPermissions> {
|
|
341
|
+
let answer: unknown;
|
|
342
|
+
try {
|
|
343
|
+
answer = await this.host.fetchMcpAppPermissions(mcpAccessToken);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
const status = errorStatus(error);
|
|
346
|
+
if (status === 401 && (error as { code?: unknown }).code === 'invalid_grant') {
|
|
347
|
+
throw new OxyAppPermissionError(
|
|
348
|
+
'APP_PERMISSION_SESSION_REQUIRED',
|
|
349
|
+
'Oxy refused the MCP access token while resolving application permissions',
|
|
350
|
+
{ cause: error },
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (status === 403) {
|
|
354
|
+
throw new OxyAppPermissionError(
|
|
355
|
+
'APP_PERMISSION_DENIED',
|
|
356
|
+
'Oxy refused this application’s permissions for the MCP access token',
|
|
357
|
+
{ cause: error },
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
// Anything else — this service's own credential refused (401
|
|
361
|
+
// `invalid_client`), an Oxy without the route, a 5xx, a timeout — is not
|
|
362
|
+
// an answer about the person.
|
|
363
|
+
logger.warn('[oxy.appPermissions] Could not resolve application permissions for MCP', {
|
|
364
|
+
component: 'auth',
|
|
365
|
+
method: 'appPermissions',
|
|
366
|
+
applicationId,
|
|
367
|
+
status,
|
|
368
|
+
});
|
|
369
|
+
throw new OxyAppPermissionError(
|
|
370
|
+
'APP_PERMISSION_UNAVAILABLE',
|
|
371
|
+
'Could not resolve application permissions from Oxy',
|
|
372
|
+
{ cause: error },
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const parsed = applicationRolesMeSchema.safeParse(answer);
|
|
377
|
+
if (!parsed.success) {
|
|
378
|
+
throw new OxyAppPermissionError(
|
|
379
|
+
'APP_PERMISSION_UNAVAILABLE',
|
|
380
|
+
'Oxy answered with a malformed permission set',
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
if (parsed.data.applicationId !== applicationId) {
|
|
384
|
+
// Oxy answers about the application behind this server's service token;
|
|
385
|
+
// a different one means the configured id names another application.
|
|
386
|
+
logger.error(
|
|
387
|
+
`[oxy.appPermissions] Oxy answered for application ${parsed.data.applicationId}, not ${applicationId}; check OxyServerConfig.applicationId`,
|
|
388
|
+
undefined,
|
|
389
|
+
{ component: 'auth', method: 'appPermissions' },
|
|
390
|
+
);
|
|
391
|
+
throw new OxyAppPermissionError(
|
|
392
|
+
'APP_PERMISSION_NOT_CONFIGURED',
|
|
393
|
+
'An MCP access token answers only for the application behind this server’s service token',
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
return heldPermissions(applicationId, parsed.data.userId, parsed.data.permissions);
|
|
252
397
|
}
|
|
253
398
|
|
|
254
399
|
private async lookup(applicationId: string, accessToken: string): Promise<OxyAppPermissions> {
|
|
@@ -42,6 +42,17 @@ export interface OxyCallerSession {
|
|
|
42
42
|
readonly accessToken: string;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Whose answer a cache entry is: the session (or other credential) it was read
|
|
47
|
+
* with, and the person, when known before asking. A caller whose person only
|
|
48
|
+
* Oxy's answer names (an MCP access token) passes `userId: null`; the reader's
|
|
49
|
+
* `isAbout` still matches the answer for {@link CallerAuthorityCache.invalidate}.
|
|
50
|
+
*/
|
|
51
|
+
export interface OxyCallerCacheKey {
|
|
52
|
+
readonly sessionId: string;
|
|
53
|
+
readonly userId: string | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
45
56
|
/**
|
|
46
57
|
* The person behind `req`, or why there is none: `service` for a request
|
|
47
58
|
* authenticated by a service token (delegated or not — its bearer is the
|
|
@@ -175,7 +186,7 @@ export function accessFailure(error: unknown, what: string): OxyAccessError {
|
|
|
175
186
|
|
|
176
187
|
interface CacheEntry<T> {
|
|
177
188
|
value: T;
|
|
178
|
-
userId: string;
|
|
189
|
+
userId: string | null;
|
|
179
190
|
startedAt: number;
|
|
180
191
|
expiresAt: number;
|
|
181
192
|
}
|
|
@@ -204,7 +215,7 @@ export class CallerAuthorityCache<T> {
|
|
|
204
215
|
|
|
205
216
|
async read(
|
|
206
217
|
resourceKey: readonly string[],
|
|
207
|
-
session:
|
|
218
|
+
session: OxyCallerCacheKey,
|
|
208
219
|
fresh: boolean,
|
|
209
220
|
lookup: () => Promise<T>,
|
|
210
221
|
): Promise<T> {
|
|
@@ -244,7 +255,13 @@ export class CallerAuthorityCache<T> {
|
|
|
244
255
|
}
|
|
245
256
|
}
|
|
246
257
|
|
|
247
|
-
private remember(
|
|
258
|
+
private remember(
|
|
259
|
+
key: string,
|
|
260
|
+
userId: string | null,
|
|
261
|
+
value: T,
|
|
262
|
+
startedAt: number,
|
|
263
|
+
ttl: number,
|
|
264
|
+
): void {
|
|
248
265
|
const existing = this.entries.get(key);
|
|
249
266
|
if (existing && existing.startedAt > startedAt) return;
|
|
250
267
|
this.entries.delete(key);
|