UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

328 lines (276 loc) 9.53 kB
import { createHash, randomBytes } from "node:crypto"; import { $inject, Alepha } from "alepha"; import { $cache } from "alepha/cache"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { $repository, sql } from "alepha/orm"; import type { IssuerResolver, UserInfo } from "alepha/security"; import { ForbiddenError, type ServerRequest } from "alepha/server"; import { type ApiKeyEntity, apiKeyEntity } from "../entities/apiKeyEntity.ts"; export class ApiKeyService { protected readonly alepha = $inject(Alepha); protected readonly dateTimeProvider = $inject(DateTimeProvider); protected readonly log = $logger(); protected readonly repo = $repository(apiKeyEntity); /** * Cache validated API keys for 15 minutes. * * Pinned to per-isolate memory: * - The cache replaces a single indexed SELECT on `api_keys`. Routing it * through a distributed K/V (KV/Redis) buys little — the SELECT is * already cheap — and pinning to DB would actively trade one SQL read * for another. * - Cold-start gives a fresh DB read on every new isolate, which is * *better* for revocation visibility than a distributed cache that * keeps serving stale entries until its own TTL. * - Avoids provisioning KV/Redis just for this one cache. Users who need * cross-isolate sharing for high-throughput API auth can override * globally via `alepha.with({ provide: CacheProvider, use: ... })`. */ protected readonly validationCache = $cache<ApiKeyEntity | null, [string]>({ provider: "memory", name: "api:keys:validation", ttl: [15, "minutes"], }); // ------------------------------------------------------------------------- // Resolver // ------------------------------------------------------------------------- /** * Create an issuer resolver for API key authentication. * Lower priority means it runs before JWT resolver. * * @param options.priority - Priority of this resolver (default: 50, JWT is 100) * @param options.prefix - API key prefix to match in Bearer header (default: "ak") */ public createResolver( options: { priority?: number; prefix?: string } = {}, ): IssuerResolver { const { priority = 50, prefix = "ak" } = options; const prefixPattern = `${prefix}_`; return { priority, onRequest: async (req: ServerRequest) => { // Try query param first const url = typeof req.url === "string" ? new URL(req.url) : req.url; let token = url.searchParams.get("api_key"); // Try Bearer header - only if token starts with expected prefix if (!token) { const auth = req.headers.authorization; if (auth?.startsWith("Bearer ")) { const bearerToken = auth.slice(7); if (bearerToken.startsWith(prefixPattern)) { token = bearerToken; } } } if (!token) { return null; } return this.validate(token); }, }; } // ------------------------------------------------------------------------- // CRUD // ------------------------------------------------------------------------- /** * Create a new API key for a user. * Returns both the API key entity and the plain token (which is only available once). */ public async create(options: { userId: string; name: string; roles: string[]; description?: string; expiresAt?: Date; prefix?: string; }): Promise<{ apiKey: ApiKeyEntity; token: string }> { const prefix = options.prefix ?? "ak"; const random = randomBytes(24).toString("base64url"); const token = `${prefix}_${random}`; const hash = this.hashToken(token); const suffix = token.slice(-8); const apiKey = await this.repo.create({ userId: options.userId, name: options.name, description: options.description, tokenHash: hash, tokenPrefix: prefix, tokenSuffix: suffix, roles: options.roles, expiresAt: options.expiresAt?.toISOString(), }); this.log.info("API key created", { apiKeyId: apiKey.id, userId: options.userId, name: options.name, }); return { apiKey, token }; } /** * List all non-revoked API keys for a user. */ public async list(userId: string): Promise<ApiKeyEntity[]> { return this.repo.findMany({ where: { userId: { eq: userId }, revokedAt: { isNull: true }, }, orderBy: { column: "createdAt", direction: "desc" }, }); } // ------------------------------------------------------------------------- // Admin Operations // ------------------------------------------------------------------------- /** * Find all API keys with optional filtering (admin only). */ public async findAll(query: { userId?: string; includeRevoked?: boolean; page?: number; size?: number; sort?: string; }) { query.sort ??= "-createdAt"; const where = this.repo.createQueryWhere(); if (query.userId) { where.userId = { eq: query.userId }; } if (!query.includeRevoked) { where.revokedAt = { isNull: true }; } return this.repo.paginate(query, { where }, { count: true }); } /** * Get an API key by ID (admin only). */ public async getById(id: string): Promise<ApiKeyEntity> { return await this.repo.getById(id); } /** * Revoke any API key (admin only). */ public async revokeByAdmin(id: string): Promise<void> { const apiKey = await this.repo.getById(id); if (apiKey.revokedAt) { return; // Already revoked } // Invalidate cache await this.validationCache.invalidate(apiKey.tokenHash); await this.repo.updateById(id, { revokedAt: this.dateTimeProvider.now().toISOString(), }); this.log.info("API key revoked by admin", { apiKeyId: id, userId: apiKey.userId, }); } /** * Revoke many API keys in one repository call (admin only). Already-revoked * keys are silently skipped. Returns the ids that were actually revoked. */ public async revokeManyByAdmin(ids: string[]): Promise<string[]> { if (ids.length === 0) return []; const keys = await this.repo.findMany({ where: { id: { inArray: ids } }, columns: ["id", "tokenHash", "revokedAt"], }); const toRevoke = keys.filter((k) => !k.revokedAt); if (toRevoke.length === 0) return []; await Promise.all( toRevoke.map((k) => this.validationCache.invalidate(k.tokenHash)), ); await this.repo.updateMany( { id: { inArray: toRevoke.map((k) => k.id) } }, { revokedAt: this.dateTimeProvider.now().toISOString(), }, ); this.log.info("API keys revoked by admin", { count: toRevoke.length }); return toRevoke.map((k) => k.id); } // ------------------------------------------------------------------------- // User Operations // ------------------------------------------------------------------------- /** * Revoke an API key. Only the owner can revoke their own keys. */ public async revoke(id: string, userId: string): Promise<void> { const apiKey = await this.repo.getById(id); if (apiKey.userId !== userId) { throw new ForbiddenError("Not your API key"); } await this.validationCache.invalidate(apiKey.tokenHash); await this.repo.updateById(id, { revokedAt: this.dateTimeProvider.now().toISOString(), }); this.log.info("API key revoked", { apiKeyId: id, userId, }); } // ------------------------------------------------------------------------- // Validation // ------------------------------------------------------------------------- /** * Validate an API key token and return user info if valid. */ public async validate(token: string): Promise<UserInfo | null> { // Quick check for API key format if (!token.includes("_")) { return null; } const hash = this.hashToken(token); // Try cache first let apiKey = await this.validationCache.get(hash); // If not in cache, look up in database if (apiKey === undefined) { apiKey = (await this.repo.findOne({ where: { tokenHash: { eq: hash } }, })) ?? null; // Store in cache (even if null, to prevent repeated lookups) await this.validationCache.set(hash, apiKey); } if (!apiKey) { return null; } // Check revocation if (apiKey.revokedAt) { return null; } // Check expiration if ( apiKey.expiresAt && this.dateTimeProvider.now().isAfter(apiKey.expiresAt) ) { return null; } // Update usage stats (fire and forget) this.updateUsage(apiKey.id).catch((error) => { this.log.warn("Failed to update API key usage", { error }); }); return { id: apiKey.userId, roles: apiKey.roles, }; } /** * Update usage statistics for an API key. */ protected async updateUsage(id: string): Promise<void> { const request = this.alepha.store.get("alepha.http.request"); await this.repo.updateById(id, { lastUsedAt: this.dateTimeProvider.now().toISOString(), lastUsedIp: request?.ip, usageCount: sql`${this.repo.table.usageCount} + 1`, }); } /** * Hash a token using SHA-256. */ protected hashToken(token: string): string { return createHash("sha256").update(token).digest("hex"); } }