@coffeerunhobby/mcp-ha-connect 0.7.0 → 0.8.0
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/.env.example +19 -0
- package/README.md +7 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +19 -0
- package/dist/config.js.map +1 -1
- package/dist/server/auth.d.ts +20 -0
- package/dist/server/auth.js +74 -0
- package/dist/server/auth.js.map +1 -0
- package/dist/server/common.js +2 -1
- package/dist/server/common.js.map +1 -1
- package/dist/server/http.js +45 -4
- package/dist/server/http.js.map +1 -1
- package/dist/utils/sanitize.d.ts +25 -0
- package/dist/utils/sanitize.js +92 -0
- package/dist/utils/sanitize.js.map +1 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +17 -0
- package/dist/version.js.map +1 -0
- package/docs/CHANGELOG.md +19 -7
- package/docs/SSE_API.md +1 -1
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/config.ts +29 -0
- package/src/server/auth.ts +103 -0
- package/src/server/common.ts +2 -1
- package/src/server/http.ts +56 -4
- package/src/version.ts +16 -0
- package/tests/server/auth.test.ts +206 -0
- package/tests/server/http.test.ts +118 -5
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
|
-
### 0.
|
|
1
|
+
### 0.8.0
|
|
2
|
+
- **Bearer Token Authentication**: Secure HTTP endpoints with bearer tokens
|
|
3
|
+
- `MCP_AUTH_METHOD`: Set to `bearer` to enable authentication
|
|
4
|
+
- `MCP_AUTH_TOKEN`: Comma-separated tokens for multiple clients
|
|
5
|
+
- `WWW-Authenticate` header on 401 responses
|
|
6
|
+
- Health check and OpenAPI endpoints skip authentication
|
|
7
|
+
- **Server-Timing Header**: Response timing on all HTTP responses
|
|
8
|
+
- `Server-Timing: total;dur=X` header (integer milliseconds)
|
|
9
|
+
- Visible in browser DevTools Network tab
|
|
10
|
+
- **Modular Auth Middleware**: Clean separation in `src/server/auth.ts`
|
|
11
|
+
- All 648 tests passing
|
|
12
|
+
|
|
13
|
+
### 0.7.0
|
|
2
14
|
- **Calendar Tools**: Access Home Assistant calendar entities
|
|
3
15
|
- `listCalendars`: Get all calendar entities
|
|
4
16
|
- `getCalendarEvents`: Get events from one or all calendars with date filtering
|
|
@@ -8,7 +20,7 @@
|
|
|
8
20
|
- Returns onsite/away counts and linked device trackers
|
|
9
21
|
- Total tools increased from 34 to 37
|
|
10
22
|
|
|
11
|
-
### 0.6.0
|
|
23
|
+
### 0.6.0
|
|
12
24
|
- **Mobile App Notifications**: Full support for Home Assistant mobile app notifications
|
|
13
25
|
- `sendNotification`: Enhanced with action buttons, priority, images, videos
|
|
14
26
|
- `listNotificationTargets`: Discover available mobile app notification targets
|
|
@@ -27,7 +39,7 @@
|
|
|
27
39
|
- Switched to NodeNext module resolution for TypeScript
|
|
28
40
|
- All 310 tests passing
|
|
29
41
|
|
|
30
|
-
### 0.5.0
|
|
42
|
+
### 0.5.0
|
|
31
43
|
- **Real-time SSE Event Subscription**: Subscribe to Home Assistant events via Server-Sent Events
|
|
32
44
|
- Filter by domain, entity_id, or event types
|
|
33
45
|
- Automatic WebSocket connection to Home Assistant
|
|
@@ -60,7 +72,7 @@
|
|
|
60
72
|
- `MCP_RATE_LIMIT_MAX_REQUESTS`: Max requests per window
|
|
61
73
|
- Total tools increased from 16 to 33
|
|
62
74
|
|
|
63
|
-
### 0.4.0
|
|
75
|
+
### 0.4.0
|
|
64
76
|
- **Extensible AI Provider System**: Refactored AI client to support multiple providers
|
|
65
77
|
- New `src/localAI/` folder with provider-based architecture
|
|
66
78
|
- Supports Ollama (native API) and OpenAI-compatible APIs (LocalAI, LM Studio, vLLM)
|
|
@@ -80,7 +92,7 @@
|
|
|
80
92
|
- No changes to public API or tools - fully backward compatible
|
|
81
93
|
- All 325 tests pass
|
|
82
94
|
|
|
83
|
-
### 0.3.0
|
|
95
|
+
### 0.3.0
|
|
84
96
|
- Added 8 new tools: `getVersion`, `entityAction`, `listEntities`, `getDomainSummary`, `listAutomations`, `restartHomeAssistant`, `getSystemLog`, `checkUpdates`
|
|
85
97
|
- Added MCP Resources support with 5 URI-based endpoints (`hass://entities`, etc.)
|
|
86
98
|
- Added `entityAction` tool for simplified turn_on/turn_off/toggle operations
|
|
@@ -93,7 +105,7 @@
|
|
|
93
105
|
- Total tools increased from 8 to 16
|
|
94
106
|
- Enhanced type definitions for Automation, DomainSummary, HaVersion, LogbookEntry, UpdateInfo
|
|
95
107
|
|
|
96
|
-
### 0.2.0
|
|
108
|
+
### 0.2.0
|
|
97
109
|
- Added AI-powered sensor analysis with Ollama
|
|
98
110
|
- Added `getAllSensors` tool
|
|
99
111
|
- Added `analyzeSensors` tool
|
|
@@ -108,7 +120,7 @@
|
|
|
108
120
|
- Added Docker CLI workflow import method
|
|
109
121
|
- Enhanced troubleshooting with network/firewall diagnostics
|
|
110
122
|
|
|
111
|
-
### 0.1.0
|
|
123
|
+
### 0.1.0
|
|
112
124
|
- Basic Home Assistant integration
|
|
113
125
|
- 5 core tools (getStates, getState, callService, getEntitiesByDomain, searchEntities)
|
|
114
126
|
- Multiple transport modes (stdio, SSE, HTTP streaming)
|
package/docs/SSE_API.md
CHANGED
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Home Assistant MCP Wrapper",
|
|
5
|
-
"version": "0.
|
|
5
|
+
"version": "0.8.0",
|
|
6
6
|
"description": "REST endpoints that wrap MCP tools for Home Assistant integration. Raw JSON-RPC passthrough available at POST / for n8n compatibility."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -71,6 +71,10 @@ const envSchema = z
|
|
|
71
71
|
rateLimitEnabled: createBooleanStringSchema(true),
|
|
72
72
|
rateLimitWindowMs: numericStringSchema,
|
|
73
73
|
rateLimitMaxRequests: numericStringSchema,
|
|
74
|
+
|
|
75
|
+
// Authentication Configuration
|
|
76
|
+
authMethod: z.enum(['none', 'bearer']).optional().default('none'),
|
|
77
|
+
authToken: listStringSchema,
|
|
74
78
|
})
|
|
75
79
|
.refine(
|
|
76
80
|
(data) => {
|
|
@@ -104,6 +108,19 @@ const envSchema = z
|
|
|
104
108
|
path: ['httpAllowedOrigins'],
|
|
105
109
|
};
|
|
106
110
|
}
|
|
111
|
+
)
|
|
112
|
+
.refine(
|
|
113
|
+
(data) => {
|
|
114
|
+
// If bearer auth is enabled, token must be provided
|
|
115
|
+
if (data.authMethod === 'bearer' && !data.authToken) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
message: 'MCP_AUTH_TOKEN is required when MCP_AUTH_METHOD is "bearer"',
|
|
122
|
+
path: ['authToken'],
|
|
123
|
+
}
|
|
107
124
|
);
|
|
108
125
|
|
|
109
126
|
export interface EnvironmentConfig {
|
|
@@ -144,6 +161,10 @@ export interface EnvironmentConfig {
|
|
|
144
161
|
rateLimitEnabled: boolean;
|
|
145
162
|
rateLimitWindowMs: number;
|
|
146
163
|
rateLimitMaxRequests: number;
|
|
164
|
+
|
|
165
|
+
// Authentication Configuration
|
|
166
|
+
authMethod: 'none' | 'bearer';
|
|
167
|
+
authToken?: Set<string>;
|
|
147
168
|
}
|
|
148
169
|
|
|
149
170
|
export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentConfig {
|
|
@@ -185,6 +206,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
|
|
|
185
206
|
rateLimitEnabled: env.MCP_RATE_LIMIT_ENABLED,
|
|
186
207
|
rateLimitWindowMs: env.MCP_RATE_LIMIT_WINDOW_MS,
|
|
187
208
|
rateLimitMaxRequests: env.MCP_RATE_LIMIT_MAX_REQUESTS,
|
|
209
|
+
|
|
210
|
+
// Authentication Configuration
|
|
211
|
+
authMethod: env.MCP_AUTH_METHOD,
|
|
212
|
+
authToken: env.MCP_AUTH_TOKEN,
|
|
188
213
|
});
|
|
189
214
|
|
|
190
215
|
if (!parsed.success) {
|
|
@@ -244,6 +269,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): EnvironmentCon
|
|
|
244
269
|
rateLimitEnabled: parsed.data.rateLimitEnabled,
|
|
245
270
|
rateLimitWindowMs: parsed.data.rateLimitWindowMs ?? 60000,
|
|
246
271
|
rateLimitMaxRequests: parsed.data.rateLimitMaxRequests ?? 100,
|
|
272
|
+
|
|
273
|
+
// Authentication Configuration
|
|
274
|
+
authMethod: parsed.data.authMethod,
|
|
275
|
+
authToken: parsed.data.authToken ? new Set(parsed.data.authToken) : undefined,
|
|
247
276
|
};
|
|
248
277
|
}
|
|
249
278
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication middleware for HTTP server
|
|
3
|
+
* Supports bearer token authentication with multi-token support
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
+
import { logger } from '../utils/logger.js';
|
|
8
|
+
|
|
9
|
+
export type AuthMethod = 'none' | 'bearer';
|
|
10
|
+
|
|
11
|
+
export interface AuthConfig {
|
|
12
|
+
method: AuthMethod;
|
|
13
|
+
tokens?: Set<string>;
|
|
14
|
+
skipPaths?: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface AuthResult {
|
|
18
|
+
authenticated: boolean;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validate bearer token from Authorization header
|
|
24
|
+
*/
|
|
25
|
+
function validateBearerToken(req: IncomingMessage, validTokens: Set<string>): AuthResult {
|
|
26
|
+
const authHeader = req.headers.authorization;
|
|
27
|
+
|
|
28
|
+
if (!authHeader) {
|
|
29
|
+
return { authenticated: false, error: 'Missing Authorization header' };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!authHeader.startsWith('Bearer ')) {
|
|
33
|
+
return { authenticated: false, error: 'Invalid Authorization header format. Expected: Bearer <token>' };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const token = authHeader.slice(7);
|
|
37
|
+
if (!validTokens.has(token)) {
|
|
38
|
+
return { authenticated: false, error: 'Invalid token' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { authenticated: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Authentication middleware
|
|
46
|
+
* Returns true if request should proceed, false if blocked
|
|
47
|
+
*/
|
|
48
|
+
export function createAuthMiddleware(config: AuthConfig): (req: IncomingMessage, res: ServerResponse) => boolean {
|
|
49
|
+
const { method, tokens, skipPaths = [] } = config;
|
|
50
|
+
|
|
51
|
+
return (req: IncomingMessage, res: ServerResponse): boolean => {
|
|
52
|
+
// No auth required
|
|
53
|
+
if (method === 'none') {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check if path should skip auth
|
|
58
|
+
const url = req.url ?? '/';
|
|
59
|
+
const urlPath = url.split('?')[0];
|
|
60
|
+
|
|
61
|
+
if (skipPaths.some((path) => urlPath === path || urlPath.startsWith(path + '/'))) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Bearer auth
|
|
66
|
+
if (method === 'bearer') {
|
|
67
|
+
if (!tokens?.size) {
|
|
68
|
+
logger.error('Bearer auth enabled but no token configured');
|
|
69
|
+
res.statusCode = 500;
|
|
70
|
+
res.setHeader('Content-Type', 'application/json');
|
|
71
|
+
res.end(JSON.stringify({ error: 'Server misconfiguration: auth token not set' }));
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = validateBearerToken(req, tokens);
|
|
76
|
+
|
|
77
|
+
if (!result.authenticated) {
|
|
78
|
+
logger.warn('Authentication failed', {
|
|
79
|
+
method: req.method,
|
|
80
|
+
url: urlPath,
|
|
81
|
+
error: result.error,
|
|
82
|
+
ip: req.socket.remoteAddress,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
res.statusCode = 401;
|
|
86
|
+
res.setHeader('Content-Type', 'application/json');
|
|
87
|
+
res.setHeader('WWW-Authenticate', result.error === 'Invalid token' ? 'Bearer error="invalid_token"' : 'Bearer');
|
|
88
|
+
res.end(JSON.stringify({ error: 'Unauthorized', message: result.error }));
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
logger.debug('Authentication successful', { method: req.method, url: urlPath });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Unknown auth method
|
|
97
|
+
logger.error('Unknown auth method', { method });
|
|
98
|
+
res.statusCode = 500;
|
|
99
|
+
res.setHeader('Content-Type', 'application/json');
|
|
100
|
+
res.end(JSON.stringify({ error: 'Server misconfiguration: unknown auth method' }));
|
|
101
|
+
return false;
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/server/common.ts
CHANGED
|
@@ -9,13 +9,14 @@ import type { LocalAIClient } from '../localAI/index.js';
|
|
|
9
9
|
import { registerAllTools } from '../tools/index.js';
|
|
10
10
|
import { registerAllResources } from '../resources/index.js';
|
|
11
11
|
import { logger } from '../utils/logger.js';
|
|
12
|
+
import { VERSION } from '../version.js';
|
|
12
13
|
|
|
13
14
|
export function createServer(client: HaClient, aiClient?: LocalAIClient): McpServer {
|
|
14
15
|
logger.debug('Creating MCP server instance');
|
|
15
16
|
|
|
16
17
|
const server = new McpServer({
|
|
17
18
|
name: 'mcp-ha-connect',
|
|
18
|
-
version:
|
|
19
|
+
version: VERSION,
|
|
19
20
|
});
|
|
20
21
|
|
|
21
22
|
// Register all Home Assistant tools
|
package/src/server/http.ts
CHANGED
|
@@ -13,10 +13,45 @@ import { handleSseConnection, handleSseMessage, getSseMessagePath, type SSETrans
|
|
|
13
13
|
import { handleStreamRequest, type StreamTransportState } from './stream.js';
|
|
14
14
|
import { handleEventSubscription, getClientCount } from './eventSubscription.js';
|
|
15
15
|
import { RateLimiter } from './rateLimiter.js';
|
|
16
|
+
import { createAuthMiddleware } from './auth.js';
|
|
17
|
+
import { VERSION } from '../version.js';
|
|
16
18
|
|
|
17
19
|
// Session storage for stateful mode
|
|
18
20
|
const sessions = new Map<string, SSETransportState | StreamTransportState>();
|
|
19
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Wrap response to add Server-Timing header with request duration
|
|
24
|
+
* The timing is calculated and header set just before res.end() is called
|
|
25
|
+
*/
|
|
26
|
+
function wrapResponseWithTiming(res: ServerResponse): { res: ServerResponse; startTime: number } {
|
|
27
|
+
const startTime = performance.now();
|
|
28
|
+
const originalEnd = res.end.bind(res);
|
|
29
|
+
|
|
30
|
+
// Override res.end to set timing header before sending
|
|
31
|
+
res.end = function (
|
|
32
|
+
chunk?: unknown,
|
|
33
|
+
encodingOrCallback?: BufferEncoding | (() => void),
|
|
34
|
+
callback?: () => void
|
|
35
|
+
): ServerResponse {
|
|
36
|
+
// Set Server-Timing header if headers haven't been sent
|
|
37
|
+
if (!res.headersSent) {
|
|
38
|
+
const durationMs = Math.round(performance.now() - startTime);
|
|
39
|
+
res.setHeader('Server-Timing', `total;dur=${durationMs}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Call original end with proper overload handling
|
|
43
|
+
if (typeof encodingOrCallback === 'function') {
|
|
44
|
+
return originalEnd(chunk, encodingOrCallback);
|
|
45
|
+
}
|
|
46
|
+
if (encodingOrCallback !== undefined) {
|
|
47
|
+
return originalEnd(chunk, encodingOrCallback, callback);
|
|
48
|
+
}
|
|
49
|
+
return originalEnd(chunk, callback);
|
|
50
|
+
} as typeof res.end;
|
|
51
|
+
|
|
52
|
+
return { res, startTime };
|
|
53
|
+
}
|
|
54
|
+
|
|
20
55
|
/**
|
|
21
56
|
* Parse JSON body from request
|
|
22
57
|
*/
|
|
@@ -43,7 +78,8 @@ function parseBody(req: IncomingMessage): Promise<unknown> {
|
|
|
43
78
|
* Send JSON response
|
|
44
79
|
*/
|
|
45
80
|
function sendJson(res: ServerResponse, statusCode: number, data: unknown): void {
|
|
46
|
-
res.
|
|
81
|
+
res.statusCode = statusCode;
|
|
82
|
+
res.setHeader('Content-Type', 'application/json');
|
|
47
83
|
res.end(JSON.stringify(data));
|
|
48
84
|
}
|
|
49
85
|
|
|
@@ -53,9 +89,10 @@ function sendJson(res: ServerResponse, statusCode: number, data: unknown): void
|
|
|
53
89
|
function handleHealthCheck(res: ServerResponse, config: EnvironmentConfig, aiEnabled: boolean, sseEnabled: boolean): void {
|
|
54
90
|
sendJson(res, 200, {
|
|
55
91
|
status: 'healthy',
|
|
56
|
-
version:
|
|
92
|
+
version: VERSION,
|
|
57
93
|
transport: config.httpTransport,
|
|
58
94
|
stateful: config.stateful,
|
|
95
|
+
authMethod: config.authMethod,
|
|
59
96
|
aiEnabled,
|
|
60
97
|
aiProvider: aiEnabled ? config.aiProvider : undefined,
|
|
61
98
|
aiUrl: aiEnabled ? config.aiUrl : undefined,
|
|
@@ -197,7 +234,7 @@ function getOpenApiSpec(baseUrl: string): object {
|
|
|
197
234
|
info: {
|
|
198
235
|
title: 'Home Assistant MCP Tools',
|
|
199
236
|
description: 'MCP server for Home Assistant - exposes entity states and service calls',
|
|
200
|
-
version:
|
|
237
|
+
version: VERSION,
|
|
201
238
|
},
|
|
202
239
|
servers: [{ url: baseUrl }],
|
|
203
240
|
paths: {
|
|
@@ -361,6 +398,13 @@ export async function startHttpServer(client: HaClient, config: EnvironmentConfi
|
|
|
361
398
|
})
|
|
362
399
|
: null;
|
|
363
400
|
|
|
401
|
+
// Initialize auth middleware
|
|
402
|
+
const authMiddleware = createAuthMiddleware({
|
|
403
|
+
method: config.authMethod,
|
|
404
|
+
tokens: config.authToken,
|
|
405
|
+
skipPaths: [healthPath, '/openapi.json'],
|
|
406
|
+
});
|
|
407
|
+
|
|
364
408
|
// Initialize event subscriber if SSE events are enabled
|
|
365
409
|
let eventSubscriber: EventSubscriber | null = null;
|
|
366
410
|
if (config.sseEventsEnabled) {
|
|
@@ -394,7 +438,10 @@ export async function startHttpServer(client: HaClient, config: EnvironmentConfi
|
|
|
394
438
|
sseEventsEnabled: config.sseEventsEnabled && !!eventSubscriber,
|
|
395
439
|
});
|
|
396
440
|
|
|
397
|
-
const server = createHttpServer(async (req: IncomingMessage,
|
|
441
|
+
const server = createHttpServer(async (req: IncomingMessage, originalRes: ServerResponse) => {
|
|
442
|
+
// Wrap response to add Server-Timing header
|
|
443
|
+
const { res } = wrapResponseWithTiming(originalRes);
|
|
444
|
+
|
|
398
445
|
const url = req.url ?? '/';
|
|
399
446
|
const urlPath = url.split('?')[0];
|
|
400
447
|
|
|
@@ -418,6 +465,11 @@ export async function startHttpServer(client: HaClient, config: EnvironmentConfi
|
|
|
418
465
|
}
|
|
419
466
|
}
|
|
420
467
|
|
|
468
|
+
// Apply authentication (skip health checks and openapi.json)
|
|
469
|
+
if (!authMiddleware(req, res)) {
|
|
470
|
+
return; // Response already sent by auth middleware
|
|
471
|
+
}
|
|
472
|
+
|
|
421
473
|
// Add CORS headers for MCP endpoints
|
|
422
474
|
handleCors(req, res, config);
|
|
423
475
|
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package version
|
|
3
|
+
* Uses npm_package_version when available (npm scripts), falls back to package.json
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const VERSION: string = process.env.npm_package_version ?? (() => {
|
|
7
|
+
// Fallback: read from package.json at runtime (for direct node execution)
|
|
8
|
+
// Uses process.cwd() to avoid dist folder path issues
|
|
9
|
+
try {
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
11
|
+
const pkg = require(`${process.cwd()}/package.json`) as { version: string };
|
|
12
|
+
return pkg.version;
|
|
13
|
+
} catch {
|
|
14
|
+
return 'dev';
|
|
15
|
+
}
|
|
16
|
+
})();
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication middleware tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
6
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
7
|
+
import { createAuthMiddleware } from '../../src/server/auth.js';
|
|
8
|
+
|
|
9
|
+
vi.mock('../../src/utils/logger.js');
|
|
10
|
+
|
|
11
|
+
describe('Auth Middleware', () => {
|
|
12
|
+
let mockReq: Partial<IncomingMessage>;
|
|
13
|
+
let mockRes: Partial<ServerResponse>;
|
|
14
|
+
let responseData: { statusCode?: number; headers: Record<string, string>; body?: string };
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
responseData = { headers: {} };
|
|
18
|
+
|
|
19
|
+
mockReq = {
|
|
20
|
+
url: '/api/states',
|
|
21
|
+
method: 'GET',
|
|
22
|
+
headers: {},
|
|
23
|
+
socket: { remoteAddress: '127.0.0.1' } as any,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
mockRes = {
|
|
27
|
+
statusCode: 200,
|
|
28
|
+
setHeader: vi.fn((key: string, value: string) => {
|
|
29
|
+
responseData.headers[key] = value;
|
|
30
|
+
return mockRes as ServerResponse;
|
|
31
|
+
}),
|
|
32
|
+
end: vi.fn((body?: string) => {
|
|
33
|
+
responseData.body = body;
|
|
34
|
+
return mockRes as ServerResponse;
|
|
35
|
+
}) as unknown as ServerResponse['end'],
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
Object.defineProperty(mockRes, 'statusCode', {
|
|
39
|
+
get: () => responseData.statusCode,
|
|
40
|
+
set: (val) => { responseData.statusCode = val; },
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('No Auth', () => {
|
|
45
|
+
it('should allow all requests when method is none', () => {
|
|
46
|
+
const middleware = createAuthMiddleware({ method: 'none' });
|
|
47
|
+
const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
|
|
48
|
+
|
|
49
|
+
expect(result).toBe(true);
|
|
50
|
+
expect(mockRes.end).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('Bearer Auth', () => {
|
|
55
|
+
const validTokens = new Set(['test-token-123', 'another-token']);
|
|
56
|
+
|
|
57
|
+
it('should allow request with valid token', () => {
|
|
58
|
+
mockReq.headers = { authorization: 'Bearer test-token-123' };
|
|
59
|
+
const middleware = createAuthMiddleware({ method: 'bearer', tokens: validTokens });
|
|
60
|
+
|
|
61
|
+
const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
|
|
62
|
+
|
|
63
|
+
expect(result).toBe(true);
|
|
64
|
+
expect(mockRes.end).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should allow request with any valid token from set', () => {
|
|
68
|
+
mockReq.headers = { authorization: 'Bearer another-token' };
|
|
69
|
+
const middleware = createAuthMiddleware({ method: 'bearer', tokens: validTokens });
|
|
70
|
+
|
|
71
|
+
const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
|
|
72
|
+
|
|
73
|
+
expect(result).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should reject request without Authorization header', () => {
|
|
77
|
+
mockReq.headers = {};
|
|
78
|
+
const middleware = createAuthMiddleware({ method: 'bearer', tokens: validTokens });
|
|
79
|
+
|
|
80
|
+
const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
|
|
81
|
+
|
|
82
|
+
expect(result).toBe(false);
|
|
83
|
+
expect(responseData.statusCode).toBe(401);
|
|
84
|
+
expect(responseData.headers['WWW-Authenticate']).toBe('Bearer');
|
|
85
|
+
expect(responseData.body).toContain('Missing Authorization header');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should reject request with invalid token', () => {
|
|
89
|
+
mockReq.headers = { authorization: 'Bearer wrong-token' };
|
|
90
|
+
const middleware = createAuthMiddleware({ method: 'bearer', tokens: validTokens });
|
|
91
|
+
|
|
92
|
+
const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
|
|
93
|
+
|
|
94
|
+
expect(result).toBe(false);
|
|
95
|
+
expect(responseData.statusCode).toBe(401);
|
|
96
|
+
expect(responseData.headers['WWW-Authenticate']).toBe('Bearer error="invalid_token"');
|
|
97
|
+
expect(responseData.body).toContain('Invalid token');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should reject request with malformed Authorization header', () => {
|
|
101
|
+
mockReq.headers = { authorization: 'Basic dXNlcjpwYXNz' };
|
|
102
|
+
const middleware = createAuthMiddleware({ method: 'bearer', tokens: validTokens });
|
|
103
|
+
|
|
104
|
+
const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
|
|
105
|
+
|
|
106
|
+
expect(result).toBe(false);
|
|
107
|
+
expect(responseData.statusCode).toBe(401);
|
|
108
|
+
expect(responseData.body).toContain('Invalid Authorization header format');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should return 500 if bearer auth enabled but no tokens configured', () => {
|
|
112
|
+
mockReq.headers = { authorization: 'Bearer test-token' };
|
|
113
|
+
const middleware = createAuthMiddleware({ method: 'bearer', tokens: undefined });
|
|
114
|
+
|
|
115
|
+
const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
|
|
116
|
+
|
|
117
|
+
expect(result).toBe(false);
|
|
118
|
+
expect(responseData.statusCode).toBe(500);
|
|
119
|
+
expect(responseData.body).toContain('auth token not set');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('Skip Paths', () => {
|
|
124
|
+
const validTokens = new Set(['secret-token']);
|
|
125
|
+
|
|
126
|
+
it('should skip auth for exact path match', () => {
|
|
127
|
+
mockReq.url = '/health';
|
|
128
|
+
mockReq.headers = {}; // No auth header
|
|
129
|
+
const middleware = createAuthMiddleware({
|
|
130
|
+
method: 'bearer',
|
|
131
|
+
tokens: validTokens,
|
|
132
|
+
skipPaths: ['/health'],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
|
|
136
|
+
|
|
137
|
+
expect(result).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should skip auth for path prefix', () => {
|
|
141
|
+
mockReq.url = '/health/detailed';
|
|
142
|
+
mockReq.headers = {};
|
|
143
|
+
const middleware = createAuthMiddleware({
|
|
144
|
+
method: 'bearer',
|
|
145
|
+
tokens: validTokens,
|
|
146
|
+
skipPaths: ['/health'],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
|
|
150
|
+
|
|
151
|
+
expect(result).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should require auth for non-skipped paths', () => {
|
|
155
|
+
mockReq.url = '/api/states';
|
|
156
|
+
mockReq.headers = {};
|
|
157
|
+
const middleware = createAuthMiddleware({
|
|
158
|
+
method: 'bearer',
|
|
159
|
+
tokens: validTokens,
|
|
160
|
+
skipPaths: ['/health'],
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
|
|
164
|
+
|
|
165
|
+
expect(result).toBe(false);
|
|
166
|
+
expect(responseData.statusCode).toBe(401);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should handle query strings in URL', () => {
|
|
170
|
+
mockReq.url = '/health?check=true';
|
|
171
|
+
mockReq.headers = {};
|
|
172
|
+
const middleware = createAuthMiddleware({
|
|
173
|
+
method: 'bearer',
|
|
174
|
+
tokens: validTokens,
|
|
175
|
+
skipPaths: ['/health'],
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const result = middleware(mockReq as IncomingMessage, mockRes as ServerResponse);
|
|
179
|
+
|
|
180
|
+
expect(result).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('Multi-token Support', () => {
|
|
185
|
+
it('should support multiple tokens for different clients', () => {
|
|
186
|
+
const tokens = new Set(['n8n-token', 'openwebui-token', 'cli-token']);
|
|
187
|
+
const middleware = createAuthMiddleware({ method: 'bearer', tokens });
|
|
188
|
+
|
|
189
|
+
// n8n client
|
|
190
|
+
mockReq.headers = { authorization: 'Bearer n8n-token' };
|
|
191
|
+
expect(middleware(mockReq as IncomingMessage, mockRes as ServerResponse)).toBe(true);
|
|
192
|
+
|
|
193
|
+
// Open WebUI client
|
|
194
|
+
mockReq.headers = { authorization: 'Bearer openwebui-token' };
|
|
195
|
+
expect(middleware(mockReq as IncomingMessage, mockRes as ServerResponse)).toBe(true);
|
|
196
|
+
|
|
197
|
+
// CLI client
|
|
198
|
+
mockReq.headers = { authorization: 'Bearer cli-token' };
|
|
199
|
+
expect(middleware(mockReq as IncomingMessage, mockRes as ServerResponse)).toBe(true);
|
|
200
|
+
|
|
201
|
+
// Invalid client
|
|
202
|
+
mockReq.headers = { authorization: 'Bearer unknown-token' };
|
|
203
|
+
expect(middleware(mockReq as IncomingMessage, mockRes as ServerResponse)).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
});
|