Verify Cloudflare Access's signed token before trusting the proxy

The proxy sign-in believed Cf-Access-Authenticated-User-Email from any
address in trusted_proxies. On Tower that address is the Docker gateway,
so any container there could name itself anyone (docs/sso.md said as
much, and CLAUDE.md listed it as a known gap).

With [web] access_team and access_aud set, a proxied request must also
carry a Cf-Access-Jwt-Assertion that verifies against Cloudflare's keys
(RS256 only, this application's audience, the team's issuer, not
expired), and the name comes from its email claim. The keys are fetched
at start and again when a token names an unseen key, at most once a
minute, so made-up key ids cannot make every request a request to
Cloudflare. While the keys cannot be had, proxied sign-in is refused;
password and token sign-in are unaffected. Both settings empty, nothing
changes.

jsonwebtoken does the checking, on the aws-lc-rs backend already in the
tree through rustls. Tests sign with throwaway keys in tests/data: a
valid token, another app's audience, expired, a forged signature, HS256,
alg none, the refetch limit, and keys that cannot be fetched. Checked
live on a scratch daemon: the header alone and a forged token got 401,
the admin token still signed in.

vouched_name takes the peer and headers rather than the request: a
&Request held across the new await made the auth middleware's future
unsendable, as a body is not Sync.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 14:38:25 +00:00
parent f1f605e180
commit c1187a7926
13 changed files with 354 additions and 18 deletions

View File

@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It - The daemon no longer prints the web token when it starts, so it stays out of `docker logs`. It
says where the token is kept instead: `[web] token` in config.toml. says where the token is kept instead: `[web] token` in config.toml.
- Signing in through Cloudflare Access can check the token Access signs: set `access_team` and
`access_aud` under `[web]`, and a request has to carry a valid `Cf-Access-Jwt-Assertion` as well as
the email header. Without it, anything on the same Docker host as ipx could send the header. See
docs/sso.md.
### Fixed ### Fixed

View File

@@ -183,8 +183,7 @@ Deliberate simplifications get a `ponytail:` comment naming the ceiling and the
## Known gaps ## Known gaps
* Cloudflare's `Cf-Access-Jwt-Assertion` is not verified — ipx trusts the hop plus `trusted_proxies` * Nothing at the moment. Add one here when a limitation is known and left in place.
(documented in [docs/sso.md](docs/sso.md)).
<!-- rtk-instructions v2 --> <!-- rtk-instructions v2 -->
# Command output # Command output

40
Cargo.lock generated
View File

@@ -1830,6 +1830,7 @@ dependencies = [
"chrono", "chrono",
"clap", "clap",
"futures-util", "futures-util",
"jsonwebtoken",
"librqbit", "librqbit",
"opml", "opml",
"percent-encoding", "percent-encoding",
@@ -2007,6 +2008,22 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "jsonwebtoken"
version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
dependencies = [
"aws-lc-rs",
"base64 0.22.1",
"getrandom 0.2.17",
"js-sys",
"serde",
"serde_json",
"signature",
"zeroize",
]
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@@ -3786,6 +3803,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.10" version = "0.3.10"
@@ -5183,6 +5209,20 @@ name = "zeroize"
version = "1.9.0" version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"

View File

@@ -12,6 +12,7 @@ axum = "0.8.9"
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
clap = { version = "4.6.6", features = ["derive"] } clap = { version = "4.6.6", features = ["derive"] }
futures-util = { version = "0.3.34", default-features = false, features = ["std"] } futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] }
librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] } librqbit = { version = "9.0.1", default-features = false, features = ["rust-tls", "http-api-client"] }
opml = "1.1.6" opml = "1.1.6"
percent-encoding = "2.3.2" percent-encoding = "2.3.2"

View File

