feat: add external proxy auth support (Authelia/Authentik)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-21 08:30:04 +02:00
parent 9eba4c34c6
commit caaa6a7ba6
4 changed files with 147 additions and 8 deletions
+2 -2
View File
@@ -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
+93
View File
@@ -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<string, string> = {}) {
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();
+39 -6
View File
@@ -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<void>) {
const env = c.env as unknown as Env;
@@ -76,11 +95,25 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
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,
+13
View File
@@ -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