Sign out through the proxy when the proxy signed you in
Sign out cleared ipx's cookies and showed its password page, while Cloudflare Access still vouched for the person: nothing was signed out, and the page looked like the wrong login. /api/me now says, for someone the proxy signed in, where to go instead ([web] sign_out_url, which is /cdn-cgi/access/logout behind Access), and /login sends anyone the proxy vouches for on to their feeds. The header check both use is one function. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TAC7sLVqfKmY6rsTLXzNgk
This commit is contained in:
@@ -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
|
- 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.
|
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
|
- 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
|
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.
|
been checked against a real setup, and pointed at the wrong address.
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ token = "" # generated and saved on first run
|
|||||||
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
trusted_header = "" # e.g. "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1"]
|
trusted_proxies = ["127.0.0.1", "::1"]
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
|
sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -77,6 +78,9 @@ session_days = 30
|
|||||||
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
* **`trusted_proxies`** — addresses allowed to assert that header, and the entire security boundary
|
||||||
for it. Name the proxy, never a subnet.
|
for it. Name the proxy, never a subnet.
|
||||||
* **`auto_create_users`** — create an account the first time the proxy vouches for a new name.
|
* **`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.
|
* **`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
|
It is plain HTTP. On a LAN bind everything crosses the network in the clear — and a feed URL can
|
||||||
|
|||||||
@@ -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
|
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
|
in. Undo: delete it under Applications → Applications, or
|
||||||
`DELETE /api/v3/core/applications/ipodderx/`.
|
`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
|
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
|
`cloudflared`. Verifying Cloudflare's signed `Cf-Access-Jwt-Assertion` would remove that, and is
|
||||||
|
|||||||
13
docs/sso.md
13
docs/sso.md
@@ -40,6 +40,7 @@ bind = "0.0.0.0:8099"
|
|||||||
trusted_header = "Cf-Access-Authenticated-User-Email"
|
trusted_header = "Cf-Access-Authenticated-User-Email"
|
||||||
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
trusted_proxies = ["127.0.0.1", "::1", "192.168.16.1"]
|
||||||
auto_create_users = true
|
auto_create_users = true
|
||||||
|
sign_out_url = "/cdn-cgi/access/logout"
|
||||||
session_days = 30
|
session_days = 30
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -89,6 +90,17 @@ send:
|
|||||||
docker exec iPodderX ipx user rename <old name> <email address>
|
docker exec iPodderX ipx user rename <old name> <email address>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
### The tile in Authentik's library
|
||||||
|
|
||||||
Authentik's library lists Authentik's own applications, and ipodderx signs in through the one
|
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_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. |
|
| `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. |
|
| `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. |
|
| `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
|
The first account ever created is an admin. Every later one is an ordinary user, who cannot change
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ pub struct Web {
|
|||||||
pub trusted_proxies: Vec<String>,
|
pub trusted_proxies: Vec<String>,
|
||||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||||
pub auto_create_users: bool,
|
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.
|
/// Sign a session out after this long without a request.
|
||||||
pub session_days: i64,
|
pub session_days: i64,
|
||||||
}
|
}
|
||||||
@@ -93,6 +97,7 @@ impl Default for Web {
|
|||||||
trusted_header: String::new(),
|
trusted_header: String::new(),
|
||||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||||
auto_create_users: true,
|
auto_create_users: true,
|
||||||
|
sign_out_url: String::new(),
|
||||||
session_days: 30,
|
session_days: 30,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/web.rs
64
src/web.rs
@@ -91,23 +91,8 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
let cfg = state.ctx.cfg();
|
let cfg = state.ctx.cfg();
|
||||||
let token = cfg.web.token.clone();
|
let token = cfg.web.token.clone();
|
||||||
|
|
||||||
let peer = req
|
// 1. A header, but only from a hop we were told to believe.
|
||||||
.extensions()
|
let vouched = vouched_name(&cfg, &req);
|
||||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
|
||||||
.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();
|
|
||||||
|
|
||||||
let mut set_cookie: Option<String> = None;
|
let mut set_cookie: Option<String> = None;
|
||||||
let mut user = None;
|
let mut user = None;
|
||||||
@@ -139,6 +124,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
let _ = state.ctx.db.signed_in(u.id);
|
let _ = state.ctx.db.signed_in(u.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let by_proxy = user.is_some();
|
||||||
|
|
||||||
// 2. A session cookie from signing in here.
|
// 2. A session cookie from signing in here.
|
||||||
if user.is_none() {
|
if user.is_none() {
|
||||||
@@ -189,6 +175,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
};
|
};
|
||||||
|
|
||||||
req.extensions_mut().insert(user);
|
req.extensions_mut().insert(user);
|
||||||
|
req.extensions_mut().insert(Proxied(by_proxy));
|
||||||
let mut resp = next.run(req).await;
|
let mut resp = next.run(req).await;
|
||||||
if let Some(c) = set_cookie {
|
if let Some(c) = set_cookie {
|
||||||
if let Ok(v) = header::HeaderValue::from_str(&c) {
|
if let Ok(v) = header::HeaderValue::from_str(&c) {
|
||||||
@@ -200,6 +187,29 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
|
|||||||
|
|
||||||
const SESSION_COOKIE: &str = "ipx_session";
|
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<String> {
|
||||||
|
let peer = req
|
||||||
|
.extensions()
|
||||||
|
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||||
|
.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
|
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||||
/// reaches a handler without passing through it.
|
/// reaches a handler without passing through it.
|
||||||
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
|
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
|
||||||
@@ -293,8 +303,15 @@ async fn logout(State(state): State<WebState>, req: Request) -> Response {
|
|||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn me(user: crate::db::User) -> Json<serde_json::Value> {
|
/// Who is signed in, and, for someone the proxy signed in, where Sign out should send them.
|
||||||
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
|
async fn me(
|
||||||
|
State(state): State<WebState>,
|
||||||
|
user: crate::db::User,
|
||||||
|
axum::Extension(Proxied(by_proxy)): axum::Extension<Proxied>,
|
||||||
|
) -> Json<serde_json::Value> {
|
||||||
|
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 ----
|
// ---- accounts: admin only ----
|
||||||
@@ -417,8 +434,13 @@ async fn remove_user(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn login_page() -> Html<&'static str> {
|
/// The password form, except for someone the proxy vouches for: they are signed in already, and
|
||||||
Html(include_str!("../web/login.html"))
|
/// the form only made it look as if they were not.
|
||||||
|
async fn login_page(State(state): State<WebState>, 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
|
/// The 2004 icon, served once for both pages rather than inlined as base64 into each. The
|
||||||
|
|||||||
@@ -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 expect(page.locator('audio')).toHaveCount(1); // the player bar's, and nothing else
|
||||||
await page.locator('#pclose').click();
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ enabled = false
|
|||||||
enabled = true
|
enabled = true
|
||||||
bind = "127.0.0.1:8791"
|
bind = "127.0.0.1:8791"
|
||||||
token = "${TOKEN}"
|
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]
|
[feeds.test-show]
|
||||||
url = "http://127.0.0.1:8792/show.xml"
|
url = "http://127.0.0.1:8792/show.xml"
|
||||||
|
|||||||
@@ -1869,7 +1869,9 @@ function opmlModal(){
|
|||||||
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
async function scanAll(){ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); }
|
||||||
$('#scanAll').onclick=scanAll;
|
$('#scanAll').onclick=scanAll;
|
||||||
$('#prefs').onclick=prefsModal;
|
$('#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=>{
|
api('/api/me').then(u=>{
|
||||||
S.me=u;
|
S.me=u;
|
||||||
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
$('#who').textContent=u.name+(u.admin?' · admin':'');
|
||||||
|
|||||||
Reference in New Issue
Block a user