From 73cd631d3e0d79eb868de5d14f364a29c3f13ebe 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, 8 May 2026 17:22:51 -0400 Subject: [PATCH] Add authenticated API controls --- cloudflare/src/api_controls.ts | 103 ++++++++++++-- cloudflare/src/auth.ts | 195 ++++++++++++++++++++++++++ cloudflare/tests/api_controls.test.ts | 158 +++++++++++++++++++++ 3 files changed, 448 insertions(+), 8 deletions(-) create mode 100644 cloudflare/src/auth.ts diff --git a/cloudflare/src/api_controls.ts b/cloudflare/src/api_controls.ts index 9510527..750d1b8 100644 --- a/cloudflare/src/api_controls.ts +++ b/cloudflare/src/api_controls.ts @@ -1,9 +1,17 @@ import type { Env } from "./bindings.js"; +import { + type AuthContext, + AuthError, + AuthSessionCacheSchemaError, + authenticatedRateLimitKey, + readAuthContext, +} from "./auth.js"; import { jsonResponse } from "./responses.js"; const RATE_LIMIT_WINDOW_SECONDS = 60; export type ApiHandler = () => Promise; +export type AuthenticatedApiHandler = (context: AuthContext) => Promise; export async function withPublicApiControls( request: Request, @@ -42,6 +50,59 @@ export async function withPublicApiControls( } } +export async function withAuthenticatedApiControls( + request: Request, + env: Env, + route: string, + allowedMethods: readonly string[], + handler: AuthenticatedApiHandler, +): Promise { + if (!allowedMethods.includes(request.method)) { + const response = methodNotAllowedResponse(allowedMethods); + recordApiAuditEvent(request, env, route, response, "method_not_allowed"); + return response; + } + + const limit = await env.ELY_RATE_LIMITER.limit({ + key: await authenticatedRateLimitKey(env.ELY_ENVIRONMENT, route, request), + }); + if (!limit.success) { + const response = rateLimitedResponse(); + recordApiAuditEvent(request, env, route, response, "rate_limited"); + return response; + } + + let context: AuthContext; + try { + context = await readAuthContext(request, env); + } catch (error) { + if (error instanceof AuthError) { + const response = authErrorResponse(error); + recordApiAuditEvent(request, env, route, response, error.code); + return response; + } + if (error instanceof AuthSessionCacheSchemaError) { + const response = jsonResponse( + { error: "auth_session_cache_invalid" }, + 500, + { "Cache-Control": "no-store" }, + ); + recordApiAuditEvent(request, env, route, response, "auth_session_cache_invalid"); + return response; + } + throw error; + } + + try { + const response = await handler(context); + recordApiAuditEvent(request, env, route, response, "handled", context); + return response; + } catch (error) { + recordApiAuditEvent(request, env, route, internalErrorResponse(), "exception", context); + throw error; + } +} + function rateLimitKey(environment: string, route: string): string { return `${environment}:${route}`; } @@ -52,22 +113,48 @@ function recordApiAuditEvent( route: string, response: Response, outcome: string, + context?: AuthContext, ): void { const url = new URL(request.url); + const blobs = [ + route, + request.method, + url.pathname, + outcome, + request.headers.get("cf-ray") ?? "", + request.headers.get("user-agent") ?? "", + ]; + if (context !== undefined) { + blobs.push(context.userId, context.deviceId ?? ""); + } + env.ELY_API_AUDIT.writeDataPoint({ indexes: [env.ELY_ENVIRONMENT], - blobs: [ - route, - request.method, - url.pathname, - outcome, - request.headers.get("cf-ray") ?? "", - request.headers.get("user-agent") ?? "", - ], + blobs, doubles: [response.status, Date.now()], }); } +function methodNotAllowedResponse(allowedMethods: readonly string[]): Response { + return jsonResponse({ error: "method_not_allowed" }, 405, { + Allow: allowedMethods.join(", "), + }); +} + +function rateLimitedResponse(): Response { + return jsonResponse({ error: "rate_limited" }, 429, { + "Cache-Control": "no-store", + "Retry-After": RATE_LIMIT_WINDOW_SECONDS.toString(), + }); +} + +function authErrorResponse(error: AuthError): Response { + return jsonResponse({ error: error.code }, 401, { + "Cache-Control": "no-store", + "WWW-Authenticate": "Bearer", + }); +} + function internalErrorResponse(): Response { return jsonResponse({ error: "internal_error" }, 500, { "Cache-Control": "no-store" }); } diff --git a/cloudflare/src/auth.ts b/cloudflare/src/auth.ts new file mode 100644 index 0000000..be29e9e --- /dev/null +++ b/cloudflare/src/auth.ts @@ -0,0 +1,195 @@ +import type { Env } from "./bindings.js"; +import { prefixedKvKey } from "./kv_keys.js"; + +const AUTH_SESSION_CACHE_NAMESPACE = "auth_session_cache"; +const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]{32,4096}$/; +const SUBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; +const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/; + +export interface AuthContext { + userId: string; + sessionId: string; + tokenHash: string; + expiresAt: string; + deviceId?: string; +} + +export type AuthErrorCode = + | "authorization_missing" + | "authorization_invalid" + | "session_not_found" + | "session_expired"; + +export class AuthError extends Error { + constructor(readonly code: AuthErrorCode) { + super(code); + this.name = "AuthError"; + } +} + +export class AuthSessionCacheSchemaError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthSessionCacheSchemaError"; + } +} + +export function authSessionCacheKvKey(environment: string, tokenHash: string): string { + if (!/^[a-f0-9]{64}$/.test(tokenHash)) { + throw new AuthSessionCacheSchemaError("token_hash_invalid"); + } + return `${prefixedKvKey(environment, AUTH_SESSION_CACHE_NAMESPACE)}:${tokenHash}`; +} + +export async function authenticatedRateLimitKey( + environment: string, + route: string, + request: Request, +): Promise { + let token: string | null; + try { + token = bearerToken(request); + } catch (error) { + if (error instanceof AuthError) { + return `${environment}:${route}:authorization_invalid`; + } + throw error; + } + if (token === null) { + return `${environment}:${route}:anonymous`; + } + return `${environment}:${route}:bearer:${await sha256Hex(token)}`; +} + +export async function readAuthContext( + request: Request, + env: Env, + now: Date = new Date(), +): Promise { + const token = bearerToken(request); + if (token === null) { + throw new AuthError("authorization_missing"); + } + + const tokenHash = await sha256Hex(token); + const cacheKey = authSessionCacheKvKey(env.ELY_ENVIRONMENT, tokenHash); + const document = await env.ELY_KV.get(cacheKey); + if (document === null) { + throw new AuthError("session_not_found"); + } + + const session = parseAuthSessionCacheDocument(document, tokenHash); + if (Date.parse(session.expiresAt) <= now.getTime()) { + throw new AuthError("session_expired"); + } + return session; +} + +export async function authTokenHash(token: string): Promise { + return sha256Hex(token); +} + +function bearerToken(request: Request): string | null { + const header = request.headers.get("authorization"); + if (header === null) { + return null; + } + + const [scheme, token, extra] = header.trim().split(/\s+/); + if (scheme !== "Bearer" || token === undefined || extra !== undefined) { + throw new AuthError("authorization_invalid"); + } + if (!BEARER_TOKEN_PATTERN.test(token)) { + throw new AuthError("authorization_invalid"); + } + 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`); + } + return value; +} + +function deviceIdValue(value: string): string { + if (!DEVICE_ID_PATTERN.test(value)) { + throw new AuthSessionCacheSchemaError("device_id_invalid"); + } + return value; +} + +function isoTimestamp(value: string, label: string): string { + const timestamp = Date.parse(value); + if (!Number.isFinite(timestamp)) { + throw new AuthSessionCacheSchemaError(`${label}_invalid`); + } + return new Date(timestamp).toISOString(); +} + +function stringField(value: Record, field: string): string { + const fieldValue = value[field]; + if (typeof fieldValue !== "string" || fieldValue.trim() === "") { + throw new AuthSessionCacheSchemaError(`${field}_required`); + } + return fieldValue.trim(); +} + +function optionalStringField(value: Record, field: string): string | undefined { + const fieldValue = value[field]; + if (fieldValue === undefined || fieldValue === null) { + return undefined; + } + if (typeof fieldValue !== "string" || fieldValue.trim() === "") { + throw new AuthSessionCacheSchemaError(`${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); + return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/cloudflare/tests/api_controls.test.ts b/cloudflare/tests/api_controls.test.ts index 7ae40ff..d7a78ca 100644 --- a/cloudflare/tests/api_controls.test.ts +++ b/cloudflare/tests/api_controls.test.ts @@ -2,10 +2,14 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import type { ElyAnalyticsDataPoint, Env } from "../src/bindings.js"; +import { authSessionCacheKvKey, authTokenHash } from "../src/auth.js"; +import { withAuthenticatedApiControls } from "../src/api_controls.js"; import { handleRequest } from "../src/index.js"; +import { jsonResponse } from "../src/responses.js"; import { publicSigningKeysKvKey } from "../src/signing_keys.js"; const PUBLIC_KEY = "a".repeat(64); +const ACCESS_TOKEN = "A".repeat(48); describe("api controls", () => { it("rate limits public API routes before reading KV", async () => { @@ -74,10 +78,151 @@ describe("api controls", () => { ]); assert.equal(auditEvents[0]?.doubles?.[0], 405); }); + + it("rate limits authenticated API routes before reading session cache", async () => { + const auditEvents: ElyAnalyticsDataPoint[] = []; + const kvReads: string[] = []; + const response = await withAuthenticatedApiControls( + new Request("https://elydora.test/api/devices", { + headers: { authorization: `Bearer ${ACCESS_TOKEN}` }, + }), + testEnv({ auditEvents, kvReads, rateLimitSuccess: false }), + "devices.list", + ["GET"], + () => Promise.resolve(jsonResponse({ ok: true }, 200)), + ); + + assert.equal(response.status, 429); + assert.deepEqual(kvReads, []); + assert.deepEqual(auditEvents[0]?.blobs?.slice(0, 4), [ + "devices.list", + "GET", + "/api/devices", + "rate_limited", + ]); + }); + + it("rejects missing authenticated API credentials after rate limit", async () => { + const auditEvents: ElyAnalyticsDataPoint[] = []; + const rateLimitKeys: string[] = []; + const response = await withAuthenticatedApiControls( + new Request("https://elydora.test/api/devices"), + testEnv({ auditEvents, rateLimitKeys }), + "devices.list", + ["GET"], + () => Promise.resolve(jsonResponse({ ok: true }, 200)), + ); + + assert.equal(response.status, 401); + assert.equal(response.headers.get("www-authenticate"), "Bearer"); + assert.deepEqual(await response.json(), { error: "authorization_missing" }); + assert.deepEqual(rateLimitKeys, ["local:devices.list:anonymous"]); + assert.deepEqual(auditEvents[0]?.blobs?.slice(0, 4), [ + "devices.list", + "GET", + "/api/devices", + "authorization_missing", + ]); + }); + + it("rejects malformed authenticated API credentials with audit coverage", async () => { + const auditEvents: ElyAnalyticsDataPoint[] = []; + const rateLimitKeys: string[] = []; + const response = await withAuthenticatedApiControls( + new Request("https://elydora.test/api/devices", { + headers: { authorization: "Bearer short" }, + }), + testEnv({ auditEvents, rateLimitKeys }), + "devices.list", + ["GET"], + () => Promise.resolve(jsonResponse({ ok: true }, 200)), + ); + + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "authorization_invalid" }); + assert.deepEqual(rateLimitKeys, ["local:devices.list:authorization_invalid"]); + assert.deepEqual(auditEvents[0]?.blobs?.slice(0, 4), [ + "devices.list", + "GET", + "/api/devices", + "authorization_invalid", + ]); + }); + + it("passes authenticated session context and records subject audit fields", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + const auditEvents: ElyAnalyticsDataPoint[] = []; + const kvReads: string[] = []; + const rateLimitKeys: string[] = []; + let receivedTokenHash = ""; + const response = await withAuthenticatedApiControls( + new Request("https://elydora.test/api/devices", { + headers: { + authorization: `Bearer ${ACCESS_TOKEN}`, + "cf-ray": "ray-auth", + "user-agent": "ely-auth-test", + }, + }), + testEnv({ + auditEvents, + kvReads, + rateLimitKeys, + kvEntries: [[authSessionCacheKvKey("local", tokenHash), sessionDocument()]], + }), + "devices.list", + ["GET"], + (context) => { + receivedTokenHash = context.tokenHash; + return Promise.resolve( + jsonResponse({ user_id: context.userId, device_id: context.deviceId }, 200), + ); + }, + ); + + 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.equal(receivedTokenHash, tokenHash); + assert.deepEqual(auditEvents[0]?.blobs, [ + "devices.list", + "GET", + "/api/devices", + "handled", + "ray-auth", + "ely-auth-test", + "user-01", + "device-01", + ]); + }); + + it("rejects expired authenticated sessions", async () => { + const tokenHash = await authTokenHash(ACCESS_TOKEN); + 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"), + ], + ], + }), + "devices.list", + ["GET"], + () => Promise.resolve(jsonResponse({ ok: true }, 200)), + ); + + assert.equal(response.status, 401); + assert.deepEqual(await response.json(), { error: "session_expired" }); + }); }); interface TestEnvOptions { auditEvents?: ElyAnalyticsDataPoint[]; + kvEntries?: [string, string][]; kvReads?: string[]; rateLimitKeys?: string[]; rateLimitSuccess?: boolean; @@ -93,6 +238,9 @@ function testEnv(options: TestEnvOptions = {}): Env { }), ], ]); + for (const [key, value] of options.kvEntries ?? []) { + values.set(key, value); + } return { ELY_ENVIRONMENT: "local", @@ -120,6 +268,16 @@ function testEnv(options: TestEnvOptions = {}): Env { }; } +function sessionDocument(expiresAt = "2099-01-01T00:00:00.000Z"): string { + return JSON.stringify({ + version: 1, + user_id: "user-01", + session_id: "session-01", + device_id: "device-01", + expires_at: expiresAt, + }); +} + function testR2Bucket(): Env["ELY_STORAGE"] { return { get() {