refactor(tokens): async DB-touching methods; the auth token handlers await them

This commit is contained in:
jubnl
2026-09-22 01:21:29 +02:00
parent 4700bb38a5
commit fc49c0c768
12 changed files with 242 additions and 241 deletions
+2 -2
View File
@@ -306,11 +306,11 @@ export class AdminController {
// ── MCP tokens / OAuth sessions ──
@Get('mcp-tokens')
listMcpTokens() { return { tokens: this.tokens.listAllMcpTokens() }; }
async listMcpTokens() { return { tokens: await this.tokens.listAllMcpTokens() }; }
@Delete('mcp-tokens/:id')
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
ok(this.tokens.adminDeleteMcpToken(id));
ok(await this.tokens.adminDeleteMcpToken(id));
await this.audit.writeAudit({ userId: user.id, action: 'admin.mcp_token_delete', resource: String(id), ip: getClientIp(req) });
return { success: true };
}
+14 -14
View File
@@ -297,16 +297,16 @@ export class AuthController {
}
@Get('mcp-tokens')
listMcpTokens(@CurrentUser() user: User) {
return { tokens: this.tokens.listMcpTokens(user.id) };
async listMcpTokens(@CurrentUser() user: User) {
return { tokens: await this.tokens.listMcpTokens(user.id) };
}
@ManagedForbidden('a static token never expires and carries every scope; OAuth covers the same ground')
@Post('mcp-tokens')
@HttpCode(201)
createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
this.limit('login', req, 5);
const result = this.tokens.createMcpToken(user.id, body.name);
const result = await this.tokens.createMcpToken(user.id, body.name);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -314,8 +314,8 @@ export class AuthController {
}
@Delete('mcp-tokens/:id')
deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) {
const result = this.tokens.deleteMcpToken(user.id, id);
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) {
const result = await this.tokens.deleteMcpToken(user.id, id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -335,18 +335,18 @@ export class AuthController {
* whole reason it exists.
*/
@Get('api-tokens')
listApiTokens(@CurrentUser() user: User) {
return { tokens: this.tokens.listApiTokens(user.id) };
async listApiTokens(@CurrentUser() user: User) {
return { tokens: await this.tokens.listApiTokens(user.id) };
}
@Post('api-tokens')
@HttpCode(201)
createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) {
async createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) {
this.limit('login', req, 5);
// No `scopes` means the key reads everything, which is what every key minted
// before this field existed does. Narrowing stays opt-in so the change
// cannot break an integration that is already running.
const result = this.tokens.createApiToken(user.id, body.name, body.scopes);
const result = await this.tokens.createApiToken(user.id, body.name, body.scopes);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -354,8 +354,8 @@ export class AuthController {
}
@Delete('api-tokens/:id')
deleteApiToken(@CurrentUser() user: User, @Param('id') id: string) {
const result = this.tokens.deleteApiToken(user.id, id);
async deleteApiToken(@CurrentUser() user: User, @Param('id') id: string) {
const result = await this.tokens.deleteApiToken(user.id, id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -364,14 +364,14 @@ export class AuthController {
@Post('ws-token')
@HttpCode(200)
wsToken(@CurrentUser() user: User) {
async wsToken(@CurrentUser() user: User) {
// Own bucket, not 'login': a client that reconnects its socket in a loop
// must not be able to lock itself out of signing in. The ceiling is far
// above any real client, which mints one token per socket connect, but it
// stops a single account from filling the process-wide ephemeral store and
// 503-ing every other user's ws and download tokens.
this.limitUser('ws_token', user.id, 120);
const result = this.tokens.createWsToken(user.id);
const result = await this.tokens.createWsToken(user.id);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -147,7 +147,7 @@ export class McpTransportService {
// Long-lived static MCP token (trek_...) — full access + deprecation notice
if (token.startsWith('trek_')) {
const user = this.tokens.verifyMcpToken(token);
const user = await this.tokens.verifyMcpToken(token);
if (!user) return null;
return { user, scopes: null, clientId: null, isStaticToken: true };
}
@@ -37,7 +37,7 @@ import { TokenService } from '../tokens/token.service';
export class ApiTokenGuard implements CanActivate {
constructor(private readonly tokens: TokenService) {}
canActivate(context: ExecutionContext): boolean {
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const token = extractApiToken(req);
if (!token) {
@@ -46,7 +46,7 @@ export class ApiTokenGuard implements CanActivate {
401,
);
}
const resolved = this.tokens.verifyApiTokenWithGrant(token);
const resolved = await this.tokens.verifyApiTokenWithGrant(token);
if (!resolved) {
throw new HttpException(
{ error: 'Invalid API token', code: 'API_TOKEN_INVALID' },
+24 -24
View File
@@ -43,8 +43,8 @@ export class TokenService {
private readonly ephemeral: EphemeralTokenService,
) {}
listMcpTokens(userId: number) {
return this.listTokens(userId, 'mcp');
async listMcpTokens(userId: number) {
return await this.listTokens(userId, 'mcp');
}
/**
@@ -57,11 +57,11 @@ export class TokenService {
* radius nobody asked for, so `kind` keeps the two apart and each surface
* verifies the one it accepts.
*/
listApiTokens(userId: number) {
return this.listTokens(userId, 'api');
async listApiTokens(userId: number) {
return await this.listTokens(userId, 'api');
}
private listTokens(userId: number, kind: TokenKind) {
private async listTokens(userId: number, kind: TokenKind) {
const rows = this.db.all<TokenRow>(
'SELECT id, name, token_prefix, created_at, last_used_at, scope_mode, api_scopes FROM mcp_tokens WHERE user_id = ? AND kind = ? ORDER BY created_at DESC',
userId, kind
@@ -86,8 +86,8 @@ export class TokenService {
});
}
createMcpToken(userId: number, rawName: unknown) {
return this.createToken(userId, rawName, 'mcp');
async createMcpToken(userId: number, rawName: unknown) {
return await this.createToken(userId, rawName, 'mcp');
}
/**
@@ -97,11 +97,11 @@ export class TokenService {
* before this argument existed. Narrowing is opt-in on purpose: shipping the
* column must not change what an already-running integration can do.
*/
createApiToken(userId: number, rawName: unknown, scopes?: readonly string[]) {
return this.createToken(userId, rawName, 'api', scopes);
async createApiToken(userId: number, rawName: unknown, scopes?: readonly string[]) {
return await this.createToken(userId, rawName, 'api', scopes);
}
private createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): { error?: string; status?: number; token?: Record<string, unknown> } {
private async createToken(userId: number, rawName: unknown, kind: TokenKind, scopes?: readonly string[]): Promise<{ error?: string; status?: number; token?: Record<string, unknown> }> {
const name = rawName as string | undefined;
if (!name?.trim()) return { error: 'Token name is required', status: 400 };
if (name.trim().length > 100) return { error: 'Token name must be 100 characters or less', status: 400 };
@@ -135,12 +135,12 @@ export class TokenService {
return { token: { ...(token as object), ...grant, raw_token: rawToken } };
}
deleteMcpToken(userId: number, tokenId: string) {
return this.deleteToken(userId, tokenId, 'mcp');
async deleteMcpToken(userId: number, tokenId: string) {
return await this.deleteToken(userId, tokenId, 'mcp');
}
deleteApiToken(userId: number, tokenId: string) {
return this.deleteToken(userId, tokenId, 'api');
async deleteApiToken(userId: number, tokenId: string) {
return await this.deleteToken(userId, tokenId, 'api');
}
/**
@@ -148,7 +148,7 @@ export class TokenService {
* happily delete a token the MCP panel manages, and the user would find a key
* missing from a screen they never opened.
*/
private deleteToken(userId: number, tokenId: string, kind: TokenKind): { error?: string; status?: number; success?: boolean } {
private async deleteToken(userId: number, tokenId: string, kind: TokenKind): Promise<{ error?: string; status?: number; success?: boolean }> {
const token = this.db.get('SELECT id FROM mcp_tokens WHERE id = ? AND user_id = ? AND kind = ?', tokenId, userId, kind);
if (!token) return { error: 'Token not found', status: 404 };
this.db.run('DELETE FROM mcp_tokens WHERE id = ?', tokenId);
@@ -162,7 +162,7 @@ export class TokenService {
// Ephemeral tokens
// -------------------------------------------------------------------------
createWsToken(userId: number): { error?: string; status?: number; token?: string } {
async createWsToken(userId: number): Promise<{ error?: string; status?: number; token?: string }> {
// Bind the ws-token to the user's current password_version so a token minted
// before a password reset is rejected on connect (defence-in-depth session gate).
const pv = this.db.get<{ password_version?: number }>('SELECT password_version FROM users WHERE id = ?', userId)?.password_version ?? 0;
@@ -192,7 +192,7 @@ export class TokenService {
// user-facing one treats that as best-effort.
// -------------------------------------------------------------------------
listAllMcpTokens() {
async listAllMcpTokens() {
return this.db.all(`
SELECT t.id, t.name, t.token_prefix, t.created_at, t.last_used_at, t.user_id, u.username
FROM mcp_tokens t
@@ -201,7 +201,7 @@ export class TokenService {
`);
}
adminDeleteMcpToken(id: string) {
async adminDeleteMcpToken(id: string) {
const token = this.db.get<{ id: number; user_id: number }>('SELECT id, user_id FROM mcp_tokens WHERE id = ?', id);
if (!token) return { error: 'Token not found', status: 404 };
this.db.run('DELETE FROM mcp_tokens WHERE id = ?', id);
@@ -213,13 +213,13 @@ export class TokenService {
// Verification
// -------------------------------------------------------------------------
verifyMcpToken(rawToken: string): User | null {
return this.verifyToken(rawToken, 'mcp');
async verifyMcpToken(rawToken: string): Promise<User | null> {
return await this.verifyToken(rawToken, 'mcp');
}
/** Verifies an integration key. An MCP token presented here does not resolve. */
verifyApiToken(rawToken: string): User | null {
return this.verifyToken(rawToken, 'api');
async verifyApiToken(rawToken: string): Promise<User | null> {
return await this.verifyToken(rawToken, 'api');
}
/**
@@ -229,7 +229,7 @@ export class TokenService {
* both halves, nothing else needs either, and the existing signature is
* pinned by tests that assert exactly a `User`.
*/
verifyApiTokenWithGrant(rawToken: string): { user: User; grant: PublicApiGrant } | null {
async verifyApiTokenWithGrant(rawToken: string): Promise<{ user: User; grant: PublicApiGrant } | null> {
const hash = createHash('sha256').update(rawToken).digest('hex');
const row = this.db.get<User & { scope_mode: string | null; api_scopes: string | null }>(`
SELECT u.id, u.username, u.email, u.role, mt.scope_mode, mt.api_scopes
@@ -251,7 +251,7 @@ export class TokenService {
* neither the caller nor a timing measurement learns that the string was a
* real credential for somewhere else.
*/
private verifyToken(rawToken: string, kind: TokenKind): User | null {
private async verifyToken(rawToken: string, kind: TokenKind): Promise<User | null> {
const hash = createHash('sha256').update(rawToken).digest('hex');
const row = this.db.get<User>(`
SELECT u.id, u.username, u.email, u.role
@@ -190,7 +190,7 @@ describe('AdminController read-only getters', () => {
await expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
expect(await adminCtl(svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }).listInvites()).toEqual({ invites: [{ id: 1 }] });
expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
expect(adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
expect(await adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
expect(await adminCtl(svc(), undefined, undefined, {}, {}, { listAllOAuthSessions: vi.fn().mockResolvedValue([{ id: 1 }]) }).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
});
+24 -24
View File
@@ -245,7 +245,7 @@ describe('AuthController (authenticated)', () => {
expect(await enable.mfaEnable(user, { code: '123456' }, req)).toEqual({ success: true, mfa_enabled: true, backup_codes: ['a', 'b'] });
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_enable' }));
const tok = ac(asvc({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ token: 'mcp_x' }) });
expect(tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' });
expect(await tok.createMcpToken(user, { name: 'CLI' }, req)).toEqual({ token: 'mcp_x' });
});
it('resource-token 503 when unavailable, else returns the token payload', () => {
@@ -253,11 +253,11 @@ describe('AuthController (authenticated)', () => {
expect(ac(asvc({}), rl(), { createResourceToken: vi.fn().mockReturnValue({ token: 'rt' }) }).resourceToken(user, { purpose: 'download' })).toEqual({ token: 'rt' });
});
it('ws/resource tokens throttle on their own buckets, so a mint loop cannot drain the store or block login', () => {
it('ws/resource tokens throttle on their own buckets, so a mint loop cannot drain the store or block login', async () => {
const s = rl();
const now = Date.now();
for (let i = 0; i < 120; i++) s.check('ws_token', String(user.id), 120, 15 * 60 * 1000, now);
expect(thrown(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)))
expect(await thrownAsync(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)))
.toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
// The login bucket is untouched: an exhausted socket loop must not lock the account out.
expect(s.check('login', '9.9.9.9', 5, 15 * 60 * 1000, now)).toBe(true);
@@ -268,19 +268,19 @@ describe('AuthController (authenticated)', () => {
.toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
});
it('the ws/resource ceilings are per account, not per address: one heavy user cannot 429 the office', () => {
it('the ws/resource ceilings are per account, not per address: one heavy user cannot 429 the office', async () => {
const s = rl();
const now = Date.now();
const other = { ...user, id: 2 } as User;
// Both users sit behind the same NAT address, and the first one burns its
// whole ceiling.
for (let i = 0; i < 120; i++) {
ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user);
await ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user);
}
expect(thrown(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)))
expect(await thrownAsync(() => ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)))
.toEqual({ status: 429, body: { error: 'Too many attempts. Please try again later.' } });
expect(ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws2' }) }).wsToken(other)).toEqual({ token: 'ws2' });
expect(await ac(asvc({}), s, { createWsToken: vi.fn().mockReturnValue({ token: 'ws2' }) }).wsToken(other)).toEqual({ token: 'ws2' });
expect(ac(asvc({}), s, { createResourceToken: vi.fn().mockReturnValue({ token: 'rt2' }) }).resourceToken(other, {})).toEqual({ token: 'rt2' });
});
@@ -408,39 +408,39 @@ describe('AuthController (authenticated)', () => {
expect(writeAudit).toHaveBeenCalledWith(expect.objectContaining({ action: 'user.mfa_disable' }));
});
it('mcp-tokens list + create error + delete error/success', () => {
expect(ac(asvc({}), rl(), { listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) }).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
expect(thrown(() => ac(asvc({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
expect(thrown(() => ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
expect(ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, 'tid')).toEqual({ success: true });
it('mcp-tokens list + create error + delete error/success', async () => {
expect(await ac(asvc({}), rl(), { listMcpTokens: vi.fn().mockReturnValue([{ id: 't' }]) }).listMcpTokens(user)).toEqual({ tokens: [{ id: 't' }] });
expect(await thrownAsync(() => ac(asvc({}), rl(), { createMcpToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createMcpToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
expect(await thrownAsync(() => ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteMcpToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
expect(await ac(asvc({}), rl(), { deleteMcpToken: vi.fn().mockReturnValue({}) }).deleteMcpToken(user, 'tid')).toEqual({ success: true });
});
// Same four paths as the MCP block above, against the other token kind. They are
// separate routes on purpose — an API key and an MCP token open different doors —
// so nothing here is implied by the MCP tests passing.
it('api-tokens list + create success/error + delete error/success', () => {
expect(ac(asvc({}), rl(), { listApiTokens: vi.fn().mockReturnValue([{ id: 'a' }]) }).listApiTokens(user)).toEqual({ tokens: [{ id: 'a' }] });
expect(ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ token: 'trek_x' }) }).createApiToken(user, { name: 'Homepage' }, req)).toEqual({ token: 'trek_x' });
expect(thrown(() => ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createApiToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
expect(thrown(() => ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteApiToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
expect(ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({}) }).deleteApiToken(user, 'tid')).toEqual({ success: true });
it('api-tokens list + create success/error + delete error/success', async () => {
expect(await ac(asvc({}), rl(), { listApiTokens: vi.fn().mockReturnValue([{ id: 'a' }]) }).listApiTokens(user)).toEqual({ tokens: [{ id: 'a' }] });
expect(await ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ token: 'trek_x' }) }).createApiToken(user, { name: 'Homepage' }, req)).toEqual({ token: 'trek_x' });
expect(await thrownAsync(() => ac(asvc({}), rl(), { createApiToken: vi.fn().mockReturnValue({ error: 'Name taken', status: 409 }) }).createApiToken(user, { name: 'x' }, req))).toEqual({ status: 409, body: { error: 'Name taken' } });
expect(await thrownAsync(() => ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({ error: 'Not found', status: 404 }) }).deleteApiToken(user, 'tid'))).toEqual({ status: 404, body: { error: 'Not found' } });
expect(await ac(asvc({}), rl(), { deleteApiToken: vi.fn().mockReturnValue({}) }).deleteApiToken(user, 'tid')).toEqual({ success: true });
});
// The create route shares the 'login' limiter bucket with the MCP one, at 5/window.
// Unlike MCP tokens it is NOT refused on a managed instance, so the limiter is the
// only thing standing between a scripted caller and an unbounded key list.
it('api-tokens create is rate limited after 5 in the window', () => {
it('api-tokens create is rate limited after 5 in the window', async () => {
const limiter = rl();
const createApiToken = vi.fn().mockReturnValue({ token: 'trek_x' });
const ctl = ac(asvc({}), limiter, { createApiToken });
for (let i = 0; i < 5; i++) expect(ctl.createApiToken(user, { name: `k${i}` }, req)).toEqual({ token: 'trek_x' });
expect(thrown(() => ctl.createApiToken(user, { name: 'k5' }, req)).status).toBe(429);
for (let i = 0; i < 5; i++) expect(await ctl.createApiToken(user, { name: `k${i}` }, req)).toEqual({ token: 'trek_x' });
expect((await thrownAsync(() => ctl.createApiToken(user, { name: 'k5' }, req))).status).toBe(429);
expect(createApiToken).toHaveBeenCalledTimes(5);
});
it('ws-token maps error, else returns the token', () => {
expect(thrown(() => ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) }).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
expect(ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)).toEqual({ token: 'ws' });
it('ws-token maps error, else returns the token', async () => {
expect(await thrownAsync(() => ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ error: 'down', status: 503 }) }).wsToken(user))).toEqual({ status: 503, body: { error: 'down' } });
expect(await ac(asvc({}), rl(), { createWsToken: vi.fn().mockReturnValue({ token: 'ws' }) }).wsToken(user)).toEqual({ token: 'ws' });
});
it('avatar saves when not in demo mode (env present but email is not a demo email)', async () => {
+5 -5
View File
@@ -465,7 +465,7 @@ describe('changePassword — session invalidation', () => {
it('AUTH-DB-036b: bumps password_version, prunes MCP tokens, and re-issues a session', async () => {
const { user, password } = createUser(testDb);
tokens.createMcpToken(user.id, 'cli');
await tokens.createMcpToken(user.id, 'cli');
expect(pvOf(user.id)).toBe(0);
expect(mcpCount(user.id)).toBe(1);
@@ -1028,13 +1028,13 @@ describe('auth quirk fixes', () => {
// ---------------------------------------------------------------------------
describe('MCP token verification round-trips', () => {
it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', () => {
it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', async () => {
const { user } = createUser(testDb);
const created = tokens.createMcpToken(user.id, 'bridge-case');
const created = await tokens.createMcpToken(user.id, 'bridge-case');
const raw = (created.token as { raw_token: string }).raw_token;
const resolved = tokens.verifyMcpToken(raw);
const resolved = await tokens.verifyMcpToken(raw);
expect(resolved?.id).toBe(user.id);
expect(tokens.verifyMcpToken('trek_no_such_token')).toBeNull();
expect(await tokens.verifyMcpToken('trek_no_such_token')).toBeNull();
});
it('AUTH-BR-003: verifyJwtToken round-trips a service-minted token through the pv gate', async () => {
@@ -229,11 +229,11 @@ describe('ApiTokenGuard — the grant it leaves behind', () => {
};
}
it('PUBAPI-SCOPE-U010: puts the user AND the grant on the request, as two separate things', () => {
it('PUBAPI-SCOPE-U010: puts the user AND the grant on the request, as two separate things', async () => {
const grant = limited('trips');
const { guard, verifyApiTokenWithGrant } = guardWith({ user: USER, grant });
const ctx = contextWith({ authorization: 'Bearer trek_abc123' });
expect(guard.canActivate(ctx)).toBe(true);
expect(await guard.canActivate(ctx)).toBe(true);
expect(verifyApiTokenWithGrant).toHaveBeenCalledWith('trek_abc123');
expect(ctx.req.user).toBe(USER);
expect(ctx.req.apiToken).toBe(grant);
@@ -247,25 +247,25 @@ describe('ApiTokenGuard — the grant it leaves behind', () => {
* The two refusals predate scopes and are load-bearing for every integration
* that already handles them. Adding a third answer must not reword them.
*/
it('PUBAPI-SCOPE-U011: the 401 bodies are unchanged — no scope wording leaked into them', () => {
it('PUBAPI-SCOPE-U011: the 401 bodies are unchanged — no scope wording leaked into them', async () => {
const { guard: noCredential } = guardWith(null);
expect(thrown(() => noCredential.canActivate(contextWith({})))).toEqual({
expect(await thrownAsync(() => noCredential.canActivate(contextWith({})))).toEqual({
status: 401,
body: { error: 'API token required', code: 'API_TOKEN_REQUIRED' },
});
const { guard: unknownToken } = guardWith(null);
expect(
thrown(() => unknownToken.canActivate(contextWith({ authorization: 'Bearer trek_nope' }))),
await thrownAsync(() => unknownToken.canActivate(contextWith({ authorization: 'Bearer trek_nope' }))),
).toEqual({
status: 401,
body: { error: 'Invalid API token', code: 'API_TOKEN_INVALID' },
});
});
it('PUBAPI-SCOPE-U012: a rejected token leaves no grant behind for a later handler to trust', () => {
it('PUBAPI-SCOPE-U012: a rejected token leaves no grant behind for a later handler to trust', async () => {
const { guard } = guardWith(null);
const ctx = contextWith({ authorization: 'Bearer trek_nope' });
thrown(() => guard.canActivate(ctx));
await thrownAsync(() => guard.canActivate(ctx));
expect(ctx.req.apiToken).toBeUndefined();
expect(ctx.req.user).toBeUndefined();
});
@@ -417,17 +417,17 @@ describe('TokenService — storing and resolving a grant', () => {
api_scopes: string | null;
};
const mint = (userId: number, name: string, scopes?: readonly string[]) =>
tokens.createApiToken(userId, name, scopes).token as {
const mint = async (userId: number, name: string, scopes?: readonly string[]) =>
(await tokens.createApiToken(userId, name, scopes)).token as {
id: number;
raw_token: string;
scope_mode: string;
scopes: string[];
};
it('PUBAPI-SCOPE-U040: a key minted without scopes is stored as a full grant, not an empty one', () => {
it('PUBAPI-SCOPE-U040: a key minted without scopes is stored as a full grant, not an empty one', async () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich');
const created = await mint(user.id, 'Dawarich');
// 'all' is written explicitly: a NULL sentinel here would mean a bug that
// drops the column silently grants everything.
expect(rowFor(created.id)).toEqual({ kind: 'api', scope_mode: 'all', api_scopes: null });
@@ -435,10 +435,10 @@ describe('TokenService — storing and resolving a grant', () => {
expect(created.scopes).toEqual([...PUBLIC_API_SCOPES]);
});
it('PUBAPI-SCOPE-U041: and resolves to every scope when it is presented', () => {
it('PUBAPI-SCOPE-U041: and resolves to every scope when it is presented', async () => {
const { user } = createUser(testDb);
const raw = mint(user.id, 'Dawarich').raw_token;
const resolved = tokens.verifyApiTokenWithGrant(raw);
const raw = (await mint(user.id, 'Dawarich')).raw_token;
const resolved = await tokens.verifyApiTokenWithGrant(raw);
expect(resolved?.user.id).toBe(user.id);
expect(resolved?.grant).toEqual({ mode: 'all', scopes: [...PUBLIC_API_SCOPES] });
});
@@ -449,7 +449,7 @@ describe('TokenService — storing and resolving a grant', () => {
* 'all', and a DatabaseService that hands back NULL anyway must land in the
* same place. Both are "nobody ever narrowed this key".
*/
it('PUBAPI-SCOPE-U042: a row with no narrowing at all still reads everything', () => {
it('PUBAPI-SCOPE-U042: a row with no narrowing at all still reads everything', async () => {
const noColumns = new TokenService(
{
get: () => ({ id: 3, username: 'ada', email: 'a@b.c', role: 'user', scope_mode: null, api_scopes: null }),
@@ -457,15 +457,15 @@ describe('TokenService — storing and resolving a grant', () => {
} as unknown as DatabaseService,
new EphemeralTokenService(),
);
expect(noColumns.verifyApiTokenWithGrant('trek_legacy')?.grant).toEqual({
expect((await noColumns.verifyApiTokenWithGrant('trek_legacy'))?.grant).toEqual({
mode: 'all',
scopes: [...PUBLIC_API_SCOPES],
});
});
it('PUBAPI-SCOPE-U043: a narrowed key stores exactly the chosen sections, in the canonical order', () => {
it('PUBAPI-SCOPE-U043: a narrowed key stores exactly the chosen sections, in the canonical order', async () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['stats', 'trips']);
const created = await mint(user.id, 'Dawarich', ['stats', 'trips']);
expect(rowFor(created.id)).toEqual({
kind: 'api',
scope_mode: 'limited',
@@ -476,81 +476,81 @@ describe('TokenService — storing and resolving a grant', () => {
expect(created).toMatchObject({ scope_mode: 'limited', scopes: ['trips', 'stats'] });
});
it('PUBAPI-SCOPE-U044: and resolves to exactly those, and no more', () => {
it('PUBAPI-SCOPE-U044: and resolves to exactly those, and no more', async () => {
const { user } = createUser(testDb);
const raw = mint(user.id, 'Dawarich', ['trips', 'days']).raw_token;
expect(tokens.verifyApiTokenWithGrant(raw)?.grant).toEqual({
const raw = (await mint(user.id, 'Dawarich', ['trips', 'days'])).raw_token;
expect((await tokens.verifyApiTokenWithGrant(raw))?.grant).toEqual({
mode: 'limited',
scopes: ['trips', 'days'],
});
});
it('PUBAPI-SCOPE-U045: an unknown scope name is dropped rather than stored', () => {
it('PUBAPI-SCOPE-U045: an unknown scope name is dropped rather than stored', async () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['trips', 'passwords', 'trips']);
const created = await mint(user.id, 'Dawarich', ['trips', 'passwords', 'trips']);
expect(rowFor(created.id).api_scopes).toBe(JSON.stringify(['trips']));
});
it('PUBAPI-SCOPE-U046: a list of nothing but unknown names is no narrowing at all', () => {
it('PUBAPI-SCOPE-U046: a list of nothing but unknown names is no narrowing at all', async () => {
const { user } = createUser(testDb);
// Not an empty limited grant: that would mint a key that can read nothing,
// which nobody asked for and which fails hours later at the integration.
const created = mint(user.id, 'Dawarich', ['everything']);
const created = await mint(user.id, 'Dawarich', ['everything']);
expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
});
it('PUBAPI-SCOPE-U047: an empty scopes array means the caller did not narrow anything', () => {
it('PUBAPI-SCOPE-U047: an empty scopes array means the caller did not narrow anything', async () => {
const { user } = createUser(testDb);
expect(rowFor(mint(user.id, 'Dawarich', []).id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
expect(rowFor((await mint(user.id, 'Dawarich', [])).id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
});
it('PUBAPI-SCOPE-U048: a key that says it is narrowed and cannot say how reads nothing', () => {
it('PUBAPI-SCOPE-U048: a key that says it is narrowed and cannot say how reads nothing', async () => {
// The direction matters. A row that never said 'limited' is a legacy
// full-access key and stays one; a row that HAS said it is restricted must
// never widen because its list became unreadable — that is the failure this
// whole column exists to prevent. A key that stops working is a support
// ticket; a key that quietly reads every trip is the bug.
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['trips']);
const created = await mint(user.id, 'Dawarich', ['trips']);
for (const broken of ['{"not":"a list"', 'null', '"trips"', '{}', '[]', '[42, null]']) {
testDb.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?').run(broken, created.id);
expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({
expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({
mode: 'limited',
scopes: [],
});
}
});
it('PUBAPI-SCOPE-U051: a narrowed key whose scopes were all renamed away denies rather than widens', () => {
it('PUBAPI-SCOPE-U051: a narrowed key whose scopes were all renamed away denies rather than widens', async () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['trips']);
const created = await mint(user.id, 'Dawarich', ['trips']);
// What a future version dropping or renaming a scope constant leaves behind.
testDb
.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?')
.run(JSON.stringify(['itineraries']), created.id);
expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({
expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({
mode: 'limited',
scopes: [],
});
});
it('PUBAPI-SCOPE-U049: a partly unreadable list keeps the names it can read', () => {
it('PUBAPI-SCOPE-U049: a partly unreadable list keeps the names it can read', async () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['trips']);
const created = await mint(user.id, 'Dawarich', ['trips']);
testDb
.prepare('UPDATE mcp_tokens SET api_scopes = ? WHERE id = ?')
.run(JSON.stringify(['trips', 42, 'nope', 'stats']), created.id);
expect(tokens.verifyApiTokenWithGrant(created.raw_token)?.grant).toEqual({
expect((await tokens.verifyApiTokenWithGrant(created.raw_token))?.grant).toEqual({
mode: 'limited',
scopes: ['trips', 'stats'],
});
});
it('PUBAPI-SCOPE-U050: the list route reports the grant of every key, narrowed or not', () => {
it('PUBAPI-SCOPE-U050: the list route reports the grant of every key, narrowed or not', async () => {
const { user } = createUser(testDb);
mint(user.id, 'Wide');
mint(user.id, 'Narrow', ['trips']);
const listed = tokens.listApiTokens(user.id) as Array<Record<string, unknown>>;
await mint(user.id, 'Wide');
await mint(user.id, 'Narrow', ['trips']);
const listed = await tokens.listApiTokens(user.id) as Array<Record<string, unknown>>;
expect(listed).toHaveLength(2);
expect(listed.map((t) => [t.name, t.scope_mode, t.scopes])).toEqual(
expect.arrayContaining([
@@ -562,13 +562,13 @@ describe('TokenService — storing and resolving a grant', () => {
expect(JSON.stringify(listed)).not.toContain('token_hash');
});
it('PUBAPI-SCOPE-U051: an MCP token is untouched by any of this', () => {
it('PUBAPI-SCOPE-U051: an MCP token is untouched by any of this', async () => {
const { user } = createUser(testDb);
const mcp = tokens.createMcpToken(user.id, 'Assistant').token as { id: number };
const mcp = (await tokens.createMcpToken(user.id, 'Assistant')).token as { id: number };
// Stored as the column default, because an MCP token carries no read scopes
// and must not look as if it did.
expect(rowFor(mcp.id)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null });
const listed = tokens.listMcpTokens(user.id) as Array<Record<string, unknown>>;
const listed = await tokens.listMcpTokens(user.id) as Array<Record<string, unknown>>;
expect(listed).toHaveLength(1);
// The MCP panel cannot act on scopes, so it is not shown two columns that
// would always say the same thing.
@@ -579,22 +579,22 @@ describe('TokenService — storing and resolving a grant', () => {
);
});
it('PUBAPI-SCOPE-U052: an API key still does not verify as an MCP token, scopes or not', () => {
it('PUBAPI-SCOPE-U052: an API key still does not verify as an MCP token, scopes or not', async () => {
const { user } = createUser(testDb);
const raw = mint(user.id, 'Dawarich', ['trips']).raw_token;
expect(tokens.verifyMcpToken(raw)).toBeNull();
expect(tokens.verifyApiTokenWithGrant(raw)).not.toBeNull();
const raw = (await mint(user.id, 'Dawarich', ['trips'])).raw_token;
expect(await tokens.verifyMcpToken(raw)).toBeNull();
expect(await tokens.verifyApiTokenWithGrant(raw)).not.toBeNull();
});
it('PUBAPI-SCOPE-U053: presenting a narrowed key stamps last_used_at like any other', () => {
it('PUBAPI-SCOPE-U053: presenting a narrowed key stamps last_used_at like any other', async () => {
const { user } = createUser(testDb);
const created = mint(user.id, 'Dawarich', ['trips']);
const created = await mint(user.id, 'Dawarich', ['trips']);
expect(
(testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as {
last_used_at: string | null;
}).last_used_at,
).toBeNull();
tokens.verifyApiTokenWithGrant(created.raw_token);
await tokens.verifyApiTokenWithGrant(created.raw_token);
expect(
(testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(created.id) as {
last_used_at: string | null;
@@ -619,13 +619,13 @@ describe('TokenService — storing and resolving a grant', () => {
);
const httpReq = { ip: '9.9.9.9', headers: {} } as Request;
it('PUBAPI-SCOPE-U060: POST /api/auth/api-tokens stores the scopes it was given', () => {
it('PUBAPI-SCOPE-U060: POST /api/auth/api-tokens stores the scopes it was given', async () => {
const { user } = createUser(testDb);
const created = authController().createApiToken(
const created = (await authController().createApiToken(
{ id: user.id } as User,
{ name: 'Dawarich', scopes: ['trips', 'days'] },
httpReq,
).token as { id: number; scope_mode: string; scopes: string[]; raw_token: string };
)).token as { id: number; scope_mode: string; scopes: string[]; raw_token: string };
expect(rowFor(created.id)).toMatchObject({
scope_mode: 'limited',
@@ -636,42 +636,42 @@ describe('TokenService — storing and resolving a grant', () => {
expect(created.raw_token).toMatch(/^trek_/);
});
it('PUBAPI-SCOPE-U061: and the list route hands the same grant back', () => {
it('PUBAPI-SCOPE-U061: and the list route hands the same grant back', async () => {
const { user } = createUser(testDb);
const ctl = authController();
ctl.createApiToken({ id: user.id } as User, { name: 'Dawarich', scopes: ['bucket-list'] }, httpReq);
expect(ctl.listApiTokens({ id: user.id } as User)).toEqual({
await ctl.createApiToken({ id: user.id } as User, { name: 'Dawarich', scopes: ['bucket-list'] }, httpReq);
expect(await ctl.listApiTokens({ id: user.id } as User)).toEqual({
tokens: [expect.objectContaining({ name: 'Dawarich', scope_mode: 'limited', scopes: ['bucket-list'] })],
});
});
it('PUBAPI-SCOPE-U062: a key minted with no scopes field is a full grant, as it always was', () => {
it('PUBAPI-SCOPE-U062: a key minted with no scopes field is a full grant, as it always was', async () => {
const { user } = createUser(testDb);
const ctl = authController();
const created = ctl.createApiToken({ id: user.id } as User, { name: 'Legacy' }, httpReq).token as {
const created = (await ctl.createApiToken({ id: user.id } as User, { name: 'Legacy' }, httpReq)).token as {
id: number;
};
expect(rowFor(created.id)).toMatchObject({ scope_mode: 'all', api_scopes: null });
expect(ctl.listApiTokens({ id: user.id } as User)).toEqual({
expect(await ctl.listApiTokens({ id: user.id } as User)).toEqual({
tokens: [expect.objectContaining({ scope_mode: 'all', scopes: [...PUBLIC_API_SCOPES] })],
});
});
it('PUBAPI-SCOPE-U063: POST /api/auth/mcp-tokens ignores scopes in the body entirely', () => {
it('PUBAPI-SCOPE-U063: POST /api/auth/mcp-tokens ignores scopes in the body entirely', async () => {
const { user } = createUser(testDb);
const ctl = authController();
// The DTO is a different schema on purpose, so the field never survives the
// pipe — and even handed straight to the controller it changes nothing.
const created = ctl.createMcpToken(
const created = (await ctl.createMcpToken(
{ id: user.id } as User,
{ name: 'Assistant', scopes: ['trips'] } as { name: string },
httpReq,
).token as Record<string, unknown>;
)).token as Record<string, unknown>;
expect(rowFor(created.id as number)).toEqual({ kind: 'mcp', scope_mode: 'all', api_scopes: null });
expect(created).not.toHaveProperty('scope_mode');
expect(created).not.toHaveProperty('scopes');
expect(ctl.listMcpTokens({ id: user.id } as User).tokens[0]).not.toHaveProperty('scope_mode');
expect((await ctl.listMcpTokens({ id: user.id } as User)).tokens[0]).not.toHaveProperty('scope_mode');
});
it('PUBAPI-SCOPE-U064: the two create schemas differ, which is what keeps them apart', () => {
+18 -18
View File
@@ -37,9 +37,9 @@ function makeGuard(verify: (raw: string) => User | null) {
}
/** Run the guard, expecting a refusal; return its { status, body }. */
function refused(fn: () => boolean): { status: number; body: unknown } {
async function refused(fn: () => Promise<boolean>): Promise<{ status: number; body: unknown }> {
try {
fn();
await fn();
} catch (err) {
expect(err).toBeInstanceOf(HttpException);
const e = err as HttpException;
@@ -49,51 +49,51 @@ function refused(fn: () => boolean): { status: number; body: unknown } {
}
describe('ApiTokenGuard', () => {
it('accepts a trek_ token via Authorization: Bearer and resolves the user', () => {
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(makeGuard(verify).canActivate(ctx)).toBe(true);
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', () => {
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(makeGuard(verify).canActivate(ctx)).toBe(true);
expect(await makeGuard(verify).canActivate(ctx)).toBe(true);
expect(verify).toHaveBeenCalledWith('trek_abc123');
});
it('trims surrounding whitespace before looking a token up', () => {
it('trims surrounding whitespace before looking a token up', async () => {
const verify = vi.fn().mockReturnValue(USER);
makeGuard(verify).canActivate(contextWith({ 'x-api-key': ' trek_abc123 ' }));
await makeGuard(verify).canActivate(contextWith({ 'x-api-key': ' trek_abc123 ' }));
expect(verify).toHaveBeenCalledWith('trek_abc123');
});
it('401s without any credential', () => {
it('401s without any credential', async () => {
const verify = vi.fn();
expect(refused(() => makeGuard(verify).canActivate(contextWith({})))).toEqual({
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', () => {
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);
guard.canActivate(contextWith({ authorization: 'Bearer trek_abc123' }));
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', () => {
it('401s on a token the store does not know, without saying which part was wrong', async () => {
const verify = vi.fn().mockReturnValue(null);
expect(
refused(() => makeGuard(verify).canActivate(contextWith({ authorization: 'Bearer trek_nope' }))),
await refused(() => makeGuard(verify).canActivate(contextWith({ authorization: 'Bearer trek_nope' }))),
).toEqual({
status: 401,
body: { error: 'Invalid API token', code: 'API_TOKEN_INVALID' },
@@ -112,19 +112,19 @@ describe('ApiTokenGuard', () => {
['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', (_label, headers) => {
])('refuses %s without ever hitting the token store', async (_label, headers) => {
const verify = vi.fn();
expect(refused(() => makeGuard(verify).canActivate(contextWith(headers)))).toEqual({
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', () => {
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(refused(() => makeGuard(verify).canActivate(ctx)).status).toBe(401);
expect((await refused(() => makeGuard(verify).canActivate(ctx))).status).toBe(401);
expect(verify).not.toHaveBeenCalled();
});
});
+84 -84
View File
@@ -69,83 +69,83 @@ afterAll(() => {
// ---------------------------------------------------------------------------
describe('MCP token service', () => {
it('AUTH-DB-041: createMcpToken returns 400 when name is missing', () => {
it('AUTH-DB-041: createMcpToken returns 400 when name is missing', async () => {
const { user } = createUser(testDb);
const result = svc.createMcpToken(user.id, undefined);
const result = await svc.createMcpToken(user.id, undefined);
expect(result.status).toBe(400);
});
it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', () => {
it('AUTH-DB-042: createMcpToken returns 400 when name exceeds 100 chars', async () => {
const { user } = createUser(testDb);
const result = svc.createMcpToken(user.id, 'a'.repeat(101));
const result = await svc.createMcpToken(user.id, 'a'.repeat(101));
expect(result.status).toBe(400);
});
it('AUTH-DB-043: createMcpToken creates token and returns raw_token', () => {
it('AUTH-DB-043: createMcpToken creates token and returns raw_token', async () => {
const { user } = createUser(testDb);
const result = svc.createMcpToken(user.id, 'My Token');
const result = await svc.createMcpToken(user.id, 'My Token');
expect(result.token).toBeDefined();
expect((result.token as any).raw_token).toMatch(/^trek_/);
});
it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', () => {
it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', async () => {
const { user } = createUser(testDb);
for (let i = 0; i < 10; i++) {
testDb.prepare(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)'
).run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`);
}
const result = svc.createMcpToken(user.id, 'One More');
const result = await svc.createMcpToken(user.id, 'One More');
expect(result.status).toBe(400);
});
it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', () => {
it('AUTH-DB-045: deleteMcpToken returns 404 for non-existent token', async () => {
const { user } = createUser(testDb);
const result = svc.deleteMcpToken(user.id, '99999');
const result = await svc.deleteMcpToken(user.id, '99999');
expect(result.status).toBe(404);
});
it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', () => {
it('AUTH-DB-046: deleteMcpToken deletes the token and returns success', async () => {
const { user } = createUser(testDb);
const created = svc.createMcpToken(user.id, 'Deletable Token');
const created = await svc.createMcpToken(user.id, 'Deletable Token');
const tokenId = String((created.token as any).id);
const result = svc.deleteMcpToken(user.id, tokenId);
const result = await svc.deleteMcpToken(user.id, tokenId);
expect(result).toEqual({ success: true });
const row = testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId);
expect(row).toBeUndefined();
});
it('AUTH-DB-092: deleteMcpToken succeeds even when the session sweep throws (best-effort)', () => {
it('AUTH-DB-092: deleteMcpToken succeeds even when the session sweep throws (best-effort)', async () => {
const { user } = createUser(testDb);
const created = svc.createMcpToken(user.id, 'sweep-down');
const created = await svc.createMcpToken(user.id, 'sweep-down');
const tokenId = String((created.token as { id: number }).id);
vi.mocked(revokeUserSessions).mockImplementationOnce(() => { throw new Error('sweep down'); });
expect(svc.deleteMcpToken(user.id, tokenId)).toEqual({ success: true });
expect(await svc.deleteMcpToken(user.id, tokenId)).toEqual({ success: true });
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeUndefined();
});
it('TOKEN-001: listMcpTokens is scoped to the caller and never exposes the hash', () => {
it('TOKEN-001: listMcpTokens is scoped to the caller and never exposes the hash', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
svc.createMcpToken(user.id, 'mine');
svc.createMcpToken(other.id, 'theirs');
await svc.createMcpToken(user.id, 'mine');
await svc.createMcpToken(other.id, 'theirs');
const mine = svc.listMcpTokens(user.id) as Record<string, unknown>[];
const mine = await svc.listMcpTokens(user.id) as Record<string, unknown>[];
expect(mine).toHaveLength(1);
expect(mine[0].name).toBe('mine');
expect(mine[0]).not.toHaveProperty('token_hash');
});
it('TOKEN-002: deleteMcpToken refuses a token that belongs to someone else', () => {
it('TOKEN-002: deleteMcpToken refuses a token that belongs to someone else', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const created = svc.createMcpToken(other.id, 'not-yours');
const created = await svc.createMcpToken(other.id, 'not-yours');
const tokenId = String((created.token as { id: number }).id);
expect(svc.deleteMcpToken(user.id, tokenId)).toEqual({ error: 'Token not found', status: 404 });
expect(await svc.deleteMcpToken(user.id, tokenId)).toEqual({ error: 'Token not found', status: 404 });
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeDefined();
});
});
@@ -159,60 +159,60 @@ describe('MCP token service', () => {
// ---------------------------------------------------------------------------
describe('API key service', () => {
it('TOKEN-010: an API key does not verify as an MCP token', () => {
it('TOKEN-010: an API key does not verify as an MCP token', async () => {
const { user } = createUser(testDb);
const created = svc.createApiToken(user.id, 'dawarich');
const created = await svc.createApiToken(user.id, 'dawarich');
const raw = (created.token as { raw_token: string }).raw_token;
expect(svc.verifyApiToken(raw)?.id).toBe(user.id);
expect(svc.verifyMcpToken(raw)).toBeNull();
expect((await svc.verifyApiToken(raw))?.id).toBe(user.id);
expect(await svc.verifyMcpToken(raw)).toBeNull();
});
it('TOKEN-011: an MCP token does not verify as an API key', () => {
it('TOKEN-011: an MCP token does not verify as an API key', async () => {
const { user } = createUser(testDb);
const created = svc.createMcpToken(user.id, 'claude');
const created = await svc.createMcpToken(user.id, 'claude');
const raw = (created.token as { raw_token: string }).raw_token;
expect(svc.verifyMcpToken(raw)?.id).toBe(user.id);
expect(svc.verifyApiToken(raw)).toBeNull();
expect((await svc.verifyMcpToken(raw))?.id).toBe(user.id);
expect(await svc.verifyApiToken(raw)).toBeNull();
});
it('TOKEN-012: each list shows only its own kind', () => {
it('TOKEN-012: each list shows only its own kind', async () => {
const { user } = createUser(testDb);
svc.createMcpToken(user.id, 'claude');
svc.createApiToken(user.id, 'dawarich');
await svc.createMcpToken(user.id, 'claude');
await svc.createApiToken(user.id, 'dawarich');
const mcp = svc.listMcpTokens(user.id) as Record<string, unknown>[];
const api = svc.listApiTokens(user.id) as Record<string, unknown>[];
const mcp = await svc.listMcpTokens(user.id) as Record<string, unknown>[];
const api = await svc.listApiTokens(user.id) as Record<string, unknown>[];
expect(mcp.map((t) => t.name)).toEqual(['claude']);
expect(api.map((t) => t.name)).toEqual(['dawarich']);
});
it('TOKEN-013: deleting across kinds 404s, identically to an unknown id', () => {
it('TOKEN-013: deleting across kinds 404s, identically to an unknown id', async () => {
const { user } = createUser(testDb);
const mcpId = String((svc.createMcpToken(user.id, 'claude').token as { id: number }).id);
const apiId = String((svc.createApiToken(user.id, 'dawarich').token as { id: number }).id);
const mcpId = String((await svc.createMcpToken(user.id, 'claude')).token!.id as number);
const apiId = String((await svc.createApiToken(user.id, 'dawarich')).token!.id as number);
expect(svc.deleteApiToken(user.id, mcpId)).toEqual({ error: 'Token not found', status: 404 });
expect(svc.deleteMcpToken(user.id, apiId)).toEqual({ error: 'Token not found', status: 404 });
expect(await svc.deleteApiToken(user.id, mcpId)).toEqual({ error: 'Token not found', status: 404 });
expect(await svc.deleteMcpToken(user.id, apiId)).toEqual({ error: 'Token not found', status: 404 });
// Neither row was touched: a wrong-kind delete must not be a way to revoke
// someone's assistant access from the API-key screen.
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(mcpId)).toBeDefined();
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(apiId)).toBeDefined();
});
it('TOKEN-014: the ten-token ceiling counts each kind on its own', () => {
it('TOKEN-014: the ten-token ceiling counts each kind on its own', async () => {
const { user } = createUser(testDb);
for (let i = 0; i < 10; i += 1) svc.createMcpToken(user.id, `mcp-${i}`);
for (let i = 0; i < 10; i += 1) await svc.createMcpToken(user.id, `mcp-${i}`);
expect(svc.createMcpToken(user.id, 'one-too-many').status).toBe(400);
expect((await svc.createMcpToken(user.id, 'one-too-many')).status).toBe(400);
// A full MCP shelf must not lock the user out of minting an API key.
expect(svc.createApiToken(user.id, 'dawarich').status).toBeUndefined();
expect((await svc.createApiToken(user.id, 'dawarich')).status).toBeUndefined();
});
it('TOKEN-015: an API key is stored hashed, with only a prefix in the clear', () => {
it('TOKEN-015: an API key is stored hashed, with only a prefix in the clear', async () => {
const { user } = createUser(testDb);
const created = svc.createApiToken(user.id, 'dawarich');
const created = await svc.createApiToken(user.id, 'dawarich');
const raw = (created.token as { raw_token: string }).raw_token;
const row = testDb
@@ -223,15 +223,15 @@ describe('API key service', () => {
expect(raw.startsWith(row.token_prefix)).toBe(true);
});
it('TOKEN-016: verifying an API key records last_used_at, so a stale key is visible', () => {
it('TOKEN-016: verifying an API key records last_used_at, so a stale key is visible', async () => {
const { user } = createUser(testDb);
const raw = (svc.createApiToken(user.id, 'dawarich').token as { raw_token: string }).raw_token;
const raw = (await svc.createApiToken(user.id, 'dawarich')).token!.raw_token as string;
const before = svc.listApiTokens(user.id) as Record<string, unknown>[];
const before = await svc.listApiTokens(user.id) as Record<string, unknown>[];
expect(before[0].last_used_at).toBeNull();
svc.verifyApiToken(raw);
const after = svc.listApiTokens(user.id) as Record<string, unknown>[];
await svc.verifyApiToken(raw);
const after = await svc.listApiTokens(user.id) as Record<string, unknown>[];
expect(after[0].last_used_at).not.toBeNull();
});
});
@@ -244,42 +244,42 @@ describe('API key service', () => {
// ---------------------------------------------------------------------------
describe('MCP token service (admin view)', () => {
it('ADMIN-SVC-068 — listAllMcpTokens returns empty array initially', () => {
const result = svc.listAllMcpTokens() as any[];
it('ADMIN-SVC-068 — listAllMcpTokens returns empty array initially', async () => {
const result = await svc.listAllMcpTokens() as any[];
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(0);
});
it('ADMIN-SVC-069 — adminDeleteMcpToken returns 404 for non-existent token', () => {
const result = svc.adminDeleteMcpToken('99999') as any;
it('ADMIN-SVC-069 — adminDeleteMcpToken returns 404 for non-existent token', async () => {
const result = await svc.adminDeleteMcpToken('99999') as any;
expect(result.status).toBe(404);
expect(result.error).toBeDefined();
});
it('TOKEN-003: listAllMcpTokens spans users and carries the owner username', () => {
it('TOKEN-003: listAllMcpTokens spans users and carries the owner username', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
svc.createMcpToken(user.id, 'a');
svc.createMcpToken(other.id, 'b');
await svc.createMcpToken(user.id, 'a');
await svc.createMcpToken(other.id, 'b');
const all = svc.listAllMcpTokens() as Record<string, unknown>[];
const all = await svc.listAllMcpTokens() as Record<string, unknown>[];
expect(all).toHaveLength(2);
expect(all.every(t => typeof t.username === 'string')).toBe(true);
expect(all.some(t => t.token_hash !== undefined)).toBe(false);
});
it('TOKEN-004: adminDeleteMcpToken removes any user token and revokes that user', () => {
it('TOKEN-004: adminDeleteMcpToken removes any user token and revokes that user', async () => {
const { user } = createUser(testDb);
const created = svc.createMcpToken(user.id, 'admin-killed');
const created = await svc.createMcpToken(user.id, 'admin-killed');
const tokenId = String((created.token as { id: number }).id);
expect(svc.adminDeleteMcpToken(tokenId)).toEqual({});
expect(await svc.adminDeleteMcpToken(tokenId)).toEqual({});
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE id = ?').get(tokenId)).toBeUndefined();
expect(revokeUserSessions).toHaveBeenCalledWith(user.id);
});
it('TOKEN-005: adminDeleteMcpToken 404s on an unknown id without revoking anyone', () => {
expect(svc.adminDeleteMcpToken('99999')).toEqual({ error: 'Token not found', status: 404 });
it('TOKEN-005: adminDeleteMcpToken 404s on an unknown id without revoking anyone', async () => {
expect(await svc.adminDeleteMcpToken('99999')).toEqual({ error: 'Token not found', status: 404 });
expect(revokeUserSessions).not.toHaveBeenCalled();
});
});
@@ -297,31 +297,31 @@ describe('ephemeral tokens', () => {
expect(svc.createResourceToken(user.id, 'download')).toEqual({ token: 'tok-1' });
});
it('AUTH-DB-087: createWsToken returns the ephemeral token when the store answers', () => {
it('AUTH-DB-087: createWsToken returns the ephemeral token when the store answers', async () => {
const { user } = createUser(testDb);
vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok');
expect(svc.createWsToken(user.id)).toEqual({ token: 'ws-tok' });
expect(await svc.createWsToken(user.id)).toEqual({ token: 'ws-tok' });
});
it('TOKEN-006: createWsToken binds the caller password_version, so a pre-reset token is rejected on connect', () => {
it('TOKEN-006: createWsToken binds the caller password_version, so a pre-reset token is rejected on connect', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET password_version = 7 WHERE id = ?').run(user.id);
vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok');
svc.createWsToken(user.id);
await svc.createWsToken(user.id);
expect(createEphemeralToken).toHaveBeenCalledWith(user.id, 'ws', { pv: 7 });
});
it('TOKEN-007: createWsToken falls back to pv 0 for a user row without one', () => {
it('TOKEN-007: createWsToken falls back to pv 0 for a user row without one', async () => {
vi.mocked(createEphemeralToken).mockReturnValueOnce('ws-tok');
svc.createWsToken(99999);
await svc.createWsToken(99999);
expect(createEphemeralToken).toHaveBeenCalledWith(99999, 'ws', { pv: 0 });
});
it('TOKEN-008: createWsToken reports 503 when the store refuses', () => {
it('TOKEN-008: createWsToken reports 503 when the store refuses', async () => {
const { user } = createUser(testDb);
vi.mocked(createEphemeralToken).mockReturnValueOnce(null as unknown as string);
expect(svc.createWsToken(user.id)).toEqual({ error: 'Service unavailable', status: 503 });
expect(await svc.createWsToken(user.id)).toEqual({ error: 'Service unavailable', status: 503 });
});
});
@@ -330,36 +330,36 @@ describe('ephemeral tokens', () => {
// ---------------------------------------------------------------------------
describe('verifyMcpToken', () => {
it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', () => {
it('AUTH-BR-002: verifyMcpToken resolves a freshly created token to its user', async () => {
const { user } = createUser(testDb);
const created = svc.createMcpToken(user.id, 'bridge-case');
const created = await svc.createMcpToken(user.id, 'bridge-case');
const raw = (created.token as { raw_token: string }).raw_token;
const resolved = svc.verifyMcpToken(raw);
const resolved = await svc.verifyMcpToken(raw);
expect(resolved?.id).toBe(user.id);
expect(svc.verifyMcpToken('trek_no_such_token')).toBeNull();
expect(await svc.verifyMcpToken('trek_no_such_token')).toBeNull();
});
it('TOKEN-009: a successful verify stamps last_used_at, a failed one changes nothing', () => {
it('TOKEN-009: a successful verify stamps last_used_at, a failed one changes nothing', async () => {
const { user } = createUser(testDb);
const created = svc.createMcpToken(user.id, 'stamped');
const created = await svc.createMcpToken(user.id, 'stamped');
const raw = (created.token as { raw_token: string }).raw_token;
const id = (created.token as { id: number }).id;
expect((testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(id) as { last_used_at: string | null }).last_used_at).toBeNull();
svc.verifyMcpToken(raw);
await svc.verifyMcpToken(raw);
expect((testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(id) as { last_used_at: string | null }).last_used_at).not.toBeNull();
svc.verifyMcpToken('trek_wrong');
await svc.verifyMcpToken('trek_wrong');
expect(testDb.prepare('SELECT COUNT(*) c FROM mcp_tokens').get()).toEqual({ c: 1 });
});
it('TOKEN-010: verifyMcpToken returns identity columns only, never the password hash', () => {
it('TOKEN-010: verifyMcpToken returns identity columns only, never the password hash', async () => {
const { user } = createUser(testDb);
const created = svc.createMcpToken(user.id, 'lean');
const created = await svc.createMcpToken(user.id, 'lean');
const raw = (created.token as { raw_token: string }).raw_token;
const resolved = svc.verifyMcpToken(raw) as unknown as Record<string, unknown>;
const resolved = await svc.verifyMcpToken(raw) as unknown as Record<string, unknown>;
expect(Object.keys(resolved).sort()).toEqual(['email', 'id', 'role', 'username']);
});
});
+4 -3
View File
@@ -42,7 +42,8 @@ import { EphemeralTokenService } from '../../src/nest/auth/ephemeral-token.servi
// instance mints tokens the app under test accepts (TokenService is a leaf —
// its own unit suite constructs it the same way).
const tokenService = new TokenService(new DatabaseService(testDb), new EphemeralTokenService());
const createWsToken = tokenService.createWsToken.bind(tokenService);
const createWsToken = (...args: Parameters<typeof tokenService.createWsToken>) =>
tokenService.createWsToken(...args);
let server: http.Server;
let wsUrl: string;
@@ -423,7 +424,7 @@ describe('WS auth edge cases', () => {
it('WS-027 — ws-token minted before a password change is rejected (session gate)', async () => {
// createWsToken stamps the user's current password_version (0) into the token.
const { user } = createUser(testDb);
const result = createWsToken(user.id);
const result = await createWsToken(user.id);
const token = result.token!;
// Simulate a password reset bumping the version AFTER the token was issued.
@@ -441,7 +442,7 @@ describe('WS auth edge cases', () => {
const { user } = createUser(testDb);
// Bump the version first, THEN mint — the token captures the current pv.
testDb.prepare('UPDATE users SET password_version = 3 WHERE id = ?').run(user.id);
const result = createWsToken(user.id);
const result = await createWsToken(user.id);
const client = await connectWs(result.token!);
try {
const msg = await client.next();