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
|
## 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.
|
- [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
|
## 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", () => {
|
describe("Email Management", () => {
|
||||||
it("should return JSON for email deletion when requested", async () => {
|
it("should return JSON for email deletion when requested", async () => {
|
||||||
const authCookie = await loginAndGetCookie();
|
const authCookie = await loginAndGetCookie();
|
||||||
|
|||||||
+39
-6
@@ -66,6 +66,25 @@ app.use("*", async (c, next) => {
|
|||||||
await 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
|
// Authentication middleware for admin routes
|
||||||
async function authMiddleware(c: Context, next: () => Promise<void>) {
|
async function authMiddleware(c: Context, next: () => Promise<void>) {
|
||||||
const env = c.env as unknown as Env;
|
const env = c.env as unknown as Env;
|
||||||
@@ -76,11 +95,25 @@ async function authMiddleware(c: Context, next: () => Promise<void>) {
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const authCookie = await getSignedCookie(
|
// Proxy auth: only active when both env vars are present
|
||||||
c,
|
if (env.PROXY_AUTH_SECRET && env.PROXY_TRUSTED_IPS) {
|
||||||
env.ADMIN_PASSWORD,
|
const trustedIps = env.PROXY_TRUSTED_IPS.split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
ADMIN_COOKIE_NAME,
|
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") {
|
if (authCookie !== "1") {
|
||||||
return c.redirect("/admin/login");
|
return c.redirect("/admin/login");
|
||||||
}
|
}
|
||||||
@@ -210,7 +243,7 @@ app.post("/login", async (c) => {
|
|||||||
authSchema.parse({ password });
|
authSchema.parse({ password });
|
||||||
|
|
||||||
// Check password against environment variable
|
// 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, {
|
await setSignedCookie(c, ADMIN_COOKIE_NAME, "1", env.ADMIN_PASSWORD, {
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ invocation_logs = true
|
|||||||
[vars]
|
[vars]
|
||||||
DOMAIN = "REPLACE_WITH_YOUR_DOMAIN" # Your custom domain for emails
|
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
|
# Development environment
|
||||||
[env.dev]
|
[env.dev]
|
||||||
workers_dev = true
|
workers_dev = true
|
||||||
|
|||||||
Reference in New Issue
Block a user