@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.
@@ -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
- * An app declares its keys (`payments.operate`, …) in the Console, its owners
7
- * grant them to people, and its backend asks — so no app keeps a
8
- * `*_OPERATOR_OXY_USER_IDS` allow-list in its environment.
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
- /** Ask about another application than the server's configured one. */
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 `req` holds. Throws {@link OxyAppPermissionError};
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
- req: AppPermissionsRequest,
258
+ source: AppPermissionsSource,
209
259
  options: AppPermissionsOptions = {},
210
260
  ): Promise<OxyAppPermissions> {
211
- const caller = readCallerSession(req);
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
- * Forget cached answers: every one, or those about one person (matched as
248
- * either the session's user or the resolved operator).
249
- */
250
- invalidate(filter: { userId?: string } = {}): void {
251
- this.cache.invalidate(filter);
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: OxyCallerSession,
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(key: string, userId: string, value: T, startedAt: number, ttl: number): void {
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);
@@ -97,6 +97,8 @@ export {
97
97
  } from './appPermissions';
98
98
  export type {
99
99
  AppPermissionsOptions,
100
+ AppPermissionsSource,
101
+ OxyMcpCallerRequest,
100
102
  OxyAppPermissionDenyStatus,
101
103
  OxyAppPermissionErrorCode,
102
104
  OxyAppPermissions,