fix(auth): revoke bearer sessions on sign out
This commit is contained in:
@@ -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
|
||||
);
|
||||
`);
|
||||
}
|
||||
Reference in New Issue
Block a user