actual-mcp-server 0.22.1 → 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/config-registry.js +2 -2
- 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/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) {
|
|
@@ -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' },
|
|
@@ -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
|
+
}
|
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/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",
|
|
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",
|