xpeditis2.0/apps/backend/src/application/api-keys/api-keys.service.ts
David ec0173483a
All checks were successful
Dev CI / Backend — Lint (push) Successful in 10m23s
Dev CI / Backend — Unit Tests (push) Successful in 10m17s
Dev CI / Frontend — Lint & Type-check (push) Successful in 11m3s
Dev CI / Frontend — Unit Tests (push) Successful in 10m33s
Dev CI / Notify Failure (push) Has been skipped
fix language
2026-04-21 18:04:02 +02:00

195 lines
6.0 KiB
TypeScript

/**
* 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<CreateApiKeyResultDto> {
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<ApiKeyDto[]> {
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<void> {
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<ApiKeyUserContext | null> {
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<void> {
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,
};
}
}