Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
Expand Down
13 changes: 11 additions & 2 deletions apps/server/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
50 changes: 50 additions & 0 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand Down
Loading