@oxy.so/core 7.19.0 → 7.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -3
- package/dist/cjs/HttpService.js +33 -1
- package/dist/cjs/server/OxyServer.js +48 -5
- package/dist/cjs/server/appPermissions.js +99 -15
- package/dist/esm/HttpService.js +33 -1
- package/dist/esm/server/OxyServer.js +49 -6
- package/dist/esm/server/appPermissions.js +100 -16
- package/dist/types/HttpService.d.ts +12 -1
- package/dist/types/server/OxyServer.d.ts +14 -2
- package/dist/types/server/appPermissions.d.ts +60 -8
- package/dist/types/server/callerAuthority.d.ts +11 -1
- package/dist/types/server/index.d.ts +1 -1
- package/package.json +2 -2
- package/src/HttpService.ts +43 -1
- package/src/server/OxyServer.ts +70 -16
- package/src/server/appPermissions.ts +165 -20
- package/src/server/callerAuthority.ts +20 -3
- package/src/server/index.ts +2 -0
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
* Application roles, from the application's side: which of THIS application's
|
|
3
3
|
* declared permissions does the person behind a request hold?
|
|
4
4
|
*
|
|
5
|
-
* Oxy decides who holds an app's operator powers (`docs/auth/application-roles.md`)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Oxy decides who holds an app's operator powers (`docs/auth/application-roles.md`),
|
|
6
|
+
* and the backend asks — so no app keeps a `*_OPERATOR_OXY_USER_IDS` allow-list
|
|
7
|
+
* in its environment. One of Oxy's own applications declares its keys
|
|
8
|
+
* (`payments.operate`, …) in Oxy's first-party registry, each held by roles of
|
|
9
|
+
* the owning organization (ADR 0056); a third-party app declares them in the
|
|
10
|
+
* Console and its owners grant them to people. The answer has the same shape
|
|
11
|
+
* either way.
|
|
9
12
|
*
|
|
10
13
|
* ```ts
|
|
11
14
|
* const oxy = new OxyServer({ baseURL: 'https://api.oxy.so', applicationId: process.env.OXY_APPLICATION_ID });
|
|
@@ -31,6 +34,23 @@
|
|
|
31
34
|
* `X-Oxy-User-Id`, is refused — an operator power is exercised by a person in
|
|
32
35
|
* a session, never through delegation.
|
|
33
36
|
*
|
|
37
|
+
* ## An MCP tool call
|
|
38
|
+
*
|
|
39
|
+
* An MCP request carries no session: its bearer is an access token Oxy issued
|
|
40
|
+
* for this app's MCP resource, which `@oxy.so/mcp`'s transport introspected
|
|
41
|
+
* live before the tool ran. Pass the call's `extra` (`context.request` in a
|
|
42
|
+
* catalog handler or `authorize`) the same way:
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* const held = await oxy.appPermissionsFor(context.request, { fresh: true });
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* The SDK presents that token, with THIS process's service token, to
|
|
49
|
+
* `POST /auth/mcp/oauth/app-permissions`. Oxy answers only for a token whose
|
|
50
|
+
* resource this application registered, only about this application, and for
|
|
51
|
+
* the person who authorized the token — whichever account the connection is
|
|
52
|
+
* acting as. Same answer type, cache, `fresh` and errors as a session.
|
|
53
|
+
*
|
|
34
54
|
* ## Caching, and how soon a revocation lands
|
|
35
55
|
*
|
|
36
56
|
* Answers are cached per (application, session, user) for
|
|
@@ -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 `
|
|
119
|
+
* What the person behind `source` holds. Throws {@link OxyAppPermissionError};
|
|
96
120
|
* never answers "nothing held" for a failure.
|
|
97
121
|
*/
|
|
98
|
-
async resolve(
|
|
99
|
-
|
|
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
|
-
|
|
118
|
-
return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
|
|
173
|
+
return applicationId;
|
|
119
174
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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:
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Oxy decides who holds an app's operator powers (`docs/auth/application-roles.md`),
|
|
6
|
+
* and the backend asks — so no app keeps a `*_OPERATOR_OXY_USER_IDS` allow-list
|
|
7
|
+
* in its environment. One of Oxy's own applications declares its keys
|
|
8
|
+
* (`payments.operate`, …) in Oxy's first-party registry, each held by roles of
|
|
9
|
+
* the owning organization (ADR 0056); a third-party app declares them in the
|
|
10
|
+
* Console and its owners grant them to people. The answer has the same shape
|
|
11
|
+
* either way.
|
|
9
12
|
*
|
|
10
13
|
* ```ts
|
|
11
14
|
* const oxy = new OxyServer({ baseURL: 'https://api.oxy.so', applicationId: process.env.OXY_APPLICATION_ID });
|
|
@@ -31,6 +34,23 @@
|
|
|
31
34
|
* `X-Oxy-User-Id`, is refused — an operator power is exercised by a person in
|
|
32
35
|
* a session, never through delegation.
|
|
33
36
|
*
|
|
37
|
+
* ## An MCP tool call
|
|
38
|
+
*
|
|
39
|
+
* An MCP request carries no session: its bearer is an access token Oxy issued
|
|
40
|
+
* for this app's MCP resource, which `@oxy.so/mcp`'s transport introspected
|
|
41
|
+
* live before the tool ran. Pass the call's `extra` (`context.request` in a
|
|
42
|
+
* catalog handler or `authorize`) the same way:
|
|
43
|
+
*
|
|
44
|
+
* ```ts
|
|
45
|
+
* const held = await oxy.appPermissionsFor(context.request, { fresh: true });
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* The SDK presents that token, with THIS process's service token, to
|
|
49
|
+
* `POST /auth/mcp/oauth/app-permissions`. Oxy answers only for a token whose
|
|
50
|
+
* resource this application registered, only about this application, and for
|
|
51
|
+
* the person who authorized the token — whichever account the connection is
|
|
52
|
+
* acting as. Same answer type, cache, `fresh` and errors as a session.
|
|
53
|
+
*
|
|
34
54
|
* ## Caching, and how soon a revocation lands
|
|
35
55
|
*
|
|
36
56
|
* Answers are cached per (application, session, user) for
|
|
@@ -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
|
-
/**
|
|
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 `
|
|
179
|
+
* What the person behind `source` holds. Throws {@link OxyAppPermissionError};
|
|
136
180
|
* never answers "nothing held" for a failure.
|
|
137
181
|
*/
|
|
138
|
-
resolve(
|
|
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:
|
|
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.
|
|
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.
|
|
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",
|
package/src/HttpService.ts
CHANGED
|
@@ -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(
|
|
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 {
|
package/src/server/OxyServer.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
(
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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:
|
|
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
|
/**
|