feat(forwardauth): add browser 302 redirect with safe return URL and open-redirect protection

This commit is contained in:
Tyler Gillispie 2026-08-24 12:52:23 -07:00
parent 590a13e694
commit 96bcf69179
4 changed files with 85 additions and 6 deletions

View File

@ -71,11 +71,20 @@ operational and integration scenarios**.
- "traefik.http.routers.grafana.middlewares=authyes-forwardauth@docker" - "traefik.http.routers.grafana.middlewares=authyes-forwardauth@docker"
``` ```
- **Why it's painless:** - **Why it's painless:**
- Traefik intercepts unauthenticated requests at the network edge in $<1$ms - **Dual-Response Protocol:** If a human opens a protected app in a browser
before traffic reaches the container. (`Accept: text/html`), Auth-Yes immediately issues a `302 Redirect` to
`https://auth.atyg.org/login?redirect=...`. Upon touching their passkey, the
user is automatically bounced back to the exact URL/tab they originally
requested.
- **Headless & API Transparency:** Background scripts, daemons, and cURL calls
receive clean `401 Unauthorized` responses without getting stuck in HTML
redirect loops.
- **Open Redirect Protection (CWE-601):** The return URL is strictly validated
against approved domains (`*.atyg.org` and localhost) to eliminate phishing
attack vectors.
- Auth-Yes validates the wildcard session cookie and injects - Auth-Yes validates the wildcard session cookie and injects
`X-Forwarded-User: alice` downstream so Grafana automatically logs the user `X-Forwarded-User: alice` downstream so Grafana or Portainer automatically
into their profile. logs the user into their profile.
--- ---

View File

@ -71,7 +71,7 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => {
valkeyStub.restore(); valkeyStub.restore();
}); });
Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session", async () => { Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session (API request)", async () => {
const req = new Request("http://localhost/api/forward-auth", { const req = new Request("http://localhost/api/forward-auth", {
headers: { "X-Forwarded-Host": "test.app.local" }, headers: { "X-Forwarded-Host": "test.app.local" },
}); });
@ -91,6 +91,39 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session", async () => {
valkeyStub.restore(); valkeyStub.restore();
}); });
Deno.test("Tier 1 & 2: GET /api/forward-auth - Missing session (Browser request with text/html)", async () => {
const req = new Request("http://localhost/api/forward-auth", {
headers: {
"X-Forwarded-Host": "ed-droid.atyg.org",
"X-Forwarded-Uri": "/control-panel",
"X-Forwarded-Proto": "https",
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
},
});
const valkeyStub = stub(valkey, "get", (key: any) => {
const k = String(key);
if (k.startsWith("auth:app_by_host:")) {
return Promise.resolve(
JSON.stringify({ id: "app-id-1", name: "ed-droid" }),
);
}
return Promise.resolve(null);
});
const res = await app.request(req);
assertEquals(res.status, 302);
const location = res.headers.get("Location");
assertExists(location);
assertEquals(
location?.includes(
"login?redirect=https%3A%2F%2Fed-droid.atyg.org%2Fcontrol-panel",
),
true,
);
valkeyStub.restore();
});
Deno.test("Tier 1 & 2: GET /api/forward-auth - Expired session", async () => { Deno.test("Tier 1 & 2: GET /api/forward-auth - Expired session", async () => {
const valkeyStub = stub(valkey, "get", (key: any) => { const valkeyStub = stub(valkey, "get", (key: any) => {
const k = String(key); const k = String(key);

View File

@ -1055,6 +1055,22 @@ app.get("/api/forward-auth", async (c) => {
// Standard User Session Path // Standard User Session Path
const auth = await getAuthenticatedUser(c); const auth = await getAuthenticatedUser(c);
if (!auth) { if (!auth) {
const accept = c.req.header("Accept") || "";
const proto = c.req.header("X-Forwarded-Proto") || "https";
const uri = c.req.header("X-Forwarded-Uri") || "/";
const originalUrl = `${proto}://${host}${uri}`;
// If a browser is requesting a webpage, seamlessly redirect to login
if (accept.includes("text/html")) {
const loginDomain = rpID || "auth.atyg.org";
return c.redirect(
`https://${loginDomain}/login?redirect=${
encodeURIComponent(originalUrl)
}`,
302,
);
}
return c.text("Unauthorized", 401); return c.text("Unauthorized", 401);
} }

View File

@ -220,8 +220,29 @@ async function startWebAuthnLogin(username) {
if (verificationJSON.success) { if (verificationJSON.success) {
setStatus("Login successful! Redirecting..."); setStatus("Login successful! Redirecting...");
let targetRedirect = "/dashboard";
try {
const params = new URLSearchParams(window.location.search);
const rawRedirect = params.get("redirect");
if (rawRedirect) {
if (rawRedirect.startsWith("/") && !rawRedirect.startsWith("//")) {
targetRedirect = rawRedirect;
} else {
const parsed = new URL(rawRedirect);
if (
parsed.hostname.endsWith(".atyg.org") ||
parsed.hostname === "atyg.org" ||
parsed.hostname === "localhost"
) {
targetRedirect = rawRedirect;
}
}
}
} catch (_e) {
// Fallback to default
}
setTimeout(() => { setTimeout(() => {
globalThis.location.href = "/dashboard"; globalThis.location.href = targetRedirect;
}, 1000); }, 1000);
} else { } else {
setStatus(verificationJSON.error || "Login verification failed", true); setStatus(verificationJSON.error || "Login verification failed", true);