@@ -83,6 +83,8 @@ bind = "0.0.0.0:8099" # 127.0.0.1:8080 by default
token = "" # generated and saved on first run 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"]
access_team = "" # e.g. "<team>.cloudflareaccess.com"
access_aud = "" # the Access application's AUD tag
auto_create_users = true auto_create_users = true
sign_out_url = "" # e.g. "/cdn-cgi/access/logout" sign_out_url = "" # e.g. "/cdn-cgi/access/logout"
session_days = 30 session_days = 30
@@ -94,6 +96,9 @@ session_days = 30
disables that path. See [sso.md](sso.md). disables that path. See [sso.md](sso.md).
* **`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.
* **`access_team`**, **`access_aud`** — with both set, a request through the proxy also has to
carry the `Cf-Access-Jwt-Assertion` Cloudflare Access signed for this application, and the name
comes from that token instead of the header. See [sso.md](sso.md#verifying-cloudflares-token).
* **`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, * **`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 `/cdn-cgi/access/logout` behind Cloudflare Access. Empty sends them to the sign-in page, where

View File

@@ -172,9 +172,33 @@ itself, arrive under their own addresses and cannot set the header; the checks a
sides. Never list a LAN address or range: anyone there could then send sides. Never list a LAN address or range: anyone there could then send
`Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you. `Cf-Access-Authenticated-User-Email: rays@sdf1.net` and be you.
**What ipx does not do:** it does not verify Cloudflare's signed `Cf-Access-Jwt-Assertion`. It **Unless the token is checked.** With `access_team` and `access_aud` set (next section), the
trusts the hop. Verifying the signature would make the containers on Tower irrelevant to the header is not enough on its own: the request has to carry the token Cloudflare Access signed, and
boundary, and is the upgrade if that ever matters. a container on Tower cannot make one.
### Verifying Cloudflare's token
Access adds `Cf-Access-Jwt-Assertion` to every request it forwards: a JWT naming the person,
signed with keys only Cloudflare holds. With these two settings ipx checks it on every proxied
request, and takes the name from its `email` claim.
```toml
[web]
access_team = "<team>.cloudflareaccess.com" # Zero Trust → Settings: the team domain
access_aud = "…" # Access → Applications → ipodderx → Overview: Application Audience (AUD) Tag
```
ipx fetches the public keys from `https://<access_team>/cdn-cgi/access/certs` when it starts, and
again when a token names a key it has not seen (Cloudflare rotates them every six weeks or so), at
most once a minute. It checks the signature (RS256 only), that the audience is this application's
tag, the issuer, and the expiry. Anything else is refused, and so is every proxied request while
the keys cannot be fetched; password and token sign-in still work then.
`trusted_header` and `trusted_proxies` still apply: the check is added to them, not put in their
place.
Check it: the busybox request under [Check it](#check-it), which sends the email header without a
token from the Docker bridge, now gets `sign in`, and the site still signs you in through Authentik.
**Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign **Turning it off:** clear `trusted_header` and restart. Proxy-made accounts stay, but nobody can sign
in with them until they are given a password (`ipx user passwd <name>`). in with them until they are given a password (`ipx user passwd <name>`).

215
src/access.rs Normal file
View File

@@ -0,0 +1,215 @@
//! Cloudflare Access's signed assertion, `Cf-Access-Jwt-Assertion`. Without it, the proxy
//! sign-in trusts a plain header from any address in `trusted_proxies`, and on Tower that
//! address is the Docker gateway: any container there could send the header and be anyone.
//! Access signs the same identity with keys only Cloudflare holds, so checking that signature
//! takes the network out of the question.
use std::collections::HashMap;
use std::future::Future;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use jsonwebtoken::jwk::JwkSet;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
/// Cloudflare rotates its keys every six weeks or so, publishing the new one before using it.
/// A token naming a key not seen yet refetches, but no more often than this, so a stream of
/// made-up key ids cannot turn every request into a request to Cloudflare.
const REFETCH_EVERY: Duration = Duration::from_secs(60);
#[derive(Default)]
pub struct Keys {
cache: Mutex<Cache>,
}
#[derive(Default)]
struct Cache {
keys: HashMap<String, DecodingKey>,
fetched: Option<Instant>,
}
#[derive(serde::Deserialize)]
struct Claims {
email: Option<String>,
}
impl Keys {
/// The name the token vouches for, or None: a bad signature, the wrong audience or issuer, an
/// expired token, a key that cannot be had, or no email in it (a service token has none).
pub async fn verify(&self, client: &reqwest::Client, team: &str, aud: &str, token: &str) -> Option<String> {
self.verify_with(team, aud, token, || fetch(client, team)).await
}
/// Fill the cache before the first request needs it. A failure is only logged: the next
/// request tries again, and until one succeeds the proxy sign-in refuses everyone.
pub async fn prefetch(&self, client: &reqwest::Client, team: &str) {
match fetch(client, team).await {
Ok(set) => self.store(set),
Err(e) => tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys"),
}
}
async fn verify_with<F, Fut>(&self, team: &str, aud: &str, token: &str, fetch: F) -> Option<String>
where
F: FnOnce() -> Fut,
Fut: Future<Output = Result<JwkSet>>,
{
let kid = decode_header(token).ok()?.kid?;
let key = match self.key(&kid) {
Some(k) => k,
None => {
if !self.may_refetch() {
return None;
}
match fetch().await {
Ok(set) => self.store(set),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "could not fetch Cloudflare Access's signing keys");
return None;
}
}
self.key(&kid)?
}
};
// RS256 only: a token that names HS256 or none is refused here, before its signature
// is looked at, rather than checked with the public key as if it were a secret.
let mut v = Validation::new(Algorithm::RS256);
v.set_audience(&[aud]);
v.set_issuer(&[format!("https://{team}")]);
v.validate_nbf = true;
match decode::<Claims>(token, &key, &v) {
Ok(data) => crate::auth::name_from_header(&data.claims.email?),
Err(e) => {
tracing::warn!(error = %e, "refused a Cloudflare Access token");
None
}
}
}
fn key(&self, kid: &str) -> Option<DecodingKey> {
self.cache.lock().unwrap().keys.get(kid).cloned()
}
/// Takes the slot as it answers, so two requests at once do not both fetch.
fn may_refetch(&self) -> bool {
let mut c = self.cache.lock().unwrap();
if c.fetched.is_some_and(|t| t.elapsed() < REFETCH_EVERY) {
return false;
}
c.fetched = Some(Instant::now());
true
}
/// Replaces the whole set, so a key Cloudflare has retired stops being accepted.
fn store(&self, set: JwkSet) {
let keys = set
.keys
.iter()
.filter_map(|k| Some((k.common.key_id.clone()?, DecodingKey::from_jwk(k).ok()?)))
.collect();
let mut c = self.cache.lock().unwrap();
c.keys = keys;
c.fetched = Some(Instant::now());
}
}
async fn fetch(client: &reqwest::Client, team: &str) -> Result<JwkSet> {
let url = format!("https://{team}/cdn-cgi/access/certs");
client
.get(&url)
.timeout(Duration::from_secs(10))
.send()
.await
.with_context(|| format!("fetching {url}"))?
.error_for_status()?
.json()
.await
.context("reading the signing keys")
}
#[cfg(test)]
mod tests {
use super::*;
use jsonwebtoken::{EncodingKey, Header, encode, get_current_timestamp};
const TEAM: &str = "team.cloudflareaccess.com";
const AUD: &str = "aud-tag";
fn jwks() -> JwkSet {
serde_json::from_str(include_str!("../tests/data/access-test.jwks.json")).unwrap()
}
fn token(key: &[u8], alg: Algorithm, claims: serde_json::Value) -> String {
let mut h = Header::new(alg);
h.kid = Some("k1".into());
let k = if alg == Algorithm::RS256 { EncodingKey::from_rsa_der(key) } else { EncodingKey::from_secret(key) };
encode(&h, &claims, &k).unwrap()
}
fn claims(aud: &str, exp_in: i64) -> serde_json::Value {
let now = get_current_timestamp() as i64;
serde_json::json!({
"aud": [aud], "iss": format!("https://{TEAM}"), "email": "Rays@SDF1.net",
"iat": now, "nbf": now, "exp": now + exp_in, "type": "app",
})
}
const SIGNER: &[u8] = include_bytes!("../tests/data/access-test.der");
const FORGER: &[u8] = include_bytes!("../tests/data/access-forger.der");
async fn check(keys: &Keys, t: &str) -> Option<String> {
keys.verify_with(TEAM, AUD, t, || async { Ok(jwks()) }).await
}
#[tokio::test]
async fn only_a_token_cloudflare_signed_for_this_app_signs_anyone_in() {
let keys = Keys::default();
keys.store(jwks());
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
assert_eq!(check(&keys, &ok).await.as_deref(), Some("rays@sdf1.net"), "lower-cased like the header");
let other_app = token(SIGNER, Algorithm::RS256, claims("another-app", 300));
assert_eq!(check(&keys, &other_app).await, None, "an Access token for another application");
let expired = token(SIGNER, Algorithm::RS256, claims(AUD, -3600));
assert_eq!(check(&keys, &expired).await, None, "expired");
let forged = token(FORGER, Algorithm::RS256, claims(AUD, 300));
assert_eq!(check(&keys, &forged).await, None, "signed by a key that is not Cloudflare's");
// HMAC and none, the classic ways to get a token past a verifier that trusts its header.
let hs = token(b"any secret at all", Algorithm::HS256, claims(AUD, 300));
assert_eq!(check(&keys, &hs).await, None, "HS256");
let none = format!("{}.{}.", "eyJhbGciOiJub25lIiwia2lkIjoiazEifQ",
ok.split('.').nth(1).unwrap());
assert_eq!(check(&keys, &none).await, None, "alg none");
}
#[tokio::test]
async fn an_unknown_key_refetches_once_a_minute_at_most() {
let keys = Keys::default();
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
let fetches = std::sync::atomic::AtomicUsize::new(0);
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
assert_eq!(keys.verify_with(TEAM, AUD, &ok, count).await.as_deref(), Some("rays@sdf1.net"),
"a key not cached yet is fetched");
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1);
// A made-up key id straight after: not fetched again.
let mut h = Header::new(Algorithm::RS256);
h.kid = Some("nobody".into());
let stray = encode(&h, &claims(AUD, 300), &EncodingKey::from_rsa_der(SIGNER)).unwrap();
let count = || { fetches.fetch_add(1, std::sync::atomic::Ordering::SeqCst); async { Ok(jwks()) } };
assert_eq!(keys.verify_with(TEAM, AUD, &stray, count).await, None);
assert_eq!(fetches.load(std::sync::atomic::Ordering::SeqCst), 1, "rate-limited");
}
#[tokio::test]
async fn keys_that_cannot_be_fetched_refuse_rather_than_wave_through() {
let keys = Keys::default();
let ok = token(SIGNER, Algorithm::RS256, claims(AUD, 300));
let got = keys.verify_with(TEAM, AUD, &ok, || async { Err(anyhow::anyhow!("offline")) }).await;
assert_eq!(got, None);
}
}

