diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6b2b374..86a7b13 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -86,8 +86,8 @@ model ApiKey { id String @id @default(cuid()) userId String name String - key String @unique keyHash String @unique + expiresAt DateTime? lastUsedAt DateTime? revokedAt DateTime? createdAt DateTime @default(now()) @@ -95,6 +95,8 @@ model ApiKey { user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@index([userId]) + @@index([expiresAt]) + @@index([revokedAt]) @@map("api_keys") } diff --git a/src/api-keys/api-keys.controller.ts b/src/api-keys/api-keys.controller.ts new file mode 100644 index 0000000..e05f2d4 --- /dev/null +++ b/src/api-keys/api-keys.controller.ts @@ -0,0 +1,33 @@ +import { Body, Controller, Delete, Get, Param, Post, Req, UseGuards } from '@nestjs/common'; + +import { CreateApiKeyDto } from './dto/create-api-key.dto'; +import { ApiKeysService } from './api-keys.service'; + +@Controller('api-keys') +@UseGuards() +export class ApiKeysController { + constructor(private readonly apiKeysService: ApiKeysService) {} + + @Post() + async create(@Req() request: any, @Body() dto: CreateApiKeyDto) { + const userId = request.user?.id ?? request.user?.userId; + + const expiresAt = dto.expiresAt ? new Date(dto.expiresAt) : null; + + return this.apiKeysService.create(userId, dto.name, expiresAt); + } + + @Get() + async findAll(@Req() request: any) { + const userId = request.user?.id ?? request.user?.userId; + + return this.apiKeysService.findAll(userId); + } + + @Delete(':id') + async revoke(@Req() request: any, @Param('id') id: string) { + const userId = request.user?.id ?? request.user?.userId; + + return this.apiKeysService.revoke(userId, id); + } +} diff --git a/src/api-keys/api-keys.module.ts b/src/api-keys/api-keys.module.ts new file mode 100644 index 0000000..5eabf9c --- /dev/null +++ b/src/api-keys/api-keys.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; + +import { ApiKeysController } from './api-keys.controller'; +import { ApiKeysService } from './api-keys.service'; + +@Module({ + controllers: [ApiKeysController], + providers: [ApiKeysService], + exports: [ApiKeysService], +}) +export class ApiKeysModule {} diff --git a/src/api-keys/api-keys.service.spec.ts b/src/api-keys/api-keys.service.spec.ts new file mode 100644 index 0000000..94bdd48 --- /dev/null +++ b/src/api-keys/api-keys.service.spec.ts @@ -0,0 +1,166 @@ +import { ConflictException, NotFoundException, UnauthorizedException } from '@nestjs/common'; + +import { ApiKeysService } from './api-keys.service'; + +describe('ApiKeysService', () => { + let service: ApiKeysService; + + const prisma = { + apiKey: { + create: jest.fn(), + findUnique: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + service = new ApiKeysService(prisma as any); + }); + + describe('create', () => { + it('creates an API key with an expiration date', async () => { + const expiresAt = new Date(Date.now() + 86_400_000); + + prisma.apiKey.create.mockResolvedValue({ + id: 'key-1', + name: 'Production', + expiresAt, + createdAt: new Date(), + }); + + const result = await service.create('user-1', 'Production', expiresAt); + + expect(prisma.apiKey.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + userId: 'user-1', + name: 'Production', + expiresAt, + keyHash: expect.any(String), + }), + }), + ); + + expect(result.key).toMatch(/^sk_live_/); + expect(result.expiresAt).toEqual(expiresAt); + }); + + it('rejects an expired creation date', async () => { + const expiresAt = new Date(Date.now() - 1_000); + + await expect(service.create('user-1', 'Expired', expiresAt)).rejects.toBeInstanceOf( + ConflictException, + ); + + expect(prisma.apiKey.create).not.toHaveBeenCalled(); + }); + }); + + describe('validate', () => { + it('rejects an unknown API key', async () => { + prisma.apiKey.findUnique.mockResolvedValue(null); + + await expect(service.validate('sk_live_invalid')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('rejects a revoked API key', async () => { + prisma.apiKey.findUnique.mockResolvedValue({ + id: 'key-1', + revokedAt: new Date(), + expiresAt: null, + user: { + id: 'user-1', + }, + }); + + await expect(service.validate('sk_live_revoked')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('rejects an expired API key', async () => { + prisma.apiKey.findUnique.mockResolvedValue({ + id: 'key-1', + revokedAt: null, + expiresAt: new Date(Date.now() - 1_000), + user: { + id: 'user-1', + }, + }); + + await expect(service.validate('sk_live_expired')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it('updates lastUsedAt for a valid API key', async () => { + prisma.apiKey.findUnique.mockResolvedValue({ + id: 'key-1', + revokedAt: null, + expiresAt: null, + user: { + id: 'user-1', + email: 'user@example.com', + }, + }); + + const user = await service.validate('sk_live_valid'); + + expect(user.id).toBe('user-1'); + expect(prisma.apiKey.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'key-1', + }, + data: { + lastUsedAt: expect.any(Date), + }, + }), + ); + }); + }); + + describe('revoke', () => { + it('rejects an API key owned by another user', async () => { + prisma.apiKey.findFirst.mockResolvedValue(null); + + await expect(service.revoke('user-1', 'key-1')).rejects.toBeInstanceOf(NotFoundException); + }); + + it('revokes an owned API key', async () => { + prisma.apiKey.findFirst.mockResolvedValue({ + id: 'key-1', + revokedAt: null, + }); + + prisma.apiKey.update.mockResolvedValue({ + id: 'key-1', + name: 'Production', + expiresAt: null, + lastUsedAt: null, + revokedAt: new Date(), + createdAt: new Date(), + }); + + const result = await service.revoke('user-1', 'key-1'); + + expect(prisma.apiKey.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + id: 'key-1', + }, + data: { + revokedAt: expect.any(Date), + }, + }), + ); + + expect(result.id).toBe('key-1'); + }); + }); +}); diff --git a/src/api-keys/api-keys.service.ts b/src/api-keys/api-keys.service.ts new file mode 100644 index 0000000..0e23fad --- /dev/null +++ b/src/api-keys/api-keys.service.ts @@ -0,0 +1,171 @@ +import { + ConflictException, + Injectable, + NotFoundException, + UnauthorizedException, +} from '@nestjs/common'; +import { createHash, randomBytes } from 'node:crypto'; + +import { PrismaService } from '../prisma/prisma.service'; + +export interface CreateApiKeyResult { + id: string; + name: string; + key: string; + expiresAt: Date | null; + createdAt: Date; +} + +@Injectable() +export class ApiKeysService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Creates a new API key. + * + * The plaintext key is returned exactly once and is never stored. + */ + async create(userId: string, name: string, expiresAt?: Date | null): Promise { + const normalizedName = name.trim(); + + if (!normalizedName) { + throw new ConflictException('API key name is required'); + } + + if (expiresAt && expiresAt.getTime() <= Date.now()) { + throw new ConflictException('API key expiration date must be in the future'); + } + + const plaintextKey = this.generateApiKey(); + const keyHash = this.hashApiKey(plaintextKey); + + const apiKey = await this.prisma.apiKey.create({ + data: { + userId, + name: normalizedName, + keyHash, + expiresAt: expiresAt ?? null, + }, + }); + + return { + id: apiKey.id, + name: apiKey.name, + key: plaintextKey, + expiresAt: apiKey.expiresAt, + createdAt: apiKey.createdAt, + }; + } + + /** + * Validates an API key and returns its owning user. + */ + async validate(key: string) { + if (!key?.trim()) { + throw new UnauthorizedException('API key is required'); + } + + const keyHash = this.hashApiKey(key); + + const apiKey = await this.prisma.apiKey.findUnique({ + where: { + keyHash, + }, + include: { + user: true, + }, + }); + + if (!apiKey) { + throw new UnauthorizedException('Invalid API key'); + } + + if (apiKey.revokedAt) { + throw new UnauthorizedException('API key has been revoked'); + } + + if (apiKey.expiresAt && apiKey.expiresAt.getTime() <= Date.now()) { + throw new UnauthorizedException('API key has expired'); + } + + await this.prisma.apiKey.update({ + where: { + id: apiKey.id, + }, + data: { + lastUsedAt: new Date(), + }, + }); + + return apiKey.user; + } + + /** + * Revokes an API key without deleting its audit/history record. + */ + async revoke(userId: string, apiKeyId: string) { + const apiKey = await this.prisma.apiKey.findFirst({ + where: { + id: apiKeyId, + userId, + }, + }); + + if (!apiKey) { + throw new NotFoundException('API key not found'); + } + + if (apiKey.revokedAt) { + return apiKey; + } + + return this.prisma.apiKey.update({ + where: { + id: apiKey.id, + }, + data: { + revokedAt: new Date(), + }, + select: { + id: true, + name: true, + expiresAt: true, + lastUsedAt: true, + revokedAt: true, + createdAt: true, + }, + }); + } + + /** + * Lists API keys belonging to a user. + * + * Never returns keyHash. + */ + async findAll(userId: string) { + return this.prisma.apiKey.findMany({ + where: { + userId, + }, + select: { + id: true, + name: true, + expiresAt: true, + lastUsedAt: true, + revokedAt: true, + createdAt: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + private generateApiKey(): string { + return `sk_live_${randomBytes(32).toString('hex')}`; + } + + private hashApiKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); + } +} diff --git a/src/api-keys/dto/create-api-key.dto.ts b/src/api-keys/dto/create-api-key.dto.ts new file mode 100644 index 0000000..6b4dd9d --- /dev/null +++ b/src/api-keys/dto/create-api-key.dto.ts @@ -0,0 +1,11 @@ +import { IsISO8601, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateApiKeyDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsOptional() + @IsISO8601() + expiresAt?: string; +} diff --git a/src/api-keys/guards/api-key.guard.ts b/src/api-keys/guards/api-key.guard.ts new file mode 100644 index 0000000..a133003 --- /dev/null +++ b/src/api-keys/guards/api-key.guard.ts @@ -0,0 +1,35 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Request } from 'express'; + +import { ApiKeysService } from '../api-keys.service'; + +@Injectable() +export class ApiKeyGuard implements CanActivate { + constructor(private readonly apiKeysService: ApiKeysService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + + const apiKey = this.extractApiKey(request); + + if (!apiKey) { + throw new UnauthorizedException('API key is required'); + } + + const user = await this.apiKeysService.validate(apiKey); + + request.user = user; + + return true; + } + + private extractApiKey(request: Request): string | null { + const header = request.headers['x-api-key']; + + if (Array.isArray(header)) { + return header[0] ?? null; + } + + return header ?? null; + } +}