fix(auth): invalidate session server-side on logout, not just the cookie

/auth/logout previously only cleared the client's cookie, leaving the
session token valid in the sessions table — a stolen cookie captured
before logout would still work afterwards. Add BackendDbInterface#deleteSession
(implemented in SqliteInterface) and call it from the logout route using
the session token from the cookie. Caught by the new auth-http.test.ts
integration test, updated to assert the session is actually invalidated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit was merged in pull request #2.
This commit is contained in:
2026-07-02 01:43:50 +00:00
co-authored by Claude
parent 4d9a0cf228
commit 73c4b169c5
5 changed files with 18 additions and 1 deletions
+7
View File
@@ -392,6 +392,13 @@ export class SqliteInterface implements BackendDbInterface {
WHERE "uid" = ${{ uuid: uid }} WHERE "uid" = ${{ uuid: uid }}
`); `);
} }
async deleteSession(token: `as_${string}`): Promise<void> {
this.#checkReadonly();
this.#db.run(sql`
DELETE FROM "sessions"
WHERE "token" = ${{ text: token }}
`);
}
#getApikeyByToken(token: `at_${string}`): ClientApikey { #getApikeyByToken(token: `at_${string}`): ClientApikey {
const apikey = selectClientApikey( const apikey = selectClientApikey(
+2
View File
@@ -85,6 +85,7 @@ export interface BackendDbInterface extends DbInterface {
// auth by session // auth by session
getUserBySession(token: `as_${string}`): Promise<ClientUser>; getUserBySession(token: `as_${string}`): Promise<ClientUser>;
createSession(uid: string): Promise<`as_${string}`>; createSession(uid: string): Promise<`as_${string}`>;
deleteSession(token: `as_${string}`): Promise<void>;
// auth by apikey // auth by apikey
getUserByApikey(token: `at_${string}`): Promise<[ClientUser, ClientApikey]>; getUserByApikey(token: `at_${string}`): Promise<[ClientUser, ClientApikey]>;
@@ -98,6 +99,7 @@ export function isBackendInterface(db: DbInterface): db is BackendDbInterface {
"getUserByLogin", "getUserByLogin",
"getUserBySession", "getUserBySession",
"createSession", "createSession",
"deleteSession",
"getUserByApikey", "getUserByApikey",
] as const ] as const
).every( ).every(
+2
View File
@@ -28,6 +28,8 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
}); });
router.post("/auth/logout", authenticate(db), async (ctx) => { router.post("/auth/logout", authenticate(db), async (ctx) => {
if (ctx.session!.source !== "session") throw new InvalidAbodeError(); if (ctx.session!.source !== "session") throw new InvalidAbodeError();
const token = ctx.cookies.get("abode_session");
if (token) await db.deleteSession(token as `as_${string}`);
ctx.cookies.set("abode_session", "", { expires: new Date("1970-01-01") }); ctx.cookies.set("abode_session", "", { expires: new Date("1970-01-01") });
ctx.status = 204; ctx.status = 204;
}); });
+6 -1
View File
@@ -74,7 +74,7 @@ describe("api backend: auth over HTTP", async () => {
assert.equal(res.status, 401); assert.equal(res.status, 401);
}); });
it("POST /auth/logout clears the session cookie", async () => { it("POST /auth/logout clears the cookie and invalidates the session server-side", async () => {
const login = await fetch(`${server.url}/auth/login`, { const login = await fetch(`${server.url}/auth/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -88,6 +88,11 @@ describe("api backend: auth over HTTP", async () => {
}); });
assert.equal(logout.status, 204); assert.equal(logout.status, 204);
assert.equal(getCookie(logout, "abode_session"), ""); assert.equal(getCookie(logout, "abode_session"), "");
const self = await fetch(`${server.url}/auth/self`, {
headers: { Cookie: `abode_session=${cookie}` },
});
assert.equal(self.status, 401, "session was invalidated server-side, not just the cookie cleared");
}); });
it("POST /auth/clear-sessions invalidates outstanding session cookies", async () => { it("POST /auth/clear-sessions invalidates outstanding session cookies", async () => {
+1
View File
@@ -71,6 +71,7 @@ function makeMockDb(
getUserByLogin: async () => { throw new NotFoundAbodeError(); }, getUserByLogin: async () => { throw new NotFoundAbodeError(); },
getUserBySession: async () => { throw new NotFoundAbodeError(); }, getUserBySession: async () => { throw new NotFoundAbodeError(); },
createSession: async () => `as_${"0".repeat(32)}`, createSession: async () => `as_${"0".repeat(32)}`,
deleteSession: async () => {},
getUserByApikey: async () => { throw new NotFoundAbodeError(); }, getUserByApikey: async () => { throw new NotFoundAbodeError(); },
...overrides, ...overrides,
}; };