From ac8fbedd7a667d0d20d9b615cd5d67e48b6b83fc Mon Sep 17 00:00:00 2001 From: Ayush Date: Sun, 25 Oct 2026 20:27:16 +0530 Subject: [PATCH 1/3] feat(server): revoke the current session on DELETE /api/session --- apps/server/src/app.ts | 4 ++++ apps/server/src/auth.ts | 4 ++++ tests/api.test.ts | 13 +++++++++++++ 3 files changed, 21 insertions(+) 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..39111ff79 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -39,6 +39,10 @@ export class Auth { 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..38641369c 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -61,6 +61,19 @@ 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("file operations recover a published PDF when metadata persistence fails", async (t) => { const owner = "file-recovery"; const bytes = await createSamplePdf(); From 0c287ae55176a56012067a5890a3b3be1268540a Mon Sep 17 00:00:00 2001 From: Ayush Date: Sun, 25 Oct 2026 20:58:45 +0530 Subject: [PATCH 2/3] fix(server): delete expired session rows when they are rejected Auth.owner returned 401 for an expired session but kept its row, so every sign-in that was never revoked left a dead row behind. Remove the row when it is found expired. Also applies biome formatting to the session-logout test. --- apps/server/src/auth.ts | 8 ++++++-- tests/api.test.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index 39111ff79..647b23786 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -30,13 +30,17 @@ 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. + if (session) await this.db.remove("system", "sessions", id); throw new AppError("Session expired. Sign in again.", 401); + } return session.owner; } async revoke(authorization?: string) { diff --git a/tests/api.test.ts b/tests/api.test.ts index 38641369c..7e7592e68 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"; @@ -69,11 +70,32 @@ test("signing out revokes only the current session", async () => { }); 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/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/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("file operations recover a published PDF when metadata persistence fails", async (t) => { const owner = "file-recovery"; const bytes = await createSamplePdf(); From 640ef0d261aeb2f0f523d0a0149184c87009057a Mon Sep 17 00:00:00 2001 From: Ayush Date: Sun, 25 Oct 2026 22:23:46 +0530 Subject: [PATCH 3/3] fix(server): keep expired-session rejection a clean 401 if cleanup fails The expiry cleanup delete runs before the 401 is thrown; a failing remove would surface as a 5xx instead of the intended 401. Make the cleanup best-effort so rejection is unaffected. --- apps/server/src/auth.ts | 3 ++- tests/api.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts index 647b23786..5cf51cf21 100644 --- a/apps/server/src/auth.ts +++ b/apps/server/src/auth.ts @@ -38,7 +38,8 @@ export class Auth { ); if (!session || session.expiresAt < Date.now()) { // Expired sessions can never be valid again, so drop the row instead of keeping it forever. - if (session) await this.db.remove("system", "sessions", id); + // 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; diff --git a/tests/api.test.ts b/tests/api.test.ts index 7e7592e68..4c98ab6bd 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -96,6 +96,21 @@ test("an expired session is rejected and its row is removed", async () => { 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();