feat: McpToken — HTTP-mode mcplocal, CLI verbs, audit plumbing #50
@@ -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;
|
||||
|
||||
162
src/mcplocal/tests/http/token-auth.test.ts
Normal file
162
src/mcplocal/tests/http/token-auth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user