test+feat: token-auth unit coverage + env-tunable introspection TTLs
Some checks failed
CI/CD / lint (pull_request) Successful in 51s
CI/CD / typecheck (pull_request) Successful in 51s
CI/CD / test (pull_request) Successful in 1m3s
CI/CD / smoke (pull_request) Failing after 3m24s
CI/CD / build (pull_request) Successful in 4m45s
CI/CD / publish (pull_request) Has been skipped

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) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-17 23:25:06 +01:00
parent 913678e400
commit 3061a5f6ae
2 changed files with 167 additions and 1 deletions

View File

@@ -59,7 +59,11 @@ export async function serve(): Promise<void> {
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;

View File

@@ -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<typeof createTokenAuthMiddleware>[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();
});
});