diff --git a/cloudflare/src/device_schema.ts b/cloudflare/src/device_schema.ts new file mode 100644 index 0000000..d281681 --- /dev/null +++ b/cloudflare/src/device_schema.ts @@ -0,0 +1,299 @@ +import type { AuthContext } from "./auth.js"; + +const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; +const PUBLIC_KEY_PATTERN = /^[a-fA-F0-9]{64,256}$/; +const DEVICE_TEXT_PATTERN = /^[^\p{Cc}\p{Cs}]{1,128}$/u; +const APPROVAL_STATUS = new Set(["pending", "approved", "revoked"]); + +export interface DeviceListDocument { + version: 1; + user_id: string; + devices: DeviceDocument[]; +} + +export interface DeviceRegistrationDocument { + version: 1; + user_id: string; + device: DeviceDocument; +} + +export interface DeviceApprovalDocument { + version: 1; + user_id: string; + approved_by_device_id: string; + approved_at: number; + device: DeviceDocument; +} + +export interface DeviceRevocationDocument { + version: 1; + user_id: string; + revoked_by_device_id: string; + revoked_at: number; + device: DeviceDocument; +} + +export interface DeviceDocument { + device_id: string; + public_key: string; + device_name: string; + platform: string; + approval_status: "pending" | "approved" | "revoked"; + created_at: number; + approved_at: number | null; + last_active_at: number | null; + revoked_at: number | null; + current: boolean; +} + +export interface DeviceRow { + device_id: unknown; + public_key: unknown; + device_name: unknown; + platform: unknown; + approval_status: unknown; + created_at: unknown; + approved_at: unknown; + last_active_at: unknown; + revoked_at: unknown; +} + +export interface DeviceRegistrationRequest { + deviceId: string; + publicKey: string; + deviceName: string; + platform: string; + idempotencyKey: string; +} + +export type DeviceApprovalRequest = { deviceId: string; idempotencyKey: string }; +export type DeviceRevocationRequest = { deviceId: string; idempotencyKey: string }; + +export interface DeviceApprovalRow { + device_id: unknown; + requester_device_id: unknown; + status: unknown; + decided_at: unknown; +} + +export interface DeviceRevocationRow { + actor_device_id: unknown; + subject_id: unknown; + outcome: unknown; + created_at: unknown; +} + +type DeviceRequestBody = Record; + +export class DeviceSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = "DeviceSchemaError"; + } +} + +export class DevicePermissionError extends Error { + constructor(message: string) { + super(message); + this.name = "DevicePermissionError"; + } +} + +export class DevicePersistenceError extends Error { + constructor(message: string) { + super(message); + this.name = "DevicePersistenceError"; + } +} + +export async function deviceRegistrationRequest( + request: Request, +): Promise { + const value = await deviceRequestBody(request, "device_registration"); + assertOnlyFields(value, [ + "version", + "device_id", + "public_key", + "device_name", + "platform", + "idempotency_key", + ]); + if (value.version !== 1) { + throw new DeviceSchemaError("device_registration_version_invalid"); + } + + return { + deviceId: deviceIdValue(value.device_id, "device_id"), + publicKey: publicKeyValue(value.public_key), + deviceName: deviceText(value.device_name, "device_name"), + platform: deviceText(value.platform, "platform"), + idempotencyKey: idempotencyKeyValue(value.idempotency_key), + }; +} + +export async function deviceApprovalRequest(request: Request): Promise { + const value = await deviceRequestBody(request, "device_approval"); + assertOnlyFields(value, ["version", "device_id", "idempotency_key"]); + if (value.version !== 1) { + throw new DeviceSchemaError("device_approval_version_invalid"); + } + + return { + deviceId: deviceIdValue(value.device_id, "device_id"), + idempotencyKey: idempotencyKeyValue(value.idempotency_key), + }; +} + +export async function deviceRevocationRequest(request: Request): Promise { + const value = await deviceRequestBody(request, "device_revocation"); + assertOnlyFields(value, ["version", "device_id", "idempotency_key"]); + if (value.version !== 1) { + throw new DeviceSchemaError("device_revocation_version_invalid"); + } + + return { + deviceId: deviceIdValue(value.device_id, "device_id"), + idempotencyKey: idempotencyKeyValue(value.idempotency_key), + }; +} + +export function approvedDeviceDocument( + userId: string, + approvedByDeviceId: string, + row: DeviceRow, +): DeviceApprovalDocument { + const device = deviceDocument(row, approvedByDeviceId); + if (device.approval_status !== "approved" || device.approved_at === null) { + throw new DevicePersistenceError("device_approval_missing"); + } + return { + version: 1, + user_id: userId, + approved_by_device_id: approvedByDeviceId, + approved_at: device.approved_at, + device, + }; +} + +export function revokedDeviceDocument( + userId: string, + revokedByDeviceId: string, + row: DeviceRow, +): DeviceRevocationDocument { + const device = deviceDocument(row, revokedByDeviceId); + if (device.approval_status !== "revoked" || device.revoked_at === null) { + throw new DevicePersistenceError("device_revocation_missing"); + } + return { + version: 1, + user_id: userId, + revoked_by_device_id: revokedByDeviceId, + revoked_at: device.revoked_at, + device, + }; +} + +export function currentDeviceId(context: AuthContext): string { + if (context.deviceId === undefined) { + throw new DevicePermissionError("device_context_required"); + } + return context.deviceId; +} + +export function deviceDocument( + row: DeviceRow, + currentDeviceIdValue: string | undefined, +): DeviceDocument { + const deviceId = deviceIdValue(row.device_id, "device_id"); + return { + device_id: deviceId, + public_key: publicKeyValue(row.public_key), + device_name: deviceText(row.device_name, "device_name"), + platform: deviceText(row.platform, "platform"), + approval_status: approvalStatus(row.approval_status), + created_at: timestamp(row.created_at, "created_at"), + approved_at: nullableTimestamp(row.approved_at, "approved_at"), + last_active_at: nullableTimestamp(row.last_active_at, "last_active_at"), + revoked_at: nullableTimestamp(row.revoked_at, "revoked_at"), + current: currentDeviceIdValue === deviceId, + }; +} + +export function deviceIdValue(value: unknown, label: string): string { + if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) { + throw new DeviceSchemaError(`${label}_invalid`); + } + return value; +} + +export function timestamp(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new DeviceSchemaError(`${label}_invalid`); + } + return value; +} + +function publicKeyValue(value: unknown): string { + if (typeof value !== "string" || !PUBLIC_KEY_PATTERN.test(value)) { + throw new DeviceSchemaError("public_key_invalid"); + } + return value.toLowerCase(); +} + +function deviceText(value: unknown, label: string): string { + if (typeof value !== "string") { + throw new DeviceSchemaError(`${label}_invalid`); + } + const trimmed = value.trim(); + if (!DEVICE_TEXT_PATTERN.test(trimmed)) { + throw new DeviceSchemaError(`${label}_invalid`); + } + return trimmed; +} + +function approvalStatus(value: unknown): DeviceDocument["approval_status"] { + if (typeof value !== "string" || !APPROVAL_STATUS.has(value)) { + throw new DeviceSchemaError("approval_status_invalid"); + } + return value as DeviceDocument["approval_status"]; +} + +function idempotencyKeyValue(value: unknown): string { + if (typeof value !== "string" || !/^[a-zA-Z0-9._:-]{16,128}$/.test(value)) { + throw new DeviceSchemaError("idempotency_key_invalid"); + } + return value; +} + +function nullableTimestamp(value: unknown, label: string): number | null { + if (value === null) { + return null; + } + return timestamp(value, label); +} + +function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void { + const allowed = new Set(fields); + for (const field of Object.keys(value)) { + if (!allowed.has(field)) { + throw new DeviceSchemaError(`unexpected_field:${field}`); + } + } +} + +async function deviceRequestBody(request: Request, label: string): Promise { + let value: unknown; + try { + value = await request.json(); + } catch { + throw new DeviceSchemaError(`${label}_json_invalid`); + } + if (!isRecord(value)) { + throw new DeviceSchemaError(`${label}_must_be_object`); + } + return value; +} + +function isRecord(value: unknown): value is DeviceRequestBody { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/cloudflare/src/devices.ts b/cloudflare/src/devices.ts index 9134d22..58b9fa7 100644 --- a/cloudflare/src/devices.ts +++ b/cloudflare/src/devices.ts @@ -1,10 +1,33 @@ import type { AuthContext } from "./auth.js"; import type { Env } from "./bindings.js"; +import { + type DeviceApprovalDocument, + type DeviceApprovalRequest, + type DeviceApprovalRow, + type DeviceListDocument, + type DeviceRegistrationDocument, + type DeviceRevocationDocument, + type DeviceRevocationRequest, + type DeviceRevocationRow, + type DeviceRow, + DevicePermissionError, + DevicePersistenceError, + approvedDeviceDocument, + currentDeviceId, + deviceApprovalRequest, + deviceDocument, + deviceIdValue, + deviceRegistrationRequest, + deviceRevocationRequest, + revokedDeviceDocument, + timestamp, +} from "./device_schema.js"; -const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; -const PUBLIC_KEY_PATTERN = /^[a-fA-F0-9]{64,256}$/; -const DEVICE_TEXT_PATTERN = /^[^\p{Cc}\p{Cs}]{1,128}$/u; -const APPROVAL_STATUS = new Set(["pending", "approved", "revoked"]); +export { + DevicePermissionError, + DevicePersistenceError, + DeviceSchemaError, +} from "./device_schema.js"; const DEVICE_LIST_QUERY = ` SELECT @@ -113,91 +136,36 @@ const DEVICE_APPROVE_QUERY = ` last_active_at = ? WHERE user_id = ? AND device_id = ? AND approval_status = 'pending' AND revoked_at IS NULL `; - -export interface DeviceListDocument { - version: 1; - user_id: string; - devices: DeviceDocument[]; -} - -export interface DeviceRegistrationDocument { - version: 1; - user_id: string; - device: DeviceDocument; -} - -export interface DeviceApprovalDocument { - version: 1; - user_id: string; - approved_by_device_id: string; - approved_at: number; - device: DeviceDocument; -} - -export interface DeviceDocument { - device_id: string; - public_key: string; - device_name: string; - platform: string; - approval_status: "pending" | "approved" | "revoked"; - created_at: number; - approved_at: number | null; - last_active_at: number | null; - revoked_at: number | null; - current: boolean; -} - -interface DeviceRow { - device_id: unknown; - public_key: unknown; - device_name: unknown; - platform: unknown; - approval_status: unknown; - created_at: unknown; - approved_at: unknown; - last_active_at: unknown; - revoked_at: unknown; -} - -interface DeviceRegistrationRequest { - deviceId: string; - publicKey: string; - deviceName: string; - platform: string; - idempotencyKey: string; -} - -type DeviceApprovalRequest = { deviceId: string; idempotencyKey: string }; - -interface DeviceApprovalRow { - device_id: unknown; - requester_device_id: unknown; - status: unknown; - decided_at: unknown; -} - -type DeviceRequestBody = Record; - -export class DeviceSchemaError extends Error { - constructor(message: string) { - super(message); - this.name = "DeviceSchemaError"; - } -} - -export class DevicePermissionError extends Error { - constructor(message: string) { - super(message); - this.name = "DevicePermissionError"; - } -} - -export class DevicePersistenceError extends Error { - constructor(message: string) { - super(message); - this.name = "DevicePersistenceError"; - } -} +const DEVICE_REVOCATION_BY_IDEMPOTENCY_KEY_QUERY = ` + SELECT + actor_device_id, + subject_id, + outcome, + created_at + FROM audit_events + WHERE user_id = ? AND event_id = ? AND event_type = 'device.revoke' +`; +const DEVICE_REVOCATION_INSERT_QUERY = ` + INSERT INTO audit_events ( + event_id, + user_id, + actor_device_id, + event_type, + subject_type, + subject_id, + outcome, + metadata_hash, + created_at + ) VALUES (?, ?, ?, 'device.revoke', 'device', ?, 'success', NULL, ?) + ON CONFLICT(event_id) DO NOTHING +`; +const DEVICE_REVOKE_QUERY = ` + UPDATE user_devices + SET + approval_status = 'revoked', + revoked_at = COALESCE(revoked_at, ?) + WHERE user_id = ? AND device_id = ? AND revoked_at IS NULL +`; export async function deviceListDocument( env: Env, @@ -260,6 +228,7 @@ export async function approveDeviceDocument( throw new DevicePermissionError("device_self_approval_forbidden"); } + await assertApprovedRequester(env, context.userId, requesterDeviceId); const existingApproval = await env.ELY_DB.prepare(DEVICE_APPROVAL_BY_IDEMPOTENCY_KEY_QUERY) .bind(context.userId, approval.idempotencyKey) .first(); @@ -267,13 +236,6 @@ export async function approveDeviceDocument( return existingApprovalDocument(env, context, approval, requesterDeviceId, existingApproval); } - const approver = await env.ELY_DB.prepare(APPROVED_DEVICE_QUERY) - .bind(context.userId, requesterDeviceId) - .first(); - if (approver === null) { - throw new DevicePermissionError("approver_device_unapproved"); - } - const pendingDevice = await deviceRowById(env, context.userId, approval.deviceId); if (pendingDevice === null) { throw new DevicePermissionError("device_not_found"); @@ -309,40 +271,56 @@ export async function approveDeviceDocument( return approvedDeviceDocument(context.userId, requesterDeviceId, approvedDevice); } -async function deviceRegistrationRequest(request: Request): Promise { - const value = await deviceRequestBody(request, "device_registration"); - assertOnlyFields(value, [ - "version", - "device_id", - "public_key", - "device_name", - "platform", - "idempotency_key", - ]); - if (value.version !== 1) { - throw new DeviceSchemaError("device_registration_version_invalid"); +export async function revokeDeviceDocument( + request: Request, + env: Env, + context: AuthContext, + nowSeconds = Math.floor(Date.now() / 1000), +): Promise { + const revocation = await deviceRevocationRequest(request); + const requesterDeviceId = currentDeviceId(context); + if (requesterDeviceId === revocation.deviceId) { + throw new DevicePermissionError("device_self_revocation_forbidden"); } - return { - deviceId: deviceIdValue(value.device_id, "device_id"), - publicKey: publicKeyValue(value.public_key), - deviceName: deviceText(value.device_name, "device_name"), - platform: deviceText(value.platform, "platform"), - idempotencyKey: idempotencyKeyValue(value.idempotency_key), - }; + const revocationEventId = deviceRevocationEventId(context.userId, revocation.idempotencyKey); + await assertApprovedRequester(env, context.userId, requesterDeviceId); + const existingRevocation = await env.ELY_DB.prepare(DEVICE_REVOCATION_BY_IDEMPOTENCY_KEY_QUERY) + .bind(context.userId, revocationEventId) + .first(); + if (existingRevocation !== null) { + return existingRevocationDocument(env, context, revocation, requesterDeviceId, existingRevocation); + } + + const targetDevice = await deviceRowById(env, context.userId, revocation.deviceId); + if (targetDevice === null) { + throw new DevicePermissionError("device_not_found"); + } + const targetDocument = deviceDocument(targetDevice, requesterDeviceId); + if (targetDocument.revoked_at !== null) { + throw new DevicePermissionError("device_already_revoked"); + } + + await env.ELY_DB.batch([ + env.ELY_DB.prepare(DEVICE_REVOCATION_INSERT_QUERY).bind( + revocationEventId, + context.userId, + requesterDeviceId, + revocation.deviceId, + nowSeconds, + ), + env.ELY_DB.prepare(DEVICE_REVOKE_QUERY).bind(nowSeconds, context.userId, revocation.deviceId), + ]); + + const revokedDevice = await deviceRowById(env, context.userId, revocation.deviceId); + if (revokedDevice === null) { + throw new DevicePersistenceError("device_revocation_missing"); + } + return revokedDeviceDocument(context.userId, requesterDeviceId, revokedDevice); } -async function deviceApprovalRequest(request: Request): Promise { - const value = await deviceRequestBody(request, "device_approval"); - assertOnlyFields(value, ["version", "device_id", "idempotency_key"]); - if (value.version !== 1) { - throw new DeviceSchemaError("device_approval_version_invalid"); - } - - return { - deviceId: deviceIdValue(value.device_id, "device_id"), - idempotencyKey: idempotencyKeyValue(value.idempotency_key), - }; +function deviceRevocationEventId(userId: string, idempotencyKey: string): string { + return `device-revoke:${userId}:${idempotencyKey}`; } async function existingApprovalDocument( @@ -373,126 +351,47 @@ async function existingApprovalDocument( }; } -function approvedDeviceDocument( - userId: string, - approvedByDeviceId: string, - row: DeviceRow, -): DeviceApprovalDocument { - const device = deviceDocument(row, approvedByDeviceId); - if (device.approval_status !== "approved" || device.approved_at === null) { - throw new DevicePersistenceError("device_approval_missing"); +async function existingRevocationDocument( + env: Env, + context: AuthContext, + revocation: DeviceRevocationRequest, + requesterDeviceId: string, + row: DeviceRevocationRow, +): Promise { + const revokedDeviceId = deviceIdValue(row.subject_id, "subject_id"); + const revokedByDeviceId = deviceIdValue(row.actor_device_id, "actor_device_id"); + if ( + revokedDeviceId !== revocation.deviceId || + revokedByDeviceId !== requesterDeviceId || + row.outcome !== "success" + ) { + throw new DevicePermissionError("device_revocation_replay_mismatch"); } + + const revokedDevice = await deviceRowById(env, context.userId, revokedDeviceId); + if (revokedDevice === null) { + throw new DevicePersistenceError("device_revocation_missing"); + } + return { - version: 1, - user_id: userId, - approved_by_device_id: approvedByDeviceId, - approved_at: device.approved_at, - device, + ...revokedDeviceDocument(context.userId, requesterDeviceId, revokedDevice), + revoked_at: timestamp(row.created_at, "created_at"), }; } -function currentDeviceId(context: AuthContext): string { - if (context.deviceId === undefined) { - throw new DevicePermissionError("device_context_required"); +async function assertApprovedRequester( + env: Env, + userId: string, + requesterDeviceId: string, +): Promise { + const requester = await env.ELY_DB.prepare(APPROVED_DEVICE_QUERY) + .bind(userId, requesterDeviceId) + .first(); + if (requester === null) { + throw new DevicePermissionError("requester_device_unapproved"); } - return context.deviceId; } async function deviceRowById(env: Env, userId: string, deviceId: string): Promise { return env.ELY_DB.prepare(DEVICE_BY_ID_QUERY).bind(userId, deviceId).first(); } - -async function deviceRequestBody(request: Request, label: string): Promise { - let value: unknown; - try { - value = await request.json(); - } catch { - throw new DeviceSchemaError(`${label}_json_invalid`); - } - if (!isRecord(value)) { - throw new DeviceSchemaError(`${label}_must_be_object`); - } - return value; -} - -function deviceDocument(row: DeviceRow, currentDeviceId: string | undefined): DeviceDocument { - const deviceId = deviceIdValue(row.device_id, "device_id"); - return { - device_id: deviceId, - public_key: publicKeyValue(row.public_key), - device_name: deviceText(row.device_name, "device_name"), - platform: deviceText(row.platform, "platform"), - approval_status: approvalStatus(row.approval_status), - created_at: timestamp(row.created_at, "created_at"), - approved_at: nullableTimestamp(row.approved_at, "approved_at"), - last_active_at: nullableTimestamp(row.last_active_at, "last_active_at"), - revoked_at: nullableTimestamp(row.revoked_at, "revoked_at"), - current: currentDeviceId === deviceId, - }; -} - -function deviceIdValue(value: unknown, label: string): string { - if (typeof value !== "string" || !DEVICE_ID_PATTERN.test(value)) { - throw new DeviceSchemaError(`${label}_invalid`); - } - return value; -} - -function publicKeyValue(value: unknown): string { - if (typeof value !== "string" || !PUBLIC_KEY_PATTERN.test(value)) { - throw new DeviceSchemaError("public_key_invalid"); - } - return value.toLowerCase(); -} - -function deviceText(value: unknown, label: string): string { - if (typeof value !== "string") { - throw new DeviceSchemaError(`${label}_invalid`); - } - const trimmed = value.trim(); - if (!DEVICE_TEXT_PATTERN.test(trimmed)) { - throw new DeviceSchemaError(`${label}_invalid`); - } - return trimmed; -} - -function approvalStatus(value: unknown): DeviceDocument["approval_status"] { - if (typeof value !== "string" || !APPROVAL_STATUS.has(value)) { - throw new DeviceSchemaError("approval_status_invalid"); - } - return value as DeviceDocument["approval_status"]; -} - -function idempotencyKeyValue(value: unknown): string { - if (typeof value !== "string" || !/^[a-zA-Z0-9._:-]{16,128}$/.test(value)) { - throw new DeviceSchemaError("idempotency_key_invalid"); - } - return value; -} - -function nullableTimestamp(value: unknown, label: string): number | null { - if (value === null) { - return null; - } - return timestamp(value, label); -} - -function timestamp(value: unknown, label: string): number { - if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { - throw new DeviceSchemaError(`${label}_invalid`); - } - return value; -} - -function assertOnlyFields(value: DeviceRequestBody, fields: string[]): void { - const allowed = new Set(fields); - for (const field of Object.keys(value)) { - if (!allowed.has(field)) { - throw new DeviceSchemaError(`unexpected_field:${field}`); - } - } -} - -function isRecord(value: unknown): value is DeviceRequestBody { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/cloudflare/src/index.ts b/cloudflare/src/index.ts index 124680a..3f2bfb3 100644 --- a/cloudflare/src/index.ts +++ b/cloudflare/src/index.ts @@ -7,6 +7,7 @@ import { approveDeviceDocument, deviceListDocument, registerDeviceDocument, + revokeDeviceDocument, } from "./devices.js"; import { PluginRegistrySchemaError, @@ -130,6 +131,44 @@ export async function handleRequest(request: Request, env: Env): Promise { + try { + return jsonResponse(await revokeDeviceDocument(request, env, context), 200, { + "Cache-Control": "no-store", + }); + } catch (error) { + if (error instanceof DevicePermissionError) { + return jsonResponse( + { error: "device_revocation_forbidden" }, + 403, + { "Cache-Control": "no-store" }, + ); + } + if (error instanceof DeviceSchemaError) { + return jsonResponse( + { error: "invalid_device_revocation" }, + 400, + { "Cache-Control": "no-store" }, + ); + } + if (error instanceof DevicePersistenceError) { + return jsonResponse( + { error: "device_revocation_failed" }, + 500, + { "Cache-Control": "no-store" }, + ); + } + throw error; + } + }, + ); + } if (url.pathname === "/api/plugins/signing-keys") { return withPublicApiControls(request, env, "plugins.signing_keys", ["GET"], () => handlePublicSigningKeys(env), diff --git a/cloudflare/tests/devices_approval_routes.test.ts b/cloudflare/tests/devices_approval_routes.test.ts index c55813d..c7635fa 100644 --- a/cloudflare/tests/devices_approval_routes.test.ts +++ b/cloudflare/tests/devices_approval_routes.test.ts @@ -18,8 +18,8 @@ describe("device approval routes", () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ firstRows: [ - null, deviceRow({ device_id: "device-01", approval_status: "approved" }), + null, deviceRow({ device_id: "device-02", approval_status: "pending", approved_at: null }), deviceRow({ device_id: "device-02", @@ -65,12 +65,12 @@ describe("device approval routes", () => { }, }); assert.equal(d1.batches[0], 2); - assert.ok(d1.queries[0]?.includes("FROM device_approvals")); - assert.ok(d1.queries[1]?.includes("approval_status = 'approved'")); + assert.ok(d1.queries[0]?.includes("approval_status = 'approved'")); + assert.ok(d1.queries[1]?.includes("FROM device_approvals")); assert.ok(d1.queries[3]?.includes("INSERT INTO device_approvals")); assert.ok(d1.queries[4]?.includes("UPDATE user_devices")); - assert.deepEqual(d1.binds[0], ["user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY]); - assert.deepEqual(d1.binds[1], ["user-01", "device-01"]); + assert.deepEqual(d1.binds[0], ["user-01", "device-01"]); + assert.deepEqual(d1.binds[1], ["user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY]); assert.deepEqual(d1.binds[2], ["user-01", "device-02"]); assert.deepEqual(d1.binds[3]?.slice(0, 4), [ "user-01", @@ -86,6 +86,7 @@ describe("device approval routes", () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); const d1 = testD1Database({ firstRows: [ + deviceRow({ device_id: "device-01", approval_status: "approved" }), { device_id: "device-02", requester_device_id: "device-01", @@ -119,8 +120,9 @@ describe("device approval routes", () => { const body = (await response.json()) as { approved_at: number }; assert.equal(body.approved_at, 1_780_000_300); assert.deepEqual(d1.batches, []); - assert.equal(d1.queries.length, 2); + assert.equal(d1.queries.length, 3); assert.deepEqual(d1.binds, [ + ["user-01", "device-01"], ["user-01", DEVICE_APPROVAL_IDEMPOTENCY_KEY], ["user-01", "device-02"], ]); @@ -128,7 +130,7 @@ describe("device approval routes", () => { it("rejects approval from a current device that is not approved", async () => { const tokenHash = await authTokenHash(ACCESS_TOKEN); - const d1 = testD1Database({ firstRows: [null, null] }); + const d1 = testD1Database({ firstRows: [null] }); const response = await handleRequest( new Request("https://elydora.test/api/devices/approve", { method: "POST", diff --git a/cloudflare/tests/devices_revocation_routes.test.ts b/cloudflare/tests/devices_revocation_routes.test.ts new file mode 100644 index 0000000..151ec2e --- /dev/null +++ b/cloudflare/tests/devices_revocation_routes.test.ts @@ -0,0 +1,236 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; +import { handleRequest } from "../src/index.js"; +import { + ACCESS_TOKEN, + PUBLIC_KEY, + sessionDocument, + testD1Database, + testEnv, +} from "./devices_test_support.js"; + +const DEVICE_REVOCATION_IDEMPOTENCY_KEY = "device-revocation-0001"; +const DEVICE_REVOCATION_EVENT_ID = `device-revoke:user-01:${DEVICE_REVOCATION_IDEMPOTENCY_KEY}`; + +describe("device revocation routes", () => { + it("revokes a device from an approved current device", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [ + deviceRow({ device_id: "device-01", approval_status: "approved" }), + null, + deviceRow({ device_id: "device-02", approval_status: "approved" }), + deviceRow({ + device_id: "device-02", + approval_status: "revoked", + revoked_at: 1_780_000_400, + }), + ], + }); + + const response = await handleRequest( + new Request("https://elydora.test/api/devices/revoke", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(deviceRevocationBody()), + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], + }), + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get("cache-control"), "no-store"); + assert.deepEqual(await response.json(), { + version: 1, + user_id: "user-01", + revoked_by_device_id: "device-01", + revoked_at: 1_780_000_400, + device: { + device_id: "device-02", + public_key: PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + approval_status: "revoked", + created_at: 1_780_000_000, + approved_at: 1_780_000_010, + last_active_at: 1_780_000_020, + revoked_at: 1_780_000_400, + current: false, + }, + }); + assert.equal(d1.batches[0], 2); + assert.ok(d1.queries[0]?.includes("approval_status = 'approved'")); + assert.ok(d1.queries[1]?.includes("FROM audit_events")); + assert.ok(d1.queries[3]?.includes("INSERT INTO audit_events")); + assert.ok(d1.queries[4]?.includes("UPDATE user_devices")); + assert.deepEqual(d1.binds[0], ["user-01", "device-01"]); + assert.deepEqual(d1.binds[1], ["user-01", DEVICE_REVOCATION_EVENT_ID]); + assert.deepEqual(d1.binds[2], ["user-01", "device-02"]); + assert.deepEqual(d1.binds[3]?.slice(0, 4), [ + DEVICE_REVOCATION_EVENT_ID, + "user-01", + "device-01", + "device-02", + ]); + assert.deepEqual(d1.binds[5], ["user-01", "device-02"]); + }); + + it("returns the existing revocation for an idempotent replay", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ + firstRows: [ + deviceRow({ device_id: "device-01", approval_status: "approved" }), + { + actor_device_id: "device-01", + subject_id: "device-02", + outcome: "success", + created_at: 1_780_000_400, + }, + deviceRow({ + device_id: "device-02", + approval_status: "revoked", + revoked_at: 1_780_000_400, + }), + ], + }); + + const response = await handleRequest( + new Request("https://elydora.test/api/devices/revoke", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(deviceRevocationBody()), + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], + }), + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as { revoked_at: number }; + assert.equal(body.revoked_at, 1_780_000_400); + assert.deepEqual(d1.batches, []); + assert.deepEqual(d1.binds, [ + ["user-01", "device-01"], + ["user-01", DEVICE_REVOCATION_EVENT_ID], + ["user-01", "device-02"], + ]); + }); + + it("rejects revocation from a current device that is not approved", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database({ firstRows: [null] }); + const response = await handleRequest( + new Request("https://elydora.test/api/devices/revoke", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify(deviceRevocationBody()), + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], + }), + ); + + assert.equal(response.status, 403); + assert.deepEqual(await response.json(), { error: "device_revocation_forbidden" }); + assert.deepEqual(d1.batches, []); + }); + + it("rejects self revocation before D1 writes", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database([]); + const response = await handleRequest( + new Request("https://elydora.test/api/devices/revoke", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify({ ...deviceRevocationBody(), device_id: "device-01" }), + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], + }), + ); + + assert.equal(response.status, 403); + assert.deepEqual(d1.queries, []); + }); + + it("rejects invalid revocation payloads before D1 writes", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const d1 = testD1Database([]); + const response = await handleRequest( + new Request("https://elydora.test/api/devices/revoke", { + method: "POST", + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "content-type": "application/json", + }, + body: JSON.stringify({ ...deviceRevocationBody(), idempotency_key: "short" }), + }), + testEnv({ + d1, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument("device-01")]], + }), + ); + + assert.equal(response.status, 400); + assert.deepEqual(await response.json(), { error: "invalid_device_revocation" }); + assert.deepEqual(d1.queries, []); + }); + + it("rejects unauthenticated device revocation before D1 writes", async () => { + const d1 = testD1Database([]); + const response = await handleRequest( + new Request("https://elydora.test/api/devices/revoke", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(deviceRevocationBody()), + }), + testEnv({ d1 }), + ); + + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "authorization_missing" }); + assert.deepEqual(d1.queries, []); + }); +}); + +function deviceRevocationBody(): Record { + return { + version: 1, + device_id: "device-02", + idempotency_key: DEVICE_REVOCATION_IDEMPOTENCY_KEY, + }; +} + +function deviceRow(overrides: Record): Record { + return { + device_id: "device-01", + public_key: PUBLIC_KEY, + device_name: "MacBook Pro", + platform: "macOS", + approval_status: "approved", + created_at: 1_780_000_000, + approved_at: 1_780_000_010, + last_active_at: 1_780_000_020, + revoked_at: null, + ...overrides, + }; +}