From caaa6a7ba616cfcfdebb09fc5f7e076bf2fb594a Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Thu, 21 May 2026 08:30:04 +0200 Subject: [PATCH] feat: add external proxy auth support (Authelia/Authentik) Co-Authored-By: Claude Sonnet 4.6 --- TODO.md | 4 +- src/routes/admin.test.ts | 93 ++++++++++++++++++++++++++++++++++++++++ src/routes/admin.ts | 45 ++++++++++++++++--- wrangler-example.toml | 13 ++++++ 4 files changed, 147 insertions(+), 8 deletions(-) diff --git a/TODO.md b/TODO.md index 2eb59fd..32d209a 100644 --- a/TODO.md +++ b/TODO.md @@ -12,11 +12,11 @@ Feature gaps identified by comparing with [kill-the-newsletter](https://github.c ## Medium effort -- [ ] **Size-based feed trimming** — instead of a fixed 50-entry cap, drop the oldest entries when the feed exceeds a size threshold (kill-the-newsletter uses ~512 KB). More robust for HTML-heavy newsletters where one entry can dominate. +- [x] **Size-based feed trimming** — instead of a fixed 50-entry cap, drop the oldest entries when the feed exceeds a size threshold (kill-the-newsletter uses ~512 KB). More robust for HTML-heavy newsletters where one entry can dominate. - [x] **Atom feed format** — expose feeds as Atom (`application/atom+xml`) in addition to or instead of RSS 2.0. Atom has better native support for HTML content and author metadata. -- [ ] **Authelia / external auth provider support** — allow delegating admin authentication to an external identity provider (e.g. Authelia, Authentik) via a trusted header (`Remote-User`, `X-Forwarded-User`) set by a reverse proxy. The Worker would accept the header as proof of authentication instead of checking the cookie, with a configurable secret or IP allowlist to trust only the proxy. +- [x] **Authelia / external auth provider support** — allow delegating admin authentication to an external identity provider (e.g. Authelia, Authentik) via a trusted header (`Remote-User`, `X-Forwarded-User`) set by a reverse proxy. The Worker would accept the header as proof of authentication instead of checking the cookie, with a configurable secret or IP allowlist to trust only the proxy. ## Heavy diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index 7cc293a..0a31e3a 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -354,6 +354,99 @@ describe("Admin Routes", () => { }); }); + describe("Proxy authentication", () => { + const TRUSTED_IP = "10.0.0.1"; + const PROXY_SECRET = "my-proxy-secret"; + + function proxyEnv() { + return { + ...createMockEnv(), + PROXY_TRUSTED_IPS: TRUSTED_IP, + PROXY_AUTH_SECRET: PROXY_SECRET, + } as unknown as Env; + } + + function makeProxyRequest(path: string, headers: Record = {}) { + const proxyApp = new Hono(); + proxyApp.route("/admin", app); + return proxyApp.request(path, { headers }, proxyEnv()); + } + + it("grants access when IP, secret and Remote-User are all valid", async () => { + const res = await makeProxyRequest("/admin", { + "CF-Connecting-IP": TRUSTED_IP, + "X-Auth-Proxy-Secret": PROXY_SECRET, + "Remote-User": "alice", + }); + expect(res.status).toBe(200); + }); + + it("grants access using X-Forwarded-User instead of Remote-User", async () => { + const res = await makeProxyRequest("/admin", { + "CF-Connecting-IP": TRUSTED_IP, + "X-Auth-Proxy-Secret": PROXY_SECRET, + "X-Forwarded-User": "bob", + }); + expect(res.status).toBe(200); + }); + + it("rejects when IP is not in trusted list", async () => { + const res = await makeProxyRequest("/admin", { + "CF-Connecting-IP": "1.2.3.4", + "X-Auth-Proxy-Secret": PROXY_SECRET, + "Remote-User": "alice", + }); + expect(res.status).toBe(302); + }); + + it("rejects when secret is wrong", async () => { + const res = await makeProxyRequest("/admin", { + "CF-Connecting-IP": TRUSTED_IP, + "X-Auth-Proxy-Secret": "wrong-secret", + "Remote-User": "alice", + }); + expect(res.status).toBe(302); + }); + + it("rejects when Remote-User is missing", async () => { + const res = await makeProxyRequest("/admin", { + "CF-Connecting-IP": TRUSTED_IP, + "X-Auth-Proxy-Secret": PROXY_SECRET, + }); + expect(res.status).toBe(302); + }); + + it("falls back to cookie auth when proxy env vars are not configured", async () => { + const res = await request("/admin"); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/admin/login"); + }); + + it("falls back to cookie auth when only one proxy env var is configured", async () => { + const partialProxyApp = new Hono(); + partialProxyApp.route("/admin", app); + const partialEnv = { + ...createMockEnv(), + PROXY_TRUSTED_IPS: "10.0.0.1", + // PROXY_AUTH_SECRET intentionally absent + } as unknown as Env; + + const res = await partialProxyApp.request( + "/admin", + { + headers: { + "CF-Connecting-IP": "10.0.0.1", + "Remote-User": "alice", + // No X-Auth-Proxy-Secret — proxy auth should NOT activate + }, + }, + partialEnv, + ); + expect(res.status).toBe(302); + expect(res.headers.get("Location")).toBe("/admin/login"); + }); + }); + describe("Email Management", () => { it("should return JSON for email deletion when requested", async () => { const authCookie = await loginAndGetCookie(); diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 909b80d..701042a 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -66,6 +66,25 @@ app.use("*", async (c, next) => { await next(); }); +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) + if (typeof (crypto.subtle as any).timingSafeEqual === "function") { + if (aBytes.length !== bBytes.length) return false; + return (crypto.subtle as any).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; +} + // Authentication middleware for admin routes async function authMiddleware(c: Context, next: () => Promise) { const env = c.env as unknown as Env; @@ -76,11 +95,25 @@ async function authMiddleware(c: Context, next: () => Promise) { return next(); } - const authCookie = await getSignedCookie( - c, - env.ADMIN_PASSWORD, - ADMIN_COOKIE_NAME, - ); + // Proxy auth: only active when both env vars are present + if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) { + const trustedIps = env.PROXY_TRUSTED_IPS.split(",").map((s) => 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") || ""; + + if ( + trustedIps.includes(clientIp) && + timingSafeEqual(providedSecret, env.PROXY_AUTH_SECRET) && + remoteUser.length > 0 + ) { + return next(); + } + } + + // Fallback: signed cookie + const authCookie = await getSignedCookie(c, env.ADMIN_PASSWORD, ADMIN_COOKIE_NAME); if (authCookie !== "1") { return c.redirect("/admin/login"); } @@ -210,7 +243,7 @@ app.post("/login", async (c) => { authSchema.parse({ password }); // Check password against environment variable - if (password === env.ADMIN_PASSWORD) { + if (timingSafeEqual(password, env.ADMIN_PASSWORD)) { await setSignedCookie(c, ADMIN_COOKIE_NAME, "1", env.ADMIN_PASSWORD, { path: "/", httpOnly: true, diff --git a/wrangler-example.toml b/wrangler-example.toml index 439ff9d..8bcb8b7 100644 --- a/wrangler-example.toml +++ b/wrangler-example.toml @@ -17,6 +17,19 @@ invocation_logs = true [vars] DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Your custom domain for emails +# Optional: size-based feed trimming threshold in bytes (default: 524288 = 512 KB) +# FEED_MAX_SIZE_BYTES = "524288" + +# Optional: external proxy auth (Authelia/Authentik) +# Comma-separated IPs of trusted reverse proxies +# PROXY_TRUSTED_IPS = "10.0.0.1" + +# ── Worker secrets (never put these in [vars]) ────────────────────────────── +# PROXY_AUTH_SECRET must be set as a Worker secret, NOT in [vars]: +# wrangler secret put PROXY_AUTH_SECRET +# WARNING: never add it to [vars] — it would be committed to version control. +# WARNING: disable the workers.dev subdomain in production when using proxy auth. + # Development environment [env.dev] workers_dev = true