@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
package/README.md
CHANGED
|
@@ -172,9 +172,12 @@ 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`),
|
|
177
|
-
|
|
175
|
+
permission questions (`appPermissionsFor` — for a session or an MCP tool call —
|
|
176
|
+
`accountAccessFor`, `applicationAccessFor`), user clients (`withUser`), per-call options
|
|
177
|
+
(`oxy.withCallOptions({ timeout, deadline, retry, signal })`), files and
|
|
178
|
+
outbound HTTP (`safeFetch`, with `validated` to reuse an `assertSafePublicUrl`
|
|
179
|
+
check and `requireHttps` for an all-https chain) are in
|
|
180
|
+
[Building a backend](./docs/backend.mdx).
|
|
178
181
|
|
|
179
182
|
## Security Headers And CSP
|
|
180
183
|
|
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
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
* Application roles, from the application's side: which of THIS application's
|
|
4
4
|
* declared permissions does the person behind a request hold?
|
|
5
5
|
*
|
|
6
|
-
* Oxy decides who holds an app's operator powers (`docs/auth/application-roles.md`)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Oxy decides who holds an app's operator powers (`docs/auth/application-roles.md`),
|
|
7
|
+
* and the backend asks — so no app keeps a `*_OPERATOR_OXY_USER_IDS` allow-list
|
|
8
|
+
* in its environment. One of Oxy's own applications declares its keys
|
|
9
|
+
* (`payments.operate`, …) in Oxy's first-party registry, each held by roles of
|
|
10
|
+
* the owning organization (ADR 0056); a third-party app declares them in the
|
|
11
|
+
* Console and its owners grant them to people. The answer has the same shape
|
|
12
|
+
* either way.
|
|
10
13
|
*
|
|
11
14
|
* ```ts
|
|
12
15
|
* const oxy = new OxyServer({ baseURL: 'https://api.oxy.so', applicationId: process.env.OXY_APPLICATION_ID });
|
|
@@ -32,6 +35,23 @@
|
|
|
32
35
|
* `X-Oxy-User-Id`, is refused — an operator power is exercised by a person in
|
|
33
36
|
* a session, never through delegation.
|
|
34
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
|
+
*
|
|
35
55
|
* ## Caching, and how soon a revocation lands
|
|
36
56
|
*
|
|
37
57
|
* Answers are cached per (application, session, user) for
|
|
@@ -48,6 +68,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
48
68
|
exports.OxyAppPermissionsResolver = exports.appPermissionErrorBody = exports.OxyAppPermissionError = exports.APP_PERMISSIONS_CACHE_TTL_MS = void 0;
|
|
49
69
|
exports.appPermissionErrorStatus = appPermissionErrorStatus;
|
|
50
70
|
exports.assertApplicationPermissionKey = assertApplicationPermissionKey;
|
|
71
|
+
const node_crypto_1 = require("node:crypto");
|
|
51
72
|
const contracts_1 = require("@oxy.so/contracts");
|
|
52
73
|
const logger_1 = require("../logger");
|
|
53
74
|
const callerAuthority_1 = require("./callerAuthority");
|
|
@@ -64,6 +85,9 @@ class OxyAppPermissionError extends Error {
|
|
|
64
85
|
}
|
|
65
86
|
}
|
|
66
87
|
exports.OxyAppPermissionError = OxyAppPermissionError;
|
|
88
|
+
function isMcpCallerRequest(source) {
|
|
89
|
+
return source.authInfo !== undefined;
|
|
90
|
+
}
|
|
67
91
|
/** The status the middleware answers an error with, under `deny`. */
|
|
68
92
|
function appPermissionErrorStatus(error, deny = 404) {
|
|
69
93
|
switch (error.code) {
|
|
@@ -98,16 +122,48 @@ class OxyAppPermissionsResolver {
|
|
|
98
122
|
this.cache = new callerAuthority_1.CallerAuthorityCache(host.ttlMs, (value, userId) => value.operatorId === userId);
|
|
99
123
|
}
|
|
100
124
|
/**
|
|
101
|
-
* What the person behind `
|
|
125
|
+
* What the person behind `source` holds. Throws {@link OxyAppPermissionError};
|
|
102
126
|
* never answers "nothing held" for a failure.
|
|
103
127
|
*/
|
|
104
|
-
async resolve(
|
|
105
|
-
|
|
128
|
+
async resolve(source, options = {}) {
|
|
129
|
+
if (isMcpCallerRequest(source))
|
|
130
|
+
return this.resolveMcp(source, options);
|
|
131
|
+
const caller = (0, callerAuthority_1.readCallerSession)(source);
|
|
106
132
|
if (!caller.ok) {
|
|
107
133
|
throw new OxyAppPermissionError('APP_PERMISSION_SESSION_REQUIRED', caller.reason === 'service'
|
|
108
134
|
? 'Application permissions are held by a person in a session; this request is authenticated by a service token'
|
|
109
135
|
: 'No verified user session on this request; mount middleware.auth() first');
|
|
110
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) {
|
|
111
167
|
let applicationId = options.applicationId ?? null;
|
|
112
168
|
if (!applicationId) {
|
|
113
169
|
try {
|
|
@@ -120,15 +176,43 @@ class OxyAppPermissionsResolver {
|
|
|
120
176
|
if (!applicationId) {
|
|
121
177
|
throw new OxyAppPermissionError('APP_PERMISSION_NOT_CONFIGURED', 'No application id: set OxyServerConfig.applicationId, configure service auth, or pass { applicationId }');
|
|
122
178
|
}
|
|
123
|
-
|
|
124
|
-
return this.cache.read([appId], caller.session, options.fresh === true, () => this.lookup(appId, caller.session.accessToken));
|
|
179
|
+
return applicationId;
|
|
125
180
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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);
|
|
132
216
|
}
|
|
133
217
|
async lookup(applicationId, accessToken) {
|
|
134
218
|
let answer;
|
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
|