diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 175f988..e02030b 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -18,6 +18,7 @@ import { ProfileModule } from './modules/profile/profile.module'; import { PrismaModule } from './database/prisma.module'; import { IncidentsModule } from './modules/incidents/incidents.module'; import { InvitationsModule } from './modules/invitations/invitations.module'; +import { ApiKeysModule } from './modules/api-keys/api-keys.module'; @Module({ imports: [ @@ -38,6 +39,7 @@ import { InvitationsModule } from './modules/invitations/invitations.module'; IncidentsModule, ProtocolHealthModule, InvitationsModule, + ApiKeysModule, ], controllers: [AppController], }) diff --git a/apps/backend/src/modules/api-keys/api-keys.controller.ts b/apps/backend/src/modules/api-keys/api-keys.controller.ts index 38a922f..5c9b29c 100644 --- a/apps/backend/src/modules/api-keys/api-keys.controller.ts +++ b/apps/backend/src/modules/api-keys/api-keys.controller.ts @@ -1,41 +1,54 @@ -import { Controller, Get, Post, Delete, Body, Param, HttpCode } from '@nestjs/common'; +import { Controller, Get, Post, Delete, Body, Param, HttpCode, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { AuthenticatedUser } from '../../common/interfaces/authenticated-user.interface'; import { ApiKeysService } from './api-keys.service'; import { CreateApiKeyDto, ApiKeyResponseDto, ApiKeyUsageDto } from './dto/api-key.dto'; -// NOTE: In production, add @UseGuards(AuthGuard) and extract userId from request -// For now, userId is passed as a path parameter for demo purposes - +@ApiTags('api-keys') +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) @Controller('api-keys') export class ApiKeysController { constructor(private readonly apiKeysService: ApiKeysService) {} - @Post(':userId') + @Post() + @ApiOperation({ summary: 'Create a new API key for the authenticated user' }) + @ApiResponse({ status: 201, type: ApiKeyResponseDto }) async createApiKey( - @Param('userId') userId: string, + @CurrentUser() user: AuthenticatedUser, @Body() dto: CreateApiKeyDto, ): Promise { - return this.apiKeysService.createApiKey(userId, dto); + return this.apiKeysService.createApiKey(user.userId, dto); } - @Get(':userId') - async getApiKeys(@Param('userId') userId: string): Promise { - return this.apiKeysService.getApiKeys(userId); + @Get() + @ApiOperation({ summary: "List the authenticated user's active API keys" }) + @ApiResponse({ status: 200, type: [ApiKeyResponseDto] }) + async getApiKeys(@CurrentUser() user: AuthenticatedUser): Promise { + return this.apiKeysService.getApiKeys(user.userId); } - @Delete(':userId/:keyId') + @Delete(':keyId') @HttpCode(204) + @ApiOperation({ summary: "Revoke one of the authenticated user's API keys" }) async revokeApiKey( - @Param('userId') userId: string, + @CurrentUser() user: AuthenticatedUser, @Param('keyId') keyId: string, ): Promise { - return this.apiKeysService.revokeApiKey(userId, keyId); + return this.apiKeysService.revokeApiKey(user.userId, keyId); } - @Get(':userId/:keyId/usage') + @Get(':keyId/usage') + @ApiOperation({ + summary: "Get usage metadata for one of the authenticated user's API keys", + }) + @ApiResponse({ status: 200, type: ApiKeyUsageDto }) async getApiKeyUsage( - @Param('userId') userId: string, + @CurrentUser() user: AuthenticatedUser, @Param('keyId') keyId: string, ): Promise { - return this.apiKeysService.getApiKeyUsage(userId, keyId); + return this.apiKeysService.getApiKeyUsage(user.userId, keyId); } } diff --git a/apps/backend/src/modules/api-keys/api-keys.module.ts b/apps/backend/src/modules/api-keys/api-keys.module.ts index 89e8402..99cfaa9 100644 --- a/apps/backend/src/modules/api-keys/api-keys.module.ts +++ b/apps/backend/src/modules/api-keys/api-keys.module.ts @@ -1,8 +1,14 @@ import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; import { ApiKeysController } from './api-keys.controller'; import { ApiKeysService } from './api-keys.service'; @Module({ + imports: [ + JwtModule.register({ + secret: process.env.JWT_SECRET, + }), + ], controllers: [ApiKeysController], providers: [ApiKeysService], exports: [ApiKeysService], diff --git a/apps/backend/src/modules/api-keys/api-keys.service.ts b/apps/backend/src/modules/api-keys/api-keys.service.ts index b2bb366..8d82f8f 100644 --- a/apps/backend/src/modules/api-keys/api-keys.service.ts +++ b/apps/backend/src/modules/api-keys/api-keys.service.ts @@ -1,15 +1,11 @@ -import { Injectable, NotFoundException, ConflictException } from '@nestjs/common'; -import { PrismaClient } from '@prisma/client'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { randomBytes, createHash } from 'crypto'; +import { PrismaService } from '../../database/prisma.service'; import { CreateApiKeyDto, ApiKeyResponseDto, ApiKeyUsageDto } from './dto/api-key.dto'; @Injectable() export class ApiKeysService { - private prisma: PrismaClient; - - constructor() { - this.prisma = new PrismaClient(); - } + constructor(private readonly prisma: PrismaService) {} private generateKey(): string { return `sk_${randomBytes(32).toString('hex')}`; @@ -72,16 +68,18 @@ export class ApiKeysService { } async revokeApiKey(userId: string, keyId: string): Promise { - const apiKey = await this.prisma.apiKey.findUnique({ - where: { id: keyId }, + // Scope the lookup to the caller's own keys so a key ID belonging to + // another user reports as not-found rather than leaking its existence. + const apiKey = await this.prisma.apiKey.findFirst({ + where: { id: keyId, userId }, }); if (!apiKey) { - throw new NotFoundException(`API key not found`); + throw new NotFoundException('API key not found'); } - if (apiKey.userId !== userId) { - throw new ConflictException(`Unauthorized access to API key`); + if (apiKey.revokedAt) { + return; } await this.prisma.apiKey.update({ @@ -90,6 +88,10 @@ export class ApiKeysService { }); } + /** + * Validate a presented key and record usage. Returns the owning userId, or + * null if the key is unknown or has been revoked. + */ async validateAndUpdateUsage(key: string): Promise { const keyHash = this.hashKey(key); @@ -110,12 +112,12 @@ export class ApiKeysService { } async getApiKeyUsage(userId: string, keyId: string): Promise { - const apiKey = await this.prisma.apiKey.findUnique({ - where: { id: keyId }, + const apiKey = await this.prisma.apiKey.findFirst({ + where: { id: keyId, userId }, }); - if (!apiKey || apiKey.userId !== userId) { - throw new NotFoundException(`API key not found`); + if (!apiKey) { + throw new NotFoundException('API key not found'); } // Placeholder for actual usage tracking diff --git a/apps/backend/src/modules/api-keys/dto/api-key.dto.ts b/apps/backend/src/modules/api-keys/dto/api-key.dto.ts index 8ad24d8..7bc0695 100644 --- a/apps/backend/src/modules/api-keys/dto/api-key.dto.ts +++ b/apps/backend/src/modules/api-keys/dto/api-key.dto.ts @@ -1,22 +1,56 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, MaxLength, MinLength } from 'class-validator'; + export class CreateApiKeyDto { + @ApiProperty({ description: 'Human-readable label for the key', example: 'CI deploy key' }) + @IsString() + @MinLength(1) + @MaxLength(100) name!: string; } export class ApiKeyResponseDto { + @ApiProperty() id!: string; + + @ApiProperty() name!: string; - key?: string; // Only returned on creation - keyPreview!: string; // Last 4 chars + + @ApiProperty({ + required: false, + description: 'The plaintext key — only ever present in the create response.', + }) + key?: string; + + @ApiProperty({ description: 'Last 4 characters of the key, for identification in listings.' }) + keyPreview!: string; + + @ApiProperty() createdAt!: Date; + + @ApiProperty({ nullable: true }) lastUsedAt!: Date | null; + + @ApiProperty({ nullable: true }) revokedAt!: Date | null; } export class ApiKeyUsageDto { + @ApiProperty() id!: string; + + @ApiProperty() name!: string; + + @ApiProperty() keyPreview!: string; + + @ApiProperty() createdAt!: Date; + + @ApiProperty({ nullable: true }) lastUsedAt!: Date | null; + + @ApiProperty() requestCount!: number; } diff --git a/apps/backend/src/modules/api-keys/tests/api-keys.controller.spec.ts b/apps/backend/src/modules/api-keys/tests/api-keys.controller.spec.ts new file mode 100644 index 0000000..cd570ee --- /dev/null +++ b/apps/backend/src/modules/api-keys/tests/api-keys.controller.spec.ts @@ -0,0 +1,61 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ApiKeysController } from '../api-keys.controller'; +import { ApiKeysService } from '../api-keys.service'; +import { AuthenticatedUser } from '../../../common/interfaces/authenticated-user.interface'; +import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard'; +import { Role } from '../../../common/enums/role.enum'; + +describe('ApiKeysController', () => { + let controller: ApiKeysController; + let service: jest.Mocked; + + const currentUser: AuthenticatedUser = { + userId: 'user-1', + email: 'user1@example.com', + roles: [Role.ANALYST], + }; + + beforeEach(async () => { + const mockService: Partial> = { + createApiKey: jest.fn(), + getApiKeys: jest.fn(), + revokeApiKey: jest.fn(), + getApiKeyUsage: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ApiKeysController], + providers: [{ provide: ApiKeysService, useValue: mockService }], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ canActivate: () => true }) + .compile(); + + controller = module.get(ApiKeysController); + service = module.get(ApiKeysService); + }); + + it('creates a key scoped to the authenticated user, ignoring any client-supplied id', async () => { + await controller.createApiKey(currentUser, { name: 'CI key' }); + + expect(service.createApiKey).toHaveBeenCalledWith('user-1', { name: 'CI key' }); + }); + + it("lists only the authenticated user's keys", async () => { + await controller.getApiKeys(currentUser); + + expect(service.getApiKeys).toHaveBeenCalledWith('user-1'); + }); + + it('revokes a key scoped to the authenticated user', async () => { + await controller.revokeApiKey(currentUser, 'key-1'); + + expect(service.revokeApiKey).toHaveBeenCalledWith('user-1', 'key-1'); + }); + + it('reads usage scoped to the authenticated user', async () => { + await controller.getApiKeyUsage(currentUser, 'key-1'); + + expect(service.getApiKeyUsage).toHaveBeenCalledWith('user-1', 'key-1'); + }); +}); diff --git a/apps/backend/src/modules/api-keys/tests/api-keys.service.spec.ts b/apps/backend/src/modules/api-keys/tests/api-keys.service.spec.ts new file mode 100644 index 0000000..ef0c378 --- /dev/null +++ b/apps/backend/src/modules/api-keys/tests/api-keys.service.spec.ts @@ -0,0 +1,210 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from '../../../database/prisma.service'; +import { ApiKeysService } from '../api-keys.service'; + +describe('ApiKeysService', () => { + let service: ApiKeysService; + + const mockPrisma = { + apiKey: { + create: jest.fn(), + findMany: jest.fn(), + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ApiKeysService, { provide: PrismaService, useValue: mockPrisma }], + }).compile(); + + service = module.get(ApiKeysService); + }); + + describe('createApiKey', () => { + it('generates a unique, sk_-prefixed key and stores only its hash', async () => { + mockPrisma.apiKey.create.mockImplementation(({ data }) => + Promise.resolve({ + id: 'key-1', + name: data.name, + keyHash: data.keyHash, + createdAt: new Date('2026-01-01'), + lastUsedAt: null, + revokedAt: null, + }), + ); + + const result = await service.createApiKey('user-1', { name: 'CI key' }); + + expect(result.key).toMatch(/^sk_[0-9a-f]{64}$/); + expect(mockPrisma.apiKey.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + userId: 'user-1', + name: 'CI key', + }), + }); + + const createCall = mockPrisma.apiKey.create.mock.calls[0][0]; + expect(createCall.data.key).not.toBe(result.key); + expect(createCall.data.keyHash).not.toBe(result.key); + expect(result.keyPreview).toBe(result.key.slice(-4)); + }); + + it('produces a different key on every call', async () => { + mockPrisma.apiKey.create.mockImplementation(({ data }) => + Promise.resolve({ + id: 'key-x', + name: data.name, + keyHash: data.keyHash, + createdAt: new Date(), + lastUsedAt: null, + revokedAt: null, + }), + ); + + const a = await service.createApiKey('user-1', { name: 'a' }); + const b = await service.createApiKey('user-1', { name: 'b' }); + + expect(a.key).not.toBe(b.key); + }); + }); + + describe('getApiKeys', () => { + it("only returns the given user's non-revoked keys, without the raw key", async () => { + mockPrisma.apiKey.findMany.mockResolvedValue([ + { + id: 'key-1', + name: 'k1', + keyHash: 'a'.repeat(60) + 'beef', + createdAt: new Date(), + lastUsedAt: null, + revokedAt: null, + }, + ]); + + const result = await service.getApiKeys('user-1'); + + expect(mockPrisma.apiKey.findMany).toHaveBeenCalledWith({ + where: { userId: 'user-1', revokedAt: null }, + orderBy: { createdAt: 'desc' }, + }); + expect(result[0]).not.toHaveProperty('key'); + expect(result[0].keyPreview).toBe('beef'); + }); + }); + + describe('revokeApiKey', () => { + it('revokes a key owned by the caller', async () => { + mockPrisma.apiKey.findFirst.mockResolvedValue({ + id: 'key-1', + userId: 'user-1', + revokedAt: null, + }); + + await service.revokeApiKey('user-1', 'key-1'); + + expect(mockPrisma.apiKey.findFirst).toHaveBeenCalledWith({ + where: { id: 'key-1', userId: 'user-1' }, + }); + expect(mockPrisma.apiKey.update).toHaveBeenCalledWith({ + where: { id: 'key-1' }, + data: { revokedAt: expect.any(Date) }, + }); + }); + + it('throws NotFoundException for a key owned by another user, without leaking existence', async () => { + // Ownership is enforced in the WHERE clause, so a key that exists but + // belongs to someone else is indistinguishable from a missing key. + mockPrisma.apiKey.findFirst.mockResolvedValue(null); + + await expect(service.revokeApiKey('user-1', 'someone-elses-key')).rejects.toThrow( + NotFoundException, + ); + expect(mockPrisma.apiKey.update).not.toHaveBeenCalled(); + }); + + it('is idempotent for an already-revoked key', async () => { + mockPrisma.apiKey.findFirst.mockResolvedValue({ + id: 'key-1', + userId: 'user-1', + revokedAt: new Date('2026-01-01'), + }); + + await service.revokeApiKey('user-1', 'key-1'); + + expect(mockPrisma.apiKey.update).not.toHaveBeenCalled(); + }); + }); + + describe('validateAndUpdateUsage', () => { + it('returns the owning userId and bumps lastUsedAt for a valid key', async () => { + mockPrisma.apiKey.findUnique.mockResolvedValue({ + id: 'key-1', + userId: 'user-1', + revokedAt: null, + }); + + const userId = await service.validateAndUpdateUsage('sk_raw-key-value'); + + expect(userId).toBe('user-1'); + expect(mockPrisma.apiKey.update).toHaveBeenCalledWith({ + where: { id: 'key-1' }, + data: { lastUsedAt: expect.any(Date) }, + }); + }); + + it('returns null for an unknown key', async () => { + mockPrisma.apiKey.findUnique.mockResolvedValue(null); + + const userId = await service.validateAndUpdateUsage('sk_unknown'); + + expect(userId).toBeNull(); + expect(mockPrisma.apiKey.update).not.toHaveBeenCalled(); + }); + + it('returns null for a revoked key', async () => { + mockPrisma.apiKey.findUnique.mockResolvedValue({ + id: 'key-1', + userId: 'user-1', + revokedAt: new Date(), + }); + + const userId = await service.validateAndUpdateUsage('sk_revoked'); + + expect(userId).toBeNull(); + expect(mockPrisma.apiKey.update).not.toHaveBeenCalled(); + }); + }); + + describe('getApiKeyUsage', () => { + it('throws NotFoundException when the key belongs to another user', async () => { + mockPrisma.apiKey.findFirst.mockResolvedValue(null); + + await expect(service.getApiKeyUsage('user-1', 'key-owned-by-user-2')).rejects.toThrow( + NotFoundException, + ); + }); + + it('returns usage metadata for a key the caller owns', async () => { + mockPrisma.apiKey.findFirst.mockResolvedValue({ + id: 'key-1', + userId: 'user-1', + name: 'k1', + keyHash: 'a'.repeat(60) + 'beef', + createdAt: new Date(), + lastUsedAt: null, + }); + + const result = await service.getApiKeyUsage('user-1', 'key-1'); + + expect(mockPrisma.apiKey.findFirst).toHaveBeenCalledWith({ + where: { id: 'key-1', userId: 'user-1' }, + }); + expect(result.keyPreview).toBe('beef'); + }); + }); +});