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 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.9 | **Tool Count:** 71 (verified LibreChat-compatible)
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.9",
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",
@@ -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 — moved to src/lib/requestContext.ts
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
- // Custom jose-based JWT verifier — bypasses mcp-auth's strict PKCE/discovery
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
- // JWKS is fetched lazily on the first request and cached by jose internally.
64
- const jwks = createRemoteJWKSet(new URL(`${config.OIDC_ISSUER}/.well-known/jwks`));
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. OIDC_RESOURCE is the client-id the IdP
70
- // puts in `aud` (Casdoor sets aud=clientId) and is required in OIDC mode,
71
- // so it is always present here; the spread is defensive. jose throws
72
- // ERR_JWT_CLAIM_VALIDATION_FAILED on a missing or mismatched aud.
73
- const { payload } = await jwtVerify(token, jwks, {
74
- issuer: config.OIDC_ISSUER,
75
- ...(config.OIDC_RESOURCE ? { audience: config.OIDC_RESOURCE } : {}),
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 — issuer: ${config.OIDC_ISSUER}`);
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 — we deliberately do NOT re-log here
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 — issuer: ${config.OIDC_ISSUER})`);
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.9",
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",