alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
510 lines (509 loc) • 15.2 kB
JavaScript
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