feat(web): add API-key authentication for the REST API

- api_keys table + hashed key store (src/data/api-keys.ts), tsmb_-prefixed
  plaintext shown once, per-user cap of 20, lastUsedAt tracking
- requireAuth accepts Authorization: Bearer / X-API-Key headers as an
  alternative to the session cookie; key inherits the owner user's
  role/capabilities/bot scope
- csrf origin check skipped for key-only requests (no ambient credentials);
  requests that also carry the session cookie stay gated
- /api/keys management endpoints (session-only, guests excluded, keys
  themselves rejected) with audit logging
- user deletion / password reset cascade-revoke the user's keys
- Settings page: API key management section (create/copy-once/revoke)
- docs: README section + full endpoint reference in docs/API.md
This commit is contained in:
senlinjun committed 2026-09-29 21:51:43 +08:00
1 parent 2ea02f54d9
commit aab8a004ae
16 files changed
+1272 -7

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { createHash } from "node:crypto";
import { createDatabase, type BotDatabase } from "./database.js";
import { createUserStore, type UserStore } from "./users.js";
import {
createApiKeyStore,
type ApiKeyStore,
MAX_API_KEYS_PER_USER,
API_KEY_TOUCH_INTERVAL_MS,
} from "./api-keys.js";
function sha256(key: string) {
return createHash("sha256").update(key).digest("hex");
}
describe("ApiKeyStore", () => {
let botDb: BotDatabase;
let users: UserStore;
let keys: ApiKeyStore;
let userId: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
users = createUserStore(botDb.db);
keys = createApiKeyStore(botDb.db);
const u = await users.createUser("alice", "pw-alice", "admin");
userId = u.id;
});
afterEach(() => {
vi.useRealTimers();
botDb.close();
});
it("create returns a tsmb_-prefixed raw key whose sha256 is stored, never the plaintext", () => {
const created = keys.create(userId, "ci");
expect(created).not.toBeNull();
expect(created!.rawKey).toMatch(/^tsmb_[A-Za-z0-9_-]{40,}$/);
const row = botDb.db.prepare("SELECT keyHash, keyPrefix FROM api_keys").get() as {
keyHash: string;
keyPrefix: string;
};
expect(row.keyHash).toBe(sha256(created!.rawKey));
expect(row.keyHash).not.toContain(created!.rawKey);
expect(created!.key.keyPrefix).toBe(created!.rawKey.slice(0, 12));
});
it("validateAndTouch resolves the owner user for a fresh key", () => {
const { rawKey } = keys.create(userId, "ci")!;
const result = keys.validateAndTouch(rawKey);
expect(result).not.toBeNull();
expect(result!.userId).toBe(userId);
expect(result!.username).toBe("alice");
expect(result!.role).toBe("admin");
});
it("validateAndTouch returns null for an unknown or empty key", () => {
keys.create(userId, "ci");
expect(keys.validateAndTouch("tsmb_not-a-real-key")).toBeNull();
expect(keys.validateAndTouch("")).toBeNull();
});
it("delete removes the key so it no longer validates", () => {
const { key, rawKey } = keys.create(userId, "ci")!;
expect(keys.delete(key.id, userId)).toBe(true);
expect(keys.validateAndTouch(rawKey)).toBeNull();
});
it("delete with userId refuses to remove another user's key", async () => {
const { key } = keys.create(userId, "ci")!;
const other = await users.createUser("bob", "pw-bob", "member");
expect(keys.delete(key.id, other.id)).toBe(false);
expect(keys.delete(key.id)).toBe(true);
});
it("keys of a deleted user stop validating", async () => {
const { rawKey } = keys.create(userId, "ci")!;
users.deleteUser(userId);
expect(keys.validateAndTouch(rawKey)).toBeNull();
});
it("enforces the per-user key cap", () => {
for (let i = 0; i < MAX_API_KEYS_PER_USER; i++) {
expect(keys.create(userId, `key-${i}`)).not.toBeNull();
}
expect(keys.create(userId, "one-too-many")).toBeNull();
expect(keys.listForUser(userId)).toHaveLength(MAX_API_KEYS_PER_USER);
});
it("touches lastUsedAt at most once per interval", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const { rawKey } = keys.create(userId, "ci")!;
keys.validateAndTouch(rawKey);
const first = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt;
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + 30_000);
keys.validateAndTouch(rawKey);
const second = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt;
expect(second).toBe(first);
vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + API_KEY_TOUCH_INTERVAL_MS + 1000);
keys.validateAndTouch(rawKey);
const third = (botDb.db.prepare("SELECT lastUsedAt FROM api_keys").get() as { lastUsedAt: number }).lastUsedAt;
expect(third).toBeGreaterThan(first);
});
it("deleteAllForUser clears every key of that user", async () => {
keys.create(userId, "a");
keys.create(userId, "b");
const other = await users.createUser("bob", "pw-bob", "member");
keys.create(other.id, "c");
keys.deleteAllForUser(userId);
expect(keys.listForUser(userId)).toHaveLength(0);
expect(keys.listForUser(other.id)).toHaveLength(1);
});
it("listAll exposes usernames for admin views", async () => {
keys.create(userId, "ci");
const other = await users.createUser("bob", "pw-bob", "member");
keys.create(other.id, "deploy");
const all = keys.listAll();
expect(all).toHaveLength(2);
expect(all.map((k) => k.username).sort()).toEqual(["alice", "bob"]);
});
});
+130
View File
@@ -0,0 +1,130 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import type Database from "better-sqlite3";
export const MAX_API_KEYS_PER_USER = 20;
export const API_KEY_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
/** Visible prefix stored for list views, e.g. "tsmb_a1b2c3d4". */
export const API_KEY_PREFIX_LENGTH = 12;
export interface ApiKeyRow {
id: string;
userId: string;
name: string;
keyPrefix: string;
createdAt: number;
lastUsedAt: number | null;
}
export interface ApiKeyWithUser extends ApiKeyRow {
username: string;
}
export interface ApiKeyValidation {
keyId: string;
userId: string;
username: string;
role: "admin" | "member";
}
export interface CreatedApiKey {
key: ApiKeyRow;
/** Plaintext key — returned exactly once, at creation time. */
rawKey: string;
}
export interface ApiKeyStore {
/** Returns null when the per-user key cap is reached. */
create(userId: string, name: string): CreatedApiKey | null;
listForUser(userId: string): ApiKeyRow[];
listAll(): ApiKeyWithUser[];
/** With userId, only deletes a key owned by that user. */
delete(id: string, userId?: string): boolean;
deleteAllForUser(userId: string): void;
validateAndTouch(rawKey: string): ApiKeyValidation | null;
}
function hashKey(rawKey: string): string {
return createHash("sha256").update(rawKey).digest("hex");
}
export function createApiKeyStore(db: Database.Database): ApiKeyStore {
const insertStmt = db.prepare(
"INSERT INTO api_keys (id, userId, name, keyHash, keyPrefix, createdAt, lastUsedAt) VALUES (?, ?, ?, ?, ?, ?, NULL)"
);
const selectForUserStmt = db.prepare(
"SELECT id, userId, name, keyPrefix, createdAt, lastUsedAt FROM api_keys WHERE userId = ? ORDER BY createdAt DESC"
);
const selectAllStmt = db.prepare(
`SELECT k.id, k.userId, k.name, k.keyPrefix, k.createdAt, k.lastUsedAt, u.username
FROM api_keys k INNER JOIN users u ON u.id = k.userId
ORDER BY k.createdAt DESC`
);
const selectByIdStmt = db.prepare(
"SELECT id, userId, name, keyPrefix, createdAt, lastUsedAt FROM api_keys WHERE id = ?"
);
const deleteStmt = db.prepare("DELETE FROM api_keys WHERE id = ?");
const deleteAllForUserStmt = db.prepare("DELETE FROM api_keys WHERE userId = ?");
const countForUserStmt = db.prepare("SELECT COUNT(*) AS n FROM api_keys WHERE userId = ?");
const validateStmt = db.prepare(
`SELECT k.id, k.userId, k.lastUsedAt, u.username, u.role
FROM api_keys k INNER JOIN users u ON u.id = k.userId
WHERE k.keyHash = ?`
);
const touchStmt = db.prepare("UPDATE api_keys SET lastUsedAt = ? WHERE id = ?");
return {
create(userId, name) {
const count = (countForUserStmt.get(userId) as { n: number }).n;
if (count >= MAX_API_KEYS_PER_USER) {
return null;
}
const rawKey = `tsmb_${randomBytes(32).toString("base64url")}`;
const row: ApiKeyRow = {
id: randomUUID(),
userId,
name,
keyPrefix: rawKey.slice(0, API_KEY_PREFIX_LENGTH),
createdAt: Date.now(),
lastUsedAt: null,
};
insertStmt.run(row.id, row.userId, row.name, hashKey(rawKey), row.keyPrefix, row.createdAt);
return { key: row, rawKey };
},
listForUser(userId) {
return selectForUserStmt.all(userId) as ApiKeyRow[];
},
listAll() {
return selectAllStmt.all() as ApiKeyWithUser[];
},
delete(id, userId) {
const row = selectByIdStmt.get(id) as ApiKeyRow | undefined;
if (!row) return false;
if (userId !== undefined && row.userId !== userId) return false;
deleteStmt.run(id);
return true;
},
deleteAllForUser(userId) {
deleteAllForUserStmt.run(userId);
},
validateAndTouch(rawKey) {
if (!rawKey) return null;
const row = validateStmt.get(hashKey(rawKey)) as
| { id: string; userId: string; lastUsedAt: number | null; username: string; role: string }
| undefined;
if (!row) return null;
// The reserved guest principal must never authenticate via API keys;
// guest access is session-only by design.
if (row.role !== "admin" && row.role !== "member") return null;
const now = Date.now();
if (row.lastUsedAt === null || now - row.lastUsedAt > API_KEY_TOUCH_INTERVAL_MS) {
touchStmt.run(now, row.id);
}
return { keyId: row.id, userId: row.userId, username: row.username, role: row.role };
},
};
}
+3 -1
View File
@@ -7,7 +7,9 @@ export type AuditAction =
| "user.password_reset"
| "user.password_changed"
| "user.role_changed"
| "user.permissions_changed";
| "user.permissions_changed"
| "api_key.created"
| "api_key.deleted";
export interface AuditEntry {
id: number;
+12
View File
@@ -268,6 +268,18 @@ function initTables(db: Database.Database): void {
CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId);
CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt);
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
userId TEXT NOT NULL,
name TEXT NOT NULL,
keyHash TEXT NOT NULL UNIQUE,
keyPrefix TEXT NOT NULL,
createdAt INTEGER NOT NULL,
lastUsedAt INTEGER,
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_api_keys_userId ON api_keys(userId);
CREATE TABLE IF NOT EXISTS user_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import express from "express";
import cookieParser from "cookie-parser";
import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore, type SessionStore } from "../../data/sessions.js";
import { createAuditStore } from "../../data/audit.js";
import { createApiKeyStore, MAX_API_KEYS_PER_USER, type ApiKeyStore } from "../../data/api-keys.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "../middleware/requireAuth.js";
import { createApiKeysRouter } from "./api-keys.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
describe("api-keys router", () => {
let botDb: BotDatabase;
let app: express.Express;
let sessions: SessionStore;
let apiKeys: ApiKeyStore;
let adminId: string;
let memberId: string;
let adminToken: string;
let memberToken: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
sessions = createSessionStore(botDb.db);
const audit = createAuditStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
apiKeys = createApiKeyStore(botDb.db);
const admin = await users.createUser("alice", "pw-alice", "admin");
const member = await users.createUser("bob", "pw-bob", "member");
adminId = admin.id;
memberId = member.id;
adminToken = sessions.createSession(adminId).token;
memberToken = sessions.createSession(memberId).token;
app = express();
app.use(express.json());
app.use(cookieParser());
app.use(
createRequireAuth(sessions, permissions, () => ({
enabled: false,
bots: "all",
permissions: {} as any,
}), apiKeys)
);
app.use("/api/keys", createApiKeysRouter(apiKeys, audit, { info: () => {}, warn: () => {}, error: () => {}, child: () => ({}) } as any));
});
afterEach(() => {
botDb.close();
});
const authed = (token: string) => {
const cookie = `${SESSION_COOKIE_NAME}=${token}`;
return {
get: (url: string) => request(app).get(url).set("Cookie", cookie),
post: (url: string) => request(app).post(url).set("Cookie", cookie),
delete: (url: string) => request(app).delete(url).set("Cookie", cookie),
};
};
const asAdmin = () => authed(adminToken);
const asMember = () => authed(memberToken);
it("lists only the caller's own keys", async () => {
apiKeys.create(adminId, "mine");
apiKeys.create(memberId, "theirs");
const res = await asAdmin().get("/api/keys");
expect(res.status).toBe(200);
expect(res.body.keys).toHaveLength(1);
expect(res.body.keys[0].name).toBe("mine");
expect(res.body.keys[0].rawKey).toBeUndefined();
});
it("creates a key and returns the plaintext exactly once", async () => {
const res = await asAdmin().post("/api/keys").send({ name: "ci" });
expect(res.status).toBe(201);
expect(res.body.rawKey).toMatch(/^tsmb_/);
expect(apiKeys.validateAndTouch(res.body.rawKey)?.userId).toBe(adminId);
// The list view never exposes the plaintext again.
const list = await asAdmin().get("/api/keys");
expect(JSON.stringify(list.body)).not.toContain(res.body.rawKey);
});
it("rejects creation without a valid name", async () => {
expect((await asAdmin().post("/api/keys").send({})).status).toBe(400);
expect((await asAdmin().post("/api/keys").send({ name: "" })).status).toBe(400);
expect((await asAdmin().post("/api/keys").send({ name: "x".repeat(65) })).status).toBe(400);
});
it("rejects creation beyond the per-user cap with 409", async () => {
for (let i = 0; i < MAX_API_KEYS_PER_USER; i++) {
apiKeys.create(memberId, `k${i}`);
}
const res = await asMember().post("/api/keys").send({ name: "overflow" });
expect(res.status).toBe(409);
});
it("deletes own key and it stops validating", async () => {
const { key } = apiKeys.create(memberId, "ci")!;
const res = await asMember().delete(`/api/keys/${key.id}`);
expect(res.status).toBe(200);
expect(apiKeys.listForUser(memberId)).toHaveLength(0);
});
it("a member cannot delete another user's key", async () => {
const { key } = apiKeys.create(adminId, "admin-key")!;
const res = await asMember().delete(`/api/keys/${key.id}`);
expect(res.status).toBe(404);
expect(apiKeys.listForUser(adminId)).toHaveLength(1);
});
it("an admin can delete another user's key", async () => {
const { key } = apiKeys.create(memberId, "member-key")!;
const res = await asAdmin().delete(`/api/keys/${key.id}`);
expect(res.status).toBe(200);
expect(apiKeys.listForUser(memberId)).toHaveLength(0);
});
it("admin can list all keys with ?all=1, members cannot", async () => {
apiKeys.create(adminId, "a");
apiKeys.create(memberId, "b");
const adminAll = await asAdmin().get("/api/keys?all=1");
expect(adminAll.body.keys).toHaveLength(2);
expect(adminAll.body.keys.map((k: any) => k.username).sort()).toEqual(["alice", "bob"]);
const memberAll = await asMember().get("/api/keys?all=1");
expect(memberAll.body.keys).toHaveLength(1);
expect(memberAll.body.keys[0].name).toBe("b");
});
it("a request authenticated by an API key cannot manage keys", async () => {
const { rawKey } = apiKeys.create(adminId, "self-mgmt")!;
const res = await request(app)
.post("/api/keys")
.set("Authorization", `Bearer ${rawKey}`)
.send({ name: "proliferate" });
expect(res.status).toBe(403);
});
it("requires authentication", async () => {
expect((await request(app).get("/api/keys")).status).toBe(401);
});
});
+90
View File
@@ -0,0 +1,90 @@
import { Router } from "express";
import type { Request, Response, NextFunction } from "express";
import type { ApiKeyStore } from "../../data/api-keys.js";
import { MAX_API_KEYS_PER_USER } from "../../data/api-keys.js";
import type { AuditStore } from "../../data/audit.js";
import type { Logger } from "../../logger.js";
/**
* API-key management (list / create / revoke), mounted at /api/keys.
* Only interactive sessions may manage keys: a leaked key must never be able
* to mint its own replacements.
*/
export function createApiKeysRouter(apiKeys: ApiKeyStore, audit: AuditStore, logger: Logger): Router {
const router = Router();
const rejectApiKeyAuth = (req: Request, res: Response, next: NextFunction): void => {
if (req.authMethod === "api-key") {
res.status(403).json({ error: "API keys cannot manage API keys — log in to the WebUI" });
return;
}
next();
};
router.use(rejectApiKeyAuth);
// GET /api/keys — the caller's keys; admins may pass ?all=1 for every user's.
router.get("/", (req, res) => {
const user = req.user!;
if (req.query.all === "1" && user.role === "admin") {
res.json({ keys: apiKeys.listAll() });
return;
}
res.json({ keys: apiKeys.listForUser(user.id) });
});
// POST /api/keys — create a key; the plaintext is returned exactly once.
router.post("/", (req, res) => {
const user = req.user!;
const name = typeof req.body?.name === "string" ? req.body.name.trim() : "";
if (!name || name.length > 64) {
res.status(400).json({ error: "name is required (1-64 characters)" });
return;
}
const created = apiKeys.create(user.id, name);
if (!created) {
res.status(409).json({ error: `每个用户最多创建 ${MAX_API_KEYS_PER_USER} 个 API Key` });
return;
}
try {
audit.record({
actorId: user.id,
actorUsername: user.username,
targetUserId: user.id,
targetUsername: user.username,
action: "api_key.created",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "api_key.created" }, "audit insert failed");
}
logger.info({ userId: user.id, keyId: created.key.id }, "API key created");
res.status(201).json(created);
});
// DELETE /api/keys/:id — revoke; members only their own, admins any.
router.delete("/:id", (req, res) => {
const user = req.user!;
const ok =
user.role === "admin"
? apiKeys.delete(req.params.id)
: apiKeys.delete(req.params.id, user.id);
if (!ok) {
res.status(404).json({ error: "API key not found" });
return;
}
try {
audit.record({
actorId: user.id,
actorUsername: user.username,
targetUserId: user.id,
targetUsername: user.username,
action: "api_key.deleted",
});
} catch (auditErr) {
logger.warn({ err: auditErr, action: "api_key.deleted" }, "audit insert failed");
}
logger.info({ userId: user.id, keyId: req.params.id }, "API key deleted");
res.json({ success: true });
});
return router;
}
+7 -1
View File
@@ -3,6 +3,7 @@ import type { Logger } from "../../logger.js";
import type { UserStore } from "../../data/users.js";
import { UsernameTakenError, GUEST_USER_ID } from "../../data/users.js";
import type { SessionStore } from "../../data/sessions.js";
import type { ApiKeyStore } from "../../data/api-keys.js";
import type { AuditStore } from "../../data/audit.js";
import { isCapability, BASIC_TIER_CAPABILITIES, type PermissionStore } from "../../data/permissions.js";
import { extractSessionToken } from "../auth/validateSession.js";
@@ -20,7 +21,8 @@ export function createUsersRouter(
sessions: SessionStore,
audit: AuditStore,
logger: Logger,
permissions: PermissionStore
permissions: PermissionStore,
apiKeys?: ApiKeyStore
): Router {
const router = Router();
@@ -85,6 +87,7 @@ export function createUsersRouter(
}
// FK CASCADE removes sessions; explicit call is belt-and-suspenders
sessions.deleteAllForUser(targetId);
apiKeys?.deleteAllForUser(targetId);
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
@@ -117,6 +120,9 @@ export function createUsersRouter(
? (extractSessionToken(req.headers.cookie) ?? undefined)
: undefined;
sessions.deleteAllForUser(targetId, exceptToken);
// A password reset must also kill the target's API keys — they are
// long-lived credentials that otherwise survive credential rotation.
apiKeys?.deleteAllForUser(targetId);
try {
audit.record({
actorId: req.user!.id, actorUsername: req.user!.username,
+36
View File
@@ -0,0 +1,36 @@
import type { Request } from "express";
import { SESSION_COOKIE_NAME } from "./validateSession.js";
/**
* Extract a raw API key from the `X-API-Key` header or an
* `Authorization: Bearer <key>` header. Returns null when neither is present.
*/
export function extractApiKey(req: Request): string | null {
const header = req.headers["x-api-key"];
if (typeof header === "string" && header.trim()) {
return header.trim();
}
const auth = req.headers.authorization;
if (typeof auth === "string") {
const match = /^bearer\s+(.+)$/i.exec(auth);
if (match) {
const key = match[1].trim();
if (key) return key;
}
}
return null;
}
export function hasApiKeyCredential(req: Request): boolean {
return extractApiKey(req) !== null;
}
/**
* API-key clients (no session cookie) skip the origin check entirely. Requests
* that ALSO carry the session cookie must NOT rely on this — an attacker page
* can set arbitrary headers while the victim's cookie rides along ambiently,
* so the cookie keeps the request under the origin check.
*/
export function isApiKeyOnlyRequest(req: Request): boolean {
return hasApiKeyCredential(req) && !req.headers.cookie?.includes(`${SESSION_COOKIE_NAME}=`);
}
+24
View File
@@ -70,4 +70,28 @@ describe("csrfOriginCheck middleware", () => {
expect(res.status).toBe(403);
expect(res.body).toEqual({ error: "bad origin" });
});
// API-key clients authenticate via a header the browser never attaches
// automatically, so CSRF cannot abuse them — the origin check is skipped.
it("allows POST with an X-API-Key header and no session cookie", async () => {
const res = await request(app).post("/").set("X-API-Key", "tsmb_abc");
expect(res.status).toBe(200);
});
it("allows POST with an Authorization: Bearer key and no session cookie", async () => {
const res = await request(app).post("/").set("Authorization", "Bearer tsmb_abc");
expect(res.status).toBe(200);
});
it("does NOT skip the origin check when a session cookie rides along with an API key", async () => {
// An attacker page can set arbitrary headers while the victim's cookie is
// attached ambiently — the cookie keeps the request under the gate.
const res = await request(app)
.post("/")
.set("Host", "example.com")
.set("Origin", "https://evil.com")
.set("Cookie", "tsmb_session=whatever")
.set("X-API-Key", "tsmb_abc");
expect(res.status).toBe(403);
});
});
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Request, Response, NextFunction } from "express";
import { isApiKeyOnlyRequest } from "../auth/api-key-header.js";
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
@@ -10,7 +11,7 @@ const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
* this header check covers the remaining attack surface.
*/
export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void {
if (SAFE_METHODS.has(req.method)) {
if (SAFE_METHODS.has(req.method) || isApiKeyOnlyRequest(req)) {
next();
return;
}
+110
View File
@@ -5,6 +5,7 @@ import request from "supertest";
import { createDatabase, type BotDatabase } from "../../data/database.js";
import { createUserStore } from "../../data/users.js";
import { createSessionStore } from "../../data/sessions.js";
import { createApiKeyStore } from "../../data/api-keys.js";
import { createPermissionStore } from "../../data/permissions.js";
import { createRequireAuth } from "./requireAuth.js";
import { SESSION_COOKIE_NAME } from "../auth/validateSession.js";
@@ -114,3 +115,112 @@ describe("requireAuth middleware", () => {
expect(req.user.bots instanceof Set && req.user.bots.has("bot1")).toBe(true);
});
});
describe("requireAuth middleware with API keys", () => {
let botDb: BotDatabase;
let app: express.Express;
let adminKey: string;
let memberKey: string;
beforeEach(async () => {
botDb = createDatabase(":memory:");
const users = createUserStore(botDb.db);
const sessions = createSessionStore(botDb.db);
const permissions = createPermissionStore(botDb.db);
const apiKeys = createApiKeyStore(botDb.db);
const admin = await users.createUser("alice", "pw-alice", "admin");
const member = await users.createUser("bob", "pw-bob", "member");
permissions.setPermissions(member.id, { capabilities: ["player.control"], bots: ["bot1"] });
adminKey = apiKeys.create(admin.id, "ci")!.rawKey;
memberKey = apiKeys.create(member.id, "deploy")!.rawKey;
app = express();
app.use(cookieParser());
app.use(
createRequireAuth(sessions, permissions, () => ({
enabled: false,
bots: "all",
permissions: {} as any,
}), apiKeys)
);
app.get("/protected", (req, res) => {
const u: any = (req as any).user;
res.json({
ok: true,
authMethod: (req as any).authMethod,
user: u
? {
username: u.username,
role: u.role,
capabilities: u.capabilities ? [...u.capabilities] : [],
bots: u.bots === "all" ? "all" : [...(u.bots ?? [])],
}
: null,
});
});
});
afterEach(() => {
botDb.close();
});
it("authenticates a valid X-API-Key header and attaches the owner user", async () => {
const res = await request(app).get("/protected").set("X-API-Key", adminKey);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.user.username).toBe("alice");
expect(res.body.user.role).toBe("admin");
expect(res.body.authMethod).toBe("api-key");
});
it("authenticates an Authorization: Bearer key", async () => {
const res = await request(app).get("/protected").set("Authorization", `Bearer ${adminKey}`);
expect(res.status).toBe(200);
expect(res.body.user.username).toBe("alice");
});
it("rejects an unknown key with 401", async () => {
const res = await request(app).get("/protected").set("X-API-Key", "tsmb_bogus");
expect(res.status).toBe(401);
expect(res.body).toEqual({ error: "invalid api key" });
});
it("ignores the session cookie when a key header is present", async () => {
// Garbage cookie + valid key → key wins.
const res = await request(app)
.get("/protected")
.set("Cookie", `${SESSION_COOKIE_NAME}=garbage`)
.set("X-API-Key", memberKey);
expect(res.status).toBe(200);
expect(res.body.user.username).toBe("bob");
});
it("a member key inherits the member's capabilities and bot scope", async () => {
const res = await request(app).get("/protected").set("X-API-Key", memberKey);
expect(res.status).toBe(200);
expect(res.body.user.role).toBe("member");
expect(res.body.user.capabilities).toContain("player.control");
expect(res.body.user.bots).toContain("bot1");
expect(res.body.user.capabilities).not.toContain("bot.manage");
});
it("returns 401 when a key header is present but no store is wired", async () => {
const sessions: any = { validateAndTouch: () => null };
const permissions: any = { getCapabilities: () => [], getBotAccess: () => [] };
const mw = createRequireAuth(sessions, permissions, () => ({ enabled: false, bots: "all", permissions: {} as any }));
const req: any = { headers: { "x-api-key": "tsmb_x" } };
const res: any = { status(c: number) { this.statusCode = c; return this; }, json() { return this; } };
const next = vi.fn();
mw(req, res, next);
expect(res.statusCode).toBe(401);
expect(next).not.toHaveBeenCalled();
});
it("a key whose owner was deleted stops working", async () => {
const users = createUserStore(botDb.db);
const member = users.findByUsername("bob")!;
users.deleteUser(member.id);
const res = await request(app).get("/protected").set("X-API-Key", memberKey);
expect(res.status).toBe(401);
});
});
+31 -1
View File
@@ -1,6 +1,7 @@
import type { Request, Response, NextFunction, RequestHandler } from "express";
import type { SessionStore } from "../../data/sessions.js";
import { SESSION_TTL_MS } from "../../data/sessions.js";
import type { ApiKeyStore } from "../../data/api-keys.js";
import { resolvePermissionContext, type PermissionStore, type GuestPermissions } from "../../data/permissions.js";
import type { GuestModeConfig } from "../../data/config.js";
import {
@@ -8,6 +9,7 @@ import {
extractSessionToken,
SESSION_COOKIE_NAME,
} from "../auth/validateSession.js";
import { extractApiKey } from "../auth/api-key-header.js";
declare module "express-serve-static-core" {
interface Request {
@@ -19,15 +21,42 @@ declare module "express-serve-static-core" {
bots?: "all" | Set<string>;
guest?: GuestPermissions;
};
/** How this request authenticated: browser session cookie or API key. */
authMethod?: "session" | "api-key";
}
}
export function createRequireAuth(
sessions: SessionStore,
permissions: PermissionStore,
getGuestConfig: () => GuestModeConfig
getGuestConfig: () => GuestModeConfig,
apiKeys?: ApiKeyStore
): RequestHandler {
return function requireAuth(req: Request, res: Response, next: NextFunction) {
// ─── API-key path ──────────────────────────────────────────────────────
// A key in a header authenticates on its own; cookies are ignored on this
// path so the two credential types can never be mixed.
const rawKey = extractApiKey(req);
if (rawKey !== null) {
const validation = apiKeys?.validateAndTouch(rawKey) ?? null;
if (!validation) {
res.status(401).json({ error: "invalid api key" });
return;
}
const ctx = resolvePermissionContext(validation.role, validation.userId, permissions);
req.user = {
id: validation.userId,
username: validation.username,
role: validation.role,
capabilities: ctx.capabilities,
bots: ctx.bots,
};
req.authMethod = "api-key";
next();
return;
}
// ─── Session-cookie path (browser) ─────────────────────────────────────
const result = validateSessionFromHeaders(req.headers.cookie, sessions);
if (!result) {
res.clearCookie(SESSION_COOKIE_NAME, { path: "/" });
@@ -56,6 +85,7 @@ export function createRequireAuth(
bots: ctx.bots,
guest: ctx.guest,
};
req.authMethod = "session";
const token = extractSessionToken(req.headers.cookie);
if (token) {
res.cookie(SESSION_COOKIE_NAME, token, {
+9 -2
View File
@@ -21,6 +21,7 @@ import { createAuditRouter } from "./api/audit.js";
import { createFavoritesRouter } from "./api/favorites.js";
import { createSavedQueuesRouter } from "./api/saved-queues.js";
import { createSpotifyRouter } from "./api/spotify.js";
import { createApiKeysRouter } from "./api/api-keys.js";
import type { SpotifyOAuth } from "../music/spotify/spotify-oauth.js";
import type { SpotifyProvider } from "../music/spotify/provider.js";
import type { JellyfinProvider } from "../music/jellyfin.js";
@@ -32,6 +33,7 @@ import {
import { setupWebSocket } from "./websocket.js";
import { createUserStore } from "../data/users.js";
import { createSessionStore } from "../data/sessions.js";
import { createApiKeyStore } from "../data/api-keys.js";
import { createPermissionStore } from "../data/permissions.js";
import { createRequireAuth } from "./middleware/requireAuth.js";
import { requireAdmin } from "./middleware/requireAdmin.js";
@@ -101,6 +103,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
const sessions = createSessionStore(options.database.db);
const audit = createAuditStore(options.database.db);
const permissions = createPermissionStore(options.database.db);
const apiKeys = createApiKeyStore(options.database.db);
// ─── Public routes (no auth, no CSRF) ───────────────────────────────────
// Disallow every crawler (issue #128). Declared before the static SPA
@@ -130,7 +133,7 @@ export function createWebServer(options: WebServerOptions): WebServer {
app.use("/api/session", createSessionRouter(users, sessions, audit, logger, permissions, () => options.config.guestMode));
// ─── Gates for everything else under /api ───────────────────────────────
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode);
const requireAuth = createRequireAuth(sessions, permissions, () => options.config.guestMode, apiKeys);
app.use("/api", csrfOriginCheck);
app.use("/api", requireAuth);
@@ -217,9 +220,13 @@ export function createWebServer(options: WebServerOptions): WebServer {
);
// admin-only routes
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions));
app.use("/api/users", requireAdmin, createUsersRouter(users, sessions, audit, logger, permissions, apiKeys));
app.use("/api/audit", requireAdmin, createAuditRouter(audit));
// API-key management — interactive sessions only (guests excluded; the
// router itself rejects key-authenticated requests).
app.use("/api/keys", requireNotGuest, createApiKeysRouter(apiKeys, audit, logger));
// ─── Static SPA (public) ────────────────────────────────────────────────
if (options.staticDir) {
app.use(express.static(options.staticDir));