View File

@@ -78,6 +78,14 @@ pub struct Web {
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could /// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon. /// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
pub trusted_proxies: Vec<String>, pub trusted_proxies: Vec<String>,
/// Cloudflare Access's team domain, `<team>.cloudflareaccess.com`. With `access_aud`, the
/// proxy sign-in also needs the `Cf-Access-Jwt-Assertion` Access signs, and takes the name
/// from it: a header from a trusted address is otherwise all it asks for, and on a Docker
/// host any container can send one from the gateway's address.
pub access_team: String,
/// The Access application's Application Audience (AUD) tag. Empty, with `access_team`,
/// leaves the signature unchecked.
pub access_aud: 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 /// Where Sign out sends someone the proxy signed in. Signing out of ipx alone cannot stick
@@ -96,6 +104,8 @@ impl Default for Web {
token: String::new(), token: String::new(),
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()],
access_team: String::new(),
access_aud: String::new(),
auto_create_users: true, auto_create_users: true,
sign_out_url: String::new(), sign_out_url: String::new(),
session_days: 30, session_days: 30,
@@ -107,6 +117,12 @@ impl Web {
pub fn binds_publicly(&self) -> bool { pub fn binds_publicly(&self) -> bool {
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost") !self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
} }
/// Both halves of the Access check, or None while either is unset.
pub fn access(&self) -> Option<(&str, &str)> {
(!self.access_team.is_empty() && !self.access_aud.is_empty())
.then_some((self.access_team.as_str(), self.access_aud.as_str()))
}
} }
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]

