/** * ApiKeys Service * * Manages API key lifecycle: * - Generation (GOLD/PLATINIUM subscribers only) * - Listing (masked — prefix only) * - Revocation * - Validation for inbound API key authentication */ import { ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; import * as crypto from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import { ApiKey } from '@domain/entities/api-key.entity'; import { ApiKeyRepository, API_KEY_REPOSITORY } from '@domain/ports/out/api-key.repository'; import { UserRepository, USER_REPOSITORY } from '@domain/ports/out/user.repository'; import { SubscriptionRepository, SUBSCRIPTION_REPOSITORY, } from '@domain/ports/out/subscription.repository'; import { CreateApiKeyDto, ApiKeyDto, CreateApiKeyResultDto } from '../dto/api-key.dto'; /** Shape of request.user populated when an API key is used. */ export interface ApiKeyUserContext { id: string; email: string; role: string; organizationId: string; firstName: string; lastName: string; plan: string; planFeatures: string[]; } const KEY_PREFIX_DISPLAY_LENGTH = 18; // "xped_live_" (10) + 8 hex chars @Injectable() export class ApiKeysService { private readonly logger = new Logger(ApiKeysService.name); constructor( @Inject(API_KEY_REPOSITORY) private readonly apiKeyRepository: ApiKeyRepository, @Inject(USER_REPOSITORY) private readonly userRepository: UserRepository, @Inject(SUBSCRIPTION_REPOSITORY) private readonly subscriptionRepository: SubscriptionRepository ) {} /** * Generate a new API key for the given user / organisation. * The full raw key is returned exactly once — it is never persisted. */ async generateApiKey( userId: string, organizationId: string, dto: CreateApiKeyDto ): Promise { await this.assertApiAccessPlan(organizationId); const rawKey = this.buildRawKey(); const keyHash = this.hashKey(rawKey); const keyPrefix = rawKey.substring(0, KEY_PREFIX_DISPLAY_LENGTH); const apiKey = ApiKey.create({ id: uuidv4(), organizationId, userId, name: dto.name, keyHash, keyPrefix, expiresAt: dto.expiresAt ? new Date(dto.expiresAt) : null, }); const saved = await this.apiKeyRepository.save(apiKey); this.logger.log(`API key created: ${saved.id} for org ${organizationId}`); return { id: saved.id, name: saved.name, keyPrefix: saved.keyPrefix, isActive: saved.isActive, lastUsedAt: saved.lastUsedAt, expiresAt: saved.expiresAt, createdAt: saved.createdAt, fullKey: rawKey, }; } /** * List all API keys for an organisation. Never exposes key hashes. */ async listApiKeys(organizationId: string): Promise { const keys = await this.apiKeyRepository.findByOrganizationId(organizationId); return keys.map(k => this.toDto(k)); } /** * Revoke (deactivate) an API key. */ async revokeApiKey(keyId: string, organizationId: string): Promise { const key = await this.apiKeyRepository.findById(keyId); if (!key || key.organizationId !== organizationId) { throw new NotFoundException('Clé API introuvable'); } const revoked = key.revoke(); await this.apiKeyRepository.save(revoked); this.logger.log(`API key revoked: ${keyId} for org ${organizationId}`); } /** * Validate an inbound raw API key and return the user context. * Returns null if the key is invalid, expired, or the plan is insufficient. * Also asynchronously updates lastUsedAt. */ async validateAndGetUser(rawKey: string): Promise { if (!rawKey?.startsWith('xped_live_')) return null; const keyHash = this.hashKey(rawKey); const apiKey = await this.apiKeyRepository.findByKeyHash(keyHash); if (!apiKey || !apiKey.isValid()) return null; // Real-time plan check — in case the org downgraded after key creation const subscription = await this.subscriptionRepository.findByOrganizationId( apiKey.organizationId ); if (!subscription || !subscription.hasFeature('api_access')) { this.logger.warn( `API key used but org ${apiKey.organizationId} no longer has api_access feature` ); return null; } // Update lastUsedAt asynchronously — don't block the request this.apiKeyRepository .save(apiKey.recordUsage()) .catch(err => this.logger.warn(`Failed to update lastUsedAt for key ${apiKey.id}: ${err}`)); const user = await this.userRepository.findById(apiKey.userId); if (!user || !user.isActive) return null; return { id: user.id, email: user.email, role: user.role, organizationId: user.organizationId, firstName: user.firstName, lastName: user.lastName, plan: subscription.plan.value, planFeatures: [...subscription.plan.planFeatures], }; } // ── Helpers ───────────────────────────────────────────────────────────── private async assertApiAccessPlan(organizationId: string): Promise { const subscription = await this.subscriptionRepository.findByOrganizationId(organizationId); if (!subscription || !subscription.hasFeature('api_access')) { throw new ForbiddenException( "L'accès API nécessite un abonnement Gold ou Platinium. Mettez à niveau votre abonnement pour générer des clés API." ); } } /** Format: xped_live_<64 random hex chars> */ private buildRawKey(): string { return `xped_live_${crypto.randomBytes(32).toString('hex')}`; } private hashKey(rawKey: string): string { return crypto.createHash('sha256').update(rawKey).digest('hex'); } private toDto(apiKey: ApiKey): ApiKeyDto { return { id: apiKey.id, name: apiKey.name, keyPrefix: apiKey.keyPrefix, isActive: apiKey.isActive, lastUsedAt: apiKey.lastUsedAt, expiresAt: apiKey.expiresAt, createdAt: apiKey.createdAt, }; } }