diff --git a/cloudflare/src/api_controls.ts b/cloudflare/src/api_controls.ts index fbe970b..8e1f96d 100644 --- a/cloudflare/src/api_controls.ts +++ b/cloudflare/src/api_controls.ts @@ -2,7 +2,7 @@ import type { Env } from "./bindings.js"; import { type AuthContext, AuthError, - AuthSessionCacheSchemaError, + AuthSessionSchemaError, authenticatedRateLimitKey, readAuthContext, } from "./auth.js"; @@ -87,13 +87,13 @@ export async function withAuthenticatedApiControls( recordApiAuditEvent(request, env, route, response, error.code); return response; } - if (error instanceof AuthSessionCacheSchemaError) { + if (error instanceof AuthSessionSchemaError) { const response = jsonResponse( - { error: "auth_session_cache_invalid" }, + { error: "auth_session_invalid" }, 500, { "Cache-Control": "no-store" }, ); - recordApiAuditEvent(request, env, route, response, "auth_session_cache_invalid"); + recordApiAuditEvent(request, env, route, response, "auth_session_invalid"); return response; } throw error; diff --git a/cloudflare/src/auth.ts b/cloudflare/src/auth.ts index c61be52..6385919 100644 --- a/cloudflare/src/auth.ts +++ b/cloudflare/src/auth.ts @@ -45,16 +45,16 @@ export class AuthError extends Error { } } -export class AuthSessionCacheSchemaError extends Error { +export class AuthSessionSchemaError extends Error { constructor(message: string) { super(message); - this.name = "AuthSessionCacheSchemaError"; + this.name = "AuthSessionSchemaError"; } } export function authSessionCacheKvKey(environment: string, tokenHash: string): string { if (!/^[a-f0-9]{64}$/.test(tokenHash)) { - throw new AuthSessionCacheSchemaError("token_hash_invalid"); + throw new AuthSessionSchemaError("token_hash_invalid"); } return `${prefixedKvKey(environment, AUTH_SESSION_CACHE_NAMESPACE)}:${tokenHash}`; } @@ -90,17 +90,7 @@ export async function readAuthContext( } const tokenHash = await sha256Hex(token); - const cacheKey = authSessionCacheKvKey(env.ELY_ENVIRONMENT, tokenHash); - const document = await env.ELY_KV.get(cacheKey); - if (document === null) { - return readBetterAuthSessionContext(env, token, tokenHash, now); - } - - const session = parseAuthSessionCacheDocument(document, tokenHash); - if (Date.parse(session.expiresAt) <= now.getTime()) { - throw new AuthError("session_expired"); - } - return session; + return readBetterAuthSessionContext(env, token, tokenHash, now); } async function readBetterAuthSessionContext( @@ -152,45 +142,16 @@ function bearerToken(request: Request): string | null { return token; } -function parseAuthSessionCacheDocument(value: string, tokenHash: string): AuthContext { - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch { - throw new AuthSessionCacheSchemaError("auth_session_cache_json_invalid"); - } - - if (!isRecord(parsed)) { - throw new AuthSessionCacheSchemaError("auth_session_cache_must_be_object"); - } - assertOnlyFields(parsed, ["version", "user_id", "session_id", "device_id", "expires_at"]); - if (parsed.version !== 1) { - throw new AuthSessionCacheSchemaError("auth_session_cache_version_invalid"); - } - - const context: AuthContext = { - userId: subjectId(stringField(parsed, "user_id"), "user_id"), - sessionId: subjectId(stringField(parsed, "session_id"), "session_id"), - tokenHash, - expiresAt: isoTimestamp(stringField(parsed, "expires_at"), "expires_at"), - }; - const deviceId = optionalStringField(parsed, "device_id"); - if (deviceId !== undefined) { - context.deviceId = deviceIdValue(deviceId); - } - return context; -} - function subjectId(value: string, label: string): string { if (!SUBJECT_ID_PATTERN.test(value)) { - throw new AuthSessionCacheSchemaError(`${label}_invalid`); + throw new AuthSessionSchemaError(`${label}_invalid`); } return value; } function deviceIdValue(value: string): string { if (!DEVICE_ID_PATTERN.test(value)) { - throw new AuthSessionCacheSchemaError("device_id_invalid"); + throw new AuthSessionSchemaError("device_id_invalid"); } return value; } @@ -198,7 +159,7 @@ function deviceIdValue(value: string): string { function isoTimestamp(value: string, label: string): string { const timestamp = Date.parse(value); if (!Number.isFinite(timestamp)) { - throw new AuthSessionCacheSchemaError(`${label}_invalid`); + throw new AuthSessionSchemaError(`${label}_invalid`); } return new Date(timestamp).toISOString(); } @@ -214,13 +175,13 @@ function timestampField(value: Record, field: string): string { if (fieldValue instanceof Date) { return fieldValue.toISOString(); } - throw new AuthSessionCacheSchemaError(`${field}_required`); + throw new AuthSessionSchemaError(`${field}_required`); } function stringField(value: Record, field: string): string { const fieldValue = value[field]; if (typeof fieldValue !== "string" || fieldValue.trim() === "") { - throw new AuthSessionCacheSchemaError(`${field}_required`); + throw new AuthSessionSchemaError(`${field}_required`); } return fieldValue.trim(); } @@ -231,24 +192,11 @@ function optionalStringField(value: Record, field: string): str return undefined; } if (typeof fieldValue !== "string" || fieldValue.trim() === "") { - throw new AuthSessionCacheSchemaError(`${field}_invalid`); + throw new AuthSessionSchemaError(`${field}_invalid`); } return fieldValue.trim(); } -function assertOnlyFields(value: Record, fields: string[]): void { - const allowed = new Set(fields); - for (const field of Object.keys(value)) { - if (!allowed.has(field)) { - throw new AuthSessionCacheSchemaError(`unexpected_field:${field}`); - } - } -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - async function sha256Hex(value: string): Promise { const bytes = new TextEncoder().encode(value); const digest = await crypto.subtle.digest("SHA-256", bytes); diff --git a/cloudflare/src/devices.ts b/cloudflare/src/devices.ts index 1e6b788..800f5b9 100644 --- a/cloudflare/src/devices.ts +++ b/cloudflare/src/devices.ts @@ -1,5 +1,4 @@ import type { AuthContext } from "./auth.js"; -import { authSessionCacheKvKey } from "./auth.js"; import type { Env } from "./bindings.js"; import { type DeviceApprovalDocument, @@ -228,7 +227,6 @@ export async function registerDeviceDocument( throw new DevicePersistenceError("device_registration_missing"); } await bindSessionDeviceContext(env, context, registration.deviceId, nowSeconds); - await refreshSessionDeviceCache(env, context, registration.deviceId); return { version: 1, @@ -248,23 +246,6 @@ async function bindSessionDeviceContext( .run(); } -async function refreshSessionDeviceCache( - env: Env, - context: AuthContext, - deviceId: string, -): Promise { - await env.ELY_KV.put( - authSessionCacheKvKey(env.ELY_ENVIRONMENT, context.tokenHash), - JSON.stringify({ - version: 1, - user_id: context.userId, - session_id: context.sessionId, - device_id: deviceId, - expires_at: context.expiresAt, - }), - ); -} - export async function approveDeviceDocument( request: Request, env: Env, diff --git a/cloudflare/tests/api_controls.test.ts b/cloudflare/tests/api_controls.test.ts index 5160415..6fcd5de 100644 --- a/cloudflare/tests/api_controls.test.ts +++ b/cloudflare/tests/api_controls.test.ts @@ -79,7 +79,7 @@ describe("api controls", () => { assert.equal(auditEvents[0]?.doubles?.[0], 405); }); - it("rate limits authenticated API routes before reading session cache", async () => { + it("rate limits authenticated API routes before reading session state", async () => { const auditEvents: ElyAnalyticsDataPoint[] = []; const kvReads: string[] = []; const response = await withAuthenticatedApiControls( @@ -167,7 +167,6 @@ describe("api controls", () => { auditEvents, kvReads, rateLimitKeys, - kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument()]], }), "devices.list", ["GET"], @@ -182,7 +181,7 @@ describe("api controls", () => { assert.equal(response.status, 200); assert.deepEqual(await response.json(), { user_id: "user-01", device_id: "device-01" }); assert.deepEqual(rateLimitKeys, [`local:devices.list:bearer:${tokenHash}`]); - assert.deepEqual(kvReads, [authSessionCacheKvKey("local", tokenHash)]); + assert.deepEqual(kvReads, []); assert.equal(receivedTokenHash, tokenHash); assert.deepEqual(auditEvents[0]?.blobs, [ "devices.list", @@ -196,7 +195,7 @@ describe("api controls", () => { ]); }); - it("uses Better Auth D1 sessions when the session cache is cold", async () => { + it("uses Better Auth D1 sessions as the authoritative source", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const auditEvents: ElyAnalyticsDataPoint[] = []; const kvReads: string[] = []; @@ -246,7 +245,7 @@ describe("api controls", () => { device_id: "device-01", }); assert.deepEqual(rateLimitKeys, [`local:devices.list:bearer:${tokenHash}`]); - assert.deepEqual(kvReads, [authSessionCacheKvKey("local", tokenHash)]); + assert.deepEqual(kvReads, []); assert.equal(d1Queries.length, 1); assert.match(d1Queries[0] ?? "", /FROM better_auth_session/); assert.deepEqual(d1Binds, [[ACCESS_TOKEN]]); @@ -262,19 +261,49 @@ describe("api controls", () => { assert.equal(auditEvents[0]?.blobs?.[7], "device-01"); }); - it("rejects expired authenticated sessions", async () => { + it("rejects a legacy cached session after D1 revocation", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); + const kvReads: string[] = []; + const d1Queries: string[] = []; + const d1Binds: unknown[][] = []; const response = await withAuthenticatedApiControls( new Request("https://elydora.test/api/devices", { headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, }), testEnv({ - kvEntries: [ - [ - authSessionCacheKvKey("local", tokenHash), - sessionDocument("2026-01-01T00:00:00.000Z"), + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument()]], + kvReads, + d1: testD1Database({ firstRows: [], queries: d1Queries, binds: d1Binds }), + }), + "devices.list", + ["GET"], + () => Promise.resolve(jsonResponse({ ok: true }, 200)), + ); + + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "session_not_found" }); + assert.deepEqual(kvReads, []); + assert.equal(d1Queries.length, 1); + assert.match(d1Queries[0] ?? "", /FROM better_auth_session/); + assert.deepEqual(d1Binds, [[ACCESS_TOKEN]]); + }); + + it("rejects expired authenticated sessions", async () => { + const response = await withAuthenticatedApiControls( + new Request("https://elydora.test/api/devices", { + headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, + }), + testEnv({ + d1: testD1Database({ + firstRows: [ + { + id: "session-01", + userId: "user-01", + expiresAt: "2026-01-01T00:00:00.000Z", + deviceId: "device-01", + }, ], - ], + }), }), "devices.list", ["GET"], @@ -383,11 +412,22 @@ interface TestD1DatabaseOptions { } function testD1Database(options: TestD1DatabaseOptions = {}): Env["ELY_DB"] { + const configuredOptions: TestD1DatabaseOptions = { + ...options, + firstRows: options.firstRows ?? [ + { + id: "session-01", + userId: "user-01", + expiresAt: "2099-01-01T00:00:00.000Z", + deviceId: "device-01", + }, + ], + }; let firstIndex = 0; return { prepare(query: string) { options.queries?.push(query); - return testD1PreparedStatement(options, () => firstIndex++); + return testD1PreparedStatement(configuredOptions, () => firstIndex++); }, batch() { return Promise.resolve([]); diff --git a/cloudflare/tests/devices_routes.test.ts b/cloudflare/tests/devices_routes.test.ts index 89547e4..694242d 100644 --- a/cloudflare/tests/devices_routes.test.ts +++ b/cloudflare/tests/devices_routes.test.ts @@ -223,26 +223,34 @@ describe("device routes", () => { assert.equal(d1.binds[0]?.[7], IDEMPOTENCY_KEY); assert.deepEqual(d1.binds[1], ["user-01", IDEMPOTENCY_KEY]); assert.deepEqual(d1.binds[2]?.slice(0, 3), ["session-01", "user-01", "device-01"]); - assert.deepEqual(kvPuts, [[sessionCacheKey, sessionDocument("device-01")]]); + assert.deepEqual(kvPuts, []); }); - it("registers and caches device context for sessions without a current device", async () => { + it("registers D1 device context for sessions without a current device", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const sessionCacheKey = authSessionCacheKvKey("local", tokenHash); const kvPuts: [string, string][] = []; - const d1 = testD1Database([ - { - device_id: "device-01", - public_key: PUBLIC_KEY, - device_name: "MacBook Pro", - platform: "macOS", - approval_status: "pending", - created_at: 1_780_000_100, - approved_at: null, - last_active_at: 1_780_000_100, - revoked_at: null, + const deviceRow = { + device_id: "device-01", + public_key: PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + approval_status: "pending", + created_at: 1_780_000_100, + approved_at: null, + last_active_at: 1_780_000_100, + revoked_at: null, + }; + const d1 = testD1Database({ + allRows: [deviceRow], + firstRows: [deviceRow], + sessionRow: { + id: "session-01", + userId: "user-01", + expiresAt: "2099-01-01T00:00:00.000Z", + deviceId: null, }, - ]); + }); const response = await handleRequest( new Request("https://elydora.test/api/devices/register", { @@ -262,7 +270,7 @@ describe("device routes", () => { assert.equal(response.status, 201); assert.deepEqual(d1.binds[2]?.slice(0, 3), ["session-01", "user-01", "device-01"]); - assert.deepEqual(kvPuts, [[sessionCacheKey, sessionDocument("device-01")]]); + assert.deepEqual(kvPuts, []); }); it("rejects invalid device registration payloads before D1 writes", async () => { diff --git a/cloudflare/tests/devices_test_support.ts b/cloudflare/tests/devices_test_support.ts index fca2acb..d0d064d 100644 --- a/cloudflare/tests/devices_test_support.ts +++ b/cloudflare/tests/devices_test_support.ts @@ -24,6 +24,8 @@ export interface TestEnvOptions { } export interface RecordedD1Database extends ElyD1Database { + authBinds: unknown[][]; + authQueries: string[]; batches: number[]; binds: unknown[][]; queries: string[]; @@ -38,8 +40,16 @@ export interface RecordedR2Put { interface TestD1DatabaseOptions { allRows?: unknown[]; firstRows?: unknown[]; + sessionRow?: unknown | null; } +const DEFAULT_AUTH_SESSION_ROW = { + id: "session-01", + userId: "user-01", + expiresAt: "2099-01-01T00:00:00.000Z", + deviceId: "device-01", +}; + export function testEnv(options: TestEnvOptions): Env { const values = new Map(options.kvEntries ?? []); return { @@ -92,19 +102,35 @@ export function testEnv(options: TestEnvOptions): Env { } export function testD1Database(rows: unknown[] | TestD1DatabaseOptions): RecordedD1Database { + const authBinds: unknown[][] = []; + const authQueries: string[] = []; const binds: unknown[][] = []; const batches: number[] = []; const queries: string[] = []; const allRows = Array.isArray(rows) ? rows : rows.allRows ?? []; const firstRows = Array.isArray(rows) ? rows : rows.firstRows ?? []; + const sessionRow = + !Array.isArray(rows) && Object.hasOwn(rows, "sessionRow") + ? rows.sessionRow ?? null + : DEFAULT_AUTH_SESSION_ROW; let firstIndex = 0; return { + authBinds, + authQueries, batches, binds, queries, prepare(query: string) { - queries.push(query); - return testD1PreparedStatement(allRows, firstRows, () => firstIndex++, binds); + const isAuthSessionQuery = query.includes("FROM better_auth_session AS session"); + (isAuthSessionQuery ? authQueries : queries).push(query); + return testD1PreparedStatement( + allRows, + firstRows, + () => firstIndex++, + isAuthSessionQuery ? authBinds : binds, + isAuthSessionQuery, + sessionRow, + ); }, batch(statements: ElyD1PreparedStatement[]) { batches.push(statements.length); @@ -131,6 +157,8 @@ function testD1PreparedStatement( firstRows: unknown[], nextFirstIndex: () => number, binds: unknown[][], + isAuthSessionQuery: boolean, + sessionRow: unknown | null, ): ElyD1PreparedStatement { return { bind(...values: unknown[]) { @@ -138,6 +166,9 @@ function testD1PreparedStatement( return this; }, first() { + if (isAuthSessionQuery) { + return Promise.resolve(sessionRow as T | null); + } return Promise.resolve((firstRows[nextFirstIndex()] as T | undefined) ?? null); }, all() {