View File

@@ -1,3 +1,4 @@
mod access;
mod auth; mod auth;
mod config; mod config;
mod db; mod db;
@@ -567,10 +568,16 @@ async fn start_web(
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it"); tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
} }
let access = Arc::new(access::Keys::default());
if let Some((team, _)) = ctx.cfg().web.access() {
let (access, ctx, team) = (access.clone(), ctx.clone(), team.to_owned());
tokio::spawn(async move { access.prefetch(&ctx.client, &team).await });
}
let state = web::WebState { let state = web::WebState {
ctx: ctx.clone(), ctx: ctx.clone(),
cmds: cmds.clone(), cmds: cmds.clone(),
events: events.clone(), events: events.clone(),
access,
}; };
Ok(Some(tokio::spawn(async move { Ok(Some(tokio::spawn(async move {
if let Err(e) = web::serve(state, &bind).await { if let Err(e) = web::serve(state, &bind).await {

View File

@@ -29,6 +29,8 @@ pub struct WebState {
pub ctx: Arc<Ctx>, pub ctx: Arc<Ctx>,
pub cmds: mpsc::Sender<Command>, pub cmds: mpsc::Sender<Command>,
pub events: broadcast::Sender<Event>, pub events: broadcast::Sender<Event>,
/// Cloudflare Access's signing keys, fetched once and kept.
pub access: Arc<crate::access::Keys>,
} }
pub fn router(state: WebState) -> Router { pub fn router(state: WebState) -> Router {
@@ -99,7 +101,7 @@ async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Re
let token = cfg.web.token.clone(); let token = cfg.web.token.clone();
// 1. A header, but only from a hop we were told to believe. // 1. A header, but only from a hop we were told to believe.
let vouched = vouched_name(&cfg, &req); let vouched = vouched_name(&state, &cfg, peer(&req), req.headers()).await;
let mut set_cookie: Option<String> = None; let mut set_cookie: Option<String> = None;
let mut user = None; let mut user = None;
@@ -201,20 +203,31 @@ struct Proxied(bool);
/// The name the proxy vouches for, when this request came from one of `trusted_proxies` and /// 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 /// carries `trusted_header`. Anyone able to reach the port could otherwise send the header and
/// be whoever they liked. /// be whoever they liked. With `access_team` and `access_aud` set, it also has to carry a
fn vouched_name(cfg: &crate::config::Config, req: &Request) -> Option<String> { /// token Cloudflare Access signed, and the name is the one in the token.
let peer = req ///
.extensions() /// Takes the request's parts rather than the request: a `&Request` held across the await makes
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>() /// the future unsendable, as a body is not `Sync`.
.map(|c| c.0.ip().to_string()) async fn vouched_name(state: &WebState, cfg: &crate::config::Config, peer: String, headers: &axum::http::HeaderMap) -> Option<String> {
.unwrap_or_default();
if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) { if cfg.web.trusted_header.is_empty() || !cfg.web.trusted_proxies.iter().any(|p| p == &peer) {
return None; return None;
} }
req.headers() let header = |name: &str| headers.get(name).and_then(|v| v.to_str().ok());
.get(&cfg.web.trusted_header) let Some((team, aud)) = cfg.web.access() else {
.and_then(|v| v.to_str().ok()) return header(&cfg.web.trusted_header).and_then(crate::auth::name_from_header);
.and_then(crate::auth::name_from_header) };
// The plain header still has to be there, as it is what switches this path on for a
// request; who it names is the token's to say.
header(&cfg.web.trusted_header)?;
let token = header("Cf-Access-Jwt-Assertion")?;
state.access.verify(&state.ctx.client, team, aud, token).await
}
fn peer(req: &Request) -> String {
req.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|c| c.0.ip().to_string())
.unwrap_or_default()
} }
/// 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
@@ -474,7 +487,7 @@ async fn remove_user(
/// The password form, except for someone the proxy vouches for: they are signed in already, and /// 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. /// the form only made it look as if they were not.
async fn login_page(State(state): State<WebState>, req: Request) -> Response { async fn login_page(State(state): State<WebState>, req: Request) -> Response {
if vouched_name(&state.ctx.cfg(), &req).is_some() { if vouched_name(&state, &state.ctx.cfg(), peer(&req), req.headers()).await.is_some() {
return Redirect::to("/").into_response(); return Redirect::to("/").into_response();
} }
([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html")))) ([(header::CACHE_CONTROL, PAGE_CACHE)], Html(include_str!(concat!(env!("OUT_DIR"), "/login.html"))))

Binary file not shown.

BIN
tests/data/access-test.der Normal file

Binary file not shown.

View File

@@ -0,0 +1,12 @@
{
"keys": [
{
"kid": "k1",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"e": "AQAB",
"n": "1T_jY4dGnU5YJonLMXdTyqFdV2J-67t5NTmTP1mf6kEYw_lW1xWB7306w8XOiplWD9cEDviKh6vQbmTTXL6-z8WnG-9YeRsPOOv0vb8txiuzJZ10ZQDBpbDdfidcESryl6ts7-ApsFz27B060wmHTwL4pywQw4wwmrubkiRwvpidzBpmDlkGZHdy3XV2TTfzQwwTtTuCR6Fd6D8lfK0XL6J5UC-RTH8_v9XEjF7DnI_bflB0olEwAqJ0-3E4xOj9okLOO5sfwE2SZk4yEMhFV4xqjtv8EN0KMT6BGIGs_VPDrSVtt23sEMsDOmeO6Pf9C6bkXy6faREpsX4einQykw"
}
]
}