@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
@@ -43,9 +63,10 @@
43
63
  *
44
64
  * Node only; reachable solely from `@oxy.so/core/server`.
45
65
  */
66
+ import { createHash } from 'node:crypto';
46
67
  import { applicationRolesMeSchema, isApplicationPermissionKey } from '@oxy.so/contracts';
47
68
  import { logger } from '../logger/index.js';
48
- import { authorityErrorBody, CallerAuthorityCache, errorStatus, permissionSet, readCallerSession, } from './callerAuthority.js';
69
+ import { authorityErrorBody, CallerAuthorityCache, errorStatus, permissionSet, isBearerToken, readCallerSession, } from './callerAuthority.js';
49
70
  /** Default cache lifetime of one answer. */
50
71
  export const APP_PERMISSIONS_CACHE_TTL_MS = 30000;
51
72
  /** The typed failure of {@link OxyAppPermissionsResolver.resolve} and the middleware. */
@@ -58,6 +79,9 @@ export class OxyAppPermissionError extends Error {
58
79
  this.cause = options.cause;
59
80
  }
60
81
  }
82
+ function isMcpCallerRequest(source) {
83
+ return source.authInfo !== undefined;
84
+ }
61
85
  /** The status the middleware answers an error with, under `deny`. */
62
86
  export function appPermissionErrorStatus(error, deny = 404) {
63
87
  switch (error.code) {
@@ -92,16 +116,48 @@ export class OxyAppPermissionsResolver {
92
116
  this.cache = new CallerAuthorityCache(host.ttlMs, (value, userId) => value.operatorId === userId);
93
117
  }
94
118
  /**
95
- * What the person behind `req` holds. Throws {@link OxyAppPermissionError};
119
+ * What the person behind `source` holds. Throws {@link OxyAppPermissionError};
96
120
  * never answers "nothing held" for a failure.
97
121
  */
98
- async resolve(req, options = {}) {
99
- const caller = readCallerSession(req);
122
+ async resolve(source, options = {}) {
123
+ if (isMcpCallerRequest(source))
124
+ return this.resolveMcp(source, options);
125
+ const caller = readCallerSession(source);
100
126
  if (!caller.ok) {
101
127
  throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', caller.reason === 'service'
102
128
  ? 'Application permissions are held by a person in a session; this request is authenticated by a service token'
103
129
  : 'No verified user session on this request; mount middleware.auth() first');
104
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) {
105
161
  let applicationId = options.applicationId ?? null;
106
162
  if (!applicationId) {
107
163
  try {
@@ -114,15 +170,43 @@ export class OxyAppPermissionsResolver {
114
170
  if (!applicationId) {
115
171
  throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }');
116
172
  }
117
- const appId = applicationId;
118
- return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
173
+ return applicationId;
119
174
  }
120
- /**
121
- * Forget cached answers: every one, or those about one person (matched as
122
- * either the session's user or the resolved operator).
123
- */
124
- invalidate(filter = {}) {
125
- 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);
126
210
  }
127
211
  async lookup(applicationId, accessToken) {
128
212
  let answer;
@@ -22,6 +22,12 @@ export type AccessTokenProvider = () => string | null;
22
22
  * — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}.
23
23
  */
24
24
  export type AnonymousAuthProvider = () => Promise<string | null>;
25
+ /**
26
+ * Told that Oxy answered 401 to a bearer an {@link AnonymousAuthProvider}
27
+ * supplied; `true` when it dropped that bearer, so the request is sent once
28
+ * more with the provider's next one.
29
+ */
30
+ export type AnonymousAuthRejectedHandler = (token: string) => boolean;
25
31
  declare const credentialGenerationBrand: unique symbol;
26
32
  /**
27
33
  * An opaque, immutable token for ONE installation of a bearer in a client.
@@ -241,6 +247,7 @@ export declare class HttpService {
241
247
  private authRefreshHandler;
242
248
  private accessTokenProvider;
243
249
  private anonymousAuthProvider;
250
+ private anonymousAuthRejected;
244
251
  private deviceSecretMintInFlight;
245
252
  /**
246
253
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -557,8 +564,12 @@ export declare class HttpService {
557
564
  * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
558
565
  * consulted only when there is no access token, and never for `skipAuth`
559
566
  * requests. `null` removes it.
567
+ *
568
+ * `onRejected` hears of a 401 to one of the provider's bearers; when it
569
+ * drops that bearer (`true`), the request is sent once more with the
570
+ * provider's next one — a rotated or revoked service token heals itself.
560
571
  */
561
- setAnonymousAuthProvider(provider: AnonymousAuthProvider | null): void;
572
+ setAnonymousAuthProvider(provider: AnonymousAuthProvider | null, onRejected?: AnonymousAuthRejectedHandler): void;
562
573
  clearTokens(): void;
563
574
  /**
564
575
  * Subscribe to access-token changes on this instance.
@@ -6,7 +6,7 @@ import type { HttpMethod } from '../client/context';
6
6
  import type { RequestOptions, ResponsePayload } from '../HttpService';
7
7
  import { type WorkloadCredentialProvider } from './workloadIdentity';
8
8
  import { type OxyAccountEvent, type OxyAccountEventFeedPage, type OxyMiddleware, type ServiceActingAsVerification, type VerifyAccountEventOptions } from './middleware';
9
- import { type AppPermissionsOptions, type AppPermissionsRequest, type OxyAppPermissions } from './appPermissions';
9
+ import { type AppPermissionsOptions, type AppPermissionsSource, type OxyAppPermissions } from './appPermissions';
10
10
  import { ServerAccountsApi, ServerAppsApi, ServerAgencyApi, ServerAssetsApi, ServerLinkedAccountsApi, ServerNotificationsApi, ServerReputationApi } from './namespaces';
11
11
  import { type CallerAccessOptions, type OxyAccountAccess, type OxyApplicationAccess } from './callerAccess';
12
12
  import type { OxyCallerRequest } from './callerAuthority';
@@ -295,12 +295,23 @@ export declare class OxyServer extends OxyServices {
295
295
  requesterContext?: string;
296
296
  serviceTokenScopes?: string[];
297
297
  }): Promise<ResponsePayload<T>>;
298
+ /**
299
+ * Drop every cached entry that holds `token` (and is not minting): `true`
300
+ * when one did, so the next mint asks Oxy again.
301
+ */
302
+ private forgetServiceToken;
298
303
  /**
299
304
  * Which of this application's own declared permissions the PERSON behind
300
305
  * `req` holds (application roles, `docs/auth/application-roles.md`). `req`
301
306
  * must have passed `middleware.auth()`: the answer is read from Oxy with the
302
307
  * request's own verified bearer, for the human operator of its session.
303
308
  *
309
+ * An MCP tool call is asked the same way: pass its `extra`
310
+ * (`CatalogInvocationContext.request`), whose `authInfo.token` is the access
311
+ * token `@oxy.so/mcp` introspected. Oxy answers, for the person who
312
+ * authorized that token, only about the application that registered its
313
+ * resource — this server's own (`APP_PERMISSION_NOT_CONFIGURED` otherwise).
314
+ *
304
315
  * Cached per session for `appPermissionsCacheTtlMs` (30 s); pass
305
316
  * `{ fresh: true }` before a write. Throws `OxyAppPermissionError` — no
306
317
  * session (`APP_PERMISSION_SESSION_REQUIRED`, also for any service-token
@@ -308,7 +319,7 @@ export declare class OxyServer extends OxyServices {
308
319
  * application id (`APP_PERMISSION_NOT_CONFIGURED`) — and never answers
309
320
  * "nothing held" for a failure.
310
321
  */
311
- appPermissionsFor(req: AppPermissionsRequest, options?: AppPermissionsOptions): Promise<OxyAppPermissions>;
322
+ appPermissionsFor(req: AppPermissionsSource, options?: AppPermissionsOptions): Promise<OxyAppPermissions>;
312
323
  /**
313
324
  * Drop cached caller-authority answers — application permissions and
314
325
  * account / application access — all of them, or one person's (as the
@@ -383,6 +394,7 @@ export declare class OxyServer extends OxyServices {
383
394
  }): void;
384
395
  private defaultApplicationId;
385
396
  private fetchRolesMe;
397
+ private fetchMcpAppPermissions;
386
398
  /**
387
399
  * Resolve authority live by default. Explicit read-only reuse lasts at most
388
400
  * 60s positive / 10s negative, anchored to request start; errors never cache.
@@ -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
@@ -51,7 +71,8 @@ export type OxyAppPermissionDenyStatus = 403 | 404;
51
71
  /**
52
72
  * - `APP_PERMISSION_DENIED` — a person in a session, without the permission.
53
73
  * - `APP_PERMISSION_SESSION_REQUIRED` — no user session on the request: anonymous,
54
- * or authenticated by a service token (delegated or not).
74
+ * or authenticated by a service token (delegated or not); for an MCP call, no
75
+ * access token, or one Oxy refused (revoked, expired, another app's resource).
55
76
  * - `APP_PERMISSION_UNAVAILABLE` — Oxy could not be asked or gave no usable
56
77
  * answer. Fails closed; answered 503.
57
78
  * - `APP_PERMISSION_NOT_CONFIGURED` — this server knows no application id, or
@@ -77,6 +98,8 @@ export interface OxyAppPermissions {
77
98
  * The HUMAN Oxy resolved from the session — record THIS as the actor in an
78
99
  * audit log. It differs from `req.userId` when a person acts as an
79
100
  * organization (the session speaks as the org; the operator is the person).
101
+ * For an MCP call it is the person who authorized the token, which may differ
102
+ * from the account the connection is acting as.
80
103
  */
81
104
  readonly operatorId: string;
82
105
  /** The held keys, sorted. */
@@ -86,7 +109,11 @@ export interface OxyAppPermissions {
86
109
  export interface AppPermissionsOptions {
87
110
  /** Skip the cache and ask Oxy (the answer still refreshes the cache). Use on writes. */
88
111
  fresh?: boolean;
89
- /** Ask about another application than the server's configured one. */
112
+ /**
113
+ * Ask about another application than the server's configured one. Not for an
114
+ * MCP call: Oxy answers it only about the application that registered the
115
+ * token's resource, and any other id is `APP_PERMISSION_NOT_CONFIGURED`.
116
+ */
90
117
  applicationId?: string;
91
118
  }
92
119
  export interface RequireAppPermissionOptions extends AppPermissionsOptions {
@@ -109,6 +136,18 @@ export interface RequireAppPermissionOptions extends AppPermissionsOptions {
109
136
  export interface AppPermissionsRequest extends OxyCallerRequest {
110
137
  oxyAppPermissions?: OxyAppPermissions;
111
138
  }
139
+ /**
140
+ * An MCP tool call: the `extra` (`RequestHandlerExtra`) an MCP handler receives,
141
+ * whose `authInfo` `@oxy.so/mcp`'s transport set after Oxy introspected the
142
+ * access token live. `CatalogInvocationContext.request` is one.
143
+ */
144
+ export interface OxyMcpCallerRequest {
145
+ readonly authInfo?: {
146
+ readonly token: string;
147
+ };
148
+ }
149
+ /** What `appPermissionsFor` reads: a request after `middleware.auth()`, or an MCP tool call. */
150
+ export type AppPermissionsSource = AppPermissionsRequest | OxyMcpCallerRequest;
112
151
  /** The status the middleware answers an error with, under `deny`. */
113
152
  export declare function appPermissionErrorStatus(error: OxyAppPermissionError, deny?: OxyAppPermissionDenyStatus): number;
114
153
  /** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
@@ -119,6 +158,11 @@ export declare function assertApplicationPermissionKey(permission: unknown): ass
119
158
  export interface AppPermissionsHost {
120
159
  /** `GET /applications/:appId/roles/me` with this bearer, uncached. Rejects with the API error. */
121
160
  fetchRolesMe(applicationId: string, accessToken: string): Promise<unknown>;
161
+ /**
162
+ * `POST /auth/mcp/oauth/app-permissions` with this MCP access token, as this
163
+ * server's service, uncached. Rejects with the API error.
164
+ */
165
+ fetchMcpAppPermissions(mcpAccessToken: string): Promise<unknown>;
122
166
  /** The configured application id, or the one this server's service token names. */
123
167
  defaultApplicationId(): Promise<string | null>;
124
168
  readonly ttlMs: number;
@@ -132,10 +176,10 @@ export declare class OxyAppPermissionsResolver {
132
176
  private readonly cache;
133
177
  constructor(host: AppPermissionsHost);
134
178
  /**
135
- * What the person behind `req` holds. Throws {@link OxyAppPermissionError};
179
+ * What the person behind `source` holds. Throws {@link OxyAppPermissionError};
136
180
  * never answers "nothing held" for a failure.
137
181
  */
138
- resolve(req: AppPermissionsRequest, options?: AppPermissionsOptions): Promise<OxyAppPermissions>;
182
+ resolve(source: AppPermissionsSource, options?: AppPermissionsOptions): Promise<OxyAppPermissions>;
139
183
  /**
140
184
  * Forget cached answers: every one, or those about one person (matched as
141
185
  * either the session's user or the resolved operator).
@@ -143,5 +187,13 @@ export declare class OxyAppPermissionsResolver {
143
187
  invalidate(filter?: {
144
188
  userId?: string;
145
189
  }): void;
190
+ /**
191
+ * An MCP call: Oxy names the application (the one that registered the token's
192
+ * resource) and the person (who authorized the token); the answer is refused
193
+ * unless the application is the one this server expects.
194
+ */
195
+ private resolveMcp;
196
+ private applicationIdFor;
197
+ private lookupMcp;
146
198
  private lookup;
147
199
  }
@@ -36,6 +36,16 @@ export interface OxyCallerSession {
36
36
  readonly sessionId: string;
37
37
  readonly accessToken: string;
38
38
  }
39
+ /**
40
+ * Whose answer a cache entry is: the session (or other credential) it was read
41
+ * with, and the person, when known before asking. A caller whose person only
42
+ * Oxy's answer names (an MCP access token) passes `userId: null`; the reader's
43
+ * `isAbout` still matches the answer for {@link CallerAuthorityCache.invalidate}.
44
+ */
45
+ export interface OxyCallerCacheKey {
46
+ readonly sessionId: string;
47
+ readonly userId: string | null;
48
+ }
39
49
  /**
40
50
  * The person behind `req`, or why there is none: `service` for a request
41
51
  * authenticated by a service token (delegated or not — its bearer is the
@@ -113,7 +123,7 @@ export declare class CallerAuthorityCache<T> {
113
123
  * resolved operator), for {@link invalidate}.
114
124
  */
115
125
  constructor(ttlMs: number, isAbout?: (value: T, userId: string) => boolean);
116
- read(resourceKey: readonly string[], session: OxyCallerSession, fresh: boolean, lookup: () => Promise<T>): Promise<T>;
126
+ read(resourceKey: readonly string[], session: OxyCallerCacheKey, fresh: boolean, lookup: () => Promise<T>): Promise<T>;
117
127
  /** Forget every answer, or those about one person. */
118
128
  invalidate(filter?: {
119
129
  userId?: string;
@@ -49,7 +49,7 @@ export type { AliaMachineCredentialIntrospection, OxyAliaMachinePrincipal, OxyAl
49
49
  export type { CapabilityTicketGrant, CapabilityTicketIntrospection, CreateExecutionAuthorizationInput, ServiceCallOptions, ServiceCapabilityCatalog, ServiceUploadBody, ServiceUploadDescription, } from './namespaces';
50
50
  export { OxyAccountEventError, OXY_ACCOUNT_DELETED_EVENT_URI } from './middleware';
51
51
  export { APP_PERMISSIONS_CACHE_TTL_MS, OxyAppPermissionError, appPermissionErrorStatus, } from './appPermissions';
52
- export type { AppPermissionsOptions, OxyAppPermissionDenyStatus, OxyAppPermissionErrorCode, OxyAppPermissions, RequireAppPermissionOptions, } from './appPermissions';
52
+ export type { AppPermissionsOptions, AppPermissionsSource, OxyMcpCallerRequest, OxyAppPermissionDenyStatus, OxyAppPermissionErrorCode, OxyAppPermissions, RequireAppPermissionOptions, } from './appPermissions';
53
53
  export type { AuthMiddlewareOptions, OxyAccountEvent, OxyAccountEventFeedItem, OxyAccountEventFeedPage, OxyMiddleware, RequireAccessOptions, ServiceActingAsVerification, ServiceApp, VerifyAccountEventOptions, } from './middleware';
54
54
  export { createOptionalOxyAuth, createOxyAuthMiddleware, getOxyActor, getOxyAuthRefusal, getOxyBillingPrincipal, getOxyDelegatedUserId, getOxyRequestAttribution, getOxyUserId, getRequiredOxyBillingPrincipal, getRequiredOxyUserId, isOxyAuthenticated, requireOxyAuth, OXY_SERVICE_ENVIRONMENTS, } from './auth';
55
55
  export type { OxyAuthHost } from './auth';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxy.so/core",
3
- "version": "7.19.0",
3
+ "version": "7.20.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -181,7 +181,7 @@
181
181
  "dependencies": {
182
182
  "@noble/ciphers": "^2.4.0",
183
183
  "@noble/hashes": "^2.4.0",
184
- "@oxy.so/contracts": "6.5.0",
184
+ "@oxy.so/contracts": "6.6.0",
185
185
  "@oxy.so/protocol": "^1.6.1",
186
186
  "@oxy.so/telemetry": "^2.1.0",
187
187
  "@scure/bip39": "^2.4.0",
@@ -55,6 +55,12 @@ export type AccessTokenProvider = () => string | null;
55
55
  * — `null` to send it anonymous. See {@link HttpService.setAnonymousAuthProvider}.
56
56
  */
57
57
  export type AnonymousAuthProvider = () => Promise<string | null>;
58
+ /**
59
+ * Told that Oxy answered 401 to a bearer an {@link AnonymousAuthProvider}
60
+ * supplied; `true` when it dropped that bearer, so the request is sent once
61
+ * more with the provider's next one.
62
+ */
63
+ export type AnonymousAuthRejectedHandler = (token: string) => boolean;
58
64
 
59
65
  declare const credentialGenerationBrand: unique symbol;
60
66
  /**
@@ -604,6 +610,7 @@ export class HttpService {
604
610
  private authRefreshHandler: AuthRefreshHandler | null = null;
605
611
  private accessTokenProvider: AccessTokenProvider | null = null;
606
612
  private anonymousAuthProvider: AnonymousAuthProvider | null = null;
613
+ private anonymousAuthRejected: AnonymousAuthRejectedHandler | null = null;
607
614
  private deviceSecretMintInFlight: Promise<DeviceSecretMintOutcome> | null = null;
608
615
  /**
609
616
  * Bumped by every {@link endSession}. A re-mint captures it before its first
@@ -1145,6 +1152,33 @@ export class HttpService {
1145
1152
 
1146
1153
  // Handle response
1147
1154
  if (!response.ok) {
1155
+ // A 401 to the bearer the anonymous provider supplied (a backend's
1156
+ // own service token, no user session): once its owner has dropped
1157
+ // it, send the request once more with the provider's next one. A
1158
+ // 401 means the request never ran, so this is safe for any method.
1159
+ if (
1160
+ response.status === 401 &&
1161
+ !contextToken &&
1162
+ usesSessionAuth &&
1163
+ !config.expectedCredentialGeneration &&
1164
+ !config._isAuthRetry &&
1165
+ authHeader?.startsWith('Bearer ') &&
1166
+ this.anonymousAuthRejected?.(authHeader.slice('Bearer '.length))
1167
+ ) {
1168
+ void discardResponseBody(response);
1169
+ if (domain.aborted) throw createCancelledError();
1170
+ // Same reasons as the session retry below: no dedupe (the original
1171
+ // is still in flight under its key), no queue slot (this attempt
1172
+ // holds one).
1173
+ return this.request<T>({
1174
+ ...config,
1175
+ _isAuthRetry: true,
1176
+ retry: false,
1177
+ deduplicate: false,
1178
+ bypassQueue: true,
1179
+ signal: domain,
1180
+ });
1181
+ }
1148
1182
  // On 401, delegate to the installed auth refresh handler and retry
1149
1183
  // once before giving up. HttpService deliberately does not know any
1150
1184
  // session routes; the refresh handler owns session rotation.
@@ -2319,9 +2353,17 @@ export class HttpService {
2319
2353
  * `OxyConfig.serviceIdentity`). A user session always wins: the provider is
2320
2354
  * consulted only when there is no access token, and never for `skipAuth`
2321
2355
  * requests. `null` removes it.
2356
+ *
2357
+ * `onRejected` hears of a 401 to one of the provider's bearers; when it
2358
+ * drops that bearer (`true`), the request is sent once more with the
2359
+ * provider's next one — a rotated or revoked service token heals itself.
2322
2360
  */
2323
- setAnonymousAuthProvider(provider: AnonymousAuthProvider | null): void {
2361
+ setAnonymousAuthProvider(
2362
+ provider: AnonymousAuthProvider | null,
2363
+ onRejected?: AnonymousAuthRejectedHandler,
2364
+ ): void {
2324
2365
  this.anonymousAuthProvider = provider;
2366
+ this.anonymousAuthRejected = provider ? (onRejected ?? null) : null;
2325
2367
  }
2326
2368
 
2327
2369
  clearTokens(): void {
@@ -66,7 +66,7 @@ import { OxyServices, type OxyConfig } from '../OxyServices';
66
66
  import { HttpService } from '../HttpService';
67
67
  import { createAuthenticatedFetch } from '../authenticatedFetch';
68
68
  import { awaitRequestSignal } from '../utils/requestSignal';
69
- import { createCancelledError } from '../utils/errorUtils';
69
+ import { createCancelledError, extractErrorStatus } from '../utils/errorUtils';
70
70
  import type { HttpMethod, ServiceLane } from '../client/context';
71
71
  import type { RequestOptions, ResponsePayload } from '../HttpService';
72
72
  import { logger } from '../logger';
@@ -94,7 +94,7 @@ import {
94
94
  APP_PERMISSIONS_CACHE_TTL_MS,
95
95
  OxyAppPermissionsResolver,
96
96
  type AppPermissionsOptions,
97
- type AppPermissionsRequest,
97
+ type AppPermissionsSource,
98
98
  type OxyAppPermissions,
99
99
  } from './appPermissions';
100
100
  import {
@@ -365,7 +365,10 @@ export class OxyServer extends OxyServices {
365
365
  };
366
366
  this.context.service = serviceLane;
367
367
  if (config.serviceIdentity === 'when-anonymous') {
368
- this.http.setAnonymousAuthProvider(() => this.serviceTokenForAnonymousRequest());
368
+ this.http.setAnonymousAuthProvider(
369
+ () => this.serviceTokenForAnonymousRequest(),
370
+ (token) => this.forgetServiceToken(token),
371
+ );
369
372
  }
370
373
  const ttl = config.appPermissionsCacheTtlMs ?? APP_PERMISSIONS_CACHE_TTL_MS;
371
374
  if (!Number.isFinite(ttl) || ttl < 0) {
@@ -381,6 +384,7 @@ export class OxyServer extends OxyServices {
381
384
  ttlMs: ttl,
382
385
  defaultApplicationId: () => this.defaultApplicationId(),
383
386
  fetchRolesMe: (applicationId, accessToken) => this.fetchRolesMe(applicationId, accessToken),
387
+ fetchMcpAppPermissions: (mcpAccessToken) => this.fetchMcpAppPermissions(mcpAccessToken),
384
388
  });
385
389
  this.callerAccess = new OxyCallerAccessResolver({
386
390
  ttlMs: ttl,
@@ -873,8 +877,7 @@ export class OxyServer extends OxyServices {
873
877
  serviceTokenScopes,
874
878
  ...rest
875
879
  } = options;
876
- const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
877
- const merged: Record<string, string> = { ...headers, Authorization: `Bearer ${token}` };
880
+ const merged: Record<string, string> = { ...headers };
878
881
  if (actAs) merged['X-Oxy-User-Id'] = actAs;
879
882
  if (
880
883
  [requesterToken, taskId, capabilityTicket, requesterContext].filter(
@@ -886,16 +889,52 @@ export class OxyServer extends OxyServices {
886
889
  if (taskId) merged['X-Oxy-Task-Id'] = taskId;
887
890
  if (capabilityTicket) merged['X-Oxy-Capability-Ticket'] = capabilityTicket;
888
891
  if (requesterContext) merged['X-Oxy-Requester-Context'] = requesterContext;
889
- return this.request<T>(method, target.href, data, {
890
- cache: false,
891
- ...rest,
892
- ...([requesterToken, taskId, capabilityTicket, requesterContext].some(
893
- (proof) => proof !== undefined,
894
- )
895
- ? { cache: false, deduplicate: false, retry: false }
896
- : {}),
897
- headers: merged,
898
- });
892
+ const send = (token: string) =>
893
+ this.request<T>(method, target.href, data, {
894
+ cache: false,
895
+ ...rest,
896
+ ...([requesterToken, taskId, capabilityTicket, requesterContext].some(
897
+ (proof) => proof !== undefined,
898
+ )
899
+ ? { cache: false, deduplicate: false, retry: false }
900
+ : {}),
901
+ headers: { ...merged, Authorization: `Bearer ${token}` },
902
+ });
903
+ const token = await this.platformServiceToken({ scopes: serviceTokenScopes });
904
+ try {
905
+ return await send(token);
906
+ } catch (error) {
907
+ // A 401 refused the request before it ran, so sending it again is safe
908
+ // whatever its method. A cached token Oxy no longer accepts (keys
909
+ // rotated, credential revoked) is dropped and the request sent once
910
+ // more with a freshly minted one: once, never after an abort, and only
911
+ // when the refused token was this server's cached one.
912
+ if (
913
+ extractErrorStatus(error) !== 401 ||
914
+ rest.signal?.aborted ||
915
+ !this.forgetServiceToken(token)
916
+ ) {
917
+ throw error;
918
+ }
919
+ const fresh = await this.platformServiceToken({ scopes: serviceTokenScopes });
920
+ if (fresh === token) throw error;
921
+ return send(fresh);
922
+ }
923
+ }
924
+
925
+ /**
926
+ * Drop every cached entry that holds `token` (and is not minting): `true`
927
+ * when one did, so the next mint asks Oxy again.
928
+ */
929
+ private forgetServiceToken(token: string): boolean {
930
+ let forgot = false;
931
+ for (const [cacheKey, entry] of this.serviceTokens) {
932
+ if (entry.token === token && !entry.pending) {
933
+ this.serviceTokens.delete(cacheKey);
934
+ forgot = true;
935
+ }
936
+ }
937
+ return forgot;
899
938
  }
900
939
 
901
940
  // ── Application roles ────────────────────────────────────────────────────
@@ -906,6 +945,12 @@ export class OxyServer extends OxyServices {
906
945
  * must have passed `middleware.auth()`: the answer is read from Oxy with the
907
946
  * request's own verified bearer, for the human operator of its session.
908
947
  *
948
+ * An MCP tool call is asked the same way: pass its `extra`
949
+ * (`CatalogInvocationContext.request`), whose `authInfo.token` is the access
950
+ * token `@oxy.so/mcp` introspected. Oxy answers, for the person who
951
+ * authorized that token, only about the application that registered its
952
+ * resource — this server's own (`APP_PERMISSION_NOT_CONFIGURED` otherwise).
953
+ *
909
954
  * Cached per session for `appPermissionsCacheTtlMs` (30 s); pass
910
955
  * `{ fresh: true }` before a write. Throws `OxyAppPermissionError` — no
911
956
  * session (`APP_PERMISSION_SESSION_REQUIRED`, also for any service-token
@@ -914,7 +959,7 @@ export class OxyServer extends OxyServices {
914
959
  * "nothing held" for a failure.
915
960
  */
916
961
  appPermissionsFor(
917
- req: AppPermissionsRequest,
962
+ req: AppPermissionsSource,
918
963
  options: AppPermissionsOptions = {},
919
964
  ): Promise<OxyAppPermissions> {
920
965
  return this.appPermissions.resolve(req, options);
@@ -1047,6 +1092,15 @@ export class OxyServer extends OxyServices {
1047
1092
  );
1048
1093
  }
1049
1094
 
1095
+ private fetchMcpAppPermissions(mcpAccessToken: string): Promise<unknown> {
1096
+ return this.serviceRequest<unknown>(
1097
+ 'POST',
1098
+ '/auth/mcp/oauth/app-permissions',
1099
+ { token: mcpAccessToken },
1100
+ { cache: false, deduplicate: false, retry: false },
1101
+ );
1102
+ }
1103
+
1050
1104
  // ── Delegation and account events ────────────────────────────────────────
1051
1105
 
1052
1106
  /**