actual-mcp-server 0.7.9 → 0.7.11
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 +3 -1
- package/dist/package.json +2 -2
- package/dist/src/config.js +11 -0
- package/dist/src/lib/oidc-audiences.js +18 -0
- package/dist/src/lib/oidc-discovery.js +132 -0
- package/dist/src/server/httpServer.js +65 -15
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -470,7 +470,9 @@ All configuration is via environment variables. Copy `.env.example` to `.env` to
|
|
|
470
470
|
| `MCP_SSE_AUTHORIZATION` | _(none)_ | No | Static Bearer token (`AUTH_PROVIDER=none`; highly recommended in production) |
|
|
471
471
|
| `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) |
|
|
472
472
|
| `OIDC_ISSUER` | _(none)_ | If OIDC | OIDC issuer URL (e.g., `https://sso.example.com`) |
|
|
473
|
+
| `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 |
|
|
473
474
|
| `OIDC_RESOURCE` | _(none)_ | No | Expected `aud` claim in JWT (your client ID) |
|
|
475
|
+
| `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 |
|
|
474
476
|
| `OIDC_SCOPES` | _(none)_ | No | Comma-separated required scopes; leave empty for Casdoor |
|
|
475
477
|
| `AUTH_BUDGET_ACL` | _(none)_ | No | Per-user budget ACL; see [AI Client Setup](docs/guides/AI_CLIENT_SETUP.md#oidc-authentication-multi-user) |
|
|
476
478
|
| `MCP_ENABLE_HTTPS` | `false` | No | Enable native TLS. Requires `MCP_HTTPS_CERT` and `MCP_HTTPS_KEY` |
|
|
@@ -716,4 +718,4 @@ The software is provided **as-is**, without warranty of any kind. The author acc
|
|
|
716
718
|
|
|
717
719
|
---
|
|
718
720
|
|
|
719
|
-
**Version:** 0.7.
|
|
721
|
+
**Version:** 0.7.11 | **Tool Count:** 71 (verified LibreChat-compatible)
|
package/dist/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actual-mcp-server",
|
|
3
3
|
"displayName": "Actual MCP Server",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.11",
|
|
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/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.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/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_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/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/payees_delete.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_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/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/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",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.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/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_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/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/payees_delete.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/budget_acl_enforcement.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/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/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",
|
|
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",
|
package/dist/src/config.js
CHANGED
|
@@ -33,8 +33,19 @@ export const configSchema = z.object({
|
|
|
33
33
|
AUTH_PROVIDER: z.enum(['none', 'oidc']).default('none'),
|
|
34
34
|
// OIDC issuer URL (e.g. https://auth.example.com/realms/myrealm). Required when AUTH_PROVIDER=oidc.
|
|
35
35
|
OIDC_ISSUER: z.string().optional(),
|
|
36
|
+
// #244: escape hatch for a plaintext (http) OIDC issuer on a trusted network
|
|
37
|
+
// (e.g. a LAN Casdoor for local testing). Off by default: an http issuer is
|
|
38
|
+
// refused because a network attacker could swap the JWKS and forge tokens.
|
|
39
|
+
// Set to 'true' ONLY when the issuer hop is genuinely trusted. Mirrors
|
|
40
|
+
// ALLOW_INSECURE_UPSTREAM (#161). Loopback issuers never need this.
|
|
41
|
+
OIDC_ALLOW_INSECURE_ISSUER: z.string().optional().transform(val => val === 'true'),
|
|
36
42
|
// This server's resource identifier URL (e.g. https://actual-mcp.example.com). Required when AUTH_PROVIDER=oidc.
|
|
37
43
|
OIDC_RESOURCE: z.string().optional(),
|
|
44
|
+
// #245: extra accepted JWT audiences beyond OIDC_RESOURCE, comma-separated.
|
|
45
|
+
// For IdPs that put the client-id (not the resource URI) in `aud`, e.g. Authentik.
|
|
46
|
+
// Strict closed allowlist: the accepted set is OIDC_RESOURCE plus these; never a
|
|
47
|
+
// wildcard. Default empty, so single-audience deployments are unchanged.
|
|
48
|
+
OIDC_ACCEPTED_AUDIENCES: z.string().optional(),
|
|
38
49
|
// Comma-separated required scopes (e.g. "read,write"). Optional.
|
|
39
50
|
OIDC_SCOPES: z.string().optional(),
|
|
40
51
|
// JSON map of principal → budget sync-ID list for per-user budget ACL.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC accepted-audiences allowlist (#245).
|
|
3
|
+
*
|
|
4
|
+
* Build the strict, closed set of audiences a JWT's `aud` claim may match: the
|
|
5
|
+
* required `OIDC_RESOURCE` plus any explicitly configured extras
|
|
6
|
+
* (`OIDC_ACCEPTED_AUDIENCES`, comma-separated, e.g. an IdP like Authentik that
|
|
7
|
+
* puts the client-id in `aud`). The list is trimmed, empties are removed, and
|
|
8
|
+
* duplicates are collapsed. There is NO wildcard and NO accept-any: an `aud`
|
|
9
|
+
* outside this set is rejected by jose, preserving the cross-relying-party replay
|
|
10
|
+
* protection from #160. With no extras configured the set is exactly
|
|
11
|
+
* `[OIDC_RESOURCE]`, so existing single-audience deployments are unchanged.
|
|
12
|
+
*
|
|
13
|
+
* Pure and side-effect-free so it is unit-testable without booting a server.
|
|
14
|
+
*/
|
|
15
|
+
export function buildAcceptedAudiences(resource, configured) {
|
|
16
|
+
const parts = [resource ?? '', ...(configured ?? '').split(',')];
|
|
17
|
+
return Array.from(new Set(parts.map((a) => a.trim()).filter((a) => a.length > 0)));
|
|
18
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createModuleLogger } from './loggerFactory.js';
|
|
2
|
+
/**
|
|
3
|
+
* OIDC JWKS discovery (#244).
|
|
4
|
+
*
|
|
5
|
+
* Background: the OIDC verifier used to build its key set from a hardcoded
|
|
6
|
+
* `${OIDC_ISSUER}/.well-known/jwks`, which is wrong for most identity providers
|
|
7
|
+
* (their real `jwks_uri` comes from the discovery document and often lives on a
|
|
8
|
+
* different path). The fetch then 404s and every token is rejected. This module
|
|
9
|
+
* resolves the real `jwks_uri` from the issuer's OpenID discovery document, with
|
|
10
|
+
* the hardening the security review required:
|
|
11
|
+
* - the issuer must be https (a plaintext issuer lets a network attacker swap
|
|
12
|
+
* the JWKS and forge tokens), except an explicit loopback host for local dev;
|
|
13
|
+
* - the discovery fetch must not follow cross-origin redirects (fail closed);
|
|
14
|
+
* - the resolved `jwks_uri` must be https and on the SAME host as the issuer, so
|
|
15
|
+
* a tampered or misconfigured discovery document cannot point key fetching at
|
|
16
|
+
* an attacker-controlled host. Cross-host issuers (for example Google, which
|
|
17
|
+
* serves keys from googleapis.com) are intentionally not auto-trusted; that is
|
|
18
|
+
* a separate, configurable follow-up (#245).
|
|
19
|
+
*
|
|
20
|
+
* The pure helpers (assertSecureIssuer, resolveJwksUri) are unit-tested without a
|
|
21
|
+
* network; discoverJwksUri wraps them around a single startup fetch and fails
|
|
22
|
+
* closed on any problem.
|
|
23
|
+
*/
|
|
24
|
+
const logger = createModuleLogger('OIDC_DISCOVERY');
|
|
25
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
|
|
26
|
+
/** Bound the startup discovery fetch so a hung IdP cannot hang startup forever. */
|
|
27
|
+
const DISCOVERY_TIMEOUT_MS = 10_000;
|
|
28
|
+
export function isLoopbackHost(host) {
|
|
29
|
+
return host ? LOOPBACK_HOSTS.has(host.toLowerCase()) : false;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse and validate the issuer URL. Throws unless it is https, or an explicit
|
|
33
|
+
* loopback host (local dev). Returns the parsed URL.
|
|
34
|
+
*/
|
|
35
|
+
export function assertSecureIssuer(issuer, allowInsecure = false) {
|
|
36
|
+
if (!issuer)
|
|
37
|
+
throw new Error('OIDC_ISSUER is not set');
|
|
38
|
+
let url;
|
|
39
|
+
try {
|
|
40
|
+
url = new URL(issuer);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new Error(`OIDC_ISSUER is not a valid URL: ${issuer}`);
|
|
44
|
+
}
|
|
45
|
+
// https is required unless the host is loopback (local dev) or the operator has
|
|
46
|
+
// explicitly opted into a plaintext issuer on a trusted network (#244).
|
|
47
|
+
if (url.protocol !== 'https:' && !isLoopbackHost(url.hostname) && !allowInsecure) {
|
|
48
|
+
throw new Error(`OIDC_ISSUER must use https (got ${url.protocol}//${url.hostname}). A plaintext issuer ` +
|
|
49
|
+
'lets a network attacker swap the JWKS and forge tokens. Use https, a loopback host for ' +
|
|
50
|
+
'local dev, or set OIDC_ALLOW_INSECURE_ISSUER=true only on a trusted network.');
|
|
51
|
+
}
|
|
52
|
+
// Reject embedded credentials (user:pass@host): URL.hostname strips them, so a
|
|
53
|
+
// credentialed issuer could slip past the same-origin check and leak secrets to logs.
|
|
54
|
+
if (url.username || url.password) {
|
|
55
|
+
throw new Error('OIDC_ISSUER must not contain embedded credentials (user:pass@host)');
|
|
56
|
+
}
|
|
57
|
+
// A real issuer has no query or fragment; reject them so the discovery URL
|
|
58
|
+
// built from the issuer cannot be malformed or misdirected.
|
|
59
|
+
if (url.search || url.hash) {
|
|
60
|
+
throw new Error('OIDC_ISSUER must not contain a query string or fragment');
|
|
61
|
+
}
|
|
62
|
+
return url;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Pure: resolve and validate the `jwks_uri` from a parsed discovery document
|
|
66
|
+
* against the issuer. Throws (fail closed) if it is missing, not https, carries
|
|
67
|
+
* embedded credentials, or is not same-origin (scheme + host + port) with the issuer.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveJwksUri(discoveryDoc, issuer, allowInsecure = false) {
|
|
70
|
+
const issuerUrl = assertSecureIssuer(issuer, allowInsecure);
|
|
71
|
+
if (!discoveryDoc || typeof discoveryDoc !== 'object') {
|
|
72
|
+
throw new Error('OIDC discovery document is empty or not an object');
|
|
73
|
+
}
|
|
74
|
+
const jwksUri = discoveryDoc.jwks_uri;
|
|
75
|
+
if (typeof jwksUri !== 'string' || jwksUri.length === 0) {
|
|
76
|
+
throw new Error('OIDC discovery document has no usable "jwks_uri"');
|
|
77
|
+
}
|
|
78
|
+
let jwks;
|
|
79
|
+
try {
|
|
80
|
+
jwks = new URL(jwksUri);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
throw new Error(`OIDC "jwks_uri" is not a valid URL: ${jwksUri}`);
|
|
84
|
+
}
|
|
85
|
+
if (jwks.protocol !== 'https:' && !isLoopbackHost(jwks.hostname) && !allowInsecure) {
|
|
86
|
+
throw new Error(`OIDC "jwks_uri" must use https (got ${jwks.protocol}); refusing to fetch keys over plaintext`);
|
|
87
|
+
}
|
|
88
|
+
if (jwks.username || jwks.password) {
|
|
89
|
+
throw new Error('OIDC "jwks_uri" must not contain embedded credentials (user:pass@host)');
|
|
90
|
+
}
|
|
91
|
+
// Same ORIGIN (scheme + host + port), not just hostname: a different port on the
|
|
92
|
+
// same host is a different service, so comparing origins keeps the guarantee the
|
|
93
|
+
// security review asked for. Both are https here, so this is effectively host+port.
|
|
94
|
+
if (jwks.origin.toLowerCase() !== issuerUrl.origin.toLowerCase()) {
|
|
95
|
+
throw new Error(`OIDC "jwks_uri" origin (${jwks.origin}) does not match the issuer origin (${issuerUrl.origin}); ` +
|
|
96
|
+
'refusing a cross-origin key fetch. A cross-host issuer (e.g. Google) needs an explicit trusted-host allowlist (#245).');
|
|
97
|
+
}
|
|
98
|
+
return jwks.toString();
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Fetch the issuer's OpenID discovery document and return a validated `jwks_uri`.
|
|
102
|
+
* Fails closed on any problem (non-https issuer, redirect, non-200, bad JSON,
|
|
103
|
+
* missing/invalid/cross-host jwks_uri). `fetchImpl` is injectable for testing.
|
|
104
|
+
*/
|
|
105
|
+
export async function discoverJwksUri(issuer, allowInsecure = false, fetchImpl = fetch) {
|
|
106
|
+
assertSecureIssuer(issuer, allowInsecure);
|
|
107
|
+
const base = issuer.endsWith('/') ? issuer.slice(0, -1) : issuer;
|
|
108
|
+
const discoveryUrl = `${base}/.well-known/openid-configuration`;
|
|
109
|
+
let res;
|
|
110
|
+
try {
|
|
111
|
+
// redirect: 'error' fails the fetch on ANY redirect, so a 30x to another
|
|
112
|
+
// origin can never silently redirect key discovery to an attacker host. The
|
|
113
|
+
// timeout keeps a hung IdP from hanging startup forever (it fails closed).
|
|
114
|
+
res = await fetchImpl(discoveryUrl, { redirect: 'error', signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) });
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
throw new Error(`OIDC discovery fetch failed for ${discoveryUrl}: ${err.message}`);
|
|
118
|
+
}
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
throw new Error(`OIDC discovery fetch returned HTTP ${res.status} for ${discoveryUrl}`);
|
|
121
|
+
}
|
|
122
|
+
let doc;
|
|
123
|
+
try {
|
|
124
|
+
doc = await res.json();
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
throw new Error(`OIDC discovery document at ${discoveryUrl} is not valid JSON`);
|
|
128
|
+
}
|
|
129
|
+
const jwksUri = resolveJwksUri(doc, issuer, allowInsecure);
|
|
130
|
+
logger.info('Resolved JWKS URI from OIDC discovery', { jwksUri });
|
|
131
|
+
return jwksUri;
|
|
132
|
+
}
|
|
@@ -11,11 +11,14 @@ import { connectionPool } from '../lib/ActualConnectionPool.js';
|
|
|
11
11
|
import observability from '../observability.js';
|
|
12
12
|
import config from '../config.js';
|
|
13
13
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
14
|
+
import { MCPAuthTokenVerificationError } from 'mcp-auth';
|
|
14
15
|
import { createMcpAuth } from '../auth/setup.js';
|
|
16
|
+
import { discoverJwksUri } from '../lib/oidc-discovery.js';
|
|
17
|
+
import { buildAcceptedAudiences } from '../lib/oidc-audiences.js';
|
|
15
18
|
import { budgetAclMiddleware } from '../auth/budget-acl.js';
|
|
16
19
|
import * as https from 'node:https';
|
|
17
20
|
import * as fs from 'node:fs';
|
|
18
|
-
// AsyncLocalStorage for request context
|
|
21
|
+
// AsyncLocalStorage for request context: moved to src/lib/requestContext.ts
|
|
19
22
|
// so adapter code can import it without a circular dependency on httpServer.
|
|
20
23
|
// Re-exported here for backward compatibility with any callers that imported
|
|
21
24
|
// `requestContext` from this module.
|
|
@@ -57,28 +60,75 @@ advertisedUrl) {
|
|
|
57
60
|
const requiredScopes = config.OIDC_SCOPES
|
|
58
61
|
? config.OIDC_SCOPES.split(',').map((s) => s.trim()).filter(Boolean)
|
|
59
62
|
: [];
|
|
60
|
-
//
|
|
63
|
+
// #245: the strict, closed audience allowlist (OIDC_RESOURCE plus any
|
|
64
|
+
// explicitly configured OIDC_ACCEPTED_AUDIENCES). jose treats the array as
|
|
65
|
+
// match-any membership, so an IdP that puts the client-id in `aud` works
|
|
66
|
+
// while an `aud` outside the set is still rejected (#160 preserved). No
|
|
67
|
+
// wildcard, empties filtered; default is exactly [OIDC_RESOURCE].
|
|
68
|
+
const acceptedAudiences = buildAcceptedAudiences(config.OIDC_RESOURCE, config.OIDC_ACCEPTED_AUDIENCES);
|
|
69
|
+
// #245: fail closed if the allowlist is empty (e.g. a whitespace-only
|
|
70
|
+
// OIDC_RESOURCE that passes the truthy check in setup.ts but trims away).
|
|
71
|
+
// Never fall through to "no audience option", which would disable the #160
|
|
72
|
+
// audience check and accept any aud from the trusted issuer.
|
|
73
|
+
if (acceptedAudiences.length === 0) {
|
|
74
|
+
throw new Error('OIDC mode requires a non-empty OIDC_RESOURCE (the JWT audience). Refusing to start ' +
|
|
75
|
+
'without audience enforcement, which would allow cross-relying-party token replay (#160/#245).');
|
|
76
|
+
}
|
|
77
|
+
// Custom jose-based JWT verifier that bypasses mcp-auth's strict PKCE/discovery
|
|
61
78
|
// validation that fails when the IdP (e.g. Casdoor v2.13) doesn't advertise
|
|
62
79
|
// code_challenge_methods_supported in its discovery document.
|
|
63
|
-
//
|
|
64
|
-
|
|
80
|
+
// #244: resolve the JWKS URI from the issuer's OpenID discovery document
|
|
81
|
+
// instead of a hardcoded `${OIDC_ISSUER}/.well-known/jwks` (which 404s for
|
|
82
|
+
// most IdPs). discoverJwksUri fetches discovery once at startup and fails
|
|
83
|
+
// closed on a non-https issuer, a cross-origin redirect, a missing/invalid
|
|
84
|
+
// jwks_uri, or a jwks_uri on a different host than the issuer. jose's
|
|
85
|
+
// createRemoteJWKSet then fetches the keys lazily and caches them.
|
|
86
|
+
const jwksUri = await discoverJwksUri(config.OIDC_ISSUER, config.OIDC_ALLOW_INSECURE_ISSUER);
|
|
87
|
+
const jwks = createRemoteJWKSet(new URL(jwksUri));
|
|
65
88
|
const customJwtVerify = async (token) => {
|
|
66
89
|
// Enforce the audience claim (#160, OWASP A07). Without it, any
|
|
67
90
|
// signature-valid token from the trusted issuer is accepted, so a token
|
|
68
91
|
// minted for a different relying party in a shared IdP tenant can be
|
|
69
|
-
// replayed against this server.
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
// ERR_JWT_CLAIM_VALIDATION_FAILED on a missing
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
92
|
+
// replayed against this server. The accepted set is the strict allowlist
|
|
93
|
+
// built above (#245: OIDC_RESOURCE plus configured extras); it is always
|
|
94
|
+
// non-empty because OIDC_RESOURCE is required in OIDC mode, so the spread
|
|
95
|
+
// is defensive. jose throws ERR_JWT_CLAIM_VALIDATION_FAILED on a missing
|
|
96
|
+
// or out-of-set aud.
|
|
97
|
+
//
|
|
98
|
+
// #244: map a jose verification failure (expired, malformed, wrong aud,
|
|
99
|
+
// bad signature) to MCPAuthTokenVerificationError so mcp-auth's bearer
|
|
100
|
+
// handler returns a clean 401 with a WWW-Authenticate header instead of
|
|
101
|
+
// re-throwing the raw jose error as a 500. Pass only the jose error as
|
|
102
|
+
// the cause (never the raw token), so no token material leaks.
|
|
103
|
+
let payload;
|
|
104
|
+
try {
|
|
105
|
+
({ payload } = await jwtVerify(token, jwks, {
|
|
106
|
+
issuer: config.OIDC_ISSUER,
|
|
107
|
+
// Guaranteed non-empty by the startup guard above, so audience is
|
|
108
|
+
// always enforced (#160/#245); never omit it.
|
|
109
|
+
audience: acceptedAudiences,
|
|
110
|
+
}));
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
throw new MCPAuthTokenVerificationError('invalid_token', err instanceof Error ? err : undefined);
|
|
114
|
+
}
|
|
115
|
+
// #244: require a usable `sub`. authenticateRequest (#163) and budget-acl
|
|
116
|
+
// key authorization on req.auth.subject, so a token with no string `sub`
|
|
117
|
+
// cannot be mapped to a principal. Reject it here as an invalid token (a
|
|
118
|
+
// clean 401) rather than letting it through to a downstream subject check.
|
|
119
|
+
if (typeof payload.sub !== 'string' || payload.sub.length === 0) {
|
|
120
|
+
throw new MCPAuthTokenVerificationError('invalid_token');
|
|
121
|
+
}
|
|
77
122
|
const rawAud = payload.aud;
|
|
78
123
|
const audience = Array.isArray(rawAud) ? rawAud : (rawAud ? [rawAud] : []);
|
|
79
124
|
const rawScope = typeof payload.scope === 'string' ? payload.scope : '';
|
|
80
125
|
return {
|
|
81
126
|
token,
|
|
127
|
+
// #244: populate subject from the JWT `sub` claim (guaranteed a non-empty
|
|
128
|
+
// string by the check above). mcp-auth sets req.auth to exactly this
|
|
129
|
+
// object, and authenticateRequest (#163) plus budget-acl.ts key off
|
|
130
|
+
// req.auth.subject; without it OIDC mode rejects every request.
|
|
131
|
+
subject: payload.sub,
|
|
82
132
|
issuer: payload.iss ?? config.OIDC_ISSUER,
|
|
83
133
|
clientId: audience[0] ?? '',
|
|
84
134
|
audience,
|
|
@@ -94,7 +144,7 @@ advertisedUrl) {
|
|
|
94
144
|
requiredScopes,
|
|
95
145
|
showErrorDetails: process.env.NODE_ENV !== 'production',
|
|
96
146
|
}), budgetAclMiddleware);
|
|
97
|
-
logger.info(`[OIDC] JWT authentication enabled
|
|
147
|
+
logger.info(`[OIDC] JWT authentication enabled. Issuer: ${config.OIDC_ISSUER}`);
|
|
98
148
|
}
|
|
99
149
|
}
|
|
100
150
|
const transports = new Map();
|
|
@@ -352,7 +402,7 @@ advertisedUrl) {
|
|
|
352
402
|
// when rejectInit fires (e.g. session init fails before any tools/call
|
|
353
403
|
// arrives), the rejection would otherwise hit process.on('unhandledRejection')
|
|
354
404
|
// and exit the server. The original error is already logged inside the
|
|
355
|
-
// onsessioninitialized catch block below
|
|
405
|
+
// onsessioninitialized catch block below: we deliberately do NOT re-log here
|
|
356
406
|
// to avoid duplicate noise and any risk of leaking credentials.
|
|
357
407
|
initPromise.catch(() => { });
|
|
358
408
|
const transport = new StreamableHTTPServerTransport({
|
|
@@ -590,7 +640,7 @@ advertisedUrl) {
|
|
|
590
640
|
console.info(`📨 MCP endpoint: ${advertised}`);
|
|
591
641
|
console.info(`❤️ Health check: ${scheme}://${healthHost}:${port}/health`);
|
|
592
642
|
if (config.AUTH_PROVIDER === 'oidc') {
|
|
593
|
-
logger.info(`🔒 OIDC authentication enabled (JWT Bearer token required
|
|
643
|
+
logger.info(`🔒 OIDC authentication enabled (JWT Bearer token required; issuer: ${config.OIDC_ISSUER})`);
|
|
594
644
|
}
|
|
595
645
|
else if (config.MCP_SSE_AUTHORIZATION) {
|
|
596
646
|
logger.info(`🔒 HTTP authentication enabled (static Bearer token required)`);
|
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.7.
|
|
4
|
+
"version": "0.7.11",
|
|
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/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.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/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_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/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/payees_delete.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_body_limit.test.js && node tests/unit/httpServer_bind_host.test.js && node tests/unit/adapter_write_pool_cooperation.test.js && node tests/unit/budget_acl_enforcement.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/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/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",
|
|
33
|
+
"test:unit-js": "node tests/unit/transactions_create.test.js && node tests/unit/generated_tools.smoke.test.js && node tests/unit/schema_validation.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/compose_profile_sync.test.js && node tests/unit/advertised_tools_sync.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/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_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/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/payees_delete.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/budget_acl_enforcement.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/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/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",
|
|
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",
|