Add authenticated API controls
This commit is contained in:
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user