fix(auth): revoke bearer sessions on sign out
This commit is contained in:
+57
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Env } from "./bindings.js";
|
||||
import {
|
||||
AuthSessionPersistenceError,
|
||||
deleteAuthenticatedSession,
|
||||
} from "./auth.js";
|
||||
import {
|
||||
withAuthenticatedApiControls,
|
||||
withPublicApiControls,
|
||||
@@ -59,6 +63,30 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
if (url.pathname === "/api/auth" || url.pathname.startsWith("/api/auth/")) {
|
||||
return handleBetterAuthRoute(request, env);
|
||||
}
|
||||
if (url.pathname === "/api/session/logout") {
|
||||
return withAuthenticatedApiControls(
|
||||
request,
|
||||
env,
|
||||
"session.logout",
|
||||
["POST"],
|
||||
async (context) => {
|
||||
try {
|
||||
return jsonResponse(await deleteAuthenticatedSession(request, env, context), 200, {
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof AuthSessionPersistenceError) {
|
||||
return jsonResponse(
|
||||
{ error: "session_logout_failed" },
|
||||
500,
|
||||
{ "Cache-Control": "no-store" },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
const deviceResponse = await handleDeviceRoute(request, env, url);
|
||||
if (deviceResponse !== null) {
|
||||
return deviceResponse;
|
||||
|
||||
Reference in New Issue
Block a user