|
| 1 | +import { |
| 2 | + Inject, |
| 3 | + Injectable, |
| 4 | + InternalServerErrorException, |
| 5 | + NotFoundException, |
| 6 | + Logger, |
| 7 | +} from '@nestjs/common'; |
| 8 | +import { DRIZZLE_TOKEN } from '../database/database.module'; |
| 9 | +import { type NodePgDatabase } from 'drizzle-orm/node-postgres'; |
| 10 | +import * as schema from '../database/schema'; |
| 11 | +import { apiKeys, type ApiKey, type ApiKeyPermissions } from '../database/schema/api-keys'; |
| 12 | +import { eq, and, desc, sql } from 'drizzle-orm'; |
| 13 | +import * as crypto from 'crypto'; |
| 14 | +import * as bcrypt from 'bcryptjs'; |
| 15 | +import type { CreateApiKeyDto, ListApiKeysQueryDto, UpdateApiKeyDto } from './dto/api-key.dto'; |
| 16 | +import type { AuthContext } from '../auth/types'; |
| 17 | + |
| 18 | +const KEY_PREFIX = 'sk_live_'; |
| 19 | + |
| 20 | +@Injectable() |
| 21 | +export class ApiKeysService { |
| 22 | + private readonly logger = new Logger(ApiKeysService.name); |
| 23 | + |
| 24 | + constructor( |
| 25 | + @Inject(DRIZZLE_TOKEN) |
| 26 | + private readonly db: NodePgDatabase<typeof schema>, |
| 27 | + ) {} |
| 28 | + |
| 29 | + async create(auth: AuthContext, dto: CreateApiKeyDto) { |
| 30 | + if (!auth.organizationId) { |
| 31 | + throw new InternalServerErrorException('Organization ID missing in context'); |
| 32 | + } |
| 33 | + |
| 34 | + const { key: plainKey, id: keyId } = this.generateKeyWithId(); |
| 35 | + const keyHash = await bcrypt.hash(plainKey, 10); |
| 36 | + |
| 37 | + const [apiKey] = await this.db |
| 38 | + .insert(apiKeys) |
| 39 | + .values({ |
| 40 | + name: dto.name, |
| 41 | + description: dto.description, |
| 42 | + keyHash, |
| 43 | + keyPrefix: KEY_PREFIX, |
| 44 | + keyHint: keyId, |
| 45 | + permissions: dto.permissions, |
| 46 | + organizationId: dto.organizationId ?? auth.organizationId, |
| 47 | + createdBy: auth.userId || 'system', |
| 48 | + expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, |
| 49 | + rateLimit: dto.rateLimit, |
| 50 | + isActive: true, |
| 51 | + }) |
| 52 | + .returning(); |
| 53 | + |
| 54 | + return { apiKey, plainKey }; |
| 55 | + } |
| 56 | + |
| 57 | + async list(auth: AuthContext, query: ListApiKeysQueryDto) { |
| 58 | + if (!auth.organizationId) { |
| 59 | + return []; |
| 60 | + } |
| 61 | + |
| 62 | + const conditions = [eq(apiKeys.organizationId, auth.organizationId)]; |
| 63 | + |
| 64 | + if (query.isActive !== undefined) { |
| 65 | + conditions.push(eq(apiKeys.isActive, query.isActive)); |
| 66 | + } |
| 67 | + |
| 68 | + return this.db |
| 69 | + .select() |
| 70 | + .from(apiKeys) |
| 71 | + .where(and(...conditions)) |
| 72 | + .orderBy(desc(apiKeys.createdAt)) |
| 73 | + .limit(query.limit) |
| 74 | + .offset(query.offset); |
| 75 | + } |
| 76 | + |
| 77 | + async get(auth: AuthContext, id: string) { |
| 78 | + if (!auth.organizationId) { |
| 79 | + throw new NotFoundException('API key not found'); |
| 80 | + } |
| 81 | + |
| 82 | + const [apiKey] = await this.db |
| 83 | + .select() |
| 84 | + .from(apiKeys) |
| 85 | + .where(and(eq(apiKeys.id, id), eq(apiKeys.organizationId, auth.organizationId))); |
| 86 | + |
| 87 | + if (!apiKey) { |
| 88 | + throw new NotFoundException('API key not found'); |
| 89 | + } |
| 90 | + |
| 91 | + return apiKey; |
| 92 | + } |
| 93 | + |
| 94 | + async update(auth: AuthContext, id: string, dto: UpdateApiKeyDto) { |
| 95 | + if (!auth.organizationId) { |
| 96 | + throw new NotFoundException('API key not found'); |
| 97 | + } |
| 98 | + |
| 99 | + const [apiKey] = await this.db |
| 100 | + .update(apiKeys) |
| 101 | + .set({ |
| 102 | + ...dto, |
| 103 | + updatedAt: new Date(), |
| 104 | + }) |
| 105 | + .where(and(eq(apiKeys.id, id), eq(apiKeys.organizationId, auth.organizationId))) |
| 106 | + .returning(); |
| 107 | + |
| 108 | + if (!apiKey) { |
| 109 | + throw new NotFoundException('API key not found'); |
| 110 | + } |
| 111 | + |
| 112 | + return apiKey; |
| 113 | + } |
| 114 | + |
| 115 | + async delete(auth: AuthContext, id: string) { |
| 116 | + if (!auth.organizationId) { |
| 117 | + throw new NotFoundException('API key not found'); |
| 118 | + } |
| 119 | + |
| 120 | + const result = await this.db |
| 121 | + .delete(apiKeys) |
| 122 | + .where(and(eq(apiKeys.id, id), eq(apiKeys.organizationId, auth.organizationId))); |
| 123 | + |
| 124 | + if (result.rowCount === 0) { |
| 125 | + throw new NotFoundException('API key not found'); |
| 126 | + } |
| 127 | + } |
| 128 | + |
| 129 | + async validateKey(plainKey: string): Promise<ApiKey | null> { |
| 130 | + // Basic format check |
| 131 | + if (!plainKey.startsWith(KEY_PREFIX)) { |
| 132 | + return null; |
| 133 | + } |
| 134 | + |
| 135 | + const parts = plainKey.split('_'); |
| 136 | + // Expected format: sk_live_<8-char-id>_<secret> |
| 137 | + if (parts.length !== 4) return null; |
| 138 | + |
| 139 | + const [sk, env, id, secret] = parts; |
| 140 | + if (sk !== 'sk' || env !== 'live') return null; |
| 141 | + |
| 142 | + // Look up by keyHint (which stores the ID part of the key) |
| 143 | + // This allows us to find the specific key record without scanning all keys |
| 144 | + const candidates = await this.db |
| 145 | + .select() |
| 146 | + .from(apiKeys) |
| 147 | + .where(and(eq(apiKeys.keyHint, id), eq(apiKeys.isActive, true))); |
| 148 | + |
| 149 | + for (const key of candidates) { |
| 150 | + const match = await bcrypt.compare(plainKey, key.keyHash); |
| 151 | + if (match) { |
| 152 | + // Check expiration |
| 153 | + if (key.expiresAt && key.expiresAt < new Date()) { |
| 154 | + return null; |
| 155 | + } |
| 156 | + |
| 157 | + // Update stats (async, don't await) |
| 158 | + this.updateUsage(key.id); |
| 159 | + |
| 160 | + return key; |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + return null; |
| 165 | + } |
| 166 | + |
| 167 | + private async updateUsage(id: string) { |
| 168 | + try { |
| 169 | + await this.db |
| 170 | + .update(apiKeys) |
| 171 | + .set({ |
| 172 | + lastUsedAt: new Date(), |
| 173 | + usageCount: sql`${apiKeys.usageCount} + 1`, |
| 174 | + }) |
| 175 | + .where(eq(apiKeys.id, id)); |
| 176 | + } catch (e) { |
| 177 | + this.logger.error(`Failed to update usage for key ${id}`, e); |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + // Adjusted generation to match the lookup strategy |
| 182 | + private generateKeyWithId(): { key: string; id: string } { |
| 183 | + const id = crypto.randomBytes(6).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 8); |
| 184 | + const secret = crypto.randomBytes(24).toString('base64').replace(/[^a-zA-Z0-9]/g, '').substring(0, 32); |
| 185 | + const key = `${KEY_PREFIX}${id}_${secret}`; |
| 186 | + return { key, id }; |
| 187 | + } |
| 188 | + |
| 189 | + |
| 190 | +} |
0 commit comments