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