mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat: add external proxy auth support (Authelia/Authentik)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user