Files
ELY-Browser/cloudflare/src/api_controls.ts
T

198 lines
5.4 KiB
TypeScript

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<Response>;
export type AuthenticatedApiHandler = (context: AuthContext) => Promise<Response>;
const APPROVED_DEVICE_QUERY = `
SELECT device_id
FROM user_devices
WHERE user_id = ? AND device_id = ? AND approval_status = 'approved' AND revoked_at IS NULL
`;
export async function withPublicApiControls(
request: Request,
env: Env,
route: string,
allowedMethods: readonly string[],
handler: ApiHandler,
): Promise<Response> {
if (!allowedMethods.includes(request.method)) {
const response = jsonResponse({ error: "method_not_allowed" }, 405, {
Allow: allowedMethods.join(", "),
});
recordApiAuditEvent(request, env, route, response, "method_not_allowed");
return response;
}
const limit = await env.ELY_RATE_LIMITER.limit({
key: rateLimitKey(env.ELY_ENVIRONMENT, route),
});
if (!limit.success) {
const response = jsonResponse({ error: "rate_limited" }, 429, {
"Cache-Control": "no-store",
"Retry-After": RATE_LIMIT_WINDOW_SECONDS.toString(),
});
recordApiAuditEvent(request, env, route, response, "rate_limited");
return response;
}
try {
const response = await handler();
recordApiAuditEvent(request, env, route, response, "handled");
return response;
} catch (error) {
recordApiAuditEvent(request, env, route, internalErrorResponse(), "exception");
throw error;
}
}
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;
}
}
export async function withApprovedDeviceApiControls(
request: Request,
env: Env,
route: string,
allowedMethods: readonly string[],
handler: AuthenticatedApiHandler,
): Promise<Response> {
return withAuthenticatedApiControls(request, env, route, allowedMethods, async (context) => {
if (context.deviceId === undefined) {
return jsonResponse({ error: "device_context_required" }, 403, {
"Cache-Control": "no-store",
});
}
const row = await env.ELY_DB.prepare(APPROVED_DEVICE_QUERY)
.bind(context.userId, context.deviceId)
.first<ApprovedDeviceRow>();
if (row === null) {
return jsonResponse({ error: "device_not_approved" }, 403, {
"Cache-Control": "no-store",
});
}
return handler(context);
});
}
interface ApprovedDeviceRow {
device_id: unknown;
}
function rateLimitKey(environment: string, route: string): string {
return `${environment}:${route}`;
}
function recordApiAuditEvent(
request: Request,
env: Env,
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,
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" });
}