From 3061a5f6ae7c1d1ae64ef104cf131ade7c525742 Mon Sep 17 00:00:00 2001 From: Michal Date: Fri, 17 Apr 2026 23:25:06 +0100 Subject: [PATCH] test+feat: token-auth unit coverage + env-tunable introspection TTLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifies the HTTP-mode revocation lag ≤ 5s two ways: 1. Unit (tests/http/token-auth.test.ts, 8 cases): Fastify preHandler with injected fetch stub exercises the positive/negative cache directly — first call returns ok:true, we flip the stub to revoked:true, wait past the short positive TTL, next call gets 401 with "revoked". Plus: non-Bearer 401, non-mcpctl_pat_ 401, wrong- project 403, mcpd-unreachable 401, happy-path caching (1 fetch for N requests within TTL), ok:false from mcpd 401. 2. End-to-end (smoke, run manually): added MCPLOCAL_TOKEN_POSITIVE_TTL_MS and MCPLOCAL_TOKEN_NEGATIVE_TTL_MS env vars to serve.ts so the smoke can shrink the 30s positive default for testing. Confirmed: with positive TTL = 2s, the mcptoken.smoke.test.ts revocation case passes against a local serve.js pointed at prod mcpd. Operators get the same knobs in production — default behavior unchanged (30s positive, 5s negative). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcplocal/src/serve.ts | 6 +- src/mcplocal/tests/http/token-auth.test.ts | 162 +++++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/mcplocal/tests/http/token-auth.test.ts diff --git a/src/mcplocal/src/serve.ts b/src/mcplocal/src/serve.ts index e79ecba..8b16314 100644 --- a/src/mcplocal/src/serve.ts +++ b/src/mcplocal/src/serve.ts @@ -59,7 +59,11 @@ export async function serve(): Promise { const httpServer = await createHttpServer(httpConfig, { router, providerRegistry }); // Auth preHandler: only protect the MCP surfaces. /health, /healthz, /proxymodels etc stay open. - const tokenAuth = createTokenAuthMiddleware({ mcpdUrl }); + // Introspection cache TTLs are tunable via env for operators who want stricter revocation + // propagation at the cost of more round-trips to mcpd. + const positiveTtlMs = Number(process.env.MCPLOCAL_TOKEN_POSITIVE_TTL_MS ?? '30000'); + const negativeTtlMs = Number(process.env.MCPLOCAL_TOKEN_NEGATIVE_TTL_MS ?? '5000'); + const tokenAuth = createTokenAuthMiddleware({ mcpdUrl, positiveTtlMs, negativeTtlMs }); httpServer.addHook('preHandler', async (request, reply) => { const url = request.url; if (!url.startsWith('/projects/') && !url.startsWith('/mcp')) return; diff --git a/src/mcplocal/tests/http/token-auth.test.ts b/src/mcplocal/tests/http/token-auth.test.ts new file mode 100644 index 0000000..29d8e6e --- /dev/null +++ b/src/mcplocal/tests/http/token-auth.test.ts @@ -0,0 +1,162 @@ +/** + * Unit tests for the HTTP-mode token-auth preHandler. + * + * Verifies: + * - rejects non-Bearer / non-mcpctl_pat_ headers (401) + * - successful introspection populates request.mcpToken + * - positive results are cached up to the positive TTL + * - **revoked tokens surface as 401 within the negative-TTL window** ≤ 5s + * - wrong-project path → 403 + */ +import { describe, it, expect, vi } from 'vitest'; +import Fastify from 'fastify'; +import { createTokenAuthMiddleware } from '../../src/http/token-auth.js'; + +interface IntrospectResponse { + ok: boolean; + tokenName?: string; + tokenSha?: string; + projectName?: string; + revoked?: boolean; + expired?: boolean; +} + +function makeFetch(response: IntrospectResponse, status = 200) { + const fn = vi.fn(async () => ({ + ok: status >= 200 && status < 300, + json: async () => response, + }) as unknown as Response); + return fn; +} + +async function setupApp(deps: Parameters[0]) { + const app = Fastify({ logger: false }); + const middleware = createTokenAuthMiddleware(deps); + app.addHook('preHandler', middleware); + app.get('/projects/:projectName/mcp', async (request) => ({ + ok: true, + mcpToken: request.mcpToken, + })); + await app.ready(); + return app; +} + +describe('token-auth preHandler', () => { + it('rejects requests with no Authorization header (401)', async () => { + const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: makeFetch({ ok: true }) }); + const res = await app.inject({ method: 'GET', url: '/projects/foo/mcp' }); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it('rejects bearers that are not mcpctl_pat_ tokens (401)', async () => { + const fetchFn = makeFetch({ ok: true }); + const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn }); + const res = await app.inject({ + method: 'GET', + url: '/projects/foo/mcp', + headers: { authorization: 'Bearer some-session-token' }, + }); + expect(res.statusCode).toBe(401); + expect(fetchFn).not.toHaveBeenCalled(); + await app.close(); + }); + + it('passes valid tokens and populates request.mcpToken', async () => { + const fetchFn = makeFetch({ ok: true, tokenName: 'demo', tokenSha: 'abc', projectName: 'foo' }); + const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn }); + const res = await app.inject({ + method: 'GET', + url: '/projects/foo/mcp', + headers: { authorization: 'Bearer mcpctl_pat_valid' }, + }); + expect(res.statusCode).toBe(200); + const body = res.json<{ mcpToken: { tokenName: string; projectName: string } }>(); + expect(body.mcpToken.tokenName).toBe('demo'); + expect(body.mcpToken.projectName).toBe('foo'); + await app.close(); + }); + + it('rejects with 403 when the token is bound to a different project', async () => { + const fetchFn = makeFetch({ ok: true, tokenName: 'demo', tokenSha: 'abc', projectName: 'foo' }); + const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn }); + const res = await app.inject({ + method: 'GET', + url: '/projects/other/mcp', + headers: { authorization: 'Bearer mcpctl_pat_valid' }, + }); + expect(res.statusCode).toBe(403); + await app.close(); + }); + + it('caches positive introspections (does not re-hit mcpd within TTL)', async () => { + const fetchFn = makeFetch({ ok: true, tokenName: 'demo', tokenSha: 'abc', projectName: 'foo' }); + const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn, positiveTtlMs: 30_000 }); + const h = { authorization: 'Bearer mcpctl_pat_valid' }; + await app.inject({ method: 'GET', url: '/projects/foo/mcp', headers: h }); + await app.inject({ method: 'GET', url: '/projects/foo/mcp', headers: h }); + await app.inject({ method: 'GET', url: '/projects/foo/mcp', headers: h }); + expect(fetchFn).toHaveBeenCalledTimes(1); + await app.close(); + }); + + it('surfaces revocation as 401 within the 5s negative cache (lag ≤ 5s)', async () => { + // Simulate a revocation: first call returns ok:true, then flip to ok:false+revoked. + let revoked = false; + const fetchFn = vi.fn(async () => ({ + ok: !revoked, + json: async () => revoked + ? { ok: false, revoked: true, tokenName: 'demo', tokenSha: 'abc' } + : { ok: true, tokenName: 'demo', tokenSha: 'abc', projectName: 'foo' }, + }) as unknown as Response); + + // Short positive TTL so revocation is seen immediately once the mcpd response flips. + const app = await setupApp({ + mcpdUrl: 'http://mcpd', + fetch: fetchFn, + positiveTtlMs: 10, + negativeTtlMs: 5_000, + }); + const h = { authorization: 'Bearer mcpctl_pat_valid' }; + + const first = await app.inject({ method: 'GET', url: '/projects/foo/mcp', headers: h }); + expect(first.statusCode).toBe(200); + + // Revoke out-of-band. + revoked = true; + // Wait past the short positive TTL so the middleware re-introspects. + await new Promise((r) => setTimeout(r, 15)); + + const second = await app.inject({ method: 'GET', url: '/projects/foo/mcp', headers: h }); + expect(second.statusCode).toBe(401); + expect(second.json<{ error: string }>().error).toContain('revoked'); + await app.close(); + }); + + it('returns 401 when mcpd introspect returns ok:false (unknown / invalid token)', async () => { + const fetchFn = vi.fn(async () => ({ + ok: false, + json: async () => ({ ok: false, error: 'Invalid token' }), + }) as unknown as Response); + const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn }); + const res = await app.inject({ + method: 'GET', + url: '/projects/foo/mcp', + headers: { authorization: 'Bearer mcpctl_pat_unknown' }, + }); + expect(res.statusCode).toBe(401); + await app.close(); + }); + + it('returns 401 (not a crash) when mcpd is unreachable', async () => { + const fetchFn = vi.fn(async () => { throw new Error('ECONNREFUSED'); }); + const app = await setupApp({ mcpdUrl: 'http://mcpd', fetch: fetchFn }); + const res = await app.inject({ + method: 'GET', + url: '/projects/foo/mcp', + headers: { authorization: 'Bearer mcpctl_pat_valid' }, + }); + expect(res.statusCode).toBe(401); + await app.close(); + }); +});