@coffeerunhobby/mcp-ha-connect 1.2.0 → 1.3.1

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.
Files changed (101) hide show
  1. package/.env.example +52 -16
  2. package/CLAUDE.md +75 -0
  3. package/Dockerfile +5 -0
  4. package/dist/config.d.ts +10 -0
  5. package/dist/config.js +59 -3
  6. package/dist/config.js.map +1 -1
  7. package/dist/haClient/automations.js +2 -2
  8. package/dist/haClient/automations.js.map +1 -1
  9. package/dist/haClient/calendars.js +1 -1
  10. package/dist/haClient/calendars.js.map +1 -1
  11. package/dist/haClient/request.d.ts +2 -1
  12. package/dist/haClient/request.js +20 -11
  13. package/dist/haClient/request.js.map +1 -1
  14. package/dist/haClient/services.js +1 -1
  15. package/dist/haClient/services.js.map +1 -1
  16. package/dist/haClient/states.js +1 -1
  17. package/dist/haClient/states.js.map +1 -1
  18. package/dist/omadaClient/auth.d.ts +2 -1
  19. package/dist/omadaClient/auth.js +19 -30
  20. package/dist/omadaClient/auth.js.map +1 -1
  21. package/dist/omadaClient/request.d.ts +2 -1
  22. package/dist/omadaClient/request.js +18 -27
  23. package/dist/omadaClient/request.js.map +1 -1
  24. package/dist/permissions/index.js +20 -5
  25. package/dist/permissions/index.js.map +1 -1
  26. package/dist/server/auth.d.ts +6 -0
  27. package/dist/server/auth.js +22 -10
  28. package/dist/server/auth.js.map +1 -1
  29. package/dist/server/eventSubscription.d.ts +8 -0
  30. package/dist/server/eventSubscription.js +27 -5
  31. package/dist/server/eventSubscription.js.map +1 -1
  32. package/dist/server/http.d.ts +46 -0
  33. package/dist/server/http.js +143 -28
  34. package/dist/server/http.js.map +1 -1
  35. package/dist/server/rateLimiter.d.ts +16 -1
  36. package/dist/server/rateLimiter.js +32 -11
  37. package/dist/server/rateLimiter.js.map +1 -1
  38. package/dist/server/stdio.js +5 -0
  39. package/dist/server/stdio.js.map +1 -1
  40. package/dist/server/stream.d.ts +25 -0
  41. package/dist/server/stream.js +53 -2
  42. package/dist/server/stream.js.map +1 -1
  43. package/dist/tools/common.d.ts +11 -10
  44. package/dist/tools/common.js +20 -2
  45. package/dist/tools/common.js.map +1 -1
  46. package/dist/utils/config-validations.d.ts +7 -0
  47. package/dist/utils/config-validations.js +17 -0
  48. package/dist/utils/config-validations.js.map +1 -1
  49. package/dist/utils/jwt.d.ts +15 -1
  50. package/dist/utils/jwt.js +29 -4
  51. package/dist/utils/jwt.js.map +1 -1
  52. package/dist/utils/sanitizeError.d.ts +1 -0
  53. package/dist/utils/sanitizeError.js +18 -0
  54. package/dist/utils/sanitizeError.js.map +1 -0
  55. package/dist/utils/tlsDispatcher.d.ts +34 -0
  56. package/dist/utils/tlsDispatcher.js +33 -0
  57. package/dist/utils/tlsDispatcher.js.map +1 -0
  58. package/dist/version.d.ts +1 -1
  59. package/dist/version.js +1 -1
  60. package/docker-compose.yml +4 -1
  61. package/docs/CHANGELOG.md +66 -0
  62. package/openapi.json +1 -1
  63. package/package.json +4 -3
  64. package/src/config.ts +83 -3
  65. package/src/haClient/automations.ts +2 -2
  66. package/src/haClient/calendars.ts +1 -1
  67. package/src/haClient/request.ts +23 -11
  68. package/src/haClient/services.ts +1 -1
  69. package/src/haClient/states.ts +1 -1
  70. package/src/omadaClient/auth.ts +20 -28
  71. package/src/omadaClient/request.ts +19 -25
  72. package/src/permissions/index.ts +22 -5
  73. package/src/server/auth.ts +41 -7
  74. package/src/server/eventSubscription.ts +30 -5
  75. package/src/server/http.ts +165 -29
  76. package/src/server/rateLimiter.ts +43 -11
  77. package/src/server/stdio.ts +6 -0
  78. package/src/server/stream.ts +65 -2
  79. package/src/tools/common.ts +23 -2
  80. package/src/utils/config-validations.ts +15 -0
  81. package/src/utils/jwt.ts +47 -4
  82. package/src/utils/sanitizeError.ts +18 -0
  83. package/src/utils/tlsDispatcher.ts +40 -0
  84. package/src/version.ts +1 -1
  85. package/tests/bdd/configuration.spec.ts +3 -0
  86. package/tests/config.test.ts +3 -0
  87. package/tests/permissions/index.test.ts +21 -0
  88. package/tests/resources/index.test.ts +3 -3
  89. package/tests/security/accessControl.test.ts +223 -0
  90. package/tests/security/authentication.test.ts +117 -0
  91. package/tests/security/configuration.test.ts +170 -0
  92. package/tests/security/cryptoTransport.test.ts +136 -0
  93. package/tests/security/dependencies.test.ts +152 -0
  94. package/tests/security/injection.test.ts +117 -0
  95. package/tests/security/insecureDesign.test.ts +122 -0
  96. package/tests/security/logging.test.ts +131 -0
  97. package/tests/security/resourceConsumption.test.ts +133 -0
  98. package/tests/tools/ai/analyzeSensors.test.ts +3 -3
  99. package/tests/tools/common.test.ts +5 -4
  100. package/tests/tools/homeassistant/handlers.test.ts +2 -2
  101. package/tests/tools/omada/handlers.test.ts +3 -3
