@oxy.so/core 7.19.1 → 7.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/cjs/HttpService.js +33 -1
- package/dist/cjs/server/OxyServer.js +48 -5
- package/dist/cjs/server/appPermissions.js +92 -11
- package/dist/esm/HttpService.js +33 -1
- package/dist/esm/server/OxyServer.js +49 -6
- package/dist/esm/server/appPermissions.js +93 -12
- package/dist/types/HttpService.d.ts +12 -1
- package/dist/types/server/OxyServer.d.ts +14 -2
- package/dist/types/server/appPermissions.d.ts +53 -4
- package/dist/types/server/callerAuthority.d.ts +11 -1
- package/dist/types/server/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/HttpService.ts +43 -1
- package/src/server/OxyServer.ts +70 -16
- package/src/server/appPermissions.ts +158 -16
- package/src/server/callerAuthority.ts +20 -3
- package/src/server/index.ts +2 -0
|
@@ -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.
|
|
@@ -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
|
|
@@ -54,7 +71,8 @@ export type OxyAppPermissionDenyStatus = 403 | 404;
|
|
|
54
71
|
/**
|
|
55
72
|
* - `APP_PERMISSION_DENIED` — a person in a session, without the permission.
|
|
56
73
|
* - `APP_PERMISSION_SESSION_REQUIRED` — no user session on the request: anonymous,
|
|
57
|
-
* 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).
|
|
58
76
|
* - `APP_PERMISSION_UNAVAILABLE` — Oxy could not be asked or gave no usable
|
|
59
77
|
* answer. Fails closed; answered 503.
|
|
60
78
|
* - `APP_PERMISSION_NOT_CONFIGURED` — this server knows no application id, or
|
|
@@ -80,6 +98,8 @@ export interface OxyAppPermissions {
|
|
|
80
98
|
* The HUMAN Oxy resolved from the session — record THIS as the actor in an
|
|
81
99
|
* audit log. It differs from `req.userId` when a person acts as an
|
|
82
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.
|
|
83
103
|
*/
|
|
84
104
|
readonly operatorId: string;
|
|
85
105
|
/** The held keys, sorted. */
|
|
@@ -89,7 +109,11 @@ export interface OxyAppPermissions {
|
|
|
89
109
|
export interface AppPermissionsOptions {
|
|
90
110
|
/** Skip the cache and ask Oxy (the answer still refreshes the cache). Use on writes. */
|
|
91
111
|
fresh?: boolean;
|
|
92
|
-
/**
|
|
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
|
+
*/
|
|
93
117
|
applicationId?: string;
|
|
94
118
|
}
|
|
95
119
|
export interface RequireAppPermissionOptions extends AppPermissionsOptions {
|
|
@@ -112,6 +136,18 @@ export interface RequireAppPermissionOptions extends AppPermissionsOptions {
|
|
|
112
136
|
export interface AppPermissionsRequest extends OxyCallerRequest {
|
|
113
137
|
oxyAppPermissions?: OxyAppPermissions;
|
|
114
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;
|
|
115
151
|
/** The status the middleware answers an error with, under `deny`. */
|
|
116
152
|
export declare function appPermissionErrorStatus(error: OxyAppPermissionError, deny?: OxyAppPermissionDenyStatus): number;
|
|
117
153
|
/** The response body for a status: the API error envelope (`docs/api-conventions.md`). */
|
|
@@ -122,6 +158,11 @@ export declare function assertApplicationPermissionKey(permission: unknown): ass
|
|
|
122
158
|
export interface AppPermissionsHost {
|
|
123
159
|
/** `GET /applications/:appId/roles/me` with this bearer, uncached. Rejects with the API error. */
|
|
124
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>;
|
|
125
166
|
/** The configured application id, or the one this server's service token names. */
|
|
126
167
|
defaultApplicationId(): Promise<string | null>;
|
|
127
168
|
readonly ttlMs: number;
|
|
@@ -135,10 +176,10 @@ export declare class OxyAppPermissionsResolver {
|
|
|
135
176
|
private readonly cache;
|
|
136
177
|
constructor(host: AppPermissionsHost);
|
|
137
178
|
/**
|
|
138
|
-
* What the person behind `
|
|
179
|
+
* What the person behind `source` holds. Throws {@link OxyAppPermissionError};
|
|
139
180
|
* never answers "nothing held" for a failure.
|
|
140
181
|
*/
|
|
141
|
-
resolve(
|
|
182
|
+
resolve(source: AppPermissionsSource, options?: AppPermissionsOptions): Promise<OxyAppPermissions>;
|
|
142
183
|
/**
|
|
143
184
|
* Forget cached answers: every one, or those about one person (matched as
|
|
144
185
|
* either the session's user or the resolved operator).
|
|
@@ -146,5 +187,13 @@ export declare class OxyAppPermissionsResolver {
|
|
|
146
187
|
invalidate(filter?: {
|
|
147
188
|
userId?: string;
|
|
148
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;
|
|
149
198
|
private lookup;
|
|
150
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
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
|
/**
|