/** * ApiTokenGuard — the only door into /api/v1. * * The cases that matter here are the refusals. A guard that lets the right token * through is easy; one that also lets a session JWT, an OAuth bearer or a random * string through is a credential-confusion bug, and those are the tests below. */ import { describe, it, expect, vi } from 'vitest'; import { HttpException } from '@nestjs/common'; import type { ExecutionContext } from '@nestjs/common'; import { PUBLIC_API_SCOPES } from '@trek/shared'; import { ApiTokenGuard } from '../../../src/nest/public-api/api-token.guard'; import type { TokenService } from '../../../src/nest/tokens/token.service'; import type { User } from '../../../src/types'; const USER: User = { id: 7, username: 'ada', email: 'ada@example.com', role: 'user' } as User; function contextWith(headers: Record) { const req: Record = { headers }; return { switchToHttp: () => ({ getRequest: () => req }), req, } as unknown as ExecutionContext & { req: Record }; } /** * The guard resolves the user and the key's read grant in one lookup (#2279). * These cases are about the credential, so every accepted token here carries the * full grant; what a narrowed one may read is public-api-scopes.test.ts. */ function makeGuard(verify: (raw: string) => User | null) { const verifyApiTokenWithGrant = (raw: string) => { const user = verify(raw); return user ? { user, grant: { mode: 'all', scopes: [...PUBLIC_API_SCOPES] } } : null; }; return new ApiTokenGuard({ verifyApiTokenWithGrant } as unknown as TokenService); } /** Run the guard, expecting a refusal; return its { status, body }. */ async function refused(fn: () => Promise): Promise<{ status: number; body: unknown }> { try { await fn(); } catch (err) { expect(err).toBeInstanceOf(HttpException); const e = err as HttpException; return { status: e.getStatus(), body: e.getResponse() }; } throw new Error('expected the guard to refuse'); } describe('ApiTokenGuard', () => { it('accepts a trek_ token via Authorization: Bearer and resolves the user', async () => { const verify = vi.fn().mockReturnValue(USER); const ctx = contextWith({ authorization: 'Bearer trek_abc123' }); expect(await makeGuard(verify).canActivate(ctx)).toBe(true); expect(verify).toHaveBeenCalledWith('trek_abc123'); expect(ctx.req.user).toBe(USER); }); it('accepts the same token via X-API-Key', async () => { const verify = vi.fn().mockReturnValue(USER); const ctx = contextWith({ 'x-api-key': 'trek_abc123' }); expect(await makeGuard(verify).canActivate(ctx)).toBe(true); expect(verify).toHaveBeenCalledWith('trek_abc123'); }); it('trims surrounding whitespace before looking a token up', async () => { const verify = vi.fn().mockReturnValue(USER); await makeGuard(verify).canActivate(contextWith({ 'x-api-key': ' trek_abc123 ' })); expect(verify).toHaveBeenCalledWith('trek_abc123'); }); it('401s without any credential', async () => { const verify = vi.fn(); expect(await refused(() => makeGuard(verify).canActivate(contextWith({})))).toEqual({ status: 401, body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, }); expect(verify).not.toHaveBeenCalled(); }); it('never reaches for the MCP verifier, so the two credentials cannot swap', async () => { const verifyApiTokenWithGrant = vi .fn() .mockReturnValue({ user: USER, grant: { mode: 'all', scopes: [...PUBLIC_API_SCOPES] } }); const verifyMcpToken = vi.fn(); const guard = new ApiTokenGuard({ verifyApiTokenWithGrant, verifyMcpToken } as unknown as TokenService); await guard.canActivate(contextWith({ authorization: 'Bearer trek_abc123' })); expect(verifyApiTokenWithGrant).toHaveBeenCalledTimes(1); expect(verifyMcpToken).not.toHaveBeenCalled(); }); it('401s on a token the store does not know, without saying which part was wrong', async () => { const verify = vi.fn().mockReturnValue(null); expect( await refused(() => makeGuard(verify).canActivate(contextWith({ authorization: 'Bearer trek_nope' }))), ).toEqual({ status: 401, body: { error: 'Invalid API token', code: 'API_TOKEN_INVALID' }, }); }); /** * The credential-confusion cases. Each of these is a valid credential somewhere * else in TREK, and none of them may open this door — a session cookie that ends * up in a third-party integration is precisely what a machine token prevents. */ it.each([ ['a session JWT', { authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig' }], ['an OAuth bearer', { authorization: 'Bearer mcp_at_9f3a2b' }], ['a bare random string', { authorization: 'Bearer hunter2' }], ['a non-Bearer scheme', { authorization: 'Basic dHJlazpwdw==' }], ['a trek_ token in the wrong scheme', { authorization: 'Token trek_abc123' }], ['an X-API-Key that is not a trek_ token', { 'x-api-key': 'eyJhbGciOiJIUzI1NiJ9.p.s' }], ])('refuses %s without ever hitting the token store', async (_label, headers) => { const verify = vi.fn(); expect(await refused(() => makeGuard(verify).canActivate(contextWith(headers)))).toEqual({ status: 401, body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' }, }); expect(verify).not.toHaveBeenCalled(); }); it('ignores an array-valued Authorization header rather than coercing it', async () => { const verify = vi.fn(); const ctx = contextWith({ authorization: ['Bearer trek_abc'] as unknown as string }); expect((await refused(() => makeGuard(verify).canActivate(ctx))).status).toBe(401); expect(verify).not.toHaveBeenCalled(); }); });