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
195 lines
6.0 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|