Add authenticated API controls
This commit is contained in:
@@ -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);
|
||||||
|
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({
|
env.ELY_API_AUDIT.writeDataPoint({
|
||||||
indexes: [env.ELY_ENVIRONMENT],
|
indexes: [env.ELY_ENVIRONMENT],
|
||||||
blobs: [
|
blobs,
|
||||||
route,
|
|
||||||
request.method,
|
|
||||||
url.pathname,
|
|
||||||
outcome,
|
|
||||||
request.headers.get("cf-ray") ?? "",
|
|
||||||
request.headers.get("user-agent") ?? "",
|
|
||||||
],
|
|
||||||
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" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user