fix(auth): revoke bearer sessions on sign out

This commit is contained in:
2026-07-10 06:39:08 -04:00
parent 540b901fd6
commit 34ac842078
8 changed files with 310 additions and 7 deletions
@@ -159,7 +159,7 @@ describe("destructive action proofs", () => {
);
assert.equal(response.status, 200);
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary", "first-primary"]);
});
it("requires the exact timestamp and session for an account deletion replay", async () => {
+6
View File
@@ -92,6 +92,7 @@ interface TestD1DatabaseOptions {
batchError?: Error;
batchRowSets?: unknown[][][];
firstRows?: unknown[];
runError?: Error;
runChanges?: number[];
sessionRow?: unknown | null;
}
@@ -190,6 +191,7 @@ export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): Recorde
isAuthSessionQuery,
sessionRow,
() => (!Array.isArray(rows) ? rows.runChanges?.[runIndex++] : undefined) ?? 1,
!Array.isArray(rows) ? rows.runError : undefined,
);
},
batch<T>(statements: ElyD1PreparedStatement[]) {
@@ -239,6 +241,7 @@ function testD1PreparedStatement(
isAuthSessionQuery: boolean,
sessionRow: unknown | null,
nextRunChanges: () => number,
runError: Error | undefined,
): ElyD1PreparedStatement {
return {
bind(...values: unknown[]) {
@@ -255,6 +258,9 @@ function testD1PreparedStatement(
return Promise.resolve({ results: allRows() as T[] });
},
run() {
if (runError !== undefined) {
return Promise.reject(runError);
}
return Promise.resolve({ results: [], meta: { changes: nextRunChanges() } });
},
};
@@ -0,0 +1,209 @@
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "node:test";
import type { AuthContext } from "../src/auth.js";
import {
authSessionCacheKvKey,
authTokenHash,
deleteAuthenticatedSession,
} from "../src/auth.js";
import { handleRequest } from "../src/index.js";
import {
ACCESS_TOKEN,
PUBLIC_KEY,
testD1Database,
testEnv,
} from "./devices_test_support.js";
import { SqliteD1Database, execute, query } from "./sqlite_d1_test_support.js";
const USER_ID = "user-01";
const DEVICE_ID = "device-01";
const SESSION_ID = "session-01";
const SIBLING_SESSION_ID = "session-02";
const SIBLING_TOKEN = "S".repeat(48);
const MIGRATIONS_DIR = join(process.cwd(), "migrations");
describe("session logout routes", () => {
it("deletes the exact authenticated session and legacy cache key", async () => {
const d1 = testD1Database({ runChanges: [1] });
const kvDeletes: string[] = [];
const response = await handleRequest(logoutRequest(), testEnv({ d1, kvDeletes }));
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { version: 1, signed_out: true });
assert.equal(response.headers.get("cache-control"), "no-store");
assert.equal(d1.queries.length, 1);
assert.match(d1.queries[0] ?? "", /DELETE FROM better_auth_session/);
assert.deepEqual(d1.binds, [[SESSION_ID, USER_ID, ACCESS_TOKEN]]);
assert.deepEqual(kvDeletes, [
authSessionCacheKvKey("local", await authTokenHash(ACCESS_TOKEN)),
]);
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
});
it("treats a concurrent exact deletion as signed out", async () => {
const response = await handleRequest(
logoutRequest(),
testEnv({ d1: testD1Database({ runChanges: [0] }) }),
);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { version: 1, signed_out: true });
});
it("maps D1 deletion failures to a stable server error", async () => {
const response = await handleRequest(
logoutRequest(),
testEnv({ d1: testD1Database({ runError: new Error("d1 unavailable") }) }),
);
assert.equal(response.status, 500);
assert.deepEqual(await response.json(), { error: "session_logout_failed" });
});
it("keeps authoritative logout successful when legacy KV cleanup fails", async () => {
const env = testEnv({ d1: testD1Database({ runChanges: [1] }) });
env.ELY_KV.delete = () => Promise.reject(new Error("kv unavailable"));
const response = await handleRequest(logoutRequest(), env);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { version: 1, signed_out: true });
});
it("rejects unsupported methods before authentication", async () => {
const d1 = testD1Database([]);
const response = await handleRequest(
new Request("https://elydora.test/api/session/logout"),
testEnv({ d1 }),
);
assert.equal(response.status, 405);
assert.equal(response.headers.get("allow"), "POST");
assert.deepEqual(d1.authQueries, []);
});
it("cascades only the current session in real SQLite", async () => {
await withDatabase(async (databasePath, env) => {
const response = await handleRequest(logoutRequest(), env);
assert.equal(response.status, 200);
assert.deepEqual(query(databasePath, `
SELECT id, token FROM better_auth_session ORDER BY id
`), [{ id: SIBLING_SESSION_ID, token: SIBLING_TOKEN }]);
assert.deepEqual(query(databasePath, `
SELECT session_id FROM better_auth_session_device_context ORDER BY session_id
`), [{ session_id: SIBLING_SESSION_ID }]);
assert.deepEqual(query(databasePath, "SELECT challenge_id FROM device_rebind_challenges"), []);
const rejected = await handleRequest(
new Request("https://elydora.test/api/devices", {
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
}),
env,
);
assert.equal(rejected.status, 401);
assert.deepEqual(await rejected.json(), { error: "session_not_found" });
});
});
it("preserves a replacement token when an authenticated context becomes stale", async () => {
await withDatabase(async (databasePath, env) => {
const replacement = "R".repeat(48);
execute(databasePath, `
UPDATE better_auth_session SET token = '${replacement}' WHERE id = '${SESSION_ID}';
`);
const document = await deleteAuthenticatedSession(logoutRequest(), env, authContext());
assert.deepEqual(document, { version: 1, signed_out: true });
assert.deepEqual(query(databasePath, `
SELECT id, token FROM better_auth_session ORDER BY id
`), [
{ id: SESSION_ID, token: replacement },
{ id: SIBLING_SESSION_ID, token: SIBLING_TOKEN },
]);
});
});
});
function logoutRequest(): Request {
return new Request("https://elydora.test/api/session/logout", {
method: "POST",
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
});
}
function authContext(): AuthContext {
return {
userId: USER_ID,
sessionId: SESSION_ID,
tokenHash: "1".repeat(64),
expiresAt: "2099-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
deviceId: DEVICE_ID,
};
}
async function withDatabase(
action: (databasePath: string, env: ReturnType<typeof testEnv>) => Promise<void>,
): Promise<void> {
const directory = mkdtempSync(join(tmpdir(), "ely-session-logout-"));
const databasePath = join(directory, "logout.sqlite");
try {
for (const migration of readdirSync(MIGRATIONS_DIR).filter((name) => name.endsWith(".sql")).sort()) {
execute(databasePath, readFileSync(join(MIGRATIONS_DIR, migration), "utf8"));
}
seedDatabase(databasePath);
const env = testEnv({ d1: new SqliteD1Database(databasePath) });
await action(databasePath, env);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
function seedDatabase(databasePath: string): void {
execute(databasePath, `
INSERT INTO better_auth_user (
id, name, email, emailVerified, createdAt, updatedAt
) VALUES (
'${USER_ID}', 'ELY User', 'user@example.com', 1,
'2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z'
);
INSERT INTO user_devices (
user_id, device_id, public_key, device_name, platform, approval_status,
created_at, approved_at, last_active_at, revoked_at, idempotency_key
) VALUES (
'${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', 'MacBook Pro', 'macos', 'approved',
1, 1, 1, NULL, 'device-register-01'
);
INSERT INTO user_device_keys (
user_id, device_id, signing_public_key, wrapping_public_key,
key_protocol_version, created_at
) VALUES (
'${USER_ID}', '${DEVICE_ID}', '${PUBLIC_KEY}', '${"b".repeat(64)}', 2, 1
);
INSERT INTO better_auth_session (
id, expiresAt, token, createdAt, updatedAt, userId
) VALUES
('${SESSION_ID}', '2099-01-01T00:00:00.000Z', '${ACCESS_TOKEN}',
'2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', '${USER_ID}'),
('${SIBLING_SESSION_ID}', '2099-01-01T00:00:00.000Z', '${SIBLING_TOKEN}',
'2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', '${USER_ID}');
INSERT INTO better_auth_session_device_context (
session_id, user_id, device_id, updated_at
) VALUES
('${SESSION_ID}', '${USER_ID}', '${DEVICE_ID}', 1),
('${SIBLING_SESSION_ID}', '${USER_ID}', '${DEVICE_ID}', 1);
INSERT INTO device_rebind_challenges (
challenge_id, user_id, session_id, device_id, challenge,
created_at, expires_at, consumed_at, consumption_nonce
) VALUES (
'challenge-01', '${USER_ID}', '${SESSION_ID}', '${DEVICE_ID}', '${"c".repeat(64)}',
1, 2, NULL, NULL
);
`);
}
@@ -141,7 +141,7 @@ describe("sync snapshot routes", () => {
assert.equal(response.status, 201);
assert.deepEqual(await response.json(), uploadDocument(committed));
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary", "first-primary"]);
});
it("rejects a stale base before writing R2", async () => {
@@ -268,7 +268,7 @@ describe("sync snapshot routes", () => {
assert.equal(response.status, 409);
assert.deepEqual(await response.json(), conflictDocument(current));
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary", "first-primary"]);
});
it("preserves legacy encryption metadata on exact downloads", async () => {
+1 -1
View File
@@ -69,7 +69,7 @@ describe("sync status routes", () => {
[USER_ID],
]);
assert.deepEqual(d1.batches, [5]);
assert.deepEqual(d1.sessionConstraints, ["first-primary"]);
assert.deepEqual(d1.sessionConstraints, ["first-primary", "first-primary"]);
});
it("returns empty status when the account has no sync facts", async () => {