fix(auth): revoke bearer sessions on sign out
This commit is contained in:
@@ -668,6 +668,7 @@ Decrypt + Merge + Apply
|
|||||||
|
|
||||||
- Email/password、Email OTP、可选 Google/GitHub OAuth。
|
- Email/password、Email OTP、可选 Google/GitHub OAuth。
|
||||||
- D1 authoritative bearer session,存储用户、账号、会话和验证记录。
|
- D1 authoritative bearer session,存储用户、账号、会话和验证记录。
|
||||||
|
- `first-primary` session validation 与 exact current-session revoke。
|
||||||
- ELY 自定义 device trust、Vault、Sync reset 与账号删除协议。
|
- ELY 自定义 device trust、Vault、Sync reset 与账号删除协议。
|
||||||
- `better_auth_session_device_context` 将会话绑定到经过证明的设备身份。
|
- `better_auth_session_device_context` 将会话绑定到经过证明的设备身份。
|
||||||
|
|
||||||
@@ -810,6 +811,7 @@ ELY Desktop Client
|
|||||||
↓ HTTPS
|
↓ HTTPS
|
||||||
Cloudflare Workers API
|
Cloudflare Workers API
|
||||||
├─ /api/auth/* Better Auth
|
├─ /api/auth/* Better Auth
|
||||||
|
├─ /api/session/logout Exact bearer session revoke
|
||||||
├─ /api/sync/push|pull Authenticated retired endpoints
|
├─ /api/sync/push|pull Authenticated retired endpoints
|
||||||
├─ /api/sync/snapshot Encrypted snapshot v3 + global head CAS
|
├─ /api/sync/snapshot Encrypted snapshot v3 + global head CAS
|
||||||
├─ /api/sync/vault/* AccountKey envelope bootstrap/read
|
├─ /api/sync/vault/* AccountKey envelope bootstrap/read
|
||||||
@@ -957,11 +959,12 @@ Better Auth 在 Cloudflare Workers 中初始化,D1 binding 作为 database 传
|
|||||||
- Encrypted Email OTP。
|
- Encrypted Email OTP。
|
||||||
- Google/GitHub OAuth 按环境启用。
|
- Google/GitHub OAuth 按环境启用。
|
||||||
- D1 session validation 与自定义 device/session binding。
|
- D1 session validation 与自定义 device/session binding。
|
||||||
|
- Bearer logout 精确删除当前 D1 session,并级联清理 session device context 与 rebind challenge。
|
||||||
- 设备注册、rebind、批准、撤销与 Vault rotation。
|
- 设备注册、rebind、批准、撤销与 Vault rotation。
|
||||||
- Signed Sync reset 和 signed account deletion。
|
- Signed Sync reset 和 signed account deletion。
|
||||||
- 管理所有 `/api/auth/*` 路由。
|
- 管理所有 `/api/auth/*` 路由。
|
||||||
|
|
||||||
目标能力包括 Apple OAuth、Passkey、Recovery Key 与完整服务端 session revoke UX。
|
目标能力包括 Apple OAuth、Passkey、Recovery Key 与完整 session 管理 UX。
|
||||||
|
|
||||||
会话策略:
|
会话策略:
|
||||||
|
|
||||||
@@ -980,6 +983,7 @@ Auth:
|
|||||||
| Endpoint | Method | 说明 |
|
| Endpoint | Method | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/api/auth/*` | Any | Better Auth handler |
|
| `/api/auth/*` | Any | Better Auth handler |
|
||||||
|
| `/api/session/logout` | POST | 精确撤销当前 bearer session |
|
||||||
| `/api/devices` | GET | 当前账号设备列表 |
|
| `/api/devices` | GET | 当前账号设备列表 |
|
||||||
| `/api/devices/register` | POST | 注册当前设备公钥 |
|
| `/api/devices/register` | POST | 注册当前设备公钥 |
|
||||||
| `/api/devices/rebind/challenge` | POST | 为未绑定的新会话签发短期 challenge |
|
| `/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-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-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-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 性能验收
|
### 19.3 性能验收
|
||||||
|
|
||||||
|
|||||||
+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";
|
import { prefixedKvKey } from "./kv_keys.js";
|
||||||
|
|
||||||
const AUTH_SESSION_CACHE_NAMESPACE = "auth_session_cache";
|
const AUTH_SESSION_CACHE_NAMESPACE = "auth_session_cache";
|
||||||
@@ -17,6 +18,10 @@ const BETTER_AUTH_SESSION_QUERY = `
|
|||||||
ON device_context.session_id = session.id
|
ON device_context.session_id = session.id
|
||||||
WHERE session.token = ?
|
WHERE session.token = ?
|
||||||
`;
|
`;
|
||||||
|
const DELETE_AUTHENTICATED_SESSION = `
|
||||||
|
DELETE FROM better_auth_session
|
||||||
|
WHERE id = ? AND userId = ? AND token = ?
|
||||||
|
`;
|
||||||
|
|
||||||
export interface AuthContext {
|
export interface AuthContext {
|
||||||
userId: string;
|
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 {
|
export function authSessionCacheKvKey(environment: string, tokenHash: string): string {
|
||||||
if (!/^[a-f0-9]{64}$/.test(tokenHash)) {
|
if (!/^[a-f0-9]{64}$/.test(tokenHash)) {
|
||||||
throw new AuthSessionSchemaError("token_hash_invalid");
|
throw new AuthSessionSchemaError("token_hash_invalid");
|
||||||
@@ -96,13 +113,46 @@ export async function readAuthContext(
|
|||||||
return readBetterAuthSessionContext(env, token, tokenHash, now);
|
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(
|
async function readBetterAuthSessionContext(
|
||||||
env: Env,
|
env: Env,
|
||||||
token: string,
|
token: string,
|
||||||
tokenHash: string,
|
tokenHash: string,
|
||||||
now: Date,
|
now: Date,
|
||||||
): Promise<AuthContext> {
|
): 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)
|
.bind(token)
|
||||||
.first<BetterAuthSessionRow>();
|
.first<BetterAuthSessionRow>();
|
||||||
if (row === null) {
|
if (row === null) {
|
||||||
@@ -126,6 +176,11 @@ async function readBetterAuthSessionContext(
|
|||||||
return session;
|
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> {
|
export async function authTokenHash(token: string): Promise<string> {
|
||||||
return sha256Hex(token);
|
return sha256Hex(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { Env } from "./bindings.js";
|
import type { Env } from "./bindings.js";
|
||||||
|
import {
|
||||||
|
AuthSessionPersistenceError,
|
||||||
|
deleteAuthenticatedSession,
|
||||||
|
} from "./auth.js";
|
||||||
import {
|
import {
|
||||||
withAuthenticatedApiControls,
|
withAuthenticatedApiControls,
|
||||||
withPublicApiControls,
|
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/")) {
|
if (url.pathname === "/api/auth" || url.pathname.startsWith("/api/auth/")) {
|
||||||
return handleBetterAuthRoute(request, env);
|
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);
|
const deviceResponse = await handleDeviceRoute(request, env, url);
|
||||||
if (deviceResponse !== null) {
|
if (deviceResponse !== null) {
|
||||||
return deviceResponse;
|
return deviceResponse;
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ describe("destructive action proofs", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(response.status, 200);
|
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 () => {
|
it("requires the exact timestamp and session for an account deletion replay", async () => {
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ interface TestD1DatabaseOptions {
|
|||||||
batchError?: Error;
|
batchError?: Error;
|
||||||
batchRowSets?: unknown[][][];
|
batchRowSets?: unknown[][][];
|
||||||
firstRows?: unknown[];
|
firstRows?: unknown[];
|
||||||
|
runError?: Error;
|
||||||
runChanges?: number[];
|
runChanges?: number[];
|
||||||
sessionRow?: unknown | null;
|
sessionRow?: unknown | null;
|
||||||
}
|
}
|
||||||
@@ -190,6 +191,7 @@ export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): Recorde
|
|||||||
isAuthSessionQuery,
|
isAuthSessionQuery,
|
||||||
sessionRow,
|
sessionRow,
|
||||||
() => (!Array.isArray(rows) ? rows.runChanges?.[runIndex++] : undefined) ?? 1,
|
() => (!Array.isArray(rows) ? rows.runChanges?.[runIndex++] : undefined) ?? 1,
|
||||||
|
!Array.isArray(rows) ? rows.runError : undefined,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
batch<T>(statements: ElyD1PreparedStatement[]) {
|
batch<T>(statements: ElyD1PreparedStatement[]) {
|
||||||
@@ -239,6 +241,7 @@ function testD1PreparedStatement(
|
|||||||
isAuthSessionQuery: boolean,
|
isAuthSessionQuery: boolean,
|
||||||
sessionRow: unknown | null,
|
sessionRow: unknown | null,
|
||||||
nextRunChanges: () => number,
|
nextRunChanges: () => number,
|
||||||
|
runError: Error | undefined,
|
||||||
): ElyD1PreparedStatement {
|
): ElyD1PreparedStatement {
|
||||||
return {
|
return {
|
||||||
bind(...values: unknown[]) {
|
bind(...values: unknown[]) {
|
||||||
@@ -255,6 +258,9 @@ function testD1PreparedStatement(
|
|||||||
return Promise.resolve({ results: allRows() as T[] });
|
return Promise.resolve({ results: allRows() as T[] });
|
||||||
},
|
},
|
||||||
run() {
|
run() {
|
||||||
|
if (runError !== undefined) {
|
||||||
|
return Promise.reject(runError);
|
||||||
|
}
|
||||||
return Promise.resolve({ results: [], meta: { changes: nextRunChanges() } });
|
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.equal(response.status, 201);
|
||||||
assert.deepEqual(await response.json(), uploadDocument(committed));
|
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 () => {
|
it("rejects a stale base before writing R2", async () => {
|
||||||
@@ -268,7 +268,7 @@ describe("sync snapshot routes", () => {
|
|||||||
|
|
||||||
assert.equal(response.status, 409);
|
assert.equal(response.status, 409);
|
||||||
assert.deepEqual(await response.json(), conflictDocument(current));
|
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 () => {
|
it("preserves legacy encryption metadata on exact downloads", async () => {
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ describe("sync status routes", () => {
|
|||||||
[USER_ID],
|
[USER_ID],
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(d1.batches, [5]);
|
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 () => {
|
it("returns empty status when the account has no sync facts", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user