Add authenticated API controls

This commit is contained in:
2026-05-08 17:22:51 -04:00
parent 8284992e11
commit 73cd631d3e
3 changed files with 448 additions and 8 deletions
+91 -4
View File
@@ -1,9 +1,17 @@
import type { Env } from "./bindings.js"; import type { Env } from "./bindings.js";
import {
type AuthContext,
AuthError,
AuthSessionCacheSchemaError,
authenticatedRateLimitKey,
readAuthContext,
} from "./auth.js";
import { jsonResponse } from "./responses.js"; import { jsonResponse } from "./responses.js";
const RATE_LIMIT_WINDOW_SECONDS = 60; const RATE_LIMIT_WINDOW_SECONDS = 60;
export type ApiHandler = () => Promise<Response>; export type ApiHandler = () => Promise<Response>;
export type AuthenticatedApiHandler = (context: AuthContext) => Promise<Response>;
export async function withPublicApiControls( export async function withPublicApiControls(
request: Request, 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<Response> {
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 { function rateLimitKey(environment: string, route: string): string {
return `${environment}:${route}`; return `${environment}:${route}`;
} }
@@ -52,22 +113,48 @@ function recordApiAuditEvent(
route: string, route: string,
response: Response, response: Response,
outcome: string, outcome: string,
context?: AuthContext,
): void { ): void {
const url = new URL(request.url); const url = new URL(request.url);
env.ELY_API_AUDIT.writeDataPoint({ const blobs = [
indexes: [env.ELY_ENVIRONMENT],
blobs: [
route, route,
request.method, request.method,
url.pathname, url.pathname,
outcome, outcome,
request.headers.get("cf-ray") ?? "", request.headers.get("cf-ray") ?? "",
request.headers.get("user-agent") ?? "", request.headers.get("user-agent") ?? "",
], ];
if (context !== undefined) {
blobs.push(context.userId, context.deviceId ?? "");
}
env.ELY_API_AUDIT.writeDataPoint({
indexes: [env.ELY_ENVIRONMENT],
blobs,
doubles: [response.status, Date.now()], 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 { function internalErrorResponse(): Response {
return jsonResponse({ error: "internal_error" }, 500, { "Cache-Control": "no-store" }); return jsonResponse({ error: "internal_error" }, 500, { "Cache-Control": "no-store" });
} }
+195
View File
@@ -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<string> {
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<AuthContext> {
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<string> {
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function sha256Hex(value: string): Promise<string> {
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("");
}
+158
View File
@@ -2,10 +2,14 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test"; import { describe, it } from "node:test";
import type { ElyAnalyticsDataPoint, Env } from "../src/bindings.js"; 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 { handleRequest } from "../src/index.js";
import { jsonResponse } from "../src/responses.js";
import { publicSigningKeysKvKey } from "../src/signing_keys.js"; import { publicSigningKeysKvKey } from "../src/signing_keys.js";
const PUBLIC_KEY = "a".repeat(64); const PUBLIC_KEY = "a".repeat(64);
const ACCESS_TOKEN = "A".repeat(48);
describe("api controls", () => { describe("api controls", () => {
it("rate limits public API routes before reading KV", async () => { it("rate limits public API routes before reading KV", async () => {
@@ -74,10 +78,151 @@ describe("api controls", () => {
]); ]);
assert.equal(auditEvents[0]?.doubles?.[0], 405); 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 { interface TestEnvOptions {
auditEvents?: ElyAnalyticsDataPoint[]; auditEvents?: ElyAnalyticsDataPoint[];
kvEntries?: [string, string][];
kvReads?: string[]; kvReads?: string[];
rateLimitKeys?: string[]; rateLimitKeys?: string[];
rateLimitSuccess?: boolean; rateLimitSuccess?: boolean;
@@ -93,6 +238,9 @@ function testEnv(options: TestEnvOptions = {}): Env {
}), }),
], ],
]); ]);
for (const [key, value] of options.kvEntries ?? []) {
values.set(key, value);
}
return { return {
ELY_ENVIRONMENT: "local", 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"] { function testR2Bucket(): Env["ELY_STORAGE"] {
return { return {
get() { get() {