diff --git a/docs/USE_CASES_AND_EFFORT.md b/docs/USE_CASES_AND_EFFORT.md index a9e4e09..579f5d2 100644 --- a/docs/USE_CASES_AND_EFFORT.md +++ b/docs/USE_CASES_AND_EFFORT.md @@ -71,11 +71,20 @@ operational and integration scenarios**. - "traefik.http.routers.grafana.middlewares=authyes-forwardauth@docker" ``` - **Why it's painless:** - - Traefik intercepts unauthenticated requests at the network edge in $<1$ms - before traffic reaches the container. + - **Dual-Response Protocol:** If a human opens a protected app in a browser + (`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 - `X-Forwarded-User: alice` downstream so Grafana automatically logs the user - into their profile. + `X-Forwarded-User: alice` downstream so Grafana or Portainer automatically + logs the user into their profile. --- diff --git a/server/main.test.ts b/server/main.test.ts index 56305d6..d02b1cd 100644 --- a/server/main.test.ts +++ b/server/main.test.ts @@ -71,7 +71,7 @@ Deno.test("Tier 1 & 2: GET /api/forward-auth - Valid session", async () => { 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", { 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(); }); +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 () => { const valkeyStub = stub(valkey, "get", (key: any) => { const k = String(key); diff --git a/server/main.ts b/server/main.ts index c298ce9..3679c2c 100644 --- a/server/main.ts +++ b/server/main.ts @@ -1055,6 +1055,22 @@ app.get("/api/forward-auth", async (c) => { // Standard User Session Path const auth = await getAuthenticatedUser(c); 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); } diff --git a/ui/public/auth-client.js b/ui/public/auth-client.js index d459558..c0bee70 100644 --- a/ui/public/auth-client.js +++ b/ui/public/auth-client.js @@ -220,8 +220,29 @@ async function startWebAuthnLogin(username) { if (verificationJSON.success) { 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(() => { - globalThis.location.href = "/dashboard"; + globalThis.location.href = targetRedirect; }, 1000); } else { setStatus(verificationJSON.error || "Login verification failed", true);