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
+57 -2
View File
@@ -1,4 +1,5 @@
import type { Env } from "./bindings.js";
import type { ElyD1Result, Env } from "./bindings.js";
import { primaryD1Session } from "./bindings.js";
import { prefixedKvKey } from "./kv_keys.js";
const AUTH_SESSION_CACHE_NAMESPACE = "auth_session_cache";
@@ -17,6 +18,10 @@ const BETTER_AUTH_SESSION_QUERY = `
ON device_context.session_id = session.id
WHERE session.token = ?
`;
const DELETE_AUTHENTICATED_SESSION = `
DELETE FROM better_auth_session
WHERE id = ? AND userId = ? AND token = ?
`;
export interface AuthContext {
userId: string;
@@ -55,6 +60,18 @@ export class AuthSessionSchemaError extends Error {
}
}
export class AuthSessionPersistenceError extends Error {
constructor(cause?: unknown) {
super("auth_session_persistence_failed", { cause });
this.name = "AuthSessionPersistenceError";
}
}
export interface SessionLogoutDocument {
version: 1;
signed_out: true;
}
export function authSessionCacheKvKey(environment: string, tokenHash: string): string {
if (!/^[a-f0-9]{64}$/.test(tokenHash)) {
throw new AuthSessionSchemaError("token_hash_invalid");
@@ -96,13 +113,46 @@ export async function readAuthContext(
return readBetterAuthSessionContext(env, token, tokenHash, now);
}
export async function deleteAuthenticatedSession(
request: Request,
env: Env,
context: AuthContext,
): Promise<SessionLogoutDocument> {
const token = bearerToken(request);
if (token === null) {
throw new AuthError("authorization_missing");
}
let result: unknown;
try {
result = await primaryD1Session(env.ELY_DB)
.prepare(DELETE_AUTHENTICATED_SESSION)
.bind(context.sessionId, context.userId, token)
.run();
} catch (cause) {
throw new AuthSessionPersistenceError(cause);
}
const changes = d1Changes(result);
if (changes < 0 || changes > 1) {
throw new AuthSessionPersistenceError();
}
try {
await env.ELY_KV.delete(authSessionCacheKvKey(env.ELY_ENVIRONMENT, context.tokenHash));
} catch {
// D1 is authoritative. Scheduled legacy cleanup converges KV failures.
}
return { version: 1, signed_out: true };
}
async function readBetterAuthSessionContext(
env: Env,
token: string,
tokenHash: string,
now: Date,
): Promise<AuthContext> {
const row = await env.ELY_DB.prepare(BETTER_AUTH_SESSION_QUERY)
const row = await primaryD1Session(env.ELY_DB)
.prepare(BETTER_AUTH_SESSION_QUERY)
.bind(token)
.first<BetterAuthSessionRow>();
if (row === null) {
@@ -126,6 +176,11 @@ async function readBetterAuthSessionContext(
return session;
}
function d1Changes(result: unknown): number {
const changes = (result as ElyD1Result | null)?.meta?.changes;
return typeof changes === "number" && Number.isSafeInteger(changes) ? changes : -1;
}
export async function authTokenHash(token: string): Promise<string> {
return sha256Hex(token);
}