UNPKG

alepha

Version:

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

510 lines (509 loc) 15.2 kB
import { $inject, $module, Alepha, z } from "alepha"; import { $secure } from "alepha/security"; import { $action, ForbiddenError, okSchema } from "alepha/server"; import { $entity, $repository, db, pageQuerySchema, sql } from "alepha/orm"; import { createHash, randomBytes } from "node:crypto"; import { $cache } from "alepha/cache"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; //#region ../../src/api/keys/schemas/adminApiKeyQuerySchema.ts const adminApiKeyQuerySchema = pageQuerySchema.extend({ userId: z.uuid().optional(), includeRevoked: z.boolean().optional() }); //#endregion //#region ../../src/api/keys/schemas/adminApiKeyResourceSchema.ts const adminApiKeyResourceSchema = z.object({ id: z.uuid(), userId: z.uuid(), name: z.string(), description: z.string().optional(), tokenPrefix: z.string(), tokenSuffix: z.string(), roles: z.array(z.string()), createdAt: z.datetime(), lastUsedAt: z.datetime().optional(), lastUsedIp: z.string().optional(), expiresAt: z.datetime().optional(), revokedAt: z.datetime().optional(), usageCount: z.integer() }); //#endregion //#region ../../src/api/keys/entities/apiKeyEntity.ts const apiKeyEntity = $entity({ name: "api_keys", schema: z.object({ id: db.primaryKey(z.uuid()), createdAt: db.createdAt(), updatedAt: db.updatedAt(), organizationId: db.organization(), userId: z.uuid(), name: z.text({ maxLength: 100 }), description: z.text({ maxLength: 500 }).optional(), tokenHash: z.string().max(256), tokenPrefix: z.string().max(10), tokenSuffix: z.string().max(8), roles: db.default(z.array(z.string()), []), lastUsedAt: z.datetime().optional(), lastUsedIp: z.string().max(45).optional(), usageCount: db.default(z.integer(), 0), expiresAt: z.datetime().optional(), revokedAt: z.datetime().optional() }), indexes: [{ columns: ["userId", "name"], unique: true }, { columns: ["tokenHash"], unique: true }] }); //#endregion //#region ../../src/api/keys/services/ApiKeyService.ts var ApiKeyService = class { alepha = $inject(Alepha); dateTimeProvider = $inject(DateTimeProvider); log = $logger(); 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: ... })`. */ validationCache = $cache({ provider: "memory", name: "api:keys:validation", ttl: [15, "minutes"] }); /** * 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") */ createResolver(options = {}) { const { priority = 50, prefix = "ak" } = options; const prefixPattern = `${prefix}_`; return { priority, onRequest: async (req) => { let token = (typeof req.url === "string" ? new URL(req.url) : req.url).searchParams.get("api_key"); 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); } }; } /** * Create a new API key for a user. * Returns both the API key entity and the plain token (which is only available once). */ async create(options) { const prefix = options.prefix ?? "ak"; const token = `${prefix}_${randomBytes(24).toString("base64url")}`; 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. */ async list(userId) { return this.repo.findMany({ where: { userId: { eq: userId }, revokedAt: { isNull: true } }, orderBy: { column: "createdAt", direction: "desc" } }); } /** * Find all API keys with optional filtering (admin only). */ async findAll(query) { 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). */ async getById(id) { return await this.repo.getById(id); } /** * Revoke any API key (admin only). */ async revokeByAdmin(id) { const apiKey = await this.repo.getById(id); if (apiKey.revokedAt) return; 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. */ async revokeManyByAdmin(ids) { if (ids.length === 0) return []; const toRevoke = (await this.repo.findMany({ where: { id: { inArray: ids } }, columns: [ "id", "tokenHash", "revokedAt" ] })).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); } /** * Revoke an API key. Only the owner can revoke their own keys. */ async revoke(id, userId) { 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 }); } /** * Validate an API key token and return user info if valid. */ async validate(token) { if (!token.includes("_")) return null; const hash = this.hashToken(token); let apiKey = await this.validationCache.get(hash); if (apiKey === void 0) { apiKey = await this.repo.findOne({ where: { tokenHash: { eq: hash } } }) ?? null; await this.validationCache.set(hash, apiKey); } if (!apiKey) return null; if (apiKey.revokedAt) return null; if (apiKey.expiresAt && this.dateTimeProvider.now().isAfter(apiKey.expiresAt)) return null; 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. */ async updateUsage(id) { 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. */ hashToken(token) { return createHash("sha256").update(token).digest("hex"); } }; //#endregion //#region ../../src/api/keys/controllers/AdminApiKeyController.ts /** * REST API controller for admin API key management. * Admins can list, view, and revoke any API key. */ var AdminApiKeyController = class { url = "/admin/api-keys"; group = "admin:api-keys"; apiKeyService = $inject(ApiKeyService); /** * Find all API keys with optional filtering. */ findApiKeys = $action({ path: this.url, group: this.group, use: [$secure({ permissions: ["admin:api-key:read"] })], description: "Find API keys with pagination and filtering", schema: { query: adminApiKeyQuerySchema, response: z.page(adminApiKeyResourceSchema) }, handler: ({ query }) => { const { userId, includeRevoked, ...pagination } = query; return this.apiKeyService.findAll({ userId, includeRevoked, ...pagination }); } }); /** * Get an API key by ID. */ getApiKey = $action({ path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:api-key:read"] })], description: "Get an API key by ID", schema: { params: z.object({ id: z.uuid() }), response: adminApiKeyResourceSchema }, handler: ({ params }) => this.apiKeyService.getById(params.id) }); /** * Revoke any API key. */ revokeApiKey = $action({ method: "DELETE", path: `${this.url}/:id`, group: this.group, use: [$secure({ permissions: ["admin:api-key:delete"] })], description: "Revoke an API key", schema: { params: z.object({ id: z.uuid() }), response: okSchema }, handler: async ({ params }) => { await this.apiKeyService.revokeByAdmin(params.id); return { ok: true, id: params.id }; } }); /** * Revoke many API keys in one request. */ revokeApiKeys = $action({ method: "POST", path: `${this.url}/revoke`, group: this.group, use: [$secure({ permissions: ["admin:api-key:delete"] })], description: "Revoke many API keys", schema: { body: z.object({ ids: z.array(z.uuid()).min(1).max(1e3) }), response: z.object({ revoked: z.array(z.uuid()) }) }, handler: async ({ body }) => { return { revoked: await this.apiKeyService.revokeManyByAdmin(body.ids) }; } }); }; //#endregion //#region ../../src/api/keys/schemas/createApiKeyBodySchema.ts const createApiKeyBodySchema = z.object({ name: z.text({ minLength: 1, maxLength: 100 }), description: z.text({ maxLength: 500 }).optional(), expiresAt: z.datetime().optional() }); //#endregion //#region ../../src/api/keys/schemas/createApiKeyResponseSchema.ts const createApiKeyResponseSchema = z.object({ id: z.uuid(), name: z.string(), token: z.string(), tokenSuffix: z.string(), roles: z.array(z.string()), createdAt: z.datetime(), expiresAt: z.datetime().optional() }); //#endregion //#region ../../src/api/keys/schemas/listApiKeyResponseSchema.ts const listApiKeyItemSchema = z.object({ id: z.uuid(), name: z.string(), tokenPrefix: z.string(), tokenSuffix: z.string(), roles: z.array(z.string()), createdAt: z.datetime(), lastUsedAt: z.datetime().optional(), lastUsedIp: z.string().optional(), expiresAt: z.datetime().optional(), usageCount: z.integer() }); const listApiKeyResponseSchema = z.array(listApiKeyItemSchema); //#endregion //#region ../../src/api/keys/schemas/revokeApiKeyParamsSchema.ts const revokeApiKeyParamsSchema = z.object({ id: z.uuid() }); //#endregion //#region ../../src/api/keys/schemas/revokeApiKeyResponseSchema.ts const revokeApiKeyResponseSchema = z.object({ ok: z.boolean() }); //#endregion //#region ../../src/api/keys/controllers/ApiKeyController.ts /** * REST API controller for user's own API key management. * Users can create, list, and revoke their own API keys. */ var ApiKeyController = class { url = "/api-keys"; group = "api-keys"; apiKeyService = $inject(ApiKeyService); /** * Create a new API key for the authenticated user. * The token is only returned once upon creation. */ createApiKey = $action({ method: "POST", path: this.url, group: this.group, description: "Create a new API key", use: [$secure({ permissions: ["api-key:create"] })], schema: { body: createApiKeyBodySchema, response: createApiKeyResponseSchema }, handler: async (request) => { const { apiKey, token } = await this.apiKeyService.create({ userId: request.user.id, name: request.body.name, description: request.body.description, roles: request.user.roles ?? [], expiresAt: request.body.expiresAt ? new Date(request.body.expiresAt) : void 0 }); return { id: apiKey.id, name: apiKey.name, token, tokenSuffix: apiKey.tokenSuffix, roles: apiKey.roles, createdAt: apiKey.createdAt, expiresAt: apiKey.expiresAt }; } }); /** * List all active API keys for the authenticated user. * Does not return the actual tokens. */ listApiKeys = $action({ path: this.url, group: this.group, description: "List your API keys", use: [$secure({ permissions: ["api-key:read"] })], schema: { response: listApiKeyResponseSchema }, handler: async (request) => { return (await this.apiKeyService.list(request.user.id)).map((apiKey) => ({ id: apiKey.id, name: apiKey.name, tokenPrefix: apiKey.tokenPrefix, tokenSuffix: apiKey.tokenSuffix, roles: apiKey.roles, createdAt: apiKey.createdAt, lastUsedAt: apiKey.lastUsedAt, lastUsedIp: apiKey.lastUsedIp, expiresAt: apiKey.expiresAt, usageCount: apiKey.usageCount })); } }); /** * Revoke an API key. Only the owner can revoke their own keys. */ revokeMyApiKey = $action({ method: "DELETE", path: `${this.url}/:id`, group: this.group, description: "Revoke an API key", use: [$secure({ permissions: ["api-key:delete"] })], schema: { params: revokeApiKeyParamsSchema, response: revokeApiKeyResponseSchema }, handler: async (request) => { await this.apiKeyService.revoke(request.params.id, request.user.id); return { ok: true }; } }); }; //#endregion //#region ../../src/api/keys/index.ts /** * API key management module for programmatic access. * * **Features:** * - Create API keys with role snapshots * - List and revoke API keys * - 15-minute validation caching * - Query param (?api_key=) and Bearer header support * * **Integration:** * To enable API key authentication for an issuer, register the resolver: * * ```ts * class MyApp { * apiKeyService = $inject(ApiKeyService); * issuer = $issuer({ * secret: env.APP_SECRET, * resolvers: [this.apiKeyService.createResolver()], * }); * } * ``` * * @module alepha.api.keys */ const AlephaApiKeys = $module({ name: "alepha.api.keys", services: [ ApiKeyService, ApiKeyController, AdminApiKeyController ] }); //#endregion export { AdminApiKeyController, AlephaApiKeys, ApiKeyController, ApiKeyService, adminApiKeyQuerySchema, adminApiKeyResourceSchema, apiKeyEntity, createApiKeyBodySchema, createApiKeyResponseSchema, listApiKeyItemSchema, listApiKeyResponseSchema, revokeApiKeyParamsSchema, revokeApiKeyResponseSchema }; //# sourceMappingURL=index.js.map