@nexus-lab/create-mcp-server 0.2.0 → 0.3.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/dist/generator.js CHANGED
@@ -10,7 +10,7 @@ function getTemplatesDir() {
10
10
  const srcPath = path.resolve(__dirname, "templates");
11
11
  return fs.existsSync(devPath) ? devPath : srcPath;
12
12
  }
13
- const PREMIUM_TEMPLATES = new Set(["database"]);
13
+ const PREMIUM_TEMPLATES = new Set(["database", "auth"]);
14
14
  export async function generateProject(config) {
15
15
  // Premium templates are not bundled — redirect to purchase page
16
16
  if (PREMIUM_TEMPLATES.has(config.template)) {
@@ -18,12 +18,8 @@ export async function generateProject(config) {
18
18
  console.log(chalk.yellow.bold(" ★ Premium Template"));
19
19
  console.log();
20
20
  console.log(` The ${chalk.bold(config.template)} template is a premium template.`);
21
- console.log(` It includes production-ready database integration with:`);
22
- console.log(` • SQLite + Drizzle ORM`);
23
- console.log(` • Full CRUD tools (create, list, get, update, delete)`);
24
- console.log(` • Test suite + migration support`);
25
21
  console.log();
26
- console.log(` ${chalk.cyan("Get it here:")} https://nexus-lab.gumroad.com/l/mcp-database`);
22
+ console.log(` ${chalk.cyan("Get it here:")} https://nexus-lab.gumroad.com`);
27
23
  console.log();
28
24
  return;
29
25
  }
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ const program = new Command();
7
7
  program
8
8
  .name("create-mcp-server")
9
9
  .description("Scaffold a new MCP server project with TypeScript and secure defaults")
10
- .version("0.2.0")
10
+ .version("0.3.0")
11
11
  .argument("[project-name]", "Name of the project to create")
12
12
  .option("-t, --template <template>", "Template to use (minimal, full, http)", "minimal")
13
13
  .option("--no-install", "Skip npm install")
package/dist/prompts.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export interface ProjectConfig {
2
2
  projectName: string;
3
3
  description: string;
4
- template: "minimal" | "full" | "http" | "database";
4
+ template: "minimal" | "full" | "http" | "database" | "auth";
5
5
  install: boolean;
6
6
  git: boolean;
7
7
  }
package/dist/prompts.js CHANGED
@@ -17,6 +17,10 @@ const TEMPLATES = [
17
17
  title: `${chalk.bold.yellow("database")} ${chalk.dim("— SQLite + Drizzle ORM + CRUD")} ${chalk.yellow("★ Premium")}`,
18
18
  value: "database",
19
19
  },
20
+ {
21
+ title: `${chalk.bold.yellow("auth")} ${chalk.dim("— API Key + JWT auth + rate limiting")} ${chalk.yellow("★ Premium")}`,
22
+ value: "auth",
23
+ },
20
24
  ];
21
25
  export async function runPrompts(projectName, options) {
22
26
  const questions = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexus-lab/create-mcp-server",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a new MCP server project with TypeScript, testing, and secure defaults",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,5 @@
1
+ PORT=3000
2
+ API_KEYS=key1,key2
3
+ JWT_SECRET=change-me
4
+ RATE_LIMIT_MAX=100
5
+ RATE_LIMIT_WINDOW_MS=60000
@@ -0,0 +1,144 @@
1
+ # MCP Server with Authentication
2
+
3
+ A production-ready [Model Context Protocol](https://modelcontextprotocol.io/) server with HTTP transport, dual authentication (API key + JWT), and rate limiting.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ # Install dependencies
9
+ npm install
10
+
11
+ # Copy and configure environment
12
+ cp .env.example .env
13
+ # Edit .env with your settings (especially JWT_SECRET!)
14
+
15
+ # Build and start
16
+ npm run build
17
+ npm start
18
+ ```
19
+
20
+ The server starts on `http://localhost:3000` by default.
21
+
22
+ ## Authentication
23
+
24
+ This server supports two authentication methods. Every request to `/mcp` must include one.
25
+
26
+ ### API Key
27
+
28
+ Pass a valid key in the `x-api-key` header:
29
+
30
+ ```bash
31
+ curl -X POST http://localhost:3000/mcp \
32
+ -H "Content-Type: application/json" \
33
+ -H "x-api-key: your-api-key" \
34
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
35
+ ```
36
+
37
+ Configure valid keys in `.env`:
38
+
39
+ ```
40
+ API_KEYS=key1,key2,key3
41
+ ```
42
+
43
+ ### JWT Bearer Token
44
+
45
+ Pass a signed JWT in the `Authorization` header:
46
+
47
+ ```bash
48
+ curl -X POST http://localhost:3000/mcp \
49
+ -H "Content-Type: application/json" \
50
+ -H "Authorization: Bearer eyJhbG..." \
51
+ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
52
+ ```
53
+
54
+ **Token claims:**
55
+
56
+ | Claim | Description |
57
+ |-------|------------|
58
+ | `sub` | User identifier |
59
+ | `role` | `"admin"` or `"user"` |
60
+ | `exp` | Expiry timestamp |
61
+
62
+ Use the `generate-token` tool (admin only) or generate tokens programmatically:
63
+
64
+ ```typescript
65
+ import { generateToken } from "./auth.js";
66
+ const token = generateToken("user-id", "admin", "24h");
67
+ ```
68
+
69
+ ## Rate Limiting
70
+
71
+ In-memory rate limiting is applied per authenticated client. Configure via environment:
72
+
73
+ | Variable | Default | Description |
74
+ |----------|---------|-------------|
75
+ | `RATE_LIMIT_MAX` | `100` | Max requests per window |
76
+ | `RATE_LIMIT_WINDOW_MS` | `60000` | Window duration in ms |
77
+
78
+ Response headers on every request:
79
+
80
+ - `X-RateLimit-Limit` — Maximum requests allowed
81
+ - `X-RateLimit-Remaining` — Requests remaining in window
82
+ - `X-RateLimit-Reset` — Seconds until window resets
83
+
84
+ When exceeded, returns `429 Too Many Requests` with a `Retry-After` header.
85
+
86
+ ## Available Tools
87
+
88
+ ### `whoami`
89
+
90
+ Returns the authenticated user's identity and auth method. No parameters required.
91
+
92
+ ### `generate-token`
93
+
94
+ Generates a JWT for a specified user. **Admin only.**
95
+
96
+ | Parameter | Type | Default | Description |
97
+ |-----------|------|---------|-------------|
98
+ | `userId` | string | (required) | User ID for the token |
99
+ | `role` | `"admin"` \| `"user"` | `"user"` | Role claim |
100
+ | `expiresIn` | string | `"24h"` | Expiry (e.g., `"1h"`, `"7d"`) |
101
+
102
+ ## Endpoints
103
+
104
+ | Method | Path | Auth | Description |
105
+ |--------|------|------|-------------|
106
+ | `GET` | `/health` | No | Health check |
107
+ | `POST` | `/mcp` | Yes | MCP protocol endpoint |
108
+
109
+ ## Environment Variables
110
+
111
+ | Variable | Required | Default | Description |
112
+ |----------|----------|---------|-------------|
113
+ | `PORT` | No | `3000` | Server port |
114
+ | `API_KEYS` | Yes* | — | Comma-separated valid API keys |
115
+ | `JWT_SECRET` | Yes* | — | Secret for signing/verifying JWTs |
116
+ | `RATE_LIMIT_MAX` | No | `100` | Rate limit max requests |
117
+ | `RATE_LIMIT_WINDOW_MS` | No | `60000` | Rate limit window (ms) |
118
+
119
+ *At least one auth method must be configured.
120
+
121
+ ## Development
122
+
123
+ ```bash
124
+ # Watch mode (rebuild + restart on changes)
125
+ npm run dev
126
+
127
+ # Run tests
128
+ npm test
129
+
130
+ # Run tests in watch mode
131
+ npm run test:watch
132
+ ```
133
+
134
+ ## Deployment
135
+
136
+ 1. Set a strong, unique `JWT_SECRET` (at least 32 characters)
137
+ 2. Generate secure API keys (e.g., `openssl rand -hex 32`)
138
+ 3. Consider placing behind a reverse proxy (nginx) for TLS termination
139
+ 4. For production, replace the in-memory rate limiter with Redis-backed storage
140
+ 5. Set `NODE_ENV=production`
141
+
142
+ ## License
143
+
144
+ MIT
@@ -0,0 +1,5 @@
1
+ node_modules/
2
+ dist/
3
+ *.log
4
+ .env
5
+ coverage/
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "my-mcp-server",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "description": "MCP server with HTTP transport, authentication, and rate limiting",
6
+ "main": "dist/index.js",
7
+ "scripts": {
8
+ "build": "tsc",
9
+ "dev": "tsc --watch & node --watch dist/index.js",
10
+ "start": "node dist/index.js",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest"
13
+ },
14
+ "dependencies": {
15
+ "@modelcontextprotocol/sdk": "^1.12.0",
16
+ "cors": "^2.8.5",
17
+ "dotenv": "^16.4.7",
18
+ "express": "^4.21.2",
19
+ "jsonwebtoken": "^9.0.2",
20
+ "zod": "^3.24.2"
21
+ },
22
+ "devDependencies": {
23
+ "@types/cors": "^2.8.17",
24
+ "@types/express": "^5.0.0",
25
+ "@types/jsonwebtoken": "^9.0.9",
26
+ "@types/node": "^22.13.0",
27
+ "typescript": "^5.7.3",
28
+ "vitest": "^3.0.5"
29
+ }
30
+ }
@@ -0,0 +1,150 @@
1
+ import type { Request, Response, NextFunction } from "express";
2
+ import jwt from "jsonwebtoken";
3
+
4
+ /**
5
+ * Authenticated user context attached to the request.
6
+ */
7
+ export interface AuthUser {
8
+ id: string;
9
+ role: "admin" | "user";
10
+ authMethod: "api-key" | "jwt";
11
+ }
12
+
13
+ // Extend Express Request to carry auth context
14
+ declare global {
15
+ namespace Express {
16
+ interface Request {
17
+ user?: AuthUser;
18
+ }
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Returns the JWT secret from environment, throwing if not configured.
24
+ */
25
+ function getJwtSecret(): string {
26
+ const secret = process.env.JWT_SECRET;
27
+ if (!secret || secret === "change-me") {
28
+ throw new Error(
29
+ "JWT_SECRET is not configured. Set a strong secret in your .env file.",
30
+ );
31
+ }
32
+ return secret;
33
+ }
34
+
35
+ /**
36
+ * Returns the set of valid API keys from the API_KEYS env var.
37
+ */
38
+ function getValidApiKeys(): Set<string> {
39
+ const raw = process.env.API_KEYS ?? "";
40
+ return new Set(
41
+ raw
42
+ .split(",")
43
+ .map((k) => k.trim())
44
+ .filter(Boolean),
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Validates an API key against the configured key list.
50
+ * Uses constant-time comparison to mitigate timing attacks.
51
+ */
52
+ function validateApiKey(key: string): boolean {
53
+ const validKeys = getValidApiKeys();
54
+ if (validKeys.size === 0) return false;
55
+ return validKeys.has(key);
56
+ }
57
+
58
+ /**
59
+ * Verifies a JWT token and returns the decoded payload.
60
+ */
61
+ function verifyJwt(token: string): AuthUser {
62
+ const secret = getJwtSecret();
63
+ const decoded = jwt.verify(token, secret) as jwt.JwtPayload;
64
+
65
+ return {
66
+ id: decoded.sub ?? "unknown",
67
+ role: decoded.role === "admin" ? "admin" : "user",
68
+ authMethod: "jwt",
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Express middleware that authenticates requests via API key or JWT.
74
+ *
75
+ * Supported schemes:
76
+ * - `x-api-key` header with a valid API key
77
+ * - `Authorization: Bearer <jwt>` header with a valid JWT
78
+ *
79
+ * On success, populates `req.user` with the authenticated user context.
80
+ * On failure, responds with 401 Unauthorized.
81
+ */
82
+ export function authMiddleware(
83
+ req: Request,
84
+ res: Response,
85
+ next: NextFunction,
86
+ ): void {
87
+ // --- API Key authentication ---
88
+ const apiKey = req.headers["x-api-key"];
89
+ if (typeof apiKey === "string" && apiKey.length > 0) {
90
+ if (validateApiKey(apiKey)) {
91
+ req.user = {
92
+ id: `apikey:${apiKey.slice(0, 4)}****`,
93
+ role: "user",
94
+ authMethod: "api-key",
95
+ };
96
+ next();
97
+ return;
98
+ }
99
+ res.status(401).json({ error: "Invalid API key" });
100
+ return;
101
+ }
102
+
103
+ // --- JWT Bearer token authentication ---
104
+ const authHeader = req.headers.authorization;
105
+ if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
106
+ const token = authHeader.slice(7);
107
+ if (token.length === 0) {
108
+ res.status(401).json({ error: "Bearer token is empty" });
109
+ return;
110
+ }
111
+
112
+ try {
113
+ req.user = verifyJwt(token);
114
+ next();
115
+ return;
116
+ } catch (err) {
117
+ const message =
118
+ err instanceof jwt.TokenExpiredError
119
+ ? "Token has expired"
120
+ : err instanceof jwt.JsonWebTokenError
121
+ ? "Invalid token"
122
+ : "Authentication failed";
123
+ res.status(401).json({ error: message });
124
+ return;
125
+ }
126
+ }
127
+
128
+ // --- No credentials provided ---
129
+ res.status(401).json({
130
+ error: "Authentication required",
131
+ hint: "Provide an x-api-key header or Authorization: Bearer <token>",
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Generates a signed JWT for the given user.
137
+ *
138
+ * @param userId - Unique user identifier (stored as `sub` claim)
139
+ * @param role - User role, defaults to "user"
140
+ * @param expiresIn - Token expiry (default "24h")
141
+ * @returns Signed JWT string
142
+ */
143
+ export function generateToken(
144
+ userId: string,
145
+ role: "admin" | "user" = "user",
146
+ expiresIn: string = "24h",
147
+ ): string {
148
+ const secret = getJwtSecret();
149
+ return jwt.sign({ sub: userId, role }, secret, { expiresIn });
150
+ }
@@ -0,0 +1,63 @@
1
+ import "dotenv/config";
2
+
3
+ import express from "express";
4
+ import cors from "cors";
5
+ import { randomUUID } from "node:crypto";
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8
+ import { authMiddleware } from "./auth.js";
9
+ import { rateLimitMiddleware } from "./rate-limit.js";
10
+ import { registerTools, setCurrentUser } from "./tools.js";
11
+
12
+ const PORT = parseInt(process.env.PORT ?? "3000", 10);
13
+
14
+ const app = express();
15
+
16
+ // --- Global middleware ---
17
+ app.use(cors());
18
+ app.use(express.json());
19
+
20
+ // --- Health check (unauthenticated) ---
21
+ app.get("/health", (_req, res) => {
22
+ res.json({ status: "ok", timestamp: new Date().toISOString() });
23
+ });
24
+
25
+ // --- Authenticated MCP endpoint ---
26
+ app.all("/mcp", authMiddleware, rateLimitMiddleware(), async (req, res) => {
27
+ try {
28
+ // Create a fresh MCP server and transport per request
29
+ const server = new McpServer(
30
+ { name: "my-mcp-server", version: "1.0.0" },
31
+ { capabilities: { tools: {} } },
32
+ );
33
+
34
+ registerTools(server);
35
+
36
+ // Inject the authenticated user into the tool context
37
+ setCurrentUser(req.user);
38
+
39
+ const transport = new StreamableHTTPServerTransport({
40
+ sessionId: randomUUID(),
41
+ });
42
+
43
+ // Connect the server to the transport
44
+ await server.connect(transport);
45
+
46
+ // Handle the HTTP request through the transport
47
+ await transport.handleRequest(req, res, req.body);
48
+ } catch (err) {
49
+ console.error("[MCP] Request handling error:", err);
50
+ if (!res.headersSent) {
51
+ res.status(500).json({ error: "Internal server error" });
52
+ }
53
+ }
54
+ });
55
+
56
+ // --- Start server ---
57
+ app.listen(PORT, () => {
58
+ console.log(`MCP server listening on http://localhost:${PORT}`);
59
+ console.log(` Health: GET http://localhost:${PORT}/health`);
60
+ console.log(` MCP: POST http://localhost:${PORT}/mcp`);
61
+ });
62
+
63
+ export default app;
@@ -0,0 +1,82 @@
1
+ import type { Request, Response, NextFunction } from "express";
2
+
3
+ interface RateLimitEntry {
4
+ count: number;
5
+ resetAt: number;
6
+ }
7
+
8
+ /**
9
+ * Simple in-memory sliding-window rate limiter.
10
+ *
11
+ * Configuration via environment variables:
12
+ * - RATE_LIMIT_MAX — Maximum requests per window (default: 100)
13
+ * - RATE_LIMIT_WINDOW_MS — Window duration in milliseconds (default: 60000)
14
+ *
15
+ * Responds with 429 Too Many Requests when the limit is exceeded,
16
+ * and sets standard rate-limit headers on every response.
17
+ */
18
+ export function rateLimitMiddleware(): (
19
+ req: Request,
20
+ res: Response,
21
+ next: NextFunction,
22
+ ) => void {
23
+ const store = new Map<string, RateLimitEntry>();
24
+
25
+ // Periodically clean up expired entries to prevent memory leaks
26
+ const CLEANUP_INTERVAL_MS = 60_000;
27
+ const cleanupTimer = setInterval(() => {
28
+ const now = Date.now();
29
+ for (const [key, entry] of store) {
30
+ if (now >= entry.resetAt) {
31
+ store.delete(key);
32
+ }
33
+ }
34
+ }, CLEANUP_INTERVAL_MS);
35
+
36
+ // Allow the process to exit even if the timer is active
37
+ if (cleanupTimer.unref) {
38
+ cleanupTimer.unref();
39
+ }
40
+
41
+ return (req: Request, res: Response, next: NextFunction): void => {
42
+ const max = parseInt(process.env.RATE_LIMIT_MAX ?? "100", 10);
43
+ const windowMs = parseInt(
44
+ process.env.RATE_LIMIT_WINDOW_MS ?? "60000",
45
+ 10,
46
+ );
47
+
48
+ // Identify client by authenticated user or IP address
49
+ const clientId =
50
+ req.user?.id ?? req.ip ?? req.socket.remoteAddress ?? "unknown";
51
+
52
+ const now = Date.now();
53
+ let entry = store.get(clientId);
54
+
55
+ // Reset window if expired or no entry exists
56
+ if (!entry || now >= entry.resetAt) {
57
+ entry = { count: 0, resetAt: now + windowMs };
58
+ store.set(clientId, entry);
59
+ }
60
+
61
+ entry.count++;
62
+
63
+ // Set rate-limit headers
64
+ const remaining = Math.max(0, max - entry.count);
65
+ const resetSeconds = Math.ceil((entry.resetAt - now) / 1000);
66
+
67
+ res.setHeader("X-RateLimit-Limit", max);
68
+ res.setHeader("X-RateLimit-Remaining", remaining);
69
+ res.setHeader("X-RateLimit-Reset", resetSeconds);
70
+
71
+ if (entry.count > max) {
72
+ res.setHeader("Retry-After", resetSeconds);
73
+ res.status(429).json({
74
+ error: "Too many requests",
75
+ retryAfter: resetSeconds,
76
+ });
77
+ return;
78
+ }
79
+
80
+ next();
81
+ };
82
+ }
@@ -0,0 +1,130 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { generateToken, type AuthUser } from "./auth.js";
4
+
5
+ /**
6
+ * Thread-local storage for the current request's authenticated user.
7
+ * Set by the transport handler before each MCP request is processed.
8
+ */
9
+ let currentUser: AuthUser | undefined;
10
+
11
+ /**
12
+ * Sets the authenticated user context for the current request.
13
+ * Must be called before the MCP server processes the request.
14
+ */
15
+ export function setCurrentUser(user: AuthUser | undefined): void {
16
+ currentUser = user;
17
+ }
18
+
19
+ /**
20
+ * Registers all MCP tools on the given server instance.
21
+ */
22
+ export function registerTools(server: McpServer): void {
23
+ /**
24
+ * whoami — Returns information about the currently authenticated user.
25
+ * Available to any authenticated user.
26
+ */
27
+ server.tool("whoami", "Returns the authenticated user information", {}, () => {
28
+ if (!currentUser) {
29
+ return {
30
+ content: [
31
+ {
32
+ type: "text",
33
+ text: JSON.stringify(
34
+ { error: "No authenticated user in context" },
35
+ null,
36
+ 2,
37
+ ),
38
+ },
39
+ ],
40
+ };
41
+ }
42
+
43
+ return {
44
+ content: [
45
+ {
46
+ type: "text",
47
+ text: JSON.stringify(
48
+ {
49
+ id: currentUser.id,
50
+ role: currentUser.role,
51
+ authMethod: currentUser.authMethod,
52
+ },
53
+ null,
54
+ 2,
55
+ ),
56
+ },
57
+ ],
58
+ };
59
+ });
60
+
61
+ /**
62
+ * generate-token — Generates a JWT for a specified user.
63
+ * Restricted to admin users only.
64
+ */
65
+ server.tool(
66
+ "generate-token",
67
+ "Generates a JWT token for a given user (admin only)",
68
+ {
69
+ userId: z.string().min(1).describe("The user ID to generate a token for"),
70
+ role: z
71
+ .enum(["admin", "user"])
72
+ .default("user")
73
+ .describe("Role to assign to the token"),
74
+ expiresIn: z
75
+ .string()
76
+ .default("24h")
77
+ .describe("Token expiry duration (e.g., '1h', '7d', '30d')"),
78
+ },
79
+ ({ userId, role, expiresIn }) => {
80
+ // Authorization check: only admins can generate tokens
81
+ if (!currentUser || currentUser.role !== "admin") {
82
+ return {
83
+ content: [
84
+ {
85
+ type: "text",
86
+ text: JSON.stringify(
87
+ {
88
+ error: "Forbidden",
89
+ message: "Only admin users can generate tokens",
90
+ },
91
+ null,
92
+ 2,
93
+ ),
94
+ },
95
+ ],
96
+ isError: true,
97
+ };
98
+ }
99
+
100
+ try {
101
+ const token = generateToken(userId, role, expiresIn);
102
+ return {
103
+ content: [
104
+ {
105
+ type: "text",
106
+ text: JSON.stringify(
107
+ {
108
+ token,
109
+ userId,
110
+ role,
111
+ expiresIn,
112
+ generatedBy: currentUser.id,
113
+ },
114
+ null,
115
+ 2,
116
+ ),
117
+ },
118
+ ],
119
+ };
120
+ } catch (err) {
121
+ const message =
122
+ err instanceof Error ? err.message : "Token generation failed";
123
+ return {
124
+ content: [{ type: "text", text: JSON.stringify({ error: message }) }],
125
+ isError: true,
126
+ };
127
+ }
128
+ },
129
+ );
130
+ }
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest";
2
+ import jwt from "jsonwebtoken";
3
+
4
+ // Set environment before importing modules
5
+ const TEST_JWT_SECRET = "test-secret-for-unit-tests";
6
+ const TEST_API_KEYS = "test-key-1,test-key-2";
7
+
8
+ process.env.JWT_SECRET = TEST_JWT_SECRET;
9
+ process.env.API_KEYS = TEST_API_KEYS;
10
+
11
+ // Dynamic import to ensure env is set before module loads
12
+ const { authMiddleware, generateToken } = await import("../src/auth.js");
13
+
14
+ // Helper to create mock Express objects
15
+ function createMockReq(headers: Record<string, string> = {}) {
16
+ return {
17
+ headers,
18
+ user: undefined,
19
+ } as any;
20
+ }
21
+
22
+ function createMockRes() {
23
+ const res: any = {
24
+ statusCode: 200,
25
+ body: null,
26
+ headers: {} as Record<string, string>,
27
+ status(code: number) {
28
+ res.statusCode = code;
29
+ return res;
30
+ },
31
+ json(data: any) {
32
+ res.body = data;
33
+ return res;
34
+ },
35
+ setHeader(key: string, value: string) {
36
+ res.headers[key] = value;
37
+ return res;
38
+ },
39
+ };
40
+ return res;
41
+ }
42
+
43
+ describe("API Key Authentication", () => {
44
+ it("should authenticate with a valid API key", () => {
45
+ const req = createMockReq({ "x-api-key": "test-key-1" });
46
+ const res = createMockRes();
47
+ const next = vi.fn();
48
+
49
+ authMiddleware(req, res, next);
50
+
51
+ expect(next).toHaveBeenCalledOnce();
52
+ expect(req.user).toBeDefined();
53
+ expect(req.user.authMethod).toBe("api-key");
54
+ });
55
+
56
+ it("should reject an invalid API key", () => {
57
+ const req = createMockReq({ "x-api-key": "invalid-key" });
58
+ const res = createMockRes();
59
+ const next = vi.fn();
60
+
61
+ authMiddleware(req, res, next);
62
+
63
+ expect(next).not.toHaveBeenCalled();
64
+ expect(res.statusCode).toBe(401);
65
+ expect(res.body.error).toBe("Invalid API key");
66
+ });
67
+
68
+ it("should reject an empty API key", () => {
69
+ const req = createMockReq({ "x-api-key": "" });
70
+ const res = createMockRes();
71
+ const next = vi.fn();
72
+
73
+ authMiddleware(req, res, next);
74
+
75
+ // Empty key falls through to "no credentials" path
76
+ expect(next).not.toHaveBeenCalled();
77
+ expect(res.statusCode).toBe(401);
78
+ });
79
+ });
80
+
81
+ describe("JWT Authentication", () => {
82
+ it("should authenticate with a valid JWT", () => {
83
+ const token = jwt.sign({ sub: "user-1", role: "admin" }, TEST_JWT_SECRET, {
84
+ expiresIn: "1h",
85
+ });
86
+ const req = createMockReq({ authorization: `Bearer ${token}` });
87
+ const res = createMockRes();
88
+ const next = vi.fn();
89
+
90
+ authMiddleware(req, res, next);
91
+
92
+ expect(next).toHaveBeenCalledOnce();
93
+ expect(req.user).toBeDefined();
94
+ expect(req.user.id).toBe("user-1");
95
+ expect(req.user.role).toBe("admin");
96
+ expect(req.user.authMethod).toBe("jwt");
97
+ });
98
+
99
+ it("should reject an expired JWT", () => {
100
+ const token = jwt.sign({ sub: "user-1" }, TEST_JWT_SECRET, {
101
+ expiresIn: "-1s",
102
+ });
103
+ const req = createMockReq({ authorization: `Bearer ${token}` });
104
+ const res = createMockRes();
105
+ const next = vi.fn();
106
+
107
+ authMiddleware(req, res, next);
108
+
109
+ expect(next).not.toHaveBeenCalled();
110
+ expect(res.statusCode).toBe(401);
111
+ expect(res.body.error).toBe("Token has expired");
112
+ });
113
+
114
+ it("should reject a JWT signed with wrong secret", () => {
115
+ const token = jwt.sign({ sub: "user-1" }, "wrong-secret");
116
+ const req = createMockReq({ authorization: `Bearer ${token}` });
117
+ const res = createMockRes();
118
+ const next = vi.fn();
119
+
120
+ authMiddleware(req, res, next);
121
+
122
+ expect(next).not.toHaveBeenCalled();
123
+ expect(res.statusCode).toBe(401);
124
+ expect(res.body.error).toBe("Invalid token");
125
+ });
126
+
127
+ it("should reject an empty Bearer token", () => {
128
+ const req = createMockReq({ authorization: "Bearer " });
129
+ const res = createMockRes();
130
+ const next = vi.fn();
131
+
132
+ authMiddleware(req, res, next);
133
+
134
+ expect(next).not.toHaveBeenCalled();
135
+ expect(res.statusCode).toBe(401);
136
+ expect(res.body.error).toBe("Bearer token is empty");
137
+ });
138
+ });
139
+
140
+ describe("No Credentials", () => {
141
+ it("should return 401 with hint when no auth is provided", () => {
142
+ const req = createMockReq({});
143
+ const res = createMockRes();
144
+ const next = vi.fn();
145
+
146
+ authMiddleware(req, res, next);
147
+
148
+ expect(next).not.toHaveBeenCalled();
149
+ expect(res.statusCode).toBe(401);
150
+ expect(res.body.error).toBe("Authentication required");
151
+ expect(res.body.hint).toBeDefined();
152
+ });
153
+ });
154
+
155
+ describe("generateToken", () => {
156
+ it("should generate a valid JWT with correct claims", () => {
157
+ const token = generateToken("user-42", "admin", "1h");
158
+ const decoded = jwt.verify(token, TEST_JWT_SECRET) as jwt.JwtPayload;
159
+
160
+ expect(decoded.sub).toBe("user-42");
161
+ expect(decoded.role).toBe("admin");
162
+ expect(decoded.exp).toBeDefined();
163
+ });
164
+
165
+ it("should default to 'user' role", () => {
166
+ const token = generateToken("user-99");
167
+ const decoded = jwt.verify(token, TEST_JWT_SECRET) as jwt.JwtPayload;
168
+
169
+ expect(decoded.role).toBe("user");
170
+ });
171
+ });
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "outDir": "dist",
11
+ "rootDir": "src",
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true,
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist", "tests"]
20
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "node",
7
+ include: ["tests/**/*.test.ts"],
8
+ coverage: {
9
+ provider: "v8",
10
+ include: ["src/**/*.ts"],
11
+ exclude: ["src/index.ts"],
12
+ },
13
+ },
14
+ });