mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
131 lines
5.7 KiB
TypeScript
131 lines
5.7 KiB
TypeScript
/**
|
|
* 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<string, string | undefined>) {
|
|
const req: Record<string, unknown> = { headers };
|
|
return {
|
|
switchToHttp: () => ({ getRequest: () => req }),
|
|
req,
|
|
} as unknown as ExecutionContext & { req: Record<string, unknown> };
|
|
}
|
|
|
|
/**
|
|
* 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<boolean>): 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();
|
|
});
|
|
});
|