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
+6 -1
View File
@@ -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 当前 sessiondevice context 与 rebind challenge 级联删除;sibling sessions 保留 |
### 19.3 性能验收
+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);
}
+28
View File
@@ -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;
@@ -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 () => {