package/src/utils/jwt.ts CHANGED
@@ -9,6 +9,9 @@ export interface JwtPayload {
9
9
  sub?: string;
10
10
  exp?: number;
11
11
  iat?: number;
12
+ nbf?: number;
13
+ iss?: string;
14
+ aud?: string | string[];
12
15
  [key: string]: unknown;
13
16
  }
14
17
 
@@ -18,6 +21,18 @@ export interface JwtResult {
18
21
  error?: string;
19
22
  }
20
23
 
24
+ /** Optional claim-validation policy for verifyJwt. */
25
+ export interface JwtVerifyOptions {
26
+ /** Reject tokens that have no `exp` claim (H3). Default: false (backward-compatible). */
27
+ requireExp?: boolean;
28
+ /** Require `iss` to equal this value when set (L6). */
29
+ issuer?: string;
30
+ /** Require `aud` to include this value when set (L6). */
31
+ audience?: string;
32
+ /** Allowable clock skew in seconds for exp/nbf comparisons (L6). Default: 0. */
33
+ clockSkewSec?: number;
34
+ }
35
+
21
36
  function b64decode(str: string): string {
22
37
  const pad = str + '='.repeat((4 - (str.length % 4)) % 4);
23
38
  return Buffer.from(pad.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString();
@@ -27,7 +42,7 @@ function b64encode(data: Buffer): string {
27
42
  return data.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
28
43
  }
29
44
 
30
- export function verifyJwt(token: string, secret: string): JwtResult {
45
+ export function verifyJwt(token: string, secret: string, options: JwtVerifyOptions = {}): JwtResult {
31
46
  const parts = token.split('.');
32
47
  if (parts.length !== 3) {
33
48
  return { valid: false, error: 'bad format' };
@@ -52,9 +67,37 @@ export function verifyJwt(token: string, secret: string): JwtResult {
52
67
  return { valid: false, error: 'bad payload' };
53
68
  }
54
69
 
55
- // Check expiration
56
- if (payload.exp && payload.exp < Date.now() / 1000) {
57
- return { valid: false, error: 'expired' };
70
+ const now = Date.now() / 1000;
71
+ const skew = options.clockSkewSec ?? 0;
72
+
73
+ // Expiration (H3): enforce when present; optionally require it.
74
+ if (payload.exp !== undefined) {
75
+ if (typeof payload.exp !== 'number' || payload.exp < now - skew) {
76
+ return { valid: false, error: 'expired' };
77
+ }
78
+ } else if (options.requireExp) {
79
+ return { valid: false, error: 'missing exp' };
80
+ }
81
+
82
+ // Not-before (L6)
83
+ if (payload.nbf !== undefined) {
84
+ if (typeof payload.nbf !== 'number' || payload.nbf > now + skew) {
85
+ return { valid: false, error: 'not yet valid' };
86
+ }
87
+ }
88
+
89
+ // Issuer (L6): only checked when an expected issuer is configured.
90
+ if (options.issuer !== undefined && payload.iss !== options.issuer) {
91
+ return { valid: false, error: 'bad issuer' };
92
+ }
93
+
94
+ // Audience (L6): only checked when an expected audience is configured.
95
+ if (options.audience !== undefined) {
96
+ const aud = payload.aud;
97
+ const ok = Array.isArray(aud) ? aud.includes(options.audience) : aud === options.audience;
98
+ if (!ok) {
99
+ return { valid: false, error: 'bad audience' };
100
+ }
58
101
  }
59
102
 
60
103
  return { valid: true, payload };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Error sanitization for client-facing responses (M6 / OWASP A09:2021).
3
+ *
4
+ * Raw `error.message` values routinely embed infrastructure detail — internal
5
+ * hostnames, ports, filesystem paths, upstream stack fragments, occasionally
6
+ * tokens. None of that belongs in an HTTP response body. Callers should log the
7
+ * full error server-side (via `logger.error`) and send the return of this helper
8
+ * to the client instead.
9
+ *
10
+ * The function intentionally ignores the error's own text: returning a constant
11
+ * generic string is the whole point — there is no "safe subset" of an arbitrary
12
+ * upstream message we can trust to forward.
13
+ */
14
+ const GENERIC_MESSAGE = 'Internal server error';
15
+
16
+ export function sanitizeError(_error: unknown, fallback: string = GENERIC_MESSAGE): string {
17
+ return fallback;
18
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Per-client TLS dispatcher helper (SEC-CRYPTO / H2).
3
+ *
4
+ * When a client must talk to a host with a self-signed certificate, the ONLY
5
+ * acceptable way to relax certificate validation is to scope it to that single
6
+ * client. The previous implementation toggled the process-global
7
+ * `NODE_TLS_REJECT_UNAUTHORIZED` env var around each `fetch` call, which
8
+ * disables TLS verification for EVERY concurrent outbound request in the
9
+ * process for the duration of the call — a TOCTOU race that silently strips
10
+ * certificate validation from unrelated Home Assistant / AI traffic.
11
+ *
12
+ * Node's global `fetch` (undici) ignores the legacy `agent` option; the
13
+ * supported mechanism is a per-call `dispatcher`. We build one undici Agent per
14
+ * client instance and pass it on every request, leaving the process-global TLS
15
+ * policy untouched.
16
+ */
17
+
18
+ import { Agent, type Dispatcher } from 'undici';
19
+
20
+ /**
21
+ * `fetch` init augmented with undici's per-request dispatcher option, which is
22
+ * not present in the lib.dom / @types/node `RequestInit` typings.
23
+ */
24
+ export type FetchInitWithDispatcher = RequestInit & { dispatcher?: Dispatcher };
25
+
26
+ /**
27
+ * Build an undici dispatcher that disables TLS certificate validation, scoped
28
+ * to a single client instance. Returns `undefined` when `strictSsl` is not
29
+ * explicitly disabled, so strict clients carry no dispatcher and use Node's
30
+ * default (validating) global agent.
31
+ *
32
+ * @param strictSsl - When `false`, certificate validation is disabled for this
33
+ * client only. Any other value (including `undefined`) keeps validation on.
34
+ */
35
+ export function createTlsDispatcher(strictSsl: boolean | undefined): Dispatcher | undefined {
36
+ if (strictSsl === false) {
37
+ return new Agent({ connect: { rejectUnauthorized: false } });
38
+ }
39
+ return undefined;
40
+ }
package/src/version.ts CHANGED
@@ -2,4 +2,4 @@
2
2
  * Package version - hardcoded at build time for reliability
3
3
  * Keep in sync with package.json version
4
4
  */
5
- export const VERSION = '1.2.0';
5
+ export const VERSION = '1.3.1';
@@ -211,6 +211,9 @@ describeFeature(feature, ({ Scenario }) => {
211
211
  HA_URL: 'http://homeassistant.local:8123',
212
212
  HA_TOKEN: 'test-token',
213
213
  MCP_HTTP_BIND_ADDR: '192.168.1.100',
214
+ // H5: a non-loopback bind requires authentication.
215
+ MCP_AUTH_METHOD: 'bearer',
216
+ MCP_AUTH_SECRET: 'test-secret-key-32chars-minimum!',
214
217
  };
215
218
  });
216
219
 
@@ -175,6 +175,9 @@ describe('loadConfig', () => {
175
175
  HA_URL: 'http://homeassistant.10.0.0.19.nip.io:8123',
176
176
  HA_TOKEN: 'test-token-12345',
177
177
  MCP_HTTP_BIND_ADDR: '192.168.1.100',
178
+ // H5: a non-loopback bind requires authentication.
179
+ MCP_AUTH_METHOD: 'bearer',
180
+ MCP_AUTH_SECRET: 'test-secret-key-32chars-minimum!',
178
181
  };
179
182
 
180
183
  const config = loadConfig(env);
@@ -143,6 +143,27 @@ describe('getUserPermissions', () => {
143
143
  const configWithDefault = { ...config, defaultRole: 'READONLY' as const };
144
144
  expect(getUserPermissions('unknown', configWithDefault)).toBe(Role.READONLY);
145
145
  });
146
+
147
+ it('should resolve a lowercase defaultRole (case-insensitive)', () => {
148
+ // Real-world config commonly uses lowercase ("operator"); it must resolve to
149
+ // the OPERATOR mask, not silently fall through to an empty/undefined mask.
150
+ const cfg = { users: [], defaultRole: 'operator' as unknown as 'OPERATOR' };
151
+ expect(getUserPermissions('unlisted-sub', cfg)).toBe(Role.OPERATOR);
152
+ expect(getUserPermissions(undefined, cfg)).toBe(Role.OPERATOR);
153
+ });
154
+
155
+ it('should resolve a lowercase per-user role (case-insensitive)', () => {
156
+ const cfg = {
157
+ users: [{ sub: 'claude', role: 'admin' as unknown as 'ADMIN' }],
158
+ defaultRole: 'NONE' as const,
159
+ };
160
+ expect(getUserPermissions('claude', cfg)).toBe(Role.ADMIN);
161
+ });
162
+
163
+ it('should fail closed to NONE for an unrecognized role name', () => {
164
+ const cfg = { users: [], defaultRole: 'wizard' as unknown as 'ADMIN' };
165
+ expect(getUserPermissions('anyone', cfg)).toBe(Role.NONE);
166
+ });
146
167
  });
147
168
 
148
169
  describe('parsePermissionsConfig', () => {
@@ -9,13 +9,13 @@ import { registerAllResources } from '../../src/resources/index.js';
9
9
 
10
10
  // Create mock server that captures registered resources
11
11
  function createMockServer() {
12
- const resources = new Map<string, { config: unknown; handler: Function }>();
12
+ const resources = new Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }>();
13
13
  return {
14
- resource: vi.fn((name: string, uriOrTemplate: unknown, config: unknown, handler: Function) => {
14
+ resource: vi.fn((name: string, uriOrTemplate: unknown, config: unknown, handler: (...args: unknown[]) => unknown) => {
15
15
  resources.set(name, { config, handler });
16
16
  }),
17
17
  resources,
18
- } as unknown as McpServer & { resources: Map<string, { config: unknown; handler: Function }> };
18
+ } as unknown as McpServer & { resources: Map<string, { config: unknown; handler: (...args: unknown[]) => unknown }> };
19
19
  }
20
20
 
21
21
  // Create mock HaClient
@@ -0,0 +1,223 @@
1
+ /**
2
+ * SEC-AC — Access Control / Authorization
3
+ * OWASP A01:2021 (Broken Access Control), API1/API5:2023 (BOLA/BFLA)
4
+ *
5
+ * Findings covered:
6
+ * H1 — REST /api/* routes bypass RBAC (authenticated but no permission check).
7
+ *
8
+ * These tests assert the SECURE behavior: every REST route is gated on the same
9
+ * Permission.* bit as its MCP-tool twin, and a token lacking that bit is rejected
10
+ * with 403 BEFORE any Home Assistant call is made.
11
+ */
12
+
13
+ import { describe, it, expect, vi } from 'vitest';
14
+ import type { IncomingMessage, ServerResponse } from 'node:http';
15
+ import type { HaClient } from '../../src/haClient/index.js';
16
+ import { Permission } from '../../src/permissions/index.js';
17
+ import { requiredRestPermission, handleRestApi } from '../../src/server/http.js';
18
+ import { handleEventSubscription, getClientCount } from '../../src/server/eventSubscription.js';
19
+ import type { EventSubscriber } from '../../src/haClient/events.js';
20
+ import type { AuthenticatedRequest } from '../../src/server/auth.js';
21
+
22
+ /** Build a mock request carrying a permission mask in auth.extra. */
23
+ function mockReq(
24
+ method: string,
25
+ url: string,
26
+ permissions: number,
27
+ body?: unknown
28
+ ): AuthenticatedRequest {
29
+ const listeners: Record<string, ((arg?: unknown) => void)[]> = {};
30
+ const req = {
31
+ method,
32
+ url,
33
+ headers: { host: 'mcp.test:3000' },
34
+ auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions } },
35
+ on(event: string, cb: (arg?: unknown) => void) {
36
+ (listeners[event] ??= []).push(cb);
37
+ // Drive the body stream synchronously on the next tick for POST parseBody().
38
+ if (event === 'end') {
39
+ queueMicrotask(() => {
40
+ if (body !== undefined) {
41
+ for (const d of listeners['data'] ?? []) d(Buffer.from(JSON.stringify(body)));
42
+ }
43
+ for (const e of listeners['end'] ?? []) e();
44
+ });
45
+ }
46
+ return req;
47
+ },
48
+ } as unknown as AuthenticatedRequest;
49
+ return req;
50
+ }
51
+
52
+ /** Capture status code and JSON body written to the response. */
53
+ function mockRes(): ServerResponse & { _status?: number; _body?: unknown } {
54
+ const res = {
55
+ statusCode: 200,
56
+ headersSent: false,
57
+ setHeader: vi.fn(),
58
+ end: vi.fn(function (this: { _body?: unknown }, payload?: unknown) {
59
+ if (typeof payload === 'string') {
60
+ try {
61
+ this._body = JSON.parse(payload);
62
+ } catch {
63
+ this._body = payload;
64
+ }
65
+ }
66
+ return this;
67
+ }),
68
+ } as unknown as ServerResponse & { _status?: number; _body?: unknown };
69
+ // mirror statusCode into _status when sendJson sets it
70
+ Object.defineProperty(res, '_status', {
71
+ get() {
72
+ return res.statusCode;
73
+ },
74
+ configurable: true,
75
+ });
76
+ return res;
77
+ }
78
+
79
+ describe('SEC-AC — Access Control', () => {
80
+ describe('H1: REST route → permission mapping (mirrors MCP-tool twins)', () => {
81
+ it('maps read routes to QUERY', () => {
82
+ expect(requiredRestPermission('GET', '/api/states')).toBe(Permission.QUERY);
83
+ expect(requiredRestPermission('GET', '/api/states/light.kitchen')).toBe(Permission.QUERY);
84
+ expect(requiredRestPermission('GET', '/api/sensors')).toBe(Permission.QUERY);
85
+ expect(requiredRestPermission('GET', '/api/entities/light')).toBe(Permission.QUERY);
86
+ expect(requiredRestPermission('GET', '/api/search')).toBe(Permission.QUERY);
87
+ expect(requiredRestPermission('GET', '/api/history/sensor.temp')).toBe(Permission.QUERY);
88
+ expect(requiredRestPermission('GET', '/api/version')).toBe(Permission.QUERY);
89
+ });
90
+
91
+ it('maps the service-call route to CONTROL', () => {
92
+ expect(requiredRestPermission('POST', '/api/services/light/turn_on')).toBe(Permission.CONTROL);
93
+ });
94
+
95
+ it('returns null for unknown routes (fall through to 404, no auth needed)', () => {
96
+ expect(requiredRestPermission('GET', '/api/unknown')).toBeNull();
97
+ expect(requiredRestPermission('DELETE', '/api/states')).toBeNull();
98
+ });
99
+ });
100
+
101
+ describe('H1: enforcement — insufficient permission is rejected with 403', () => {
102
+ it('blocks GET /api/states without QUERY and never calls the client', async () => {
103
+ const client = { getStates: vi.fn().mockResolvedValue([]) } as unknown as HaClient;
104
+ const req = mockReq('GET', '/api/states', Permission.CONTROL); // CONTROL, not QUERY
105
+ const res = mockRes();
106
+
107
+ await handleRestApi(req, res, '/api/states', client);
108
+
109
+ expect(res.statusCode).toBe(403);
110
+ expect(client.getStates).not.toHaveBeenCalled();
111
+ });
112
+
113
+ it('blocks POST /api/services without CONTROL and never calls the service', async () => {
114
+ const client = { callService: vi.fn().mockResolvedValue({ ok: true }) } as unknown as HaClient;
115
+ const req = mockReq('POST', '/api/services/light/turn_on', Permission.QUERY, {
116
+ entity_id: 'light.kitchen',
117
+ });
118
+ const res = mockRes();
119
+
120
+ await handleRestApi(req, res, '/api/services/light/turn_on', client);
121
+
122
+ expect(res.statusCode).toBe(403);
123
+ expect(client.callService).not.toHaveBeenCalled();
124
+ });
125
+ });
126
+
127
+ describe('H1: enforcement — sufficient permission is allowed', () => {
128
+ it('allows GET /api/states with QUERY', async () => {
129
+ const client = { getStates: vi.fn().mockResolvedValue([{ entity_id: 'light.x' }]) } as unknown as HaClient;
130
+ const req = mockReq('GET', '/api/states', Permission.QUERY);
131
+ const res = mockRes();
132
+
133
+ await handleRestApi(req, res, '/api/states', client);
134
+
135
+ expect(res.statusCode).toBe(200);
136
+ expect(client.getStates).toHaveBeenCalledOnce();
137
+ });
138
+
139
+ it('allows POST /api/services with CONTROL', async () => {
140
+ const client = { callService: vi.fn().mockResolvedValue({ ok: true }) } as unknown as HaClient;
141
+ const req = mockReq('POST', '/api/services/light/turn_on', Permission.CONTROL, {
142
+ entity_id: 'light.kitchen',
143
+ });
144
+ const res = mockRes();
145
+
146
+ await handleRestApi(req, res, '/api/services/light/turn_on', client);
147
+
148
+ expect(res.statusCode).toBe(200);
149
+ expect(client.callService).toHaveBeenCalledOnce();
150
+ });
151
+
152
+ it('treats SUPERUSER (0xFF) as allowed everywhere', async () => {
153
+ const client = { getStates: vi.fn().mockResolvedValue([]) } as unknown as HaClient;
154
+ const req = mockReq('GET', '/api/states', 0xff);
155
+ const res = mockRes();
156
+
157
+ await handleRestApi(req, res, '/api/states', client);
158
+
159
+ expect(res.statusCode).toBe(200);
160
+ });
161
+ });
162
+
163
+ describe('L2: SSE /subscribe_events requires QUERY permission', () => {
164
+ /** Minimal SSE-capable response mock. */
165
+ function sseRes() {
166
+ return {
167
+ writableEnded: false,
168
+ writeHead: vi.fn(),
169
+ write: vi.fn(),
170
+ end: vi.fn(),
171
+ } as unknown as ServerResponse & { writeHead: ReturnType<typeof vi.fn> };
172
+ }
173
+
174
+ /** Request mock that captures the 'close' handler so we can tear down timers. */
175
+ function sseReq(permissions: number) {
176
+ const handlers: Record<string, (arg?: unknown) => void> = {};
177
+ const req = {
178
+ url: '/subscribe_events',
179
+ headers: {},
180
+ auth: { token: 't', clientId: 'u', scopes: [], extra: { permissions } },
181
+ on(event: string, cb: (arg?: unknown) => void) {
182
+ handlers[event] = cb;
183
+ return req;
184
+ },
185
+ } as unknown as AuthenticatedRequest;
186
+ return { req, handlers };
187
+ }
188
+
189
+ it('rejects a subscriber lacking QUERY with 403 and creates no client', async () => {
190
+ const before = getClientCount();
191
+ const { req } = sseReq(Permission.CONTROL); // CONTROL, not QUERY
192
+ const res = sseRes();
193
+ const subscriber = { isConnected: vi.fn() } as unknown as EventSubscriber;
194
+
195
+ await handleEventSubscription(req, res, subscriber);
196
+
197
+ expect(res.writeHead).toHaveBeenCalledWith(403, expect.anything());
198
+ expect(getClientCount()).toBe(before); // no leak
199
+ expect(subscriber.isConnected).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it('allows a subscriber with QUERY to open the stream', async () => {
203
+ const { req, handlers } = sseReq(Permission.QUERY);
204
+ const res = sseRes();
205
+ const subscriber = {
206
+ isConnected: vi.fn().mockReturnValue(true),
207
+ subscribeEventType: vi.fn().mockResolvedValue('sub-1'),
208
+ unsubscribe: vi.fn().mockResolvedValue(undefined),
209
+ } as unknown as EventSubscriber;
210
+
211
+ await handleEventSubscription(req, res, subscriber);
212
+
213
+ // 200 stream opened, not 403
214
+ expect(res.writeHead).toHaveBeenCalledWith(200, expect.objectContaining({
215
+ 'Content-Type': 'text/event-stream',
216
+ }));
217
+ expect(subscriber.subscribeEventType).toHaveBeenCalled();
218
+
219
+ // tear down the keep-alive interval the handler registered
220
+ handlers['close']?.();
221
+ });
222
+ });
223
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * SEC-AUTHN — Authentication & Session
3
+ * OWASP A07:2021 (Identification & Authentication Failures), API2:2023
4
+ *
5
+ * Findings covered:
6
+ * H3 — tokens without `exp` never expire; expiry is optional and unenforceable.
7
+ * M8 — JWT secret strength is not enforced at startup.
8
+ * L6 — no `iss` / `aud` / `nbf` / clock-skew validation.
9
+ *
10
+ * Secure behavior asserted here:
11
+ * - verifyJwt can REQUIRE exp (opt-in), validate nbf, and check iss/aud when configured.
12
+ * - The default (no options) stays backward-compatible: a token without exp is accepted.
13
+ * - loadConfig refuses a bearer secret shorter than 32 chars.
14
+ */
15
+
16
+ import { describe, it, expect } from 'vitest';
17
+ import { createJwt, verifyJwt } from '../../src/utils/jwt.js';
18
+ import { loadConfig } from '../../src/config.js';
19
+
20
+ const SECRET = 'test-secret-key-32chars-minimum!';
21
+ const now = () => Math.floor(Date.now() / 1000);
22
+
23
+ describe('SEC-AUTHN — Authentication', () => {
24
+ describe('H3: enforce token expiry (opt-in via requireExp)', () => {
25
+ it('rejects a token without exp when requireExp is set', () => {
26
+ const token = createJwt({ sub: 'service' }, SECRET);
27
+ const result = verifyJwt(token, SECRET, { requireExp: true });
28
+ expect(result.valid).toBe(false);
29
+ expect(result.error).toMatch(/exp/i);
30
+ });
31
+
32
+ it('accepts a token with a future exp when requireExp is set', () => {
33
+ const token = createJwt({ sub: 'u', exp: now() + 3600 }, SECRET);
34
+ expect(verifyJwt(token, SECRET, { requireExp: true }).valid).toBe(true);
35
+ });
36
+
37
+ it('still rejects an expired token when requireExp is set', () => {
38
+ const token = createJwt({ sub: 'u', exp: now() - 100 }, SECRET);
39
+ expect(verifyJwt(token, SECRET, { requireExp: true }).valid).toBe(false);
40
+ });
41
+
42
+ it('default (no options) remains backward-compatible: accepts no-exp token', () => {
43
+ const token = createJwt({ sub: 'service' }, SECRET);
44
+ expect(verifyJwt(token, SECRET).valid).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe('L6: nbf (not-before) validation', () => {
49
+ it('rejects a token whose nbf is in the future', () => {
50
+ const token = createJwt({ sub: 'u', nbf: now() + 3600 }, SECRET);
51
+ const result = verifyJwt(token, SECRET);
52
+ expect(result.valid).toBe(false);
53
+ expect(result.error).toMatch(/not yet valid|nbf/i);
54
+ });
55
+
56
+ it('accepts a token whose nbf is in the past', () => {
57
+ const token = createJwt({ sub: 'u', nbf: now() - 100 }, SECRET);
58
+ expect(verifyJwt(token, SECRET).valid).toBe(true);
59
+ });
60
+
61
+ it('tolerates small clock skew on nbf', () => {
62
+ const token = createJwt({ sub: 'u', nbf: now() + 30 }, SECRET);
63
+ expect(verifyJwt(token, SECRET, { clockSkewSec: 60 }).valid).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe('L6: issuer / audience validation (only when configured)', () => {
68
+ it('rejects a token with the wrong issuer', () => {
69
+ const token = createJwt({ sub: 'u', iss: 'evil' }, SECRET);
70
+ const result = verifyJwt(token, SECRET, { issuer: 'mcp-ha-connect' });
71
+ expect(result.valid).toBe(false);
72
+ expect(result.error).toMatch(/issuer/i);
73
+ });
74
+
75
+ it('accepts a token with the expected issuer', () => {
76
+ const token = createJwt({ sub: 'u', iss: 'mcp-ha-connect' }, SECRET);
77
+ expect(verifyJwt(token, SECRET, { issuer: 'mcp-ha-connect' }).valid).toBe(true);
78
+ });
79
+
80
+ it('rejects a token whose audience does not match', () => {
81
+ const token = createJwt({ sub: 'u', aud: 'other' }, SECRET);
82
+ const result = verifyJwt(token, SECRET, { audience: 'mcp' });
83
+ expect(result.valid).toBe(false);
84
+ expect(result.error).toMatch(/audience/i);
85
+ });
86
+
87
+ it('accepts a token whose audience array contains the expected value', () => {
88
+ const token = createJwt({ sub: 'u', aud: ['mcp', 'other'] }, SECRET);
89
+ expect(verifyJwt(token, SECRET, { audience: 'mcp' }).valid).toBe(true);
90
+ });
91
+
92
+ it('ignores iss/aud when not configured (backward-compatible)', () => {
93
+ const token = createJwt({ sub: 'u', iss: 'whatever', aud: 'whatever' }, SECRET);
94
+ expect(verifyJwt(token, SECRET).valid).toBe(true);
95
+ });
96
+ });
97
+
98
+ describe('M8: bearer secret strength enforced at startup', () => {
99
+ const base = { MCP_SERVER_USE_HTTP: 'true', MCP_AUTH_METHOD: 'bearer' };
100
+
101
+ it('rejects a bearer secret shorter than 32 characters', () => {
102
+ expect(() =>
103
+ loadConfig({ ...base, MCP_AUTH_SECRET: 'too-short' } as NodeJS.ProcessEnv)
104
+ ).toThrow(/32|secret/i);
105
+ });
106
+
107
+ it('accepts a bearer secret of at least 32 characters', () => {
108
+ expect(() =>
109
+ loadConfig({ ...base, MCP_AUTH_SECRET: 'a'.repeat(32) } as NodeJS.ProcessEnv)
110
+ ).not.toThrow();
111
+ });
112
+
113
+ it('does not require a secret when auth method is none', () => {
114
+ expect(() => loadConfig({ MCP_AUTH_METHOD: 'none' } as NodeJS.ProcessEnv)).not.toThrow();
115
+ });
116
+ });
117
+ });