@oxy.so/core 7.19.1 → 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 +2 -2
- package/dist/cjs/HttpService.js +33 -1
- package/dist/cjs/server/OxyServer.js +48 -5
- package/dist/cjs/server/appPermissions.js +92 -11
- package/dist/esm/HttpService.js +33 -1
- package/dist/esm/server/OxyServer.js +49 -6
- package/dist/esm/server/appPermissions.js +93 -12
- package/dist/types/HttpService.d.ts +12 -1
- package/dist/types/server/OxyServer.d.ts +14 -2
- package/dist/types/server/appPermissions.d.ts +53 -4
- package/dist/types/server/callerAuthority.d.ts +11 -1
- package/dist/types/server/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/HttpService.ts +43 -1
- package/src/server/OxyServer.ts +70 -16
- package/src/server/appPermissions.ts +158 -16
- package/src/server/callerAuthority.ts +20 -3
- package/src/server/index.ts +2 -0
|
@@ -34,6 +34,23 @@
|
|
|
34
34
|
* `X-Oxy-User-Id`, is refused — an operator power is exercised by a person in
|
|
35
35
|
* a session, never through delegation.
|
|
36
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
|
+
*
|
|
37
54
|
* ## Caching, and how soon a revocation lands
|
|
38
55
|
*
|
|
39
56
|
* Answers are cached per (application, session, user) for
|
|
@@ -47,6 +64,7 @@
|
|
|
47
64
|
* Node only; reachable solely from `@oxy.so/core/server`.
|
|
48
65
|
*/
|
|
49
66
|
|
|
67
|
+
import { createHash } from 'node:crypto';
|
|
50
68
|
import { applicationRolesMeSchema, isApplicationPermissionKey } from '@oxy.so/contracts';
|
|
51
69
|
import { logger } from '../logger';
|
|
52
70
|
import {
|
|
@@ -54,6 +72,7 @@ import {
|
|
|
54
72
|
CallerAuthorityCache,
|
|
55
73
|
errorStatus,
|
|
56
74
|
permissionSet,
|
|
75
|
+
isBearerToken,
|
|
57
76
|
readCallerSession,
|
|
58
77
|
type OxyCallerRequest,
|
|
59
78
|
} from './callerAuthority';
|
|
@@ -67,7 +86,8 @@ export type OxyAppPermissionDenyStatus = 403 | 404;
|
|
|
67
86
|
/**
|
|
68
87
|
* - `APP_PERMISSION_DENIED` — a person in a session, without the permission.
|
|
69
88
|
* - `APP_PERMISSION_SESSION_REQUIRED` — no user session on the request: anonymous,
|
|
70
|
-
* 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).
|
|
71
91
|
* - `APP_PERMISSION_UNAVAILABLE` — Oxy could not be asked or gave no usable
|
|
72
92
|
* answer. Fails closed; answered 503.
|
|
73
93
|
* - `APP_PERMISSION_NOT_CONFIGURED` — this server knows no application id, or
|
|
@@ -107,6 +127,8 @@ export interface OxyAppPermissions {
|
|
|
107
127
|
* The HUMAN Oxy resolved from the session — record THIS as the actor in an
|
|
108
128
|
* audit log. It differs from `req.userId` when a person acts as an
|
|
109
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.
|
|
110
132
|
*/
|
|
111
133
|
readonly operatorId: string;
|
|
112
134
|
/** The held keys, sorted. */
|
|
@@ -117,7 +139,11 @@ export interface OxyAppPermissions {
|
|
|
117
139
|
export interface AppPermissionsOptions {
|
|
118
140
|
/** Skip the cache and ask Oxy (the answer still refreshes the cache). Use on writes. */
|
|
119
141
|
fresh?: boolean;
|
|
120
|
-
/**
|
|
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
|
+
*/
|
|
121
147
|
applicationId?: string;
|
|
122
148
|
}
|
|
123
149
|
|
|
@@ -143,6 +169,22 @@ export interface AppPermissionsRequest extends OxyCallerRequest {
|
|
|
143
169
|
oxyAppPermissions?: OxyAppPermissions;
|
|
144
170
|
}
|
|
145
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
|
+
|
|
146
188
|
/** The status the middleware answers an error with, under `deny`. */
|
|
147
189
|
export function appPermissionErrorStatus(
|
|
148
190
|
error: OxyAppPermissionError,
|
|
@@ -184,6 +226,11 @@ function heldPermissions(
|
|
|
184
226
|
export interface AppPermissionsHost {
|
|
185
227
|
/** `GET /applications/:appId/roles/me` with this bearer, uncached. Rejects with the API error. */
|
|
186
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>;
|
|
187
234
|
/** The configured application id, or the one this server's service token names. */
|
|
188
235
|
defaultApplicationId(): Promise<string | null>;
|
|
189
236
|
readonly ttlMs: number;
|
|
@@ -204,14 +251,16 @@ export class OxyAppPermissionsResolver {
|
|
|
204
251
|
}
|
|
205
252
|
|
|
206
253
|
/**
|
|
207
|
-
* What the person behind `
|
|
254
|
+
* What the person behind `source` holds. Throws {@link OxyAppPermissionError};
|
|
208
255
|
* never answers "nothing held" for a failure.
|
|
209
256
|
*/
|
|
210
257
|
async resolve(
|
|
211
|
-
|
|
258
|
+
source: AppPermissionsSource,
|
|
212
259
|
options: AppPermissionsOptions = {},
|
|
213
260
|
): Promise<OxyAppPermissions> {
|
|
214
|
-
|
|
261
|
+
if (isMcpCallerRequest(source)) return this.resolveMcp(source, options);
|
|
262
|
+
|
|
263
|
+
const caller = readCallerSession(source);
|
|
215
264
|
if (!caller.ok) {
|
|
216
265
|
throw new OxyAppPermissionError(
|
|
217
266
|
'APP_PERMISSION_SESSION_REQUIRED',
|
|
@@ -221,6 +270,49 @@ export class OxyAppPermissionsResolver {
|
|
|
221
270
|
);
|
|
222
271
|
}
|
|
223
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> {
|
|
224
316
|
let applicationId = options.applicationId ?? null;
|
|
225
317
|
if (!applicationId) {
|
|
226
318
|
try {
|
|
@@ -239,19 +331,69 @@ export class OxyAppPermissionsResolver {
|
|
|
239
331
|
'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }',
|
|
240
332
|
);
|
|
241
333
|
}
|
|
242
|
-
|
|
243
|
-
const appId = applicationId;
|
|
244
|
-
return this.cache.read([appId], caller.session, options.fresh === true, () =>
|
|
245
|
-
this.lookup(appId, caller.session.accessToken),
|
|
246
|
-
);
|
|
334
|
+
return applicationId;
|
|
247
335
|
}
|
|
248
336
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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);
|
|
255
397
|
}
|
|
256
398
|
|
|
257
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);
|