@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 CHANGED
@@ -172,8 +172,8 @@ router.get('/orgs/:accountId', async (req, res) => {
172
172
 
173
173
  `createOxyAuthMiddleware(oxy)` resolves the bearer session and requires a user
174
174
  in one middleware; `createOptionalOxyAuth(oxy)` is the optional form. The
175
- permission questions (`appPermissionsFor`, `accountAccessFor`,
176
- `applicationAccessFor`), user clients (`withUser`), per-call options
175
+ permission questions (`appPermissionsFor` — for a session or an MCP tool call —
176
+ `accountAccessFor`, `applicationAccessFor`), user clients (`withUser`), per-call options
177
177
  (`oxy.withCallOptions({ timeout, deadline, retry, signal })`), files and
178
178
  outbound HTTP (`safeFetch`, with `validated` to reuse an `assertSafePublicUrl`
179
179
  check and `requireHttps` for an all-https chain) are in
@@ -373,6 +373,7 @@ class HttpService {
373
373
  this.authRefreshHandler = null;
374
374
  this.accessTokenProvider = null;
375
375
  this.anonymousAuthProvider = null;
376
+ this.anonymousAuthRejected = null;
376
377
  this.deviceSecretMintInFlight = null;
377
378
  /**
378
379
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -819,6 +820,32 @@ class HttpService {
819
820
  }
820
821
  // Handle response
821
822
  if (!response.ok) {
823
+ // A 401 to the bearer the anonymous provider supplied (a backend's
824
+ // own service token, no user session): once its owner has dropped
825
+ // it, send the request once more with the provider's next one. A
826
+ // 401 means the request never ran, so this is safe for any method.
827
+ if (response.status === 401 &&
828
+ !contextToken &&
829
+ usesSessionAuth &&
830
+ !config.expectedCredentialGeneration &&
831
+ !config._isAuthRetry &&
832
+ authHeader?.startsWith('Bearer ') &&
833
+ this.anonymousAuthRejected?.(authHeader.slice('Bearer '.length))) {
834
+ void discardResponseBody(response);
835
+ if (domain.aborted)
836
+ throw (0, errorUtils_1.createCancelledError)();
837
+ // Same reasons as the session retry below: no dedupe (the original
838
+ // is still in flight under its key), no queue slot (this attempt
839
+ // holds one).
840
+ return this.request({
841
+ ...config,
842
+ _isAuthRetry: true,
843
+ retry: false,
844
+ deduplicate: false,
845
+ bypassQueue: true,
846
+ signal: domain,
847
+ });
848
+ }
822
849
  // On 401, delegate to the installed auth refresh handler and retry
823
850
  // once before giving up. HttpService deliberately does not know any
824
851
  // session routes; the refresh handler owns session rotation.
@@ -1842,9 +1869,14 @@ class HttpService {
1842
1869
  * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
1843
1870
  * consulted only when there is no access token, and never for `skipAuth`
1844
1871
  * requests. `null` removes it.
1872
+ *
1873
+ * `onRejected` hears of a 401 to one of the provider's bearers; when it
1874
+ * drops that bearer (`true`), the request is sent once more with the
1875
+ * provider's next one — a rotated or revoked service token heals itself.
1845
1876
  */
1846
- setAnonymousAuthProvider(provider) {
1877
+ setAnonymousAuthProvider(provider, onRejected) {
1847
1878
  this.anonymousAuthProvider = provider;
1879
+ this.anonymousAuthRejected = provider ? (onRejected ?? null) : null;
1848
1880
  }
1849
1881
  clearTokens() {
1850
1882
  if (this.tokenStore.getAccessToken())
@@ -155,7 +155,7 @@ class OxyServer extends OxyServices_1.OxyServices {
155
155
  };
156
156
  this.context.service = serviceLane;
157
157
  if (config.serviceIdentity === 'when-anonymous') {
158
- this.http.setAnonymousAuthProvider(() => this.serviceTokenForAnonymousRequest());
158
+ this.http.setAnonymousAuthProvider(() => this.serviceTokenForAnonymousRequest(), (token) => this.forgetServiceToken(token));
159
159
  }
160
160
  const ttl = config.appPermissionsCacheTtlMs ?? appPermissions_1.APP_PERMISSIONS_CACHE_TTL_MS;
161
161
  if (!Number.isFinite(ttl) || ttl < 0) {
@@ -171,6 +171,7 @@ class OxyServer extends OxyServices_1.OxyServices {
171
171
  ttlMs: ttl,
172
172
  defaultApplicationId: () => this.defaultApplicationId(),
173
173
  fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken),
174
+ fetchMcpAppPermissions: (mcpAccessToken) => this.fetchMcpAppPermissions(mcpAccessToken),
174
175
  });
175
176
  this.callerAccess = new callerAccess_1.OxyCallerAccessResolver({
176
177
  ttlMs: ttl,
@@ -558,8 +559,7 @@ class OxyServer extends OxyServices_1.OxyServices {
558
559
  throw new Error('Service requests must target the configured API origin');
559
560
  }
560
561
  const { actAs, requesterToken, taskId, capabilityTicket, requesterContext, headers, serviceTokenScopes, ...rest } = options;
561
- const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
562
- const merged = { ...headers, Authorization: `Bearer ${token}` };
562
+ const merged = { ...headers };
563
563
  if (actAs)
564
564
  merged['X-Oxy-User-Id'] = actAs;
565
565
  if ([requesterToken, taskId, capabilityTicket, requesterContext].filter((proof) => proof !== undefined).length > 1)
@@ -572,14 +572,48 @@ class OxyServer extends OxyServices_1.OxyServices {
572
572
  merged['X-Oxy-Capability-Ticket'] = capabilityTicket;
573
573
  if (requesterContext)
574
574
  merged['X-Oxy-Requester-Context'] = requesterContext;
575
- return this.request(method, target.href, data, {
575
+ const send = (token) => this.request(method, target.href, data, {
576
576
  cache: false,
577
577
  ...rest,
578
578
  ...([requesterToken, taskId, capabilityTicket, requesterContext].some((proof) => proof !== undefined)
579
579
  ? { cache: false, deduplicate: false, retry: false }
580
580
  : {}),
581
- headers: merged,
581
+ headers: { ...merged, Authorization: `Bearer ${token}` },
582
582
  });
583
+ const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
584
+ try {
585
+ return await send(token);
586
+ }
587
+ catch (error) {
588
+ // A 401 refused the request before it ran, so sending it again is safe
589
+ // whatever its method. A cached token Oxy no longer accepts (keys
590
+ // rotated, credential revoked) is dropped and the request sent once
591
+ // more with a freshly minted one: once, never after an abort, and only
592
+ // when the refused token was this server's cached one.
593
+ if ((0, errorUtils_1.extractErrorStatus)(error) !== 401 ||
594
+ rest.signal?.aborted ||
595
+ !this.forgetServiceToken(token)) {
596
+ throw error;
597
+ }
598
+ const fresh = await this.platformServiceToken({ scopes: serviceTokenScopes });
599
+ if (fresh === token)
600
+ throw error;
601
+ return send(fresh);
602
+ }
603
+ }
604
+ /**
605
+ * Drop every cached entry that holds `token` (and is not minting): `true`
606
+ * when one did, so the next mint asks Oxy again.
607
+ */
608
+ forgetServiceToken(token) {
609
+ let forgot = false;
610
+ for (const [cacheKey, entry] of this.serviceTokens) {
611
+ if (entry.token === token && !entry.pending) {
612
+ this.serviceTokens.delete(cacheKey);
613
+ forgot = true;
614
+ }
615
+ }
616
+ return forgot;
583
617
  }
584
618
  // ── Application roles ────────────────────────────────────────────────────
585
619
  /**
@@ -588,6 +622,12 @@ class OxyServer extends OxyServices_1.OxyServices {
588
622
  * must have passed `middleware.auth()`: the answer is read from Oxy with the
589
623
  * request's own verified bearer, for the human operator of its session.
590
624
  *
625
+ * An MCP tool call is asked the same way: pass its `extra`
626
+ * (`CatalogInvocationContext.request`), whose `authInfo.token` is the access
627
+ * token `@oxy.so/mcp` introspected. Oxy answers, for the person who
628
+ * authorized that token, only about the application that registered its
629
+ * resource — this server's own (`APP_PERMISSION_NOT_CONFIGURED` otherwise).
630
+ *
591
631
  * Cached per session for `appPermissionsCacheTtlMs` (30 s); pass
592
632
  * `{ fresh: true }` before a write. Throws `OxyAppPermissionError` — no
593
633
  * session (`APP_PERMISSION_SESSION_REQUIRED`, also for any service-token
@@ -700,6 +740,9 @@ class OxyServer extends OxyServices_1.OxyServices {
700
740
  }
701
741
  return this.request('GET', `/applications/${encodeURIComponent(applicationId)}/roles/me`, undefined, { cache: false, retry: false, headers: { Authorization: `Bearer ${accessToken}` } });
702
742
  }
743
+ fetchMcpAppPermissions(mcpAccessToken) {
744
+ return this.serviceRequest('POST', '/auth/mcp/oauth/app-permissions', { token: mcpAccessToken }, { cache: false, deduplicate: false, retry: false });
745
+ }
703
746
  // ── Delegation and account events ────────────────────────────────────────
704
747
  /**
705
748
  * Resolve authority live by default. Explicit read-only reuse lasts at most
@@ -35,6 +35,23 @@
35
35
  * `X-Oxy-User-Id`, is refused — an operator power is exercised by a person in
36
36
  * a session, never through delegation.
37
37
  *
38
+ * ## An MCP tool call
39
+ *
40
+ * An MCP request carries no session: its bearer is an access token Oxy issued
41
+ * for this app's MCP resource, which `@oxy.so/mcp`'s transport introspected
42
+ * live before the tool ran. Pass the call's `extra` (`context.request` in a
43
+ * catalog handler or `authorize`) the same way:
44
+ *
45
+ * ```ts
46
+ * const held = await oxy.appPermissionsFor(context.request, { fresh: true });
47
+ * ```
48
+ *
49
+ * The SDK presents that token, with THIS process's service token, to
50
+ * `POST /auth/mcp/oauth/app-permissions`. Oxy answers only for a token whose
51
+ * resource this application registered, only about this application, and for
52
+ * the person who authorized the token — whichever account the connection is
53
+ * acting as. Same answer type, cache, `fresh` and errors as a session.
54
+ *
38
55
  * ## Caching, and how soon a revocation lands
39
56
  *
40
57
  * Answers are cached per (application, session, user) for
@@ -51,6 +68,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
51
68
  exports.OxyAppPermissionsResolver = exports.appPermissionErrorBody = exports.OxyAppPermissionError = exports.APP_PERMISSIONS_CACHE_TTL_MS = void 0;
52
69
  exports.appPermissionErrorStatus = appPermissionErrorStatus;
53
70
  exports.assertApplicationPermissionKey = assertApplicationPermissionKey;
71
+ const node_crypto_1 = require("node:crypto");
54
72
  const contracts_1 = require("@oxy.so/contracts");
55
73
  const logger_1 = require("../logger");
56
74
  const callerAuthority_1 = require("./callerAuthority");
@@ -67,6 +85,9 @@ class OxyAppPermissionError extends Error {
67
85
  }
68
86
  }
69
87
  exports.OxyAppPermissionError = OxyAppPermissionError;
88
+ function isMcpCallerRequest(source) {
89
+ return source.authInfo !== undefined;
90
+ }
70
91
  /** The status the middleware answers an error with, under `deny`. */
71
92
  function appPermissionErrorStatus(error, deny = 404) {
72
93
  switch (error.code) {
@@ -101,16 +122,48 @@ class OxyAppPermissionsResolver {
101
122
  this.cache = new callerAuthority_1.CallerAuthorityCache(host.ttlMs, (value, userId) => value.operatorId === userId);
102
123
  }
103
124
  /**
104
- * What the person behind `req` holds. Throws {@link OxyAppPermissionError};
125
+ * What the person behind `source` holds. Throws {@link OxyAppPermissionError};
105
126
  * never answers "nothing held" for a failure.
106
127
  */
107
- async resolve(req, options = {}) {
108
- const caller = (0, callerAuthority_1.readCallerSession)(req);
128
+ async resolve(source, options = {}) {
129
+ if (isMcpCallerRequest(source))
130
+ return this.resolveMcp(source, options);
131
+ const caller = (0, callerAuthority_1.readCallerSession)(source);
109
132
  if (!caller.ok) {
110
133
  throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', caller.reason === 'service'
111
134
  ? 'Application permissions are held by a person in a session; this request is authenticated by a service token'
112
135
  : 'No verified user session on this request; mount middleware.auth() first');
113
136
  }
137
+ const appId = await this.applicationIdFor(options);
138
+ return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
139
+ }
140
+ /**
141
+ * Forget cached answers: every one, or those about one person (matched as
142
+ * either the session's user or the resolved operator).
143
+ */
144
+ invalidate(filter = {}) {
145
+ this.cache.invalidate(filter);
146
+ }
147
+ /**
148
+ * An MCP call: Oxy names the application (the one that registered the token's
149
+ * resource) and the person (who authorized the token); the answer is refused
150
+ * unless the application is the one this server expects.
151
+ */
152
+ async resolveMcp(source, options) {
153
+ const token = source.authInfo?.token;
154
+ if (typeof token !== 'string' || !(0, callerAuthority_1.isBearerToken)(token)) {
155
+ throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'No verified MCP access token on this call');
156
+ }
157
+ const appId = await this.applicationIdFor(options);
158
+ // The person is known only once Oxy answers; the token's digest keys the
159
+ // entry, so the bearer itself is never held as a cache key.
160
+ const cacheKey = {
161
+ sessionId: `mcp:${(0, node_crypto_1.createHash)('sha256').update(token).digest('base64url')}`,
162
+ userId: null,
163
+ };
164
+ return this.cache.read([appId], cacheKey, options.fresh === true, () => this.lookupMcp(appId, token));
165
+ }
166
+ async applicationIdFor(options) {
114
167
  let applicationId = options.applicationId ?? null;
115
168
  if (!applicationId) {
116
169
  try {
@@ -123,15 +176,43 @@ class OxyAppPermissionsResolver {
123
176
  if (!applicationId) {
124
177
  throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }');
125
178
  }
126
- const appId = applicationId;
127
- return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
179
+ return applicationId;
128
180
  }
129
- /**
130
- * Forget cached answers: every one, or those about one person (matched as
131
- * either the session's user or the resolved operator).
132
- */
133
- invalidate(filter = {}) {
134
- this.cache.invalidate(filter);
181
+ async lookupMcp(applicationId, mcpAccessToken) {
182
+ let answer;
183
+ try {
184
+ answer = await this.host.fetchMcpAppPermissions(mcpAccessToken);
185
+ }
186
+ catch (error) {
187
+ const status = (0, callerAuthority_1.errorStatus)(error);
188
+ if (status === 401 && error.code === 'invalid_grant') {
189
+ throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'Oxy refused the MCP access token while resolving application permissions', { cause: error });
190
+ }
191
+ if (status === 403) {
192
+ throw new OxyAppPermissionError('APP_PERMISSION_DENIED', 'Oxy refused this application’s permissions for the MCP access token', { cause: error });
193
+ }
194
+ // Anything else — this service's own credential refused (401
195
+ // `invalid_client`), an Oxy without the route, a 5xx, a timeout — is not
196
+ // an answer about the person.
197
+ logger_1.logger.warn('[oxy.appPermissions] Could not resolve application permissions for MCP', {
198
+ component: 'auth',
199
+ method: 'appPermissions',
200
+ applicationId,
201
+ status,
202
+ });
203
+ throw new OxyAppPermissionError('APP_PERMISSION_UNAVAILABLE', 'Could not resolve application permissions from Oxy', { cause: error });
204
+ }
205
+ const parsed = contracts_1.applicationRolesMeSchema.safeParse(answer);
206
+ if (!parsed.success) {
207
+ throw new OxyAppPermissionError('APP_PERMISSION_UNAVAILABLE', 'Oxy answered with a malformed permission set');
208
+ }
209
+ if (parsed.data.applicationId !== applicationId) {
210
+ // Oxy answers about the application behind this server's service token;
211
+ // a different one means the configured id names another application.
212
+ logger_1.logger.error(`[oxy.appPermissions] Oxy answered for application ${parsed.data.applicationId}, not ${applicationId}; check OxyServerConfig.applicationId`, undefined, { component: 'auth', method: 'appPermissions' });
213
+ throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'An MCP access token answers only for the application behind this server’s service token');
214
+ }
215
+ return heldPermissions(applicationId, parsed.data.userId, parsed.data.permissions);
135
216
  }
136
217
  async lookup(applicationId, accessToken) {
137
218
  let answer;
@@ -369,6 +369,7 @@ export class HttpService {
369
369
  this.authRefreshHandler = null;
370
370
  this.accessTokenProvider = null;
371
371
  this.anonymousAuthProvider = null;
372
+ this.anonymousAuthRejected = null;
372
373
  this.deviceSecretMintInFlight = null;
373
374
  /**
374
375
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -815,6 +816,32 @@ export class HttpService {
815
816
  }
816
817
  // Handle response
817
818
  if (!response.ok) {
819
+ // A 401 to the bearer the anonymous provider supplied (a backend's
820
+ // own service token, no user session): once its owner has dropped
821
+ // it, send the request once more with the provider's next one. A
822
+ // 401 means the request never ran, so this is safe for any method.
823
+ if (response.status === 401 &&
824
+ !contextToken &&
825
+ usesSessionAuth &&
826
+ !config.expectedCredentialGeneration &&
827
+ !config._isAuthRetry &&
828
+ authHeader?.startsWith('Bearer ') &&
829
+ this.anonymousAuthRejected?.(authHeader.slice('Bearer '.length))) {
830
+ void discardResponseBody(response);
831
+ if (domain.aborted)
832
+ throw createCancelledError();
833
+ // Same reasons as the session retry below: no dedupe (the original
834
+ // is still in flight under its key), no queue slot (this attempt
835
+ // holds one).
836
+ return this.request({
837
+ ...config,
838
+ _isAuthRetry: true,
839
+ retry: false,
840
+ deduplicate: false,
841
+ bypassQueue: true,
842
+ signal: domain,
843
+ });
844
+ }
818
845
  // On 401, delegate to the installed auth refresh handler and retry
819
846
  // once before giving up. HttpService deliberately does not know any
820
847
  // session routes; the refresh handler owns session rotation.
@@ -1838,9 +1865,14 @@ export class HttpService {
1838
1865
  * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
1839
1866
  * consulted only when there is no access token, and never for `skipAuth`
1840
1867
  * requests. `null` removes it.
1868
+ *
1869
+ * `onRejected` hears of a 401 to one of the provider's bearers; when it
1870
+ * drops that bearer (`true`), the request is sent once more with the
1871
+ * provider's next one — a rotated or revoked service token heals itself.
1841
1872
  */
1842
- setAnonymousAuthProvider(provider) {
1873
+ setAnonymousAuthProvider(provider, onRejected) {
1843
1874
  this.anonymousAuthProvider = provider;
1875
+ this.anonymousAuthRejected = provider ? (onRejected ?? null) : null;
1844
1876
  }
1845
1877
  clearTokens() {
1846
1878
  if (this.tokenStore.getAccessToken())
@@ -46,7 +46,7 @@ import { OxyServices } from '../OxyServices.js';
46
46
  import { HttpService } from '../HttpService.js';
47
47
  import { createAuthenticatedFetch } from '../authenticatedFetch.js';
48
48
  import { awaitRequestSignal } from '../utils/requestSignal.js';
49
- import { createCancelledError } from '../utils/errorUtils.js';
49
+ import { createCancelledError, extractErrorStatus } from '../utils/errorUtils.js';
50
50
  import { logger } from '../logger/index.js';
51
51
  import { canAttestWorkloadIdentity, requestWorkloadServiceToken, } from './workloadIdentity.js';
52
52
  import { createOxyMiddleware, OXY_ACCOUNT_DELETED_EVENT_URI, OXY_JWT_ISSUER, OxyAccountEventError, parseJsonSegment, resolveServiceTokenPublicKey, } from './middleware.js';
@@ -151,7 +151,7 @@ export class OxyServer extends OxyServices {
151
151
  };
152
152
  this.context.service = serviceLane;
153
153
  if (config.serviceIdentity === 'when-anonymous') {
154
- this.http.setAnonymousAuthProvider(() => this.serviceTokenForAnonymousRequest());
154
+ this.http.setAnonymousAuthProvider(() => this.serviceTokenForAnonymousRequest(), (token) => this.forgetServiceToken(token));
155
155
  }
156
156
  const ttl = config.appPermissionsCacheTtlMs ?? APP_PERMISSIONS_CACHE_TTL_MS;
157
157
  if (!Number.isFinite(ttl) || ttl < 0) {
@@ -167,6 +167,7 @@ export class OxyServer extends OxyServices {
167
167
  ttlMs: ttl,
168
168
  defaultApplicationId: () => this.defaultApplicationId(),
169
169
  fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken),
170
+ fetchMcpAppPermissions: (mcpAccessToken) => this.fetchMcpAppPermissions(mcpAccessToken),
170
171
  });
171
172
  this.callerAccess = new OxyCallerAccessResolver({
172
173
  ttlMs: ttl,
@@ -554,8 +555,7 @@ export class OxyServer extends OxyServices {
554
555
  throw new Error('Service requests must target the configured API origin');
555
556
  }
556
557
  const { actAs, requesterToken, taskId, capabilityTicket, requesterContext, headers, serviceTokenScopes, ...rest } = options;
557
- const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
558
- const merged = { ...headers, Authorization: `Bearer ${token}` };
558
+ const merged = { ...headers };
559
559
  if (actAs)
560
560
  merged['X-Oxy-User-Id'] = actAs;
561
561
  if ([requesterToken, taskId, capabilityTicket, requesterContext].filter((proof) => proof !== undefined).length > 1)
@@ -568,14 +568,48 @@ export class OxyServer extends OxyServices {
568
568
  merged['X-Oxy-Capability-Ticket'] = capabilityTicket;
569
569
  if (requesterContext)
570
570
  merged['X-Oxy-Requester-Context'] = requesterContext;
571
- return this.request(method, target.href, data, {
571
+ const send = (token) => this.request(method, target.href, data, {
572
572
  cache: false,
573
573
  ...rest,
574
574
  ...([requesterToken, taskId, capabilityTicket, requesterContext].some((proof) => proof !== undefined)
575
575
  ? { cache: false, deduplicate: false, retry: false }
576
576
  : {}),
577
- headers: merged,
577
+ headers: { ...merged, Authorization: `Bearer ${token}` },
578
578
  });
579
+ const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
580
+ try {
581
+ return await send(token);
582
+ }
583
+ catch (error) {
584
+ // A 401 refused the request before it ran, so sending it again is safe
585
+ // whatever its method. A cached token Oxy no longer accepts (keys
586
+ // rotated, credential revoked) is dropped and the request sent once
587
+ // more with a freshly minted one: once, never after an abort, and only
588
+ // when the refused token was this server's cached one.
589
+ if (extractErrorStatus(error) !== 401 ||
590
+ rest.signal?.aborted ||
591
+ !this.forgetServiceToken(token)) {
592
+ throw error;
593
+ }
594
+ const fresh = await this.platformServiceToken({ scopes: serviceTokenScopes });
595
+ if (fresh === token)
596
+ throw error;
597
+ return send(fresh);
598
+ }
599
+ }
600
+ /**
601
+ * Drop every cached entry that holds `token` (and is not minting): `true`
602
+ * when one did, so the next mint asks Oxy again.
603
+ */
604
+ forgetServiceToken(token) {
605
+ let forgot = false;
606
+ for (const [cacheKey, entry] of this.serviceTokens) {
607
+ if (entry.token === token && !entry.pending) {
608
+ this.serviceTokens.delete(cacheKey);
609
+ forgot = true;
610
+ }
611
+ }
612
+ return forgot;
579
613
  }
580
614
  // ── Application roles ────────────────────────────────────────────────────
581
615
  /**
@@ -584,6 +618,12 @@ export class OxyServer extends OxyServices {
584
618
  * must have passed `middleware.auth()`: the answer is read from Oxy with the
585
619
  * request's own verified bearer, for the human operator of its session.
586
620
  *
621
+ * An MCP tool call is asked the same way: pass its `extra`
622
+ * (`CatalogInvocationContext.request`), whose `authInfo.token` is the access
623
+ * token `@oxy.so/mcp` introspected. Oxy answers, for the person who
624
+ * authorized that token, only about the application that registered its
625
+ * resource — this server's own (`APP_PERMISSION_NOT_CONFIGURED` otherwise).
626
+ *
587
627
  * Cached per session for `appPermissionsCacheTtlMs` (30 s); pass
588
628
  * `{ fresh: true }` before a write. Throws `OxyAppPermissionError` — no
589
629
  * session (`APP_PERMISSION_SESSION_REQUIRED`, also for any service-token
@@ -696,6 +736,9 @@ export class OxyServer extends OxyServices {
696
736
  }
697
737
  return this.request('GET', `/applications/${encodeURIComponent(applicationId)}/roles/me`, undefined, { cache: false, retry: false, headers: { Authorization: `Bearer ${accessToken}` } });
698
738
  }
739
+ fetchMcpAppPermissions(mcpAccessToken) {
740
+ return this.serviceRequest('POST', '/auth/mcp/oauth/app-permissions', { token: mcpAccessToken }, { cache: false, deduplicate: false, retry: false });
741
+ }
699
742
  // ── Delegation and account events ────────────────────────────────────────
700
743
  /**
701
744
  * Resolve authority live by default. Explicit read-only reuse lasts at most
@@ -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
@@ -46,9 +63,10 @@
46
63
  *
47
64
  * Node only; reachable solely from `@oxy.so/core/server`.
48
65
  */
66
+ import { createHash } from 'node:crypto';
49
67
  import { applicationRolesMeSchema, isApplicationPermissionKey } from '@oxy.so/contracts';
50
68
  import { logger } from '../logger/index.js';
51
- import { authorityErrorBody, CallerAuthorityCache, errorStatus, permissionSet, readCallerSession, } from './callerAuthority.js';
69
+ import { authorityErrorBody, CallerAuthorityCache, errorStatus, permissionSet, isBearerToken, readCallerSession, } from './callerAuthority.js';
52
70
  /** Default cache lifetime of one answer. */
53
71
  export const APP_PERMISSIONS_CACHE_TTL_MS = 30000;
54
72
  /** The typed failure of {@link OxyAppPermissionsResolver.resolve} and the middleware. */
@@ -61,6 +79,9 @@ export class OxyAppPermissionError extends Error {
61
79
  this.cause = options.cause;
62
80
  }
63
81
  }
82
+ function isMcpCallerRequest(source) {
83
+ return source.authInfo !== undefined;
84
+ }
64
85
  /** The status the middleware answers an error with, under `deny`. */
65
86
  export function appPermissionErrorStatus(error, deny = 404) {
66
87
  switch (error.code) {
@@ -95,16 +116,48 @@ export class OxyAppPermissionsResolver {
95
116
  this.cache = new CallerAuthorityCache(host.ttlMs, (value, userId) => value.operatorId === userId);
96
117
  }
97
118
  /**
98
- * What the person behind `req` holds. Throws {@link OxyAppPermissionError};
119
+ * What the person behind `source` holds. Throws {@link OxyAppPermissionError};
99
120
  * never answers "nothing held" for a failure.
100
121
  */
101
- async resolve(req, options = {}) {
102
- const caller = readCallerSession(req);
122
+ async resolve(source, options = {}) {
123
+ if (isMcpCallerRequest(source))
124
+ return this.resolveMcp(source, options);
125
+ const caller = readCallerSession(source);
103
126
  if (!caller.ok) {
104
127
  throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', caller.reason === 'service'
105
128
  ? 'Application permissions are held by a person in a session; this request is authenticated by a service token'
106
129
  : 'No verified user session on this request; mount middleware.auth() first');
107
130
  }
131
+ const appId = await this.applicationIdFor(options);
132
+ return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
133
+ }
134
+ /**
135
+ * Forget cached answers: every one, or those about one person (matched as
136
+ * either the session's user or the resolved operator).
137
+ */
138
+ invalidate(filter = {}) {
139
+ this.cache.invalidate(filter);
140
+ }
141
+ /**
142
+ * An MCP call: Oxy names the application (the one that registered the token's
143
+ * resource) and the person (who authorized the token); the answer is refused
144
+ * unless the application is the one this server expects.
145
+ */
146
+ async resolveMcp(source, options) {
147
+ const token = source.authInfo?.token;
148
+ if (typeof token !== 'string' || !isBearerToken(token)) {
149
+ throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'No verified MCP access token on this call');
150
+ }
151
+ const appId = await this.applicationIdFor(options);
152
+ // The person is known only once Oxy answers; the token's digest keys the
153
+ // entry, so the bearer itself is never held as a cache key.
154
+ const cacheKey = {
155
+ sessionId: `mcp:${createHash('sha256').update(token).digest('base64url')}`,
156
+ userId: null,
157
+ };
158
+ return this.cache.read([appId], cacheKey, options.fresh === true, () => this.lookupMcp(appId, token));
159
+ }
160
+ async applicationIdFor(options) {
108
161
  let applicationId = options.applicationId ?? null;
109
162
  if (!applicationId) {
110
163
  try {
@@ -117,15 +170,43 @@ export class OxyAppPermissionsResolver {
117
170
  if (!applicationId) {
118
171
  throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }');
119
172
  }
120
- const appId = applicationId;
121
- return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
173
+ return applicationId;
122
174
  }
123
- /**
124
- * Forget cached answers: every one, or those about one person (matched as
125
- * either the session's user or the resolved operator).
126
- */
127
- invalidate(filter = {}) {
128
- this.cache.invalidate(filter);
175
+ async lookupMcp(applicationId, mcpAccessToken) {
176
+ let answer;
177
+ try {
178
+ answer = await this.host.fetchMcpAppPermissions(mcpAccessToken);
179
+ }
180
+ catch (error) {
181
+ const status = errorStatus(error);
182
+ if (status === 401 && error.code === 'invalid_grant') {
183
+ throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', 'Oxy refused the MCP access token while resolving application permissions', { cause: error });
184
+ }
185
+ if (status === 403) {
186
+ throw new OxyAppPermissionError('APP_PERMISSION_DENIED', 'Oxy refused this application’s permissions for the MCP access token', { cause: error });
187
+ }
188
+ // Anything else — this service's own credential refused (401
189
+ // `invalid_client`), an Oxy without the route, a 5xx, a timeout — is not
190
+ // an answer about the person.
191
+ logger.warn('[oxy.appPermissions] Could not resolve application permissions for MCP', {
192
+ component: 'auth',
193
+ method: 'appPermissions',
194
+ applicationId,
195
+ status,
196
+ });
197
+ throw new OxyAppPermissionError('APP_PERMISSION_UNAVAILABLE', 'Could not resolve application permissions from Oxy', { cause: error });
198
+ }
199
+ const parsed = applicationRolesMeSchema.safeParse(answer);
200
+ if (!parsed.success) {
201
+ throw new OxyAppPermissionError('APP_PERMISSION_UNAVAILABLE', 'Oxy answered with a malformed permission set');
202
+ }
203
+ if (parsed.data.applicationId !== applicationId) {
204
+ // Oxy answers about the application behind this server's service token;
205
+ // a different one means the configured id names another application.
206
+ logger.error(`[oxy.appPermissions] Oxy answered for application ${parsed.data.applicationId}, not ${applicationId}; check OxyServerConfig.applicationId`, undefined, { component: 'auth', method: 'appPermissions' });
207
+ throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'An MCP access token answers only for the application behind this server’s service token');
208
+ }
209
+ return heldPermissions(applicationId, parsed.data.userId, parsed.data.permissions);
129
210
  }
130
211
  async lookup(applicationId, accessToken) {
131
212
  let answer;