actual-mcp-server 0.22.0 → 0.22.4
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 +10 -5
- package/dist/src/auth/setup.js +15 -6
- package/dist/src/config.js +44 -1
- package/dist/src/lib/actual-adapter.js +27 -11
- package/dist/src/lib/budgetLoader.js +13 -24
- package/dist/src/lib/config-registry.js +2 -2
- package/dist/src/lib/init-failure.js +10 -4
- package/dist/src/lib/oidc-discovery.js +60 -0
- package/dist/src/lib/oidc-error-cause.js +40 -0
- package/dist/src/lib/oidc-resource.js +151 -0
- package/dist/src/lib/oidc-token-source.js +65 -0
- package/dist/src/lib/server-version-guard.js +81 -9
- package/dist/src/logger.js +4 -0
- package/dist/src/server/httpServer.js +57 -27
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -507,9 +507,11 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
507
507
|
| `MCP_ALLOW_UNAUTHENTICATED` | `false` | No | Opt-out for required-by-default HTTP auth (#242). On a non-loopback bind with no token and no OIDC the server refuses to start; set to `true` to run open deliberately (e.g. behind your own proxy) |
|
|
508
508
|
| `OIDC_ISSUER` | _(none)_ | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
|
|
509
509
|
| `OIDC_ALLOW_INSECURE_ISSUER` | `false` | No | Allow a plaintext (http) OIDC issuer on a trusted network (#244). Off by default (http issuer refused at startup); set `true` only for local/LAN testing |
|
|
510
|
-
| `OIDC_RESOURCE` | _(none)_ |
|
|
510
|
+
| `OIDC_RESOURCE` | _(none)_ | If OIDC | This server's canonical URL, the RFC 9728 resource identifier and the expected `aud` (#461). Use the full public MCP endpoint URL, e.g. `https://actual-mcp.example.com/http`, no trailing slash. Must be an absolute http(s) URL (a bare client id refuses to start); put a client-id `aud` in `OIDC_ACCEPTED_AUDIENCES` instead |
|
|
511
511
|
| `OIDC_ACCEPTED_AUDIENCES` | _(none)_ | No | Extra accepted `aud` values beyond `OIDC_RESOURCE`, comma-separated (#245). For IdPs that put the client-id in `aud` (e.g. Authentik). Strict allowlist, never a wildcard |
|
|
512
512
|
| `OIDC_JWKS_TRUSTED_HOSTS` | _(none)_ | No | Opt-in cross-origin JWKS hosts, comma-separated `host` or `host:port` (#254). For IdPs whose `jwks_uri` lives on another host, e.g. Google needs `www.googleapis.com`. Exact match, no wildcards; empty default keeps same-origin-only |
|
|
513
|
+
| `OIDC_JWKS_URI` | _(none)_ | No | Direct JWKS URL that bypasses OpenID discovery (#462), for issuers with no discovery document (Cloudflare Access: `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`). Same https, credentials and origin-or-trusted-host rules as a discovered `jwks_uri`, plus no query or fragment. When set, `/.well-known/oauth-authorization-server` is not served and `AUTH_BUDGET_ACL_IDENTITY_SOURCE=userinfo` is refused |
|
|
514
|
+
| `OIDC_TOKEN_SOURCE` | `authorization` | No | Where the bearer JWT is read from (#462): `authorization` (default) or `cf-access-jwt-assertion` for an MCP server behind Cloudflare Access with Managed OAuth (the edge forwards the signed Access JWT in that header; `Authorization` is then never consulted, `OIDC_SCOPES` must be empty, and the origin must be reachable only via Cloudflare). See the Cloudflare recipe in `docs/guides/AI_CLIENT_SETUP.md` |
|
|
513
515
|
| `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
|
|
514
516
|
| `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
|
|
515
517
|
| `AUTH_BUDGET_ACL_SOURCE` | `static` | No | `static` uses the `AUTH_BUDGET_ACL` map above. `actual` derives the ACL from the Actual server's own per-file access list, so revoking someone in Actual takes effect here without a config edit. Requires a multi-user (OpenID) Actual server that was password-bootstrapped first |
|
|
@@ -662,13 +664,16 @@ Clients send: `Authorization: Bearer your_token_here`
|
|
|
662
664
|
```bash
|
|
663
665
|
AUTH_PROVIDER=oidc
|
|
664
666
|
OIDC_ISSUER=https://sso.yourdomain.com
|
|
665
|
-
OIDC_RESOURCE=
|
|
666
|
-
|
|
667
|
+
OIDC_RESOURCE=https://actual-mcp.yourdomain.com/http # this server's public MCP URL (the expected 'aud')
|
|
668
|
+
OIDC_ACCEPTED_AUDIENCES=your-client-id # only if your IdP puts the client id in 'aud' (#245)
|
|
669
|
+
OIDC_SCOPES= # leave empty for Casdoor
|
|
667
670
|
```
|
|
668
671
|
|
|
672
|
+
`OIDC_RESOURCE` is the canonical URL of this MCP server (the MCP authorization spec's "canonical server URI" and the RFC 9728 resource identifier), not an IdP client id: the protected-resource metadata route and the expected token audience are both derived from it, and a value that is not an absolute http(s) URL refuses to start with a message naming the variable (#461). Use the full public endpoint URL (`https://host/http`, or whatever `MCP_BRIDGE_HTTP_PATH` / `MCP_HTTP_PATH` advertises) rather than the bare origin: with the endpoint form the server publishes the path-specific document a strict client looks for, and it warns at startup when the two disagree.
|
|
673
|
+
|
|
669
674
|
**OAuth discovery endpoints (automatic in OIDC mode).** When `AUTH_PROVIDER=oidc`, the server publishes the two metadata documents an OAuth client needs to bootstrap a login, so `mcp-remote` and Claude.ai's native connector can discover the flow without manual endpoint configuration:
|
|
670
675
|
|
|
671
|
-
- `GET /.well-known/oauth-protected-resource` (RFC 9728): identifies this server as a protected resource and points at your `OIDC_ISSUER` as the authorization server.
|
|
676
|
+
- `GET /.well-known/oauth-protected-resource[<path>]` (RFC 9728): identifies this server as a protected resource and points at your `OIDC_ISSUER` as the authorization server. The route follows `OIDC_RESOURCE`: `https://host/http` is served at `/.well-known/oauth-protected-resource/http` (RFC 9728 section 3.1), the bare origin at the root path. The official MCP SDK tries the path-specific URL first and falls back to the root; clients without that fallback need the endpoint form (#461).
|
|
672
677
|
- `GET /.well-known/oauth-authorization-server` (RFC 8414, #285): the authorization server metadata (its `authorization_endpoint` / `token_endpoint` / `registration_endpoint`), re-served from your IdP's own OpenID discovery document. This is here because several clients look for it on the resource-server origin, and some IdPs (e.g. Authentik) do not expose it where those clients look. It is fetched once at startup and served verbatim, exposes only endpoints your IdP already publishes publicly, and requires no authentication (a client reads it before it has a token). No extra configuration is needed; it is absent when `AUTH_PROVIDER` is not `oidc`.
|
|
673
678
|
|
|
674
679
|
**Where the per-user budget ACL comes from.** By default the ACL is the `AUTH_BUDGET_ACL` map you maintain by hand. Since v0.10.x it can instead be derived from the Actual server's own per-file access list, so granting or revoking someone in Actual takes effect here without a config edit and a restart:
|
|
@@ -781,7 +786,7 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
781
786
|
|
|
782
787
|
---
|
|
783
788
|
|
|
784
|
-
**Version:** 0.22.
|
|
789
|
+
**Version:** 0.22.4 | **Tool Count:** 81 (verified LibreChat-compatible)
|
|
785
790
|
|
|
786
791
|
## Sponsor
|
|
787
792
|
|
package/dist/src/auth/setup.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/auth/setup.ts
|
|
2
2
|
//
|
|
3
3
|
// Factory for the mcp-auth MCPAuth instance (CF-5: OIDC multi-user auth).
|
|
4
|
-
// Returns null when AUTH_PROVIDER !== 'oidc'
|
|
4
|
+
// Returns null when AUTH_PROVIDER !== 'oidc'; the existing static Bearer token
|
|
5
5
|
// auth (MCP_SSE_AUTHORIZATION) is then used unchanged.
|
|
6
6
|
//
|
|
7
7
|
// Uses the mcp-auth "discovery config" approach: OIDC metadata is fetched
|
|
@@ -9,14 +9,17 @@
|
|
|
9
9
|
import { MCPAuth } from 'mcp-auth';
|
|
10
10
|
import config from '../config.js';
|
|
11
11
|
import logger from '../logger.js';
|
|
12
|
+
import { validateOidcResource } from '../lib/oidc-resource.js';
|
|
12
13
|
let _instance = null;
|
|
13
14
|
/**
|
|
14
15
|
* Returns an MCPAuth instance configured for this server's OIDC settings,
|
|
15
16
|
* or null if AUTH_PROVIDER is not 'oidc'.
|
|
16
17
|
*
|
|
17
|
-
* The instance is a singleton
|
|
18
|
+
* The instance is a singleton, safe to call multiple times.
|
|
18
19
|
*
|
|
19
|
-
* @throws If AUTH_PROVIDER=oidc but OIDC_ISSUER or OIDC_RESOURCE are unset
|
|
20
|
+
* @throws If AUTH_PROVIDER=oidc but OIDC_ISSUER or OIDC_RESOURCE are unset, or if
|
|
21
|
+
* OIDC_RESOURCE is not an absolute http(s) URL (#461: mcp-auth derives the RFC 9728
|
|
22
|
+
* well-known route from it and would otherwise throw its own TypeError at startup).
|
|
20
23
|
*/
|
|
21
24
|
export function createMcpAuth() {
|
|
22
25
|
if (config.AUTH_PROVIDER !== 'oidc') {
|
|
@@ -28,15 +31,21 @@ export function createMcpAuth() {
|
|
|
28
31
|
}
|
|
29
32
|
if (!config.OIDC_RESOURCE) {
|
|
30
33
|
throw new Error('[OIDC] AUTH_PROVIDER=oidc requires OIDC_RESOURCE to be set. ' +
|
|
31
|
-
'Example: OIDC_RESOURCE=https://actual-mcp.example.com');
|
|
34
|
+
'Example: OIDC_RESOURCE=https://actual-mcp.example.com/http');
|
|
32
35
|
}
|
|
36
|
+
// #461: validate the shape BEFORE mcp-auth sees it. The parsed URL is used only for
|
|
37
|
+
// checks and for the log line below (origin plus pathname, so an accepted query
|
|
38
|
+
// component never reaches a log); the RAW string is what MCPAuth receives, because
|
|
39
|
+
// url.href would append a trailing slash to the origin form and split the metadata
|
|
40
|
+
// `resource`, the WWW-Authenticate value and the accepted audience into three strings.
|
|
41
|
+
const resourceUrl = validateOidcResource(config.OIDC_RESOURCE);
|
|
33
42
|
if (_instance)
|
|
34
43
|
return _instance;
|
|
35
44
|
const scopesSupported = config.OIDC_SCOPES
|
|
36
45
|
? config.OIDC_SCOPES.split(',').map((s) => s.trim()).filter(Boolean)
|
|
37
46
|
: [];
|
|
38
|
-
logger.info(`[OIDC] Configuring mcp-auth
|
|
39
|
-
logger.info(`[OIDC] Resource identifier: ${
|
|
47
|
+
logger.info(`[OIDC] Configuring mcp-auth, issuer: ${config.OIDC_ISSUER}`);
|
|
48
|
+
logger.info(`[OIDC] Resource identifier: ${resourceUrl.origin}${resourceUrl.pathname}`);
|
|
40
49
|
logger.info(`[OIDC] Scopes required: ${scopesSupported.length ? scopesSupported.join(', ') : '(none)'}`);
|
|
41
50
|
_instance = new MCPAuth({
|
|
42
51
|
protectedResources: [
|
package/dist/src/config.js
CHANGED
|
@@ -117,6 +117,18 @@ export const configSchema = z.object({
|
|
|
117
117
|
// here; parsed fail-fast by buildTrustedJwksHosts (oidc-discovery.ts) at the
|
|
118
118
|
// composition root, per the #245 pattern. Default empty: same-origin only.
|
|
119
119
|
OIDC_JWKS_TRUSTED_HOSTS: z.string().optional(),
|
|
120
|
+
// #462: a DIRECT JWKS URL that bypasses OpenID discovery, for issuers that publish
|
|
121
|
+
// no discovery document (Cloudflare Access: https://<team>.cloudflareaccess.com/cdn-cgi/access/certs).
|
|
122
|
+
// Validated at startup by resolveDirectJwksUri with the same https / credentials /
|
|
123
|
+
// origin-or-trusted-host rules as a discovered jwks_uri, plus no query or fragment.
|
|
124
|
+
// When set, nothing is discovered, so there is no RFC 8414 document to re-serve.
|
|
125
|
+
OIDC_JWKS_URI: z.string().optional(),
|
|
126
|
+
// #462: where the bearer JWT is read from. 'cf-access-jwt-assertion' is Cloudflare's
|
|
127
|
+
// documented pattern for an MCP server behind Access with Managed OAuth: the client's
|
|
128
|
+
// bearer is opaque, the edge forwards a signed JWT in that header, and the origin must
|
|
129
|
+
// validate it. In that mode Authorization is NEVER consulted (one source, one
|
|
130
|
+
// verification, no fallback). Closed enum, default is today's behaviour.
|
|
131
|
+
OIDC_TOKEN_SOURCE: z.enum(['authorization', 'cf-access-jwt-assertion']).default('authorization'),
|
|
120
132
|
// Comma-separated required scopes (e.g. "read,write"). Optional.
|
|
121
133
|
OIDC_SCOPES: z.string().optional(),
|
|
122
134
|
// JSON map of principal → budget sync-ID list for per-user budget ACL.
|
|
@@ -231,7 +243,38 @@ export const configSchema = z.object({
|
|
|
231
243
|
// Refuse to send the E2E budget encryption password over a plaintext upstream
|
|
232
244
|
// (#161, CWE-319). If ACTUAL_BUDGET_PASSWORD is set, the default upstream must
|
|
233
245
|
// be https:// unless ALLOW_INSECURE_UPSTREAM=true is set explicitly.
|
|
234
|
-
.refine((cfg) => !cfg.ACTUAL_BUDGET_PASSWORD || cfg.ALLOW_INSECURE_UPSTREAM || !/^http:\/\//i.test(cfg.ACTUAL_SERVER_URL), { message: 'ACTUAL_BUDGET_PASSWORD (E2E encryption) must not be sent over an http:// upstream. Use https:// for ACTUAL_SERVER_URL, or set ALLOW_INSECURE_UPSTREAM=true to override (e.g. a trusted isolated network).' })
|
|
246
|
+
.refine((cfg) => !cfg.ACTUAL_BUDGET_PASSWORD || cfg.ALLOW_INSECURE_UPSTREAM || !/^http:\/\//i.test(cfg.ACTUAL_SERVER_URL), { message: 'ACTUAL_BUDGET_PASSWORD (E2E encryption) must not be sent over an http:// upstream. Use https:// for ACTUAL_SERVER_URL, or set ALLOW_INSECURE_UPSTREAM=true to override (e.g. a trusted isolated network).' })
|
|
247
|
+
// #462: the assertion mode and the direct JWKS knob only mean something under
|
|
248
|
+
// AUTH_PROVIDER=oidc. Under 'none' the static bearer branch authenticates, and
|
|
249
|
+
// silently ignoring the knob would let an operator believe Cloudflare protects an
|
|
250
|
+
// origin that the static token does. Refuse loudly.
|
|
251
|
+
.refine((cfg) => cfg.AUTH_PROVIDER === 'oidc' || (cfg.OIDC_TOKEN_SOURCE === 'authorization' && !cfg.OIDC_JWKS_URI), {
|
|
252
|
+
path: ['OIDC_TOKEN_SOURCE'],
|
|
253
|
+
message: 'OIDC_TOKEN_SOURCE=cf-access-jwt-assertion and OIDC_JWKS_URI require AUTH_PROVIDER=oidc. Under ' +
|
|
254
|
+
'AUTH_PROVIDER=none the static bearer token is what authenticates, so these settings would be ' +
|
|
255
|
+
'silently ignored (#462).',
|
|
256
|
+
})
|
|
257
|
+
// #462: the Access application token carries no `scope` claim, so any required
|
|
258
|
+
// scope would make mcp-auth answer 403 missing_required_scopes on EVERY request,
|
|
259
|
+
// invisibly to the 401 tests. Refuse the pairing rather than ship a dead deployment.
|
|
260
|
+
.refine((cfg) => cfg.OIDC_TOKEN_SOURCE !== 'cf-access-jwt-assertion' || !(cfg.OIDC_SCOPES ?? '').trim(), {
|
|
261
|
+
path: ['OIDC_SCOPES'],
|
|
262
|
+
message: 'OIDC_SCOPES must be empty with OIDC_TOKEN_SOURCE=cf-access-jwt-assertion: the Cloudflare Access ' +
|
|
263
|
+
'assertion carries no scope claim, so every request would be refused with missing_required_scopes (#462).',
|
|
264
|
+
})
|
|
265
|
+
// #462: the UserInfo identity source (#346) needs a resolved discovery document and
|
|
266
|
+
// a bearer that the IdP's UserInfo endpoint accepts. With OIDC_JWKS_URI nothing is
|
|
267
|
+
// discovered, and userinfo-identity.ts would fetch discovery on the REQUEST path
|
|
268
|
+
// against a domain that does not publish it, denying every request with a log that
|
|
269
|
+
// blames UserInfo (the #343 shape). In assertion mode the bearer is the assertion,
|
|
270
|
+
// which Cloudflare's UserInfo does not accept. Either pairing is refused at startup.
|
|
271
|
+
.refine((cfg) => cfg.AUTH_BUDGET_ACL_IDENTITY_SOURCE !== 'userinfo' || (!cfg.OIDC_JWKS_URI && cfg.OIDC_TOKEN_SOURCE === 'authorization'), {
|
|
272
|
+
path: ['AUTH_BUDGET_ACL_IDENTITY_SOURCE'],
|
|
273
|
+
message: 'AUTH_BUDGET_ACL_IDENTITY_SOURCE=userinfo cannot be combined with OIDC_JWKS_URI (no discovery ' +
|
|
274
|
+
'document is fetched, so the UserInfo endpoint cannot be resolved) or with ' +
|
|
275
|
+
'OIDC_TOKEN_SOURCE=cf-access-jwt-assertion (the bearer is the Cloudflare assertion, which the ' +
|
|
276
|
+
"IdP's UserInfo endpoint does not accept). Use AUTH_BUDGET_ACL_IDENTITY_SOURCE=token (#462).",
|
|
277
|
+
});
|
|
235
278
|
function getConfig() {
|
|
236
279
|
const result = configSchema.safeParse(process.env);
|
|
237
280
|
if (!result.success) {
|
|
@@ -389,13 +389,29 @@ export async function withActualApi(rawOperation) {
|
|
|
389
389
|
// Denial here means the lock is never acquired and no upstream resource is
|
|
390
390
|
// touched.
|
|
391
391
|
_enforceBudgetAcl();
|
|
392
|
-
// #276: on the FIRST successful op, warn (once) if the Actual server version is outside
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
|
|
392
|
+
// #276: on the FIRST successful op, warn (once) if the Actual server version is outside the
|
|
393
|
+
// range this build supports, reusing the live connection via rawGetServerVersion (NOT the
|
|
394
|
+
// withActualApi-wrapped getServerVersion, which would re-enter the lock).
|
|
395
|
+
//
|
|
396
|
+
// It runs AFTER withOpTimeout, not inside it. Review caught that bounding the probe was not
|
|
397
|
+
// enough while it still sat inside the operation's own timeout: its 5 seconds came out of the
|
|
398
|
+
// operation's 30, so an op that took more than ~25s and SUCCEEDED was still reported to the
|
|
399
|
+
// caller as "Actual API operation timed out", and on a deliberately lowered
|
|
400
|
+
// ACTUAL_OP_TIMEOUT_MS the probe alone could consume the entire budget. The timeout text is
|
|
401
|
+
// classed transient, so it would also drop the pooled connection. Still inside withApiLock,
|
|
402
|
+
// because it needs the connection the operation established.
|
|
403
|
+
//
|
|
404
|
+
// WHAT THIS DOES AND DOES NOT BUY, stated precisely because the first version of this comment
|
|
405
|
+
// overclaimed. The probe can no longer FAIL an operation that succeeded. It can still SLOW one,
|
|
406
|
+
// by up to SERVER_VERSION_PROBE_TIMEOUT_MS, and that bound is FIXED: it does not scale down
|
|
407
|
+
// with ACTUAL_OP_TIMEOUT_MS, so an operator who sets the 250ms floor for a fast-fail posture
|
|
408
|
+
// still pays up to ~5s of api-mutex hold against a stalled /info, and on the legacy path can
|
|
409
|
+
// pay it twice in one call (the pre-download site, then here). It is bounded and finite (at
|
|
410
|
+
// most MAX_PROBE_ATTEMPTS times per process, then never again), which is the trade being made:
|
|
411
|
+
// a diagnostic that is occasionally slow beats one that is silently disabled by a single
|
|
412
|
+
// transient failure, which is what the previous design did.
|
|
413
|
+
const runOperation = async () => {
|
|
414
|
+
const result = await withOpTimeout(rawOperation);
|
|
399
415
|
await checkServerVersionOnce(() => rawGetServerVersion(), logger);
|
|
400
416
|
return result;
|
|
401
417
|
};
|
|
@@ -411,7 +427,7 @@ export async function withActualApi(rawOperation) {
|
|
|
411
427
|
// #390: verify the singleton holds THIS session's budget before the operation runs.
|
|
412
428
|
// Inside the lock, so no other session can change it in between.
|
|
413
429
|
await ensureLoadedBudgetMatchesSession();
|
|
414
|
-
return await
|
|
430
|
+
return await runOperation();
|
|
415
431
|
}
|
|
416
432
|
catch (err) {
|
|
417
433
|
// Only drop the pool connection on errors that suggest the api
|
|
@@ -442,7 +458,7 @@ export async function withActualApi(rawOperation) {
|
|
|
442
458
|
let forceFullShutdown = false;
|
|
443
459
|
try {
|
|
444
460
|
await initActualApiForOperation();
|
|
445
|
-
return await
|
|
461
|
+
return await runOperation();
|
|
446
462
|
}
|
|
447
463
|
catch (err) {
|
|
448
464
|
// #419: the stdio keep-alive branch leaves the singleton live; on an
|
|
@@ -3002,13 +3018,13 @@ export async function runBankSync(accountId) {
|
|
|
3002
3018
|
const reset = error?.details?.rateLimitHeaders?.http_x_ratelimit_account_success_reset;
|
|
3003
3019
|
const retryIn = reset ? ` Retry in ~${Math.ceil(Number(reset) / 60)} minute(s).` : '';
|
|
3004
3020
|
throw new Error(`Bank sync failed: GoCardless rate limit exceeded for this account.${retryIn} ` +
|
|
3005
|
-
`(NORDIGEN RATE_LIMIT_EXCEEDED
|
|
3021
|
+
`(NORDIGEN RATE_LIMIT_EXCEEDED: account success quota exhausted)`);
|
|
3006
3022
|
}
|
|
3007
3023
|
if (error?.type === 'BankSyncError' ||
|
|
3008
3024
|
errorMsg.includes('BankSyncError') ||
|
|
3009
3025
|
errorMsg.includes('NORDIGEN_ERROR') ||
|
|
3010
3026
|
errorMsg.includes('Failed syncing account')) {
|
|
3011
|
-
throw new Error(`Bank sync failed: Provider error
|
|
3027
|
+
throw new Error(`Bank sync failed: Provider error: ${errorMsg}`, { cause: error });
|
|
3012
3028
|
}
|
|
3013
3029
|
throw new Error(`Bank sync failed: ${errorMsg}`, { cause: error }); // #452: keep the brand
|
|
3014
3030
|
}
|
|
@@ -411,13 +411,6 @@ async function trackBudgetMutation(start, describeLoaded, label, verify, onRecor
|
|
|
411
411
|
throw err;
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
|
-
/**
|
|
415
|
-
* How long the #453 pre-download version probe may take before it is abandoned. Deliberately a
|
|
416
|
-
* CONSTANT and not an env var: it is an advisory diagnostic, nobody should have to tune it, and
|
|
417
|
-
* a new config key would need to be threaded through config.ts, .env.example and the README
|
|
418
|
-
* table (the config-drift guard enforces all three) to buy nothing.
|
|
419
|
-
*/
|
|
420
|
-
const SERVER_VERSION_PROBE_TIMEOUT_MS = 5000;
|
|
421
414
|
/**
|
|
422
415
|
* #394: the same discipline for an IMPORT.
|
|
423
416
|
*
|
|
@@ -472,27 +465,23 @@ export async function loadBudgetTracked(syncId, encryptionPassword, label = 'dow
|
|
|
472
465
|
// Three properties hold it in place:
|
|
473
466
|
// - OUTSIDE the tracked chain (before the try), because nothing in that chain may take the
|
|
474
467
|
// api lock or extend the window an abandoned load holds open.
|
|
475
|
-
// - BOUNDED by
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
// - ONCE per process,
|
|
479
|
-
//
|
|
480
|
-
//
|
|
468
|
+
// - BOUNDED by the GUARD itself, not here. Review of the first version moved it: this runs
|
|
469
|
+
// inside the process-global api mutex, so an unbounded advisory GET would stall every
|
|
470
|
+
// session, and a bound each call site has to remember is one a call site will forget.
|
|
471
|
+
// - ONCE per process ON SUCCESS, and the qualifier matters. The guard's latch is set only
|
|
472
|
+
// when a version was actually READ (#453 review: latching on the attempt meant one failed
|
|
473
|
+
// probe disabled the warning for the life of the process). It is shared with the post-op
|
|
474
|
+
// call site in actual-adapter.ts, so on success whichever fires first wins and the other is
|
|
475
|
+
// a no-op, and this one normally fires first, being earlier. On FAILURE both sites keep
|
|
476
|
+
// retrying until MAX_PROBE_ATTEMPTS, so an unreachable /info costs up to three bounded
|
|
477
|
+
// probes rather than one. That is the deliberate trade: a bounded, finite cost in exchange
|
|
478
|
+
// for not being silenced by a single transient failure.
|
|
481
479
|
//
|
|
482
480
|
// `api.getServerVersion` is read at call time (not destructured) so it needs only the server URL
|
|
483
481
|
// that api.init() already established: NO budget has to be loaded, which is what makes a probe
|
|
484
482
|
// before the download possible at all.
|
|
485
|
-
await checkServerVersionOnce(() =>
|
|
486
|
-
.getServerVersion(),
|
|
487
|
-
// A SHORT dedicated bound, not the general ACTUAL_OP_TIMEOUT_MS (30s by default).
|
|
488
|
-
//
|
|
489
|
-
// Review of the first version caught the reason: `/info` is an unauthenticated GET on a
|
|
490
|
-
// different route from the sync endpoints, so it can stall while the login and download
|
|
491
|
-
// are perfectly healthy. At the general bound that stall costs 30s of the process-global
|
|
492
|
-
// api mutex, and since a failed probe leaves the guard armed it would be paid again on
|
|
493
|
-
// each of the next two loads. An ADVISORY diagnostic must never be able to do that. If
|
|
494
|
-
// the probe cannot answer in 5 seconds it has nothing useful to say.
|
|
495
|
-
SERVER_VERSION_PROBE_TIMEOUT_MS, 'the server-version probe bound (fixed, not configurable)'), log);
|
|
483
|
+
await checkServerVersionOnce(() => api
|
|
484
|
+
.getServerVersion(), log);
|
|
496
485
|
try {
|
|
497
486
|
await trackBudgetMutation(
|
|
498
487
|
// #410: no redundant clear here any more. `trackBudgetMutation` owns the clear-before, and
|
|
@@ -32,8 +32,8 @@ export const RAW_ENV_ALLOWLIST = [
|
|
|
32
32
|
{ name: 'MCP_STDIO_MODE', reason: 'mirrors the --stdio CLI flag, set in-process before the logger import; not a .env knob', readSite: 'logger.ts:345, index.ts', documented: false },
|
|
33
33
|
// Deferred-config entry: read in src/index.ts to preserve the fast --help exit (#230).
|
|
34
34
|
{ name: 'MCP_BRIDGE_BIND_HOST', reason: 'deferred-config entry (index.ts reads process.env directly, #230)', readSite: 'index.ts' },
|
|
35
|
-
{ name: 'MCP_HTTP_PATH', reason: 'deferred-config entry; the path the server LISTENS on', readSite: 'index.ts:
|
|
36
|
-
{ name: 'MCP_BRIDGE_HTTP_PATH', reason: 'deferred-config entry; the path ADVERTISED to clients, falls back to MCP_HTTP_PATH', readSite: 'index.ts:
|
|
35
|
+
{ name: 'MCP_HTTP_PATH', reason: 'deferred-config entry; the path the server LISTENS on', readSite: 'index.ts:235' },
|
|
36
|
+
{ name: 'MCP_BRIDGE_HTTP_PATH', reason: 'deferred-config entry; the path ADVERTISED to clients, falls back to MCP_HTTP_PATH', readSite: 'index.ts:321' },
|
|
37
37
|
{ name: 'MCP_BRIDGE_PUBLIC_HOST', reason: 'deferred-config entry; advertised public host', readSite: 'index.ts:269, httpServer.ts:581' },
|
|
38
38
|
{ name: 'MCP_BRIDGE_PUBLIC_SCHEME', reason: 'deferred-config entry; advertised scheme override', readSite: 'index.ts' },
|
|
39
39
|
{ name: 'MCP_BRIDGE_USE_TLS', reason: 'deprecated alias of MCP_ENABLE_HTTPS; affects only advertised-scheme detection', readSite: 'index.ts:277' },
|
|
@@ -113,15 +113,21 @@ function classifyOne(err) {
|
|
|
113
113
|
const e = err;
|
|
114
114
|
// `.code` FIRST: upstream's api/download-budget and api/load-budget never let a
|
|
115
115
|
// SyncError escape, they throw a plain Error carrying .code via withErrorCode.
|
|
116
|
+
// `Object.hasOwn`, never a bare truthiness test on the lookup: these are object literals, so
|
|
117
|
+
// they inherit Object.prototype and an error carrying `code: 'constructor'` (or 'toString',
|
|
118
|
+
// 'valueOf') would match, returning a `cause` that is not an InitFailureCause and a sentence
|
|
119
|
+
// that is `undefined`. The stdio handler would then answer a tool call with
|
|
120
|
+
// `{ isError: true, content: [{ type: 'text', text: undefined }] }`. Pre-existing from #438,
|
|
121
|
+
// but #452 is what routes EVERY stdio tool error through here, so it is reachable now.
|
|
116
122
|
const code = typeof e?.code === 'string' ? e.code : undefined;
|
|
117
|
-
if (code && REASON_TO_CAUSE
|
|
123
|
+
if (code && Object.hasOwn(REASON_TO_CAUSE, code))
|
|
118
124
|
return done(REASON_TO_CAUSE[code]);
|
|
119
|
-
if (code && SYSTEM_CODE_TO_CAUSE
|
|
125
|
+
if (code && Object.hasOwn(SYSTEM_CODE_TO_CAUSE, code))
|
|
120
126
|
return done(SYSTEM_CODE_TO_CAUSE[code]);
|
|
121
127
|
// `.reason` only as a defensive fallback, for a SyncError that reaches us by
|
|
122
128
|
// some path that does not go through those two handlers.
|
|
123
129
|
const reason = typeof e?.reason === 'string' ? e.reason : undefined;
|
|
124
|
-
if (reason && REASON_TO_CAUSE
|
|
130
|
+
if (reason && Object.hasOwn(REASON_TO_CAUSE, reason))
|
|
125
131
|
return done(REASON_TO_CAUSE[reason]);
|
|
126
132
|
const message = typeof e?.message === 'string' ? e.message : '';
|
|
127
133
|
// Our OWN synthesized post-condition error (#396) embeds the upstream reason
|
|
@@ -130,7 +136,7 @@ function classifyOne(err) {
|
|
|
130
136
|
// handled only the upstream shapes would answer `unknown` for exactly the
|
|
131
137
|
// failures this ticket is about.
|
|
132
138
|
const embedded = /Upstream reason: \[([a-z-]+)\]/.exec(message)?.[1];
|
|
133
|
-
if (embedded && REASON_TO_CAUSE
|
|
139
|
+
if (embedded && Object.hasOwn(REASON_TO_CAUSE, embedded))
|
|
134
140
|
return done(REASON_TO_CAUSE[embedded]);
|
|
135
141
|
// Last resort, message shapes. `network-failure` is tested BEFORE the auth
|
|
136
142
|
// wording because upstream reports an unreachable server as
|
|
@@ -130,6 +130,20 @@ export function resolveJwksUri(discoveryDoc, issuer, allowInsecure = false, trus
|
|
|
130
130
|
catch {
|
|
131
131
|
throw new Error(`OIDC "jwks_uri" is not a valid URL: ${jwksUri}`);
|
|
132
132
|
}
|
|
133
|
+
return validateJwksUrl(jwks, issuerUrl, allowInsecure, trustedHosts);
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The ONE validation body shared by the discovered path (`resolveJwksUri`) and the
|
|
137
|
+
* direct path (`resolveDirectJwksUri`, #462), so the rules that matter for key-source
|
|
138
|
+
* integrity cannot drift between them: https (loopback or the insecure opt-out only
|
|
139
|
+
* same-origin, never for a trusted cross-origin host), no embedded credentials, and
|
|
140
|
+
* same ORIGIN as the issuer unless the host is in OIDC_JWKS_TRUSTED_HOSTS (#254, with
|
|
141
|
+
* the audit log). Deliberately NOT here: a query or fragment refusal. A discovered
|
|
142
|
+
* jwks_uri is IdP-controlled and a query string on it is legitimate (Azure AD B2C
|
|
143
|
+
* publishes `.../discovery/v2.0/keys?p={policy}`), so that rule lives on the direct
|
|
144
|
+
* path only, where the URL is operator-typed.
|
|
145
|
+
*/
|
|
146
|
+
function validateJwksUrl(jwks, issuerUrl, allowInsecure, trustedHosts) {
|
|
133
147
|
if (jwks.protocol !== 'https:' && !isLoopbackHost(jwks.hostname) && !allowInsecure) {
|
|
134
148
|
throw new Error(`OIDC "jwks_uri" must use https (got ${jwks.protocol}); refusing to fetch keys over plaintext`);
|
|
135
149
|
}
|
|
@@ -171,6 +185,52 @@ export function resolveJwksUri(discoveryDoc, issuer, allowInsecure = false, trus
|
|
|
171
185
|
}
|
|
172
186
|
return jwks.toString();
|
|
173
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* #462: validate an operator-supplied DIRECT JWKS URL (OIDC_JWKS_URI), for issuers
|
|
190
|
+
* that publish no discovery document (Cloudflare Access serves its keys at
|
|
191
|
+
* `https://<team>.cloudflareaccess.com/cdn-cgi/access/certs`). Two direct-path-only
|
|
192
|
+
* rules first (no query string, no fragment: an operator-typed key URL has no
|
|
193
|
+
* legitimate use for either), then the shared body. `assertSecureIssuer` still runs
|
|
194
|
+
* here because discovery, which would otherwise run it, is skipped. Network-free.
|
|
195
|
+
*/
|
|
196
|
+
export function resolveDirectJwksUri(uri, issuer, allowInsecure = false, trustedHosts = []) {
|
|
197
|
+
const issuerUrl = assertSecureIssuer(issuer, allowInsecure);
|
|
198
|
+
if (typeof uri !== 'string' || uri.length === 0) {
|
|
199
|
+
throw new Error('OIDC_JWKS_URI is empty');
|
|
200
|
+
}
|
|
201
|
+
let jwks;
|
|
202
|
+
try {
|
|
203
|
+
jwks = new URL(uri);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
throw new Error('OIDC_JWKS_URI is not a valid URL');
|
|
207
|
+
}
|
|
208
|
+
if (jwks.search || jwks.hash) {
|
|
209
|
+
throw new Error(`OIDC_JWKS_URI must not contain a query string or fragment (${jwks.origin}${jwks.pathname}). ` +
|
|
210
|
+
'An IdP whose key URL needs a query publishes it through discovery; leave OIDC_JWKS_URI unset for it.');
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
return validateJwksUrl(jwks, issuerUrl, allowInsecure, trustedHosts);
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
// Same rules, named for the knob the operator actually set.
|
|
217
|
+
throw new Error(`OIDC_JWKS_URI rejected: ${err.message}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* #462: the single JWKS-source decision for startHttpServer. With `directJwksUri` set,
|
|
222
|
+
* validate it and fetch nothing (`metadata: null`: there is no discovery document to
|
|
223
|
+
* re-serve as RFC 8414, and userinfo identity (#346) is refused at config time for this
|
|
224
|
+
* mode). Otherwise delegate to discovery exactly as before. The composition root does
|
|
225
|
+
* not branch on OIDC_JWKS_URI itself. An EMPTY string is unset (the repo convention for
|
|
226
|
+
* optional vars, `OIDC_SCOPES=` included), and the config refines read it the same way.
|
|
227
|
+
*/
|
|
228
|
+
export async function resolveJwksSource(opts, fetchImpl = fetch) {
|
|
229
|
+
if (opts.directJwksUri) {
|
|
230
|
+
return { jwksUri: resolveDirectJwksUri(opts.directJwksUri, opts.issuer, opts.allowInsecure, opts.trustedHosts), metadata: null };
|
|
231
|
+
}
|
|
232
|
+
return discoverOidcMetadata(opts.issuer, opts.allowInsecure, opts.trustedHosts, fetchImpl);
|
|
233
|
+
}
|
|
174
234
|
/**
|
|
175
235
|
* Fetch the issuer's OpenID discovery document ONCE and return both the raw
|
|
176
236
|
* document and its validated `jwks_uri` (#285). Fails closed on any problem
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// src/lib/oidc-error-cause.ts
|
|
2
|
+
//
|
|
3
|
+
// #463: the cause attached to a bearer-auth 401 must not carry the presented token's
|
|
4
|
+
// decoded claims.
|
|
5
|
+
//
|
|
6
|
+
// customJwtVerify wraps a failed jose verification as
|
|
7
|
+
// MCPAuthTokenVerificationError('invalid_token', cause). Outside NODE_ENV=production the
|
|
8
|
+
// server passes showErrorDetails=true and mcp-auth serialises that cause into the 401
|
|
9
|
+
// body with JSON.stringify, which emits every OWN ENUMERABLE property. jose's
|
|
10
|
+
// JWTClaimValidationFailed and JWTExpired both carry `payload`, the decoded token, as
|
|
11
|
+
// exactly such a property, so the body reflected `sub`, `email` and `aud` back to the
|
|
12
|
+
// caller. A Node system error from a failed JWKS fetch would reflect `address`, `port`
|
|
13
|
+
// and `syscall` the same way.
|
|
14
|
+
//
|
|
15
|
+
// This helper is STRUCTURAL rather than a list of jose classes: two classes carry the
|
|
16
|
+
// payload today, a third would be missed by any instanceof list, and plain objects keep
|
|
17
|
+
// it unit-testable without importing jose. It copies an allowlist (`code`, `claim`,
|
|
18
|
+
// `reason`, all strings) plus the message, and nothing else, so `Object.keys` of the
|
|
19
|
+
// serialised cause is always a subset of those three. The claim name stays because it
|
|
20
|
+
// is what an operator debugging an audience mismatch needs (#245); the payload goes.
|
|
21
|
+
//
|
|
22
|
+
// Pure: no imports, no logger, no config. Never log the original error whole either:
|
|
23
|
+
// `payload` is not a key the redaction format recognises.
|
|
24
|
+
const COPIED_KEYS = ['code', 'claim', 'reason'];
|
|
25
|
+
/**
|
|
26
|
+
* A fresh Error carrying only the message and the string-valued `code`, `claim` and
|
|
27
|
+
* `reason` of the thrown value, or undefined when the thrown value is not an object.
|
|
28
|
+
*/
|
|
29
|
+
export function stripJoseCause(err) {
|
|
30
|
+
if (typeof err !== 'object' || err === null)
|
|
31
|
+
return undefined;
|
|
32
|
+
const source = err;
|
|
33
|
+
const stripped = new Error(typeof source.message === 'string' ? source.message : undefined);
|
|
34
|
+
for (const key of COPIED_KEYS) {
|
|
35
|
+
const value = source[key];
|
|
36
|
+
if (typeof value === 'string')
|
|
37
|
+
stripped[key] = value;
|
|
38
|
+
}
|
|
39
|
+
return stripped;
|
|
40
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// src/lib/oidc-resource.ts
|
|
2
|
+
//
|
|
3
|
+
// #461: the OIDC resource identifier (OIDC_RESOURCE) validated and advised on, as PURE
|
|
4
|
+
// functions. This module imports no config and no logger, so every rule here is
|
|
5
|
+
// unit-testable without a server boot; the composition root (`httpServer.ts`) logs what
|
|
6
|
+
// `oidcResourceStartupWarnings` returns and does nothing else with it.
|
|
7
|
+
//
|
|
8
|
+
// WHY THE IDENTIFIER MUST BE A URL. `mcp-auth` derives the RFC 9728 well-known route
|
|
9
|
+
// from `new URL(resource).pathname` (a resource with a path such as `https://host/http`
|
|
10
|
+
// is served at `/.well-known/oauth-protected-resource/http`, RFC 9728 section 3.1) and
|
|
11
|
+
// echoes the configured string verbatim as the document's `resource` value (section 3.3
|
|
12
|
+
// requires it to be identical to what the client inserted the suffix into). A bare
|
|
13
|
+
// client id therefore throws inside the library at startup. Four of our own recipes
|
|
14
|
+
// recommended exactly that value; this validator turns the library's TypeError into a
|
|
15
|
+
// message that names the variable and the fix.
|
|
16
|
+
//
|
|
17
|
+
// WHY http: IS ACCEPTED WITH A WARNING, NOT REFUSED. Unlike OIDC_ISSUER (#244), where a
|
|
18
|
+
// plaintext issuer lets a network attacker swap the JWKS, the resource identifier is a
|
|
19
|
+
// label compared byte for byte against `aud` and published in metadata; the token in
|
|
20
|
+
// transit is protected by MCP_ENABLE_HTTPS or the reverse proxy. Refusing would break
|
|
21
|
+
// working LAN deployments to enforce nothing.
|
|
22
|
+
//
|
|
23
|
+
// WHY THE RAW STRING STILL REACHES mcp-auth. `url.href` appends a trailing slash to the
|
|
24
|
+
// origin form (`https://host` becomes `https://host/`), which would make the metadata
|
|
25
|
+
// `resource`, the `WWW-Authenticate` `resource_metadata` and the accepted-audience set
|
|
26
|
+
// three different strings. The validator returns the URL for checks and for log lines;
|
|
27
|
+
// callers keep passing the raw value on.
|
|
28
|
+
import { isLoopbackHost } from './oidc-discovery.js';
|
|
29
|
+
const VAR = 'OIDC_RESOURCE';
|
|
30
|
+
/** origin plus pathname: what every message prints, so a query or credentials never reach a log. */
|
|
31
|
+
function displayForm(url) {
|
|
32
|
+
return `${url.origin}${url.pathname}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Validate OIDC_RESOURCE as an absolute http(s) URL with no embedded credentials, no
|
|
36
|
+
* fragment and no surrounding whitespace. Throws a message that names the variable,
|
|
37
|
+
* the requirement and the recommended form; never echoes a credentialed value.
|
|
38
|
+
*/
|
|
39
|
+
export function validateOidcResource(raw) {
|
|
40
|
+
if (!raw) {
|
|
41
|
+
throw new Error(`[OIDC] ${VAR} is not set. It must be the absolute URL of this MCP server, for example https://actual-mcp.example.com/http`);
|
|
42
|
+
}
|
|
43
|
+
if (raw !== raw.trim()) {
|
|
44
|
+
throw new Error(`[OIDC] ${VAR} has surrounding whitespace. The value is published verbatim as the resource identifier, ` +
|
|
45
|
+
'so it must be the absolute URL with no padding, for example https://actual-mcp.example.com/http');
|
|
46
|
+
}
|
|
47
|
+
let url;
|
|
48
|
+
try {
|
|
49
|
+
url = new URL(raw);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
throw new Error(`[OIDC] ${VAR} must be an absolute URL, the canonical URL of this MCP server ` +
|
|
53
|
+
'(for example https://actual-mcp.example.com/http), not a client id. ' +
|
|
54
|
+
'The RFC 9728 metadata route and the expected token audience are both derived from it.');
|
|
55
|
+
}
|
|
56
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
57
|
+
throw new Error(`[OIDC] ${VAR} must be an absolute http(s) URL (got scheme ${url.protocol}).`);
|
|
58
|
+
}
|
|
59
|
+
// URL.hostname strips credentials, so a credentialed value would pass every later check
|
|
60
|
+
// and could leak into a log line. Do not echo the value.
|
|
61
|
+
if (url.username || url.password) {
|
|
62
|
+
throw new Error(`[OIDC] ${VAR} must not contain embedded credentials (user:pass@host).`);
|
|
63
|
+
}
|
|
64
|
+
// RFC 9728 section 2 and RFC 8707 section 2: no fragment.
|
|
65
|
+
if (url.hash) {
|
|
66
|
+
throw new Error(`[OIDC] ${VAR} must not contain a fragment (${displayForm(url)}#...).`);
|
|
67
|
+
}
|
|
68
|
+
return url;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Advisories about an ACCEPTED identifier: plaintext scheme on a non-loopback host, and a
|
|
72
|
+
* query component (RFC 8707 section 2 SHOULD NOT; mcp-auth publishes it verbatim and it
|
|
73
|
+
* becomes part of the expected `aud`).
|
|
74
|
+
*/
|
|
75
|
+
export function resourceSchemeAdvisories(url) {
|
|
76
|
+
const out = [];
|
|
77
|
+
if (url.protocol === 'http:' && !isLoopbackHost(url.hostname)) {
|
|
78
|
+
out.push(`[OIDC] ${VAR} uses plaintext http on a non-loopback host (${displayForm(url)}). ` +
|
|
79
|
+
'The identifier itself is only a label, but the token that carries it must travel over TLS: ' +
|
|
80
|
+
'terminate TLS at a reverse proxy or set MCP_ENABLE_HTTPS=true.');
|
|
81
|
+
}
|
|
82
|
+
if (url.search) {
|
|
83
|
+
out.push(`[OIDC] ${VAR} carries a query component (${displayForm(url)}?...). It is published verbatim in the ` +
|
|
84
|
+
'protected-resource metadata and forms part of the expected aud; never place a secret in it, and ' +
|
|
85
|
+
'prefer the canonical form without a query.');
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The path clients reach this server on, from the advertised URL when it parses, else
|
|
91
|
+
* the listen path. `advertisedUrl` is assembled from raw env in index.ts and has never
|
|
92
|
+
* been parsed before this module: a MCP_BRIDGE_HTTP_PATH without a leading slash yields
|
|
93
|
+
* `http://host:3600mcp`, which `new URL` rejects, and that must stay an advisory rather
|
|
94
|
+
* than becoming the first thing that refuses to start.
|
|
95
|
+
*/
|
|
96
|
+
export function advertisedPathFrom(advertisedUrl, httpPath) {
|
|
97
|
+
if (advertisedUrl === undefined)
|
|
98
|
+
return { path: httpPath, warning: null };
|
|
99
|
+
try {
|
|
100
|
+
return { path: new URL(advertisedUrl).pathname, warning: null };
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return {
|
|
104
|
+
path: httpPath,
|
|
105
|
+
warning: '[OIDC] The advertised MCP URL does not parse as a URL, so the resource-identifier check falls back to ' +
|
|
106
|
+
`the listen path ${httpPath}. Check MCP_BRIDGE_HTTP_PATH (it needs a leading slash) and MCP_BRIDGE_PUBLIC_HOST.`,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Null when the identifier's path equals the advertised MCP path (the form under which
|
|
112
|
+
* mcp-auth serves the path-specific RFC 9728 document a strict client looks for);
|
|
113
|
+
* otherwise one line that names the variable and recommends `<origin><advertisedPath>`.
|
|
114
|
+
*/
|
|
115
|
+
export function resourceMetadataWarning(opts) {
|
|
116
|
+
const url = new URL(opts.resource);
|
|
117
|
+
const stripped = (p) => (p.endsWith('/') && p.length > 1 ? p.slice(0, -1) : p);
|
|
118
|
+
// The canonical form has no trailing slash (MCP authorization spec, Canonical Server
|
|
119
|
+
// URI), so the recommendation never carries one even when MCP_BRIDGE_HTTP_PATH does.
|
|
120
|
+
const canonicalPath = stripped(opts.advertisedPath);
|
|
121
|
+
if (url.pathname === opts.advertisedPath || url.pathname === canonicalPath)
|
|
122
|
+
return null;
|
|
123
|
+
const recommended = `${url.origin}${canonicalPath}`;
|
|
124
|
+
const onlyTrailingSlash = stripped(url.pathname) === canonicalPath;
|
|
125
|
+
// Express serves the metadata route with and without the slash, so the slash form is
|
|
126
|
+
// not a discovery 404; the hazard is the audience: a client that canonicalises the
|
|
127
|
+
// resource mints aud=${recommended}, which the closed allowlist then rejects.
|
|
128
|
+
const detail = onlyTrailingSlash
|
|
129
|
+
? `The only difference is the trailing slash. A client that canonicalises the resource will request a token for ${recommended}, which is not in the accepted audience set, so use the form without the slash.`
|
|
130
|
+
: `Protected-resource metadata is served at /.well-known/oauth-protected-resource${url.pathname === '/' ? '' : url.pathname} only; ` +
|
|
131
|
+
`a client that looks for the path-specific document under ${canonicalPath} and does not fall back to the root will fail discovery. ` +
|
|
132
|
+
`Switching changes the expected aud and stops serving the current document, so during the switch add OIDC_ACCEPTED_AUDIENCES=${url.origin}${url.pathname === '/' ? '' : url.pathname} to keep tokens minted for the old value valid.`;
|
|
133
|
+
return (`[OIDC] ${VAR} is ${displayForm(url)} but the MCP endpoint is advertised at ${opts.advertisedPath}. ${detail} ` +
|
|
134
|
+
`Recommended: ${VAR}=${recommended}`);
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Everything the composition root should warn about for an ACCEPTED identifier, in one
|
|
138
|
+
* call. Throws only what `validateOidcResource` throws.
|
|
139
|
+
*/
|
|
140
|
+
export function oidcResourceStartupWarnings(opts) {
|
|
141
|
+
const url = validateOidcResource(opts.resource);
|
|
142
|
+
const advertised = advertisedPathFrom(opts.advertisedUrl, opts.httpPath);
|
|
143
|
+
const out = [];
|
|
144
|
+
if (advertised.warning)
|
|
145
|
+
out.push(advertised.warning);
|
|
146
|
+
const pathWarning = resourceMetadataWarning({ resource: opts.resource, advertisedPath: advertised.path });
|
|
147
|
+
if (pathWarning)
|
|
148
|
+
out.push(pathWarning);
|
|
149
|
+
out.push(...resourceSchemeAdvisories(url));
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// src/lib/oidc-token-source.ts
|
|
2
|
+
//
|
|
3
|
+
// #462: where the bearer JWT comes from. Cloudflare's documented pattern for an MCP
|
|
4
|
+
// server behind Access with Managed OAuth: the client sends an OPAQUE access token as
|
|
5
|
+
// the bearer, Cloudflare resolves it at the edge and forwards a signed JWT in
|
|
6
|
+
// `Cf-Access-Jwt-Assertion`, and "the MCP server must validate the Access JWT sent in
|
|
7
|
+
// the Cf-Access-Jwt-Assertion header". mcp-auth reads only `headers.authorization`, so
|
|
8
|
+
// this module promotes the assertion into that slot BEFORE `bearerAuth` runs, and
|
|
9
|
+
// everything downstream (signature, issuer, closed audience allowlist, non-empty sub,
|
|
10
|
+
// budget ACL) is the code that already verifies any bearer JWT.
|
|
11
|
+
//
|
|
12
|
+
// ONE token source per mode, ONE verification per request, no fallback (gate decision
|
|
13
|
+
// D4): in assertion mode `Authorization` is never consulted. A missing, empty,
|
|
14
|
+
// whitespace-containing or duplicate assertion header deletes `authorization`, so
|
|
15
|
+
// mcp-auth answers 401 `missing_auth_header`. That is what makes the mode a narrowing
|
|
16
|
+
// and never a widening: a valid JWT arriving as a bearer at the origin with no
|
|
17
|
+
// assertion is the captured-assertion-direct-to-origin case and is refused.
|
|
18
|
+
//
|
|
19
|
+
// Why whitespace is refused rather than trimmed (D3): a compact JWS never contains
|
|
20
|
+
// whitespace, Node's parser already strips OWS, and Node joins DUPLICATE custom headers
|
|
21
|
+
// with ", ". Two assertion headers therefore arrive as one whitespace-containing string
|
|
22
|
+
// and are refused, rather than one of them being silently chosen.
|
|
23
|
+
//
|
|
24
|
+
// Pure: imports neither config, logger nor Express. The composition root passes
|
|
25
|
+
// `config.OIDC_TOKEN_SOURCE` in; `httpServer.ts` never names a header, and adding a
|
|
26
|
+
// second edge value is an enum entry, a table entry here and a SENSITIVE_KEYS entry in
|
|
27
|
+
// logger.ts (a literal there, because logger.ts loads before config.ts). The
|
|
28
|
+
// `satisfies` clause makes a missing table entry a compile error.
|
|
29
|
+
/** Header carrying the JWT for every non-`authorization` source (lowercase, as Node exposes it). */
|
|
30
|
+
export const ASSERTION_HEADERS = {
|
|
31
|
+
'cf-access-jwt-assertion': 'cf-access-jwt-assertion',
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The full value to place in `headers.authorization` for the configured source, or
|
|
35
|
+
* `undefined` when the request must be refused. For `authorization` the existing value
|
|
36
|
+
* is returned untouched (this function is then a no-op for the caller).
|
|
37
|
+
*/
|
|
38
|
+
export function promoteAssertionHeader(headers, source) {
|
|
39
|
+
if (source === 'authorization') {
|
|
40
|
+
return typeof headers.authorization === 'string' ? headers.authorization : undefined;
|
|
41
|
+
}
|
|
42
|
+
const raw = headers[ASSERTION_HEADERS[source]];
|
|
43
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
44
|
+
return undefined;
|
|
45
|
+
if (/\s/.test(raw))
|
|
46
|
+
return undefined;
|
|
47
|
+
return `Bearer ${raw}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Express-shaped middleware that rewrites `req.headers.authorization` in place from the
|
|
51
|
+
* configured source. Mount it FIRST in the same chain as `bearerAuth`. With source
|
|
52
|
+
* `authorization` it is the identity middleware.
|
|
53
|
+
*/
|
|
54
|
+
export function createAssertionPromotionMiddleware(source) {
|
|
55
|
+
return function promoteAssertion(req, _res, next) {
|
|
56
|
+
if (source !== 'authorization') {
|
|
57
|
+
const promoted = promoteAssertionHeader(req.headers, source);
|
|
58
|
+
if (promoted === undefined)
|
|
59
|
+
delete req.headers.authorization;
|
|
60
|
+
else
|
|
61
|
+
req.headers.authorization = promoted;
|
|
62
|
+
}
|
|
63
|
+
next();
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -11,12 +11,19 @@
|
|
|
11
11
|
* - The comparator is PURE (no I/O, no logging), so its truth table is unit-testable.
|
|
12
12
|
* - It FAILS OPEN: an unparseable version produces no warning. A spurious warning on a
|
|
13
13
|
* healthy deployment would be worse than staying quiet.
|
|
14
|
-
* - The firing is a per-PROCESS once-guard
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
14
|
+
* - The firing is a per-PROCESS once-guard ON SUCCESS, and the qualifier is #453's change:
|
|
15
|
+
* `checked` is set only once a version has actually been READ, with `inFlight` providing the
|
|
16
|
+
* concurrency property the old synchronous latch provided and `attempts` capping the retries
|
|
17
|
+
* at MAX_PROBE_ATTEMPTS. A FAILING probe is therefore retried up to that cap instead of
|
|
18
|
+
* silencing the warning for the life of the process, which is what the synchronous latch did
|
|
19
|
+
* once the probe moved ahead of the first budget download. There are TWO call sites now: the
|
|
20
|
+
* pre-download one in budgetLoader (which issues its own bounded /info read) and the original
|
|
21
|
+
* post-op one in withActualApi (which reuses the connection the triggering op established, so
|
|
22
|
+
* it adds no auth burst, avoiding the #127/#134 class). NEITHER call site bounds its reader:
|
|
23
|
+
* the bound lives in `raceWithBound` below, deliberately, so the set of call sites cannot
|
|
24
|
+
* matter. Do not delete it on the strength of a caller looking safe. There is still NO boot-time probe:
|
|
25
|
+
* index.ts deliberately has no startup connection, and a naive boot call would double-init
|
|
26
|
+
* then be torn down by shutdownActualApi.
|
|
20
27
|
*/
|
|
21
28
|
import { SUPPORTED_ACTUAL_SERVER_RANGE } from './constants.js';
|
|
22
29
|
import { INSTALLED_API_VERSION } from './installed-api-version.js';
|
|
@@ -99,11 +106,63 @@ export function checkServerVersion(running, range = SUPPORTED_ACTUAL_SERVER_RANG
|
|
|
99
106
|
// ---------------------------------------------------------------------------
|
|
100
107
|
// Per-process once-guard.
|
|
101
108
|
// ---------------------------------------------------------------------------
|
|
109
|
+
/**
|
|
110
|
+
* How long a version probe may take before it is abandoned. Deliberately a CONSTANT and not an
|
|
111
|
+
* env var: it is an advisory diagnostic, nobody should have to tune it, and a new config key would
|
|
112
|
+
* need threading through config.ts, .env.example and the README table (the config-drift guard
|
|
113
|
+
* enforces all three) to buy nothing. 5 seconds is far more than a healthy /info needs.
|
|
114
|
+
*/
|
|
115
|
+
export const SERVER_VERSION_PROBE_TIMEOUT_MS = 5000;
|
|
102
116
|
let checked = false; // a version WAS read and judged: never check again
|
|
103
117
|
let inFlight = false; // a check is running right now: a concurrent caller must not duplicate it
|
|
104
118
|
let attempts = 0; // failed attempts, so an unreachable /info cannot be probed forever
|
|
105
119
|
/** Failed probes tolerated before the check gives up for the process. */
|
|
106
120
|
const MAX_PROBE_ATTEMPTS = 3;
|
|
121
|
+
/**
|
|
122
|
+
* Bound the read WITHOUT importing `withOpTimeout`.
|
|
123
|
+
*
|
|
124
|
+
* The obvious implementation reuses that helper, and review caught what it drags in: `opTimeout`
|
|
125
|
+
* imports `config.ts`, which Zod-validates the environment AT MODULE LOAD and hard-exits when it
|
|
126
|
+
* is missing. That breaks this module's own design note, that the comparator is PURE and
|
|
127
|
+
* unit-testable without I/O, and it makes `tests/unit/server_version_guard.test.js` unable to
|
|
128
|
+
* load at all without an environment.
|
|
129
|
+
*
|
|
130
|
+
* ONE CORRECTION to how this was first justified, since a wrong reason in a comment outlives the
|
|
131
|
+
* observation that produced it: the original wording claimed the import turned the unit chain
|
|
132
|
+
* "red locally, green in CI". The chain ALREADY dies without env vars, earlier, at
|
|
133
|
+
* `config_https_validation.test.js`. So the import does not change what the chain does on a bare
|
|
134
|
+
* machine. The property is still worth enforcing (a pure comparator that any harness can import
|
|
135
|
+
* and test in isolation), the drama was not.
|
|
136
|
+
*
|
|
137
|
+
* So the bound is an inline race with a fixed constant, and this module keeps importing nothing
|
|
138
|
+
* but its two pure siblings. The timer is always cleared, so a fast read leaves nothing pending
|
|
139
|
+
* to hold the process open.
|
|
140
|
+
*/
|
|
141
|
+
async function raceWithBound(read) {
|
|
142
|
+
let timer;
|
|
143
|
+
try {
|
|
144
|
+
return await Promise.race([
|
|
145
|
+
read(),
|
|
146
|
+
new Promise((_resolve, reject) => {
|
|
147
|
+
timer = setTimeout(() => reject(new Error(`server-version probe exceeded ${SERVER_VERSION_PROBE_TIMEOUT_MS}ms`)), SERVER_VERSION_PROBE_TIMEOUT_MS);
|
|
148
|
+
// NOT unref'd, and that is deliberate. `unref` was the first instinct ("never hold the
|
|
149
|
+
// process open for an advisory diagnostic") and it quietly breaks the bound: an unref'd
|
|
150
|
+
// timer does not keep the event loop alive, so in a process with nothing else pending
|
|
151
|
+
// Node exits before it fires and the race never rejects. The test that pins this bound
|
|
152
|
+
// caught exactly that, as an unsettled top-level await. A live server always has other
|
|
153
|
+
// work, so it would have fired there and the hazard would have stayed hidden.
|
|
154
|
+
//
|
|
155
|
+
// The cost of keeping it ref'd is bounded by construction: at most
|
|
156
|
+
// SERVER_VERSION_PROBE_TIMEOUT_MS, and the `finally` below clears it as soon as the read
|
|
157
|
+
// answers, so a healthy probe leaves nothing pending at all.
|
|
158
|
+
}),
|
|
159
|
+
]);
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
if (timer)
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
107
166
|
/**
|
|
108
167
|
* Run the compatibility check at most once per process. Call this from inside a successful
|
|
109
168
|
* `withActualApi` operation (while the connection is live), passing a function that reads the
|
|
@@ -129,13 +188,19 @@ bundledApi = INSTALLED_API_VERSION) {
|
|
|
129
188
|
// the process, including #276's surviving post-op call site. The deployment that silenced it
|
|
130
189
|
// is precisely the one the guard exists for.
|
|
131
190
|
//
|
|
191
|
+
// THE BOUND IS APPLIED HERE, not by the caller. It started at the call sites and review was
|
|
192
|
+
// right that prose cannot hold it: `inFlight` is cleared in a `finally`, so a reader whose
|
|
193
|
+
// promise NEVER settles leaves it true for the life of the process and permanently disables the
|
|
194
|
+
// warning, which is the very failure this rework removed, just relocated. Bounding inside makes
|
|
195
|
+
// the set of call sites stop mattering, the same argument #393 used for the abandoned-load wait.
|
|
196
|
+
//
|
|
132
197
|
// So: `inFlight` keeps the concurrency property the synchronous latch was actually providing,
|
|
133
198
|
// `checked` is now set only once a version was really read and judged, and `attempts` bounds
|
|
134
199
|
// the retries so an unreachable server costs at most MAX_PROBE_ATTEMPTS extra /info calls per
|
|
135
200
|
// process rather than one per budget load.
|
|
136
201
|
inFlight = true;
|
|
137
202
|
try {
|
|
138
|
-
const result = await readVersion
|
|
203
|
+
const result = await raceWithBound(readVersion);
|
|
139
204
|
if (!result || 'error' in result || typeof result.version !== 'string') {
|
|
140
205
|
attempts++;
|
|
141
206
|
log.debug('[server-version] could not read the Actual server version; skipping the compatibility check');
|
|
@@ -147,10 +212,17 @@ bundledApi = INSTALLED_API_VERSION) {
|
|
|
147
212
|
log.warn(verdict.message);
|
|
148
213
|
}
|
|
149
214
|
}
|
|
150
|
-
catch {
|
|
215
|
+
catch (err) {
|
|
151
216
|
// Advisory only: a failure to check must never surface as an error or affect the op.
|
|
217
|
+
//
|
|
218
|
+
// NAME THE CAUSE. Once the bound moved inside this function, "probe exceeded 5000ms" became
|
|
219
|
+
// the most common way through here, and it is indistinguishable from `rawGetServerVersion`
|
|
220
|
+
// being undefined (it is destructured from `api as any` at module load, so an upstream build
|
|
221
|
+
// that stops exporting it throws a TypeError down this same path). An operator wondering why
|
|
222
|
+
// the compatibility warning never appears would have had nothing to go on.
|
|
152
223
|
attempts++;
|
|
153
|
-
|
|
224
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
225
|
+
log.debug(`[server-version] compatibility check failed; ignored: ${reason}`);
|
|
154
226
|
}
|
|
155
227
|
finally {
|
|
156
228
|
inFlight = false;
|
package/dist/src/logger.js
CHANGED
|
@@ -145,6 +145,10 @@ const SENSITIVE_KEYS = new Set([
|
|
|
145
145
|
'authorization', 'proxy-authorization', 'token', 'password', 'encryptionpassword',
|
|
146
146
|
'cookie', 'set-cookie', 'secret', 'apikey', 'api_key', 'x-api-key',
|
|
147
147
|
'access_token', 'refresh_token', 'client_secret',
|
|
148
|
+
// #462: the Cloudflare Access assertion header is a bearer JWT under another name.
|
|
149
|
+
// A literal here rather than the enum in oidc-token-source.ts: logger.ts loads
|
|
150
|
+
// before config.ts and must not import from the tree.
|
|
151
|
+
'cf-access-jwt-assertion',
|
|
148
152
|
]);
|
|
149
153
|
/** A metadata key is sensitive if its lowercased name is denylisted or ends in a secret suffix. */
|
|
150
154
|
export function isSensitiveKey(key) {
|
|
@@ -13,7 +13,10 @@ import config from '../config.js';
|
|
|
13
13
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
14
14
|
import { MCPAuthTokenVerificationError } from 'mcp-auth';
|
|
15
15
|
import { createMcpAuth } from '../auth/setup.js';
|
|
16
|
-
import {
|
|
16
|
+
import { resolveJwksSource, buildTrustedJwksHosts, setResolvedOidcMetadata } from '../lib/oidc-discovery.js';
|
|
17
|
+
import { createAssertionPromotionMiddleware } from '../lib/oidc-token-source.js';
|
|
18
|
+
import { oidcResourceStartupWarnings } from '../lib/oidc-resource.js';
|
|
19
|
+
import { stripJoseCause } from '../lib/oidc-error-cause.js';
|
|
17
20
|
import { buildAcceptedAudiences } from '../lib/oidc-audiences.js';
|
|
18
21
|
import { budgetAclMiddleware } from '../auth/budget-acl.js';
|
|
19
22
|
import { runAclPreflight } from '../auth/budget-acl-dynamic.js';
|
|
@@ -59,7 +62,12 @@ advertisedUrl) {
|
|
|
59
62
|
// When AUTH_PROVIDER=none (default), the existing static Bearer token check applies.
|
|
60
63
|
let mcpAuth = null;
|
|
61
64
|
if (config.AUTH_PROVIDER === 'oidc') {
|
|
62
|
-
mcpAuth = createMcpAuth(); // throws if OIDC_ISSUER / OIDC_RESOURCE missing
|
|
65
|
+
mcpAuth = createMcpAuth(); // throws if OIDC_ISSUER / OIDC_RESOURCE missing or malformed (#461)
|
|
66
|
+
// #461: advisories only (the validator inside createMcpAuth already refused anything
|
|
67
|
+
// fatal). Compared against the ADVERTISED path, not the listen path: OIDC_RESOURCE is
|
|
68
|
+
// the public URL, and a reverse proxy may rewrite the path in between.
|
|
69
|
+
for (const w of oidcResourceStartupWarnings({ resource: config.OIDC_RESOURCE, advertisedUrl, httpPath }))
|
|
70
|
+
logger.warn(w);
|
|
63
71
|
if (mcpAuth) {
|
|
64
72
|
// Serve RFC 8707 Protected Resource Metadata (/.well-known/oauth-protected-resource/...)
|
|
65
73
|
app.use(mcpAuth.protectedResourceMetadataRouter());
|
|
@@ -93,28 +101,39 @@ advertisedUrl) {
|
|
|
93
101
|
// #254: opt-in cross-origin JWKS trusted hosts (e.g. Google serves keys from
|
|
94
102
|
// www.googleapis.com). Parsed fail-fast here at the composition root and
|
|
95
103
|
// threaded as a parameter; empty (the default) keeps same-origin-only.
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
// oauth-authorization-server well-known path against the RESOURCE server root,
|
|
106
|
-
// and some IdPs (e.g. Authentik) do not expose it where those clients look.
|
|
107
|
-
// The document is the IdP's own public discovery doc, fetched ONCE above (so no
|
|
108
|
-
// request-path fetch and no SSRF surface) and re-served verbatim as a BARE JSON
|
|
109
|
-
// object. Never emit a {jsonrpc,result} envelope on a well-known metadata path:
|
|
110
|
-
// that shape is not RFC-compliant and would break client parsing (the server-info
|
|
111
|
-
// probe was moved off this reserved path to /mcp-info as a bare object in #286).
|
|
112
|
-
// Unauthenticated by design: a client reads this before it holds a token, so it is
|
|
113
|
-
// registered here, before the bearer-auth middleware, and only when
|
|
114
|
-
// AUTH_PROVIDER=oidc (this block).
|
|
115
|
-
app.get('/.well-known/oauth-authorization-server', (_req, res) => {
|
|
116
|
-
res.json(oidcMetadata);
|
|
104
|
+
// #462: ONE decision on where the keys come from. With OIDC_JWKS_URI set the URL
|
|
105
|
+
// is validated (same rules as a discovered jwks_uri, plus no query or fragment)
|
|
106
|
+
// and NOTHING is fetched, so `oidcMetadata` is null: there is no discovery
|
|
107
|
+
// document to publish or re-serve, and this block does not branch on the knob.
|
|
108
|
+
const { jwksUri, metadata: oidcMetadata } = await resolveJwksSource({
|
|
109
|
+
issuer: config.OIDC_ISSUER,
|
|
110
|
+
allowInsecure: config.OIDC_ALLOW_INSECURE_ISSUER,
|
|
111
|
+
trustedHosts: buildTrustedJwksHosts(config.OIDC_JWKS_TRUSTED_HOSTS),
|
|
112
|
+
directJwksUri: config.OIDC_JWKS_URI,
|
|
117
113
|
});
|
|
114
|
+
if (oidcMetadata !== null) {
|
|
115
|
+
// #346: publish the startup-validated document so the ACL's UserInfo path can
|
|
116
|
+
// reuse it instead of re-fetching discovery on the authorization path with a
|
|
117
|
+
// different (empty) trusted-hosts list.
|
|
118
|
+
setResolvedOidcMetadata(oidcMetadata);
|
|
119
|
+
// #285: serve RFC 8414 OAuth Authorization Server Metadata at this origin so
|
|
120
|
+
// mcp-remote and Claude.ai's native OAuth connector can discover the IdP's
|
|
121
|
+
// token_endpoint. We already serve RFC 9728 protected-resource metadata (whose
|
|
122
|
+
// authorization_servers points at the IdP), but several clients resolve the
|
|
123
|
+
// oauth-authorization-server well-known path against the RESOURCE server root,
|
|
124
|
+
// and some IdPs (e.g. Authentik) do not expose it where those clients look.
|
|
125
|
+
// The document is the IdP's own public discovery doc, fetched ONCE above (so no
|
|
126
|
+
// request-path fetch and no SSRF surface) and re-served verbatim as a BARE JSON
|
|
127
|
+
// object. Never emit a {jsonrpc,result} envelope on a well-known metadata path:
|
|
128
|
+
// that shape is not RFC-compliant and would break client parsing (the server-info
|
|
129
|
+
// probe was moved off this reserved path to /mcp-info as a bare object in #286).
|
|
130
|
+
// Unauthenticated by design: a client reads this before it holds a token, so it is
|
|
131
|
+
// registered here, before the bearer-auth middleware, and only when
|
|
132
|
+
// AUTH_PROVIDER=oidc (this block).
|
|
133
|
+
app.get('/.well-known/oauth-authorization-server', (_req, res) => {
|
|
134
|
+
res.json(oidcMetadata);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
118
137
|
const jwks = createRemoteJWKSet(new URL(jwksUri));
|
|
119
138
|
const customJwtVerify = async (token) => {
|
|
120
139
|
// Enforce the audience claim (#160, OWASP A07). Without it, any
|
|
@@ -129,8 +148,10 @@ advertisedUrl) {
|
|
|
129
148
|
// #244: map a jose verification failure (expired, malformed, wrong aud,
|
|
130
149
|
// bad signature) to MCPAuthTokenVerificationError so mcp-auth's bearer
|
|
131
150
|
// handler returns a clean 401 with a WWW-Authenticate header instead of
|
|
132
|
-
// re-throwing the raw jose error as a 500.
|
|
133
|
-
//
|
|
151
|
+
// re-throwing the raw jose error as a 500. #463: pass a STRIPPED cause
|
|
152
|
+
// (code, claim, reason only), never the jose error itself: outside
|
|
153
|
+
// production showErrorDetails serialises the cause into the 401 body, and
|
|
154
|
+
// jose's claim errors carry the decoded token as an own property.
|
|
134
155
|
let payload;
|
|
135
156
|
try {
|
|
136
157
|
({ payload } = await jwtVerify(token, jwks, {
|
|
@@ -141,7 +162,7 @@ advertisedUrl) {
|
|
|
141
162
|
}));
|
|
142
163
|
}
|
|
143
164
|
catch (err) {
|
|
144
|
-
throw new MCPAuthTokenVerificationError('invalid_token', err
|
|
165
|
+
throw new MCPAuthTokenVerificationError('invalid_token', stripJoseCause(err));
|
|
145
166
|
}
|
|
146
167
|
// #244: require a usable `sub`. authenticateRequest (#163) and budget-acl
|
|
147
168
|
// key authorization on req.auth.subject, so a token with no string `sub`
|
|
@@ -168,7 +189,11 @@ advertisedUrl) {
|
|
|
168
189
|
claims: payload,
|
|
169
190
|
};
|
|
170
191
|
};
|
|
171
|
-
|
|
192
|
+
// #462: with OIDC_TOKEN_SOURCE=cf-access-jwt-assertion the signed JWT Cloudflare
|
|
193
|
+
// forwards in that header is promoted into `Authorization` BEFORE bearerAuth
|
|
194
|
+
// reads it, and the opaque client bearer is discarded. With the default source
|
|
195
|
+
// this middleware is the identity function. Never log headers on this path.
|
|
196
|
+
app.use(httpPath, createAssertionPromotionMiddleware(config.OIDC_TOKEN_SOURCE), mcpAuth.bearerAuth(customJwtVerify, {
|
|
172
197
|
resource: config.OIDC_RESOURCE,
|
|
173
198
|
// Audience (aud=clientId) is enforced inside customJwtVerify via jose's
|
|
174
199
|
// jwtVerify audience option (#160), not here.
|
|
@@ -176,6 +201,11 @@ advertisedUrl) {
|
|
|
176
201
|
showErrorDetails: process.env.NODE_ENV !== 'production',
|
|
177
202
|
}), budgetAclMiddleware);
|
|
178
203
|
logger.info(`[OIDC] JWT authentication enabled. Issuer: ${config.OIDC_ISSUER}`);
|
|
204
|
+
// #462 audit line: which header carries the JWT and where the keys come from.
|
|
205
|
+
logger.info('[OIDC] token source and JWKS mode', {
|
|
206
|
+
tokenSource: config.OIDC_TOKEN_SOURCE,
|
|
207
|
+
jwksMode: config.OIDC_JWKS_URI ? `direct: ${jwksUri}` : 'discovered',
|
|
208
|
+
});
|
|
179
209
|
}
|
|
180
210
|
}
|
|
181
211
|
const transports = new Map();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.22.
|
|
4
|
+
"version": "0.22.4",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0",
|
|
7
7
|
"npm": ">=10.0.0"
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"verify-tools": "npm run build && node scripts/verify-tools.js",
|
|
31
31
|
"check:coverage": "node scripts/list-actual-api-methods.mjs",
|
|
32
32
|
"direct-sync": "node scripts/direct-sync/bank-sync-direct.mjs",
|
|
33
|
-
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/transactions_aggregate.test.js && node tests/unit/account_flow_summary.test.js && node tests/unit/recurring_expenses_summary.test.js && node tests/unit/generated_tools.smoke.test.js &&node tests/unit/schema_validation.test.js && node tests/unit/schema_json_openai_compat.test.js && node tests/unit/config_https_validation.test.js && node tests/unit/config_data_dir.test.js && node tests/unit/config_drift.test.js && node tests/unit/port_alignment.test.js && node tests/unit/dockerfile_data_dir_alignment.test.js && node tests/unit/unraid_template_alignment.test.js && node tests/unit/entrypoint_invariants.test.js && node tests/unit/e2e_spec_collection.test.js && node tests/unit/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.test.js && node tests/unit/new_tool_completeness.test.js && node tests/unit/knip_config.test.js && node tests/unit/readme_stats_sync.test.js && node tests/unit/config_insecure_upstream.test.js && node tests/unit/workflow_release_guards.test.js && node tests/unit/report_train_failure.test.js && node tests/unit/unit_chain_membership.test.js && node tests/unit/api_surface_drift.test.js && node tests/unit/write_effect_audit_staleness.test.js && node tests/unit/write_effect_audit_membership.test.js && node tests/unit/tool_annotations.test.js && node tests/unit/tool_id_schema_drift.test.js && node tests/unit/filter_id_resolution.test.js && node tests/unit/filter_id_tool_wiring.test.js && node tests/unit/api_coverage_baseline.test.js && node tests/unit/train_preflight.test.js && node tests/unit/verify_published_artifacts.test.js && node tests/unit/report_train_stale.test.js && node tests/unit/stdio_framing_check.test.js && node tests/unit/server_info.test.js && node tests/unit/transfers_create.test.js && node tests/unit/bot_target_branch.test.js && node tests/unit/auth_posture.test.js && node tests/unit/auth_posture_wiring.test.js && node tests/unit/auth-acl.test.js && node tests/unit/bug76.test.js && node tests/unit/budgets_setAmount.test.js && node tests/unit/budgets_hold_for_next_month.test.js && node tests/unit/budgets_transfer.test.js && node tests/unit/transactions_uncategorized.test.js && node tests/unit/httpServer_session_init.test.js && node tests/unit/httpServer_session_not_found.test.js && node tests/unit/httpServer_init_failure_report.test.js && node tests/unit/stdio_init_failure_report.test.js && node tests/unit/manual_mcp_client_retry.test.js && node tests/unit/manual_mcp_client_session.test.js && node tests/unit/manual_mcp_client_circuit.test.js && node tests/unit/manual_runner_killswitch.test.js && node tests/unit/adapter_auth_rate_limit.test.js && node tests/unit/adapter_session_reuse.test.js && node tests/unit/retry_classifier.test.js && node tests/unit/adapter_module_surface.test.js && node tests/unit/adapter_nonidempotent_no_retry.test.js && node tests/unit/pool_liveness.test.js && node tests/unit/pool_shutdown_all.test.js && node tests/unit/query_where_operators.test.js && node tests/unit/query_run_validation.test.js && node tests/unit/adapter_with_write_session.test.js && node tests/unit/category_groups_delete.test.js && node tests/unit/rules_delete.test.js && node tests/unit/schedules_delete.test.js && node tests/unit/accounts_delete.test.js && node tests/unit/update_tools_not_found.test.js && node tests/unit/accounts_close.test.js && node tests/unit/accounts_reopen.test.js && node tests/unit/payees_delete.test.js && node tests/unit/payees_merge.test.js && node tests/unit/rules_create_or_update.test.js && node tests/unit/unhandled-rejection.test.js && node tests/unit/rejection-allowlist-purity.test.js && node tests/unit/httpServer_bearer_auth.test.js && node tests/unit/httpServer_oidc_audience.test.js && node tests/unit/httpServer_oidc_auth_verification.test.js && node tests/unit/httpServer_oidc_jwks_discovery.test.js && node tests/unit/oidc_resource_identifier.test.js && node tests/unit/httpServer_oidc_resource_metadata.test.js && node tests/unit/oidc_error_cause.test.js && node tests/unit/httpServer_oidc_error_body.test.js && node tests/unit/oidc_token_source.test.js && node tests/unit/config_oidc_assertion_refines.test.js && node tests/unit/httpServer_oidc_assertion_mode.test.js && node tests/unit/httpServer_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/adapter_drain_listing_cache.test.js && node tests/unit/budget_acl_enforcement.test.js && node tests/unit/budget_selection_precondition.test.js && node tests/unit/budget_load_abandonment.test.js && node tests/unit/budget_loader_postcondition.test.js && node tests/unit/budget_loader_import_allowlist.test.js && node tests/unit/budget_import_tracking.test.js && node tests/unit/pool_shutdown_under_lock.test.js && node tests/unit/api_lock_affinity.test.js && node tests/unit/commit_message_closing_keyword.test.js && node tests/unit/write_queue_stranding.test.js && node tests/unit/write_queue_stranded_bound.test.js && node tests/unit/budget_preference_store.test.js && node tests/unit/budget_preference_restore.test.js && node tests/unit/tags_list.test.js && node tests/unit/tags_create.test.js && node tests/unit/tags_update.test.js && node tests/unit/tags_delete.test.js && node tests/unit/notes_get.test.js && node tests/unit/notes_update.test.js && node tests/unit/payees_common_list.test.js && node tests/unit/transactions_update_guard.test.js && node tests/unit/transactions_update_splits.test.js && node tests/unit/entities_search.test.js && node tests/unit/transactions_import_schema.test.js && node tests/unit/tool_schema_shape.test.js && node tests/unit/zod_error_format.test.js && node tests/unit/tool_count_sync.test.js && node tests/unit/stdio_transport.test.js && node tests/unit/stdio_budget_switch.test.js && node tests/unit/logger_structured.test.js && node tests/unit/logger_redaction.test.js && node tests/unit/logger_correlation.test.js && node tests/unit/check_coverage.test.js && node tests/unit/adapter_op_timeout.test.js && node tests/unit/adapter_write_queue_wakeup.test.js && node tests/unit/ci_workflow_guards.test.js && node tests/unit/node_version_guard.test.js && node tests/unit/node_version_drift.test.js && node tests/unit/playwright_version_drift.test.js && node tests/unit/observability_smoke.test.js && node tests/unit/residue_pattern.test.js && node tests/unit/dual_transport_gate.test.js && node tests/unit/manual_assertions_use_helper.test.js && node tests/unit/server_version_guard.test.js && node tests/unit/server_version_probe_outside_op_timeout.test.js && node tests/unit/payee_rules_reference_filter.test.js && node tests/unit/oidc_metadata_discovery.test.js && node tests/unit/httpServer_oauth_metadata_route.test.js && node tests/unit/httpServer_mcp_info_route.test.js && node tests/unit/residue_sweep_balance_account.test.js && node tests/unit/tool_factory.test.js && node tests/unit/budget_registry_parse.test.js && node tests/unit/preferences_get.test.js && node tests/unit/budgets_export.test.js && node tests/unit/budgets_import.test.js && node tests/unit/budgets_import_syncid.test.js && node tests/unit/budget_acl_dynamic.test.js && node tests/unit/budget_acl_identity_map.test.js && node tests/unit/budget_acl_userinfo.test.js && node tests/unit/acl_e2e_fixture_contract.test.js && node tests/unit/budget_acl_middleware.test.js && node tests/unit/rules_stage.test.js",
|
|
34
34
|
"test:adapter": "npm run build && node dist/src/tests_adapter_runner.js",
|
|
35
35
|
"test:e2e": "npx playwright test",
|
|
36
36
|
"test:e2e:docker": "./tests/e2e/run-docker-e2e.sh",
|