Files
kill-the-news/src/lib/auth.ts
T
Julien Herr 45d2a14a12 feat(api): add versioned REST API with OpenAPI 3.1 spec
Expose /api/v1/* for feed and email management (feeds CRUD, email
list/get/delete, stats) so the service can be automated without scraping
the admin UI. Built on @hono/zod-openapi; the OpenAPI 3.1 spec is served at
/api/openapi.json with a Scalar reference at /api/docs.

Auth is token-based (Authorization: Bearer <ADMIN_PASSWORD>) plus the
existing reverse-proxy headers — no cookie, no CSRF. Extracted the auth
primitives into src/lib/auth.ts and the feed create/update/delete
orchestration into src/lib/feed-service.ts so the admin UI and the REST API
share a single source of truth.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 23:01:15 +02:00

79 lines
2.7 KiB
TypeScript

import { Context } from "hono";
import { Env } from "../types";
/**
* Constant-time string comparison. Prefers the runtime's native
* `crypto.subtle.timingSafeEqual` (Cloudflare Workers) and falls back to a
* manual constant-time loop in environments that lack it (Node test runtime).
*/
export function timingSafeEqual(a: string, b: string): boolean {
const enc = new TextEncoder();
const aBytes = enc.encode(a);
const bBytes = enc.encode(b);
// Try native timing-safe implementation first (Cloudflare Workers runtime)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const subtle = crypto.subtle as any;
if (typeof subtle.timingSafeEqual === "function") {
if (aBytes.length !== bBytes.length) return false;
return subtle.timingSafeEqual(aBytes, bBytes);
}
// Constant-time fallback for Node (test environment): encode length
// mismatch into `diff` so the loop always runs over the full length.
const len = Math.max(aBytes.length, bBytes.length);
let diff = aBytes.length ^ bBytes.length;
for (let i = 0; i < len; i++) {
diff |= (aBytes[i] ?? 0) ^ (bBytes[i] ?? 0);
}
return diff === 0;
}
/**
* Reverse-proxy authentication: trusted only when both `PROXY_AUTH_SECRET` and
* `PROXY_TRUSTED_IPS` are configured, the request comes from a trusted IP, the
* shared secret matches, and a `Remote-User`/`X-Forwarded-User` is present.
*/
export function checkProxyAuth(c: Context, env: Env): boolean {
if (!env.PROXY_AUTH_SECRET || !env.PROXY_TRUSTED_IPS) return false;
const trustedIps = env.PROXY_TRUSTED_IPS.split(",")
.map((s: string) => s.trim())
.filter(Boolean);
const clientIp = c.req.header("CF-Connecting-IP") ?? "";
const providedSecret = c.req.header("X-Auth-Proxy-Secret") ?? "";
const remoteUser =
c.req.header("Remote-User") || c.req.header("X-Forwarded-User") || "";
return (
trustedIps.includes(clientIp) &&
timingSafeEqual(providedSecret, env.PROXY_AUTH_SECRET) &&
remoteUser.length > 0
);
}
/**
* Authentication for the machine-facing REST API (`/api/v1/*`).
* Grants access when proxy auth passes OR the request carries a valid
* `Authorization: Bearer <ADMIN_PASSWORD>`. No cookie, no CSRF — token only.
*/
export async function apiAuthMiddleware(
c: Context<{ Bindings: Env }>,
next: () => Promise<void>,
): Promise<Response | void> {
const env = c.env;
if (checkProxyAuth(c, env)) {
return next();
}
const authHeader = c.req.header("Authorization") ?? "";
const token = authHeader.startsWith("Bearer ")
? authHeader.slice("Bearer ".length)
: "";
if (token && timingSafeEqual(token, env.ADMIN_PASSWORD)) {
return next();
}
return c.json({ error: "Unauthorized" }, 401);
}