feat(forwardauth): add browser 302 redirect with safe return URL and open-redirect protection
This commit is contained in:
parent
590a13e694
commit
96bcf69179
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user