diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 258d7a22b..4d935ea51 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -142,6 +142,10 @@ export async function createApp( c.set("owner", owner); await next(); }); + app.delete("/api/session", async (c) => { + await auth.revoke(c.req.header("authorization")); + return c.json({ ok: true }); + }); app.get("/api/workspace", async (c) => { const [snapshot, reachable] = await Promise.all([ workspace.snapshot(c.get("owner"), c.req.query("q")), diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index 2d4f73ede..5cf51cf21 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -30,15 +30,24 @@ export class Auth { } async owner(authorization?: string) { if (!authorization?.startsWith("Bearer ")) throw new AppError("Sign in to OpenMuse", 401); + const id = digest(authorization.slice(7)).toString("hex"); const session = await this.db.get<{ owner: string; expiresAt: number }>( "system", "sessions", - digest(authorization.slice(7)).toString("hex"), + id, ); - if (!session || session.expiresAt < Date.now()) + if (!session || session.expiresAt < Date.now()) { + // Expired sessions can never be valid again, so drop the row instead of keeping it forever. + // Best-effort: a failed cleanup must not turn the rejection into a 5xx. + if (session) await this.db.remove("system", "sessions", id).catch(() => {}); throw new AppError("Session expired. Sign in again.", 401); + } return session.owner; } + async revoke(authorization?: string) { + if (authorization?.startsWith("Bearer ")) + await this.db.remove("system", "sessions", digest(authorization.slice(7)).toString("hex")); + } sign(owner: string, path: string) { const expires = String(Date.now() + 15 * 60 * 1000); const signature = createHmac("sha256", this.signingKey) diff --git a/tests/api.test.ts b/tests/api.test.ts index 6ea94208b..4c98ab6bd 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -61,6 +62,55 @@ test("API protects private data and rejects unrelated web origins", async () => 403, ); }); +test("signing out revokes only the current session", async () => { + const signIn = await app.request("/api/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const revoked = (await signIn.json()).token; + const auth = { Authorization: `Bearer ${revoked}` }; + assert.equal( + (await app.request("/api/session", { method: "DELETE", headers: auth })).status, + 200, + ); + assert.equal((await app.request("/api/workspace", { headers: auth })).status, 401); + assert.equal( + (await app.request("/api/session", { method: "DELETE", headers: auth })).status, + 401, + ); + assert.equal((await app.request("/api/workspace", { headers: headers() })).status, 200); +}); +test("an expired session is rejected and its row is removed", async () => { + const signIn = await app.request("/api/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const expired = (await signIn.json()).token; + const id = createHash("sha256").update(expired).digest("hex"); + await db.put("system", "sessions", { id, owner: "local-user", expiresAt: Date.now() - 1 }); + const response = await app.request("/api/workspace", { + headers: { Authorization: `Bearer ${expired}` }, + }); + assert.equal(response.status, 401); + assert.equal(await db.get("system", "sessions", id), null); +}); +test("an expired session still returns 401 when its cleanup fails", async (t) => { + const signIn = await app.request("/api/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const expired = (await signIn.json()).token; + const id = createHash("sha256").update(expired).digest("hex"); + await db.put("system", "sessions", { id, owner: "local-user", expiresAt: Date.now() - 1 }); + t.mock.method(db, "remove", () => Promise.reject(new Error("db unavailable"))); + const response = await app.request("/api/workspace", { + headers: { Authorization: `Bearer ${expired}` }, + }); + assert.equal(response.status, 401); +}); test("file operations recover a published PDF when metadata persistence fails", async (t) => { const owner = "file-recovery"; const bytes = await createSamplePdf();