From 34ac8420785ad958df4e2dfa763ccde575a7d730 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 10 Jul 2026 06:39:08 -0400 Subject: [PATCH] fix(auth): revoke bearer sessions on sign out --- PRD.md | 7 +- cloudflare/src/auth.ts | 59 ++++- cloudflare/src/index.ts | 28 +++ .../tests/destructive_action_routes.test.ts | 2 +- cloudflare/tests/devices_test_support.ts | 6 + .../tests/session_logout_routes.test.ts | 209 ++++++++++++++++++ cloudflare/tests/sync_snapshot_routes.test.ts | 4 +- cloudflare/tests/sync_status_routes.test.ts | 2 +- 8 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 cloudflare/tests/session_logout_routes.test.ts diff --git a/PRD.md b/PRD.md index 88c394a..7cc4e9e 100644 --- a/PRD.md +++ b/PRD.md @@ -668,6 +668,7 @@ Decrypt + Merge + Apply - Email/password、Email OTP、可选 Google/GitHub OAuth。 - D1 authoritative bearer session,存储用户、账号、会话和验证记录。 +- `first-primary` session validation 与 exact current-session revoke。 - ELY 自定义 device trust、Vault、Sync reset 与账号删除协议。 - `better_auth_session_device_context` 将会话绑定到经过证明的设备身份。 @@ -810,6 +811,7 @@ ELY Desktop Client ↓ HTTPS Cloudflare Workers API ├─ /api/auth/* Better Auth + ├─ /api/session/logout Exact bearer session revoke ├─ /api/sync/push|pull Authenticated retired endpoints ├─ /api/sync/snapshot Encrypted snapshot v3 + global head CAS ├─ /api/sync/vault/* AccountKey envelope bootstrap/read @@ -957,11 +959,12 @@ Better Auth 在 Cloudflare Workers 中初始化,D1 binding 作为 database 传 - Encrypted Email OTP。 - Google/GitHub OAuth 按环境启用。 - D1 session validation 与自定义 device/session binding。 +- Bearer logout 精确删除当前 D1 session,并级联清理 session device context 与 rebind challenge。 - 设备注册、rebind、批准、撤销与 Vault rotation。 - Signed Sync reset 和 signed account deletion。 - 管理所有 `/api/auth/*` 路由。 -目标能力包括 Apple OAuth、Passkey、Recovery Key 与完整服务端 session revoke UX。 +目标能力包括 Apple OAuth、Passkey、Recovery Key 与完整 session 管理 UX。 会话策略: @@ -980,6 +983,7 @@ Auth: | Endpoint | Method | 说明 | |---|---|---| | `/api/auth/*` | Any | Better Auth handler | +| `/api/session/logout` | POST | 精确撤销当前 bearer session | | `/api/devices` | GET | 当前账号设备列表 | | `/api/devices/register` | POST | 注册当前设备公钥 | | `/api/devices/rebind/challenge` | POST | 为未绑定的新会话签发短期 challenge | @@ -1578,6 +1582,7 @@ Servo Host 需要实现: | S-010 | Account deletion | Signed request 原子删除 D1 权威数据;R2 ledger 与 legacy KV cleanup 持久排队并由请求内 drain + scheduled retry 收口 | | S-011 | Snapshot CAS | 单一 global head、exact base CAS、structured 409、exact replay zero R2 put | | S-012 | Destructive proof | Stolen bearer 缺少 device private key 时无法执行 Sync reset 或账号删除 | +| S-013 | Session logout | exact bearer 撤销 D1 当前 session;device context 与 rebind challenge 级联删除;sibling sessions 保留 | ### 19.3 性能验收 diff --git a/cloudflare/src/auth.ts b/cloudflare/src/auth.ts index 556d830..76765c5 100644 --- a/cloudflare/src/auth.ts +++ b/cloudflare/src/auth.ts @@ -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 { + 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 { - 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(); 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 { return sha256Hex(token); } diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index b51fa4b..3a3cbb8 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -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 { + 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; diff --git a/cloudflare/tests/destructive_action_routes.test.ts b/cloudflare/tests/destructive_action_routes.test.ts index 3e9090b..1937004 100644 --- a/cloudflare/tests/destructive_action_routes.test.ts +++ b/cloudflare/tests/destructive_action_routes.test.ts @@ -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 () => { diff --git a/cloudflare/tests/devices_test_support.ts b/cloudflare/tests/devices_test_support.ts index 385c8bd..98f7638 100644 --- a/cloudflare/tests/devices_test_support.ts +++ b/cloudflare/tests/devices_test_support.ts @@ -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(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() } }); }, }; diff --git a/cloudflare/tests/session_logout_routes.test.ts b/cloudflare/tests/session_logout_routes.test.ts new file mode 100644 index 0000000..8b1aafd --- /dev/null +++ b/cloudflare/tests/session_logout_routes.test.ts @@ -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) => Promise, +): Promise { + 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 + ); + `); +} diff --git a/cloudflare/tests/sync_snapshot_routes.test.ts b/cloudflare/tests/sync_snapshot_routes.test.ts index 67b678d..cf2cdf7 100644 --- a/cloudflare/tests/sync_snapshot_routes.test.ts +++ b/cloudflare/tests/sync_snapshot_routes.test.ts @@ -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 () => { diff --git a/cloudflare/tests/sync_status_routes.test.ts b/cloudflare/tests/sync_status_routes.test.ts index c133b37..0f25e0f 100644 --- a/cloudflare/tests/sync_status_routes.test.ts +++ b/cloudflare/tests/sync_status_routes.test.ts @@ -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 () => {