diff --git a/lib/api/middleware/mcp_auth.js b/lib/api/middleware/mcp_auth.js new file mode 100644 index 0000000..2e82fe8 --- /dev/null +++ b/lib/api/middleware/mcp_auth.js @@ -0,0 +1,44 @@ +// Auth gate for /mcp. External agents must present a Void agent bearer token +// bound to a Space (scopes.space_id). Owner / CF-Access identities are NOT +// accepted here — external agents never inherit owner powers. CF Access service +// tokens are enforced at the edge (Cloudflare policy on mcp.void.hynesy.com). +import * as agents from '../../db/repos/agents.js'; + +// Minimal fixed-window in-memory rate limit per token (defense-in-depth; the +// real gate is CF Access + bearer). Window is 60s. +const WINDOW_MS = 60_000; +const hits = new Map(); // token -> { count, resetAt } +export function _resetMcpRate() { hits.clear(); } +function rateLimited(token) { + const limit = Number(process.env.MCP_RATE_LIMIT || 120); + const now = Date.now(); + let h = hits.get(token); + if (!h || now > h.resetAt) { h = { count: 0, resetAt: now + WINDOW_MS }; hits.set(token, h); } + h.count += 1; + return h.count > limit; +} + +export async function mcpAuth(req, res, next) { + const auth = req.headers.authorization || ''; + const [scheme, token] = auth.split(' '); + if (scheme !== 'Bearer' || !token) { + return res.status(401).json({ error: { code: 'unauthorized', message: 'missing bearer token' } }); + } + if (process.env.OWNER_TOKEN && token === process.env.OWNER_TOKEN) { + return res.status(401).json({ error: { code: 'agent_required', message: 'owner token not valid for MCP' } }); + } + if (rateLimited(token)) { + return res.status(429).json({ error: { code: 'rate_limited', message: 'too many requests' } }); + } + let agent; + try { agent = await agents.verifyToken(token); } + catch (e) { return next(e); } + if (!agent) { + return res.status(401).json({ error: { code: 'unauthorized', message: 'invalid token' } }); + } + if (!(agent.scopes && agent.scopes.space_id)) { + return res.status(403).json({ error: { code: 'no_space_scope', message: 'agent has no space scope' } }); + } + req.mcpAgent = agent; + next(); +} diff --git a/tests/mcp/mcp_auth.test.js b/tests/mcp/mcp_auth.test.js new file mode 100644 index 0000000..96a3f67 --- /dev/null +++ b/tests/mcp/mcp_auth.test.js @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { resetDb } from '../helpers/db.js'; +import { migrateUp } from '../../lib/db/migrate.js'; +import { pool } from '../../lib/db/pool.js'; +import * as agentsRepo from '../../lib/db/repos/agents.js'; +import { mcpAuth, _resetMcpRate } from '../../lib/api/middleware/mcp_auth.js'; + +const owner = { kind: 'user', id: null }; +function mockRes() { + return { statusCode: 200, body: null, + status(c) { this.statusCode = c; return this; }, + json(b) { this.body = b; return this; } }; +} +async function run(headers) { + const req = { headers }; + const res = mockRes(); + const next = vi.fn(); + await mcpAuth(req, res, next); + return { req, res, next }; +} + +let scopedToken, unscopedToken; +beforeAll(async () => { + await resetDb(); await migrateUp(); + process.env.OWNER_TOKEN = 'test-token'; + const { rows: [{ id: spaceId }] } = await pool.query(`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`); + const scoped = await agentsRepo.create({ slug: `s-${Date.now()}`, name: 'S', kind: 'claude', model: 'sonnet', + capabilities: { read: true, suggest: true }, scopes: { space_id: spaceId } }, owner); + ({ token: scopedToken } = await agentsRepo.createToken(scoped.id, 'mcp')); + const unscoped = await agentsRepo.create({ slug: `u-${Date.now()}`, name: 'U', kind: 'claude', model: 'sonnet', + capabilities: { read: true, suggest: true }, scopes: {} }, owner); + ({ token: unscopedToken } = await agentsRepo.createToken(unscoped.id, 'mcp')); +}); + +describe('mcpAuth', () => { + it('missing bearer → 401', async () => { + const { res, next } = await run({}); expect(res.statusCode).toBe(401); expect(next).not.toHaveBeenCalled(); + }); + it('owner token → 401 agent_required', async () => { + const { res } = await run({ authorization: 'Bearer test-token' }); + expect(res.statusCode).toBe(401); expect(res.body.error.code).toBe('agent_required'); + }); + it('invalid token → 401', async () => { + const { res } = await run({ authorization: 'Bearer vk_nope.nope' }); expect(res.statusCode).toBe(401); + }); + it('agent without space scope → 403 no_space_scope', async () => { + const { res } = await run({ authorization: `Bearer ${unscopedToken}` }); + expect(res.statusCode).toBe(403); expect(res.body.error.code).toBe('no_space_scope'); + }); + it('valid scoped agent → next() + req.mcpAgent', async () => { + const { req, next } = await run({ authorization: `Bearer ${scopedToken}` }); + expect(next).toHaveBeenCalled(); expect(req.mcpAgent.scopes.space_id).toBeTruthy(); + }); + it('rate limit → 429 past the cap', async () => { + _resetMcpRate(); process.env.MCP_RATE_LIMIT = '2'; + await run({ authorization: `Bearer ${scopedToken}` }); + await run({ authorization: `Bearer ${scopedToken}` }); + const { res } = await run({ authorization: `Bearer ${scopedToken}` }); + expect(res.statusCode).toBe(429); + delete process.env.MCP_RATE_LIMIT; + }); +});