diff --git a/CHANGELOG.md b/CHANGELOG.md index d049dde..719449a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,9 @@ The long form, with what was wrong before and how it was found, is in - Show notes that the podcast's host cut off in the middle of a tag no longer open with a scrap of HTML: the item's other copy of its notes is shown instead. Daily Meditation Podcast had 57. +- Signing out after signing in through Cloudflare Access no longer lands on ipodderx's own password + page. With the new `sign_out_url` set, Sign out ends the Access session, and the password page + sends anyone the proxy signs in straight to their feeds. - The sign-in guide, `docs/sso.md`, describes the setup ipodderx.sdf1.net really runs: Authentik as Cloudflare Access's identity provider, and how to find the address ipx has to trust. It had never been checked against a real setup, and pointed at the wrong address. diff --git a/docs/configuration.md b/docs/configuration.md index 6a57c10..bb662c9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,6 +67,7 @@ token = "" # generated and saved on first run trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email" trusted_proxies = ["127.0.0.1", "::1"] auto_create_users = true +sign_out_url = "" # e.g. "/cdn-cgi/access/logout" session_days = 30 ``` @@ -77,6 +78,9 @@ session_days = 30 * **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary for it. Name the proxy, never a subnet. * **`auto_create_users`** — create an account the first time the proxy vouches for a new name. +* **`sign_out_url`** — where Sign out sends someone the proxy signed in: the proxy's own sign-out, + `/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where + the proxy signs them straight back in. * **`session_days`** — sign a session out after this long without a request. It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can diff --git a/docs/history.md b/docs/history.md index 22093c9..9d3fbac 100644 --- a/docs/history.md +++ b/docs/history.md @@ -47,6 +47,14 @@ Every change, in order, with how to undo it: iPodderX icon, and a link to `https://ipodderx.sdf1.net`. It changes nothing about who can sign in. Undo: delete it under Applications → Applications, or `DELETE /api/v3/core/applications/ipodderx/`. +6. **Signing out**, later again. Sign out landed on ipx's password page while Access still vouched + for Ray, so it signed nothing out, and the page looked like the wrong login. Cloudflare's + `/cdn-cgi/access/logout` ends the Access session for every Access application at once (there is + no per-application sign-out, and it takes no redirect), and Authentik's end-session only ends + one application's session unless single logout is set up there. Ray chose Access's sign-out. New + `[web] sign_out_url`, set to `/cdn-cgi/access/logout` in production (the file as it was is + `config.toml.2026-09-12-signout.bak`), and `/login` now sends anyone the proxy vouches for on to + `/`. Undo: take the key out and restart; the code does nothing without it. What the address trusts is any container on Tower that connects through the host's port, not only `cloudflared`. Verifying Cloudflare's signed `Cf-Access-Jwt-Assertion` would remove that, and is diff --git a/docs/sso.md b/docs/sso.md index 2b11afc..7020c21 100644 --- a/docs/sso.md +++ b/docs/sso.md @@ -40,6 +40,7 @@ bind = "0.0.0.0:8099" trusted_header = "Cf-Access-Authenticated-User-Email" trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"] auto_create_users = true +sign_out_url = "/cdn-cgi/access/logout" session_days = 30 ``` @@ -89,6 +90,17 @@ send: docker exec iPodderX ipx user rename ``` +### Signing out + +**Sign out** sends someone the proxy signed in to `sign_out_url`, here Cloudflare's +`/cdn-cgi/access/logout`. That ends your Access session for **every** Access application, +`code.sdf1.net` included: Cloudflare has no way to end just one, and its sign-out page does not send +you anywhere afterwards. The next visit goes back through Authentik, which lets you straight in if +you are still signed in there. Signing out of Authentik itself is Authentik's own sign-out. + +ipx never shows its password page to someone the proxy vouches for: `/login` sends them on to their +feeds. + ### The tile in Authentik's library Authentik's library lists Authentik's own applications, and ipodderx signs in through the one @@ -122,6 +134,7 @@ ipx should show `rays@sdf1.net` in the sidebar footer without asking for a passw | `trusted_header` | The header the proxy sets. Empty, the default, turns the proxy path off. | | `trusted_proxies` | The addresses allowed to set it. Nothing else is believed. | | `auto_create_users` | Make an account the first time the proxy vouches for a name ipx has not seen. | +| `sign_out_url` | Where Sign out sends someone the proxy signed in: the proxy's own sign-out. Empty sends them to the sign-in page, where the proxy signs them straight back in. | | `session_days` | How long a password sign-in lasts without use. | The first account ever created is an admin. Every later one is an ordinary user, who cannot change diff --git a/src/config.rs b/src/config.rs index 361fc60..d587434 100644 --- a/src/config.rs +++ b/src/config.rs @@ -80,6 +80,10 @@ pub struct Web { pub trusted_proxies: Vec, /// Create an account the first time the proxy vouches for a name it has not seen. pub auto_create_users: bool, + /// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick + /// while the proxy still vouches for them, so this is the proxy's own sign-out: + /// `/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to /login. + pub sign_out_url: String, /// Sign a session out after this long without a request. pub session_days: i64, } @@ -93,6 +97,7 @@ impl Default for Web { trusted_header: String::new(), trusted_proxies: vec!["127.0.0.1".into(), "::1".into()], auto_create_users: true, + sign_out_url: String::new(), session_days: 30, } } diff --git a/src/web.rs b/src/web.rs index 99f2102..078e554 100644 --- a/src/web.rs +++ b/src/web.rs @@ -91,23 +91,8 @@ async fn auth(State(state): State, mut req: Request, next: Next) -> Re let cfg = state.ctx.cfg(); let token = cfg.web.token.clone(); - let peer = req - .extensions() - .get::>() - .map(|c| c.0.ip().to_string()) - .unwrap_or_default(); - - // 1. A header, but only from a hop we were told to believe. Anyone able to reach the - // port could otherwise send it and be whoever they liked. - let vouched = (!cfg.web.trusted_header.is_empty() - && cfg.web.trusted_proxies.iter().any(|p| p == &peer)) - .then(|| { - req.headers() - .get(&cfg.web.trusted_header) - .and_then(|v| v.to_str().ok()) - .and_then(crate::auth::name_from_header) - }) - .flatten(); + // 1. A header, but only from a hop we were told to believe. + let vouched = vouched_name(&cfg, &req); let mut set_cookie: Option = None; let mut user = None; @@ -139,6 +124,7 @@ async fn auth(State(state): State, mut req: Request, next: Next) -> Re let _ = state.ctx.db.signed_in(u.id); } } + let by_proxy = user.is_some(); // 2. A session cookie from signing in here. if user.is_none() { @@ -189,6 +175,7 @@ async fn auth(State(state): State, mut req: Request, next: Next) -> Re }; req.extensions_mut().insert(user); + req.extensions_mut().insert(Proxied(by_proxy)); let mut resp = next.run(req).await; if let Some(c) = set_cookie { if let Ok(v) = header::HeaderValue::from_str(&c) { @@ -200,6 +187,29 @@ async fn auth(State(state): State, mut req: Request, next: Next) -> Re const SESSION_COOKIE: &str = "ipx_session"; +/// Whether the proxy signed this request in, rather than a session or the token: signing out +/// has to go through the proxy then, or its next request signs the person straight back in. +#[derive(Clone, Copy)] +struct Proxied(bool); + +/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and +/// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and +/// be whoever they liked. +fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option { + let peer = req + .extensions() + .get::>() + .map(|c| c.0.ip().to_string()) + .unwrap_or_default(); + if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) { + return None; + } + req.headers() + .get(&cfg.web.trusted_header) + .and_then(|v| v.to_str().ok()) + .and_then(crate::auth::name_from_header) +} + /// Handlers take `User` to say they need one; the auth layer put it there, and nothing /// reaches a handler without passing through it. impl axum::extract::FromRequestParts for crate::db::User { @@ -293,8 +303,15 @@ async fn logout(State(state): State, req: Request) -> Response { resp } -async fn me(user: crate::db::User) -> Json { - Json(serde_json::json!({ "name": user.name, "admin": user.is_admin })) +/// Who is signed in, and, for someone the proxy signed in, where Sign out should send them. +async fn me( + State(state): State, + user: crate::db::User, + axum::Extension(Proxied(by_proxy)): axum::Extension, +) -> Json { + let url = state.ctx.cfg().web.sign_out_url.clone(); + let sign_out = (by_proxy && !url.is_empty()).then_some(url); + Json(serde_json::json!({ "name": user.name, "admin": user.is_admin, "sign_out": sign_out })) } // ---- accounts: admin only ---- @@ -417,8 +434,13 @@ async fn remove_user( Ok(StatusCode::NO_CONTENT) } -async fn login_page() -> Html<&'static str> { - Html(include_str!("../web/login.html")) +/// The password form, except for someone the proxy vouches for: they are signed in already, and +/// the form only made it look as if they were not. +async fn login_page(State(state): State, req: Request) -> Response { + if vouched_name(&state.ctx.cfg(), &req).is_some() { + return Redirect::to("/").into_response(); + } + Html(include_str!("../web/login.html")).into_response() } /// The 2004 icon, served once for both pages rather than inlined as base64 into each. The diff --git a/tests/ui/app.spec.js b/tests/ui/app.spec.js index 60dac53..dc5372e 100644 --- a/tests/ui/app.spec.js +++ b/tests/ui/app.spec.js @@ -793,3 +793,20 @@ test('play in the Files pane plays once, in the player bar', async ({ page }) => await expect(page.locator('audio')).toHaveCount(1); // the player bar's, and nothing else await page.locator('#pclose').click(); }); + +test('someone the proxy signs in never sees the password page, and signs out through the proxy', async ({ page, browser }) => { + // Signed in with the token, not by the proxy: Sign out stays ipx's own. + expect((await (await page.request.get('/api/me')).json()).sign_out).toBeNull(); + + const ctx = await browser.newContext({ extraHTTPHeaders: { 'X-Test-User': 'proxied@example.com' } }); + const proxied = await ctx.newPage(); + // Regression: after Sign out, the password form showed to someone the proxy still vouched for. + await proxied.goto('/login'); + await expect(proxied).toHaveURL(/:8791\/$/); + await expect(proxied.locator('#who')).toContainText('proxied@example.com'); + expect(await (await proxied.request.get('/api/me')).json()) + .toMatchObject({ name: 'proxied@example.com', sign_out: '/signed-out-by-the-proxy' }); + await proxied.locator('#signout').click(); + await expect(proxied).toHaveURL(/\/signed-out-by-the-proxy$/); + await ctx.close(); +}); diff --git a/tests/ui/global-setup.js b/tests/ui/global-setup.js index 81d0d0e..812d915 100644 --- a/tests/ui/global-setup.js +++ b/tests/ui/global-setup.js @@ -37,6 +37,10 @@ enabled = false enabled = true bind = "127.0.0.1:8791" token = "${TOKEN}" +# The proxy path, for tests that send the header themselves: the daemon sees them at 127.0.0.1. +trusted_header = "X-Test-User" +trusted_proxies = ["127.0.0.1"] +sign_out_url = "/signed-out-by-the-proxy" [feeds.test-show] url = "http://127.0.0.1:8792/show.xml" diff --git a/web/index.html b/web/index.html index dc2157d..f3511a8 100644 --- a/web/index.html +++ b/web/index.html @@ -1869,7 +1869,9 @@ function opmlModal(){ async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); } $('#scanAll').onclick=scanAll; $('#prefs').onclick=prefsModal; -$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href='/login'; }; +// Someone the proxy signed in is signed out by the proxy: ipx's own sign-out cannot stick while +// the proxy still vouches for them. /api/me says where, when that is the case. +$('#signout').onclick=async()=>{ await api('/api/logout',{method:'POST'}); location.href=S.me?.sign_out||'/login'; }; api('/api/me').then(u=>{ S.me=u; $('#who').textContent=u.name+(u.admin?' · admin':'');