This commit is contained in:
Mateusz Gruszczyński committed 2026-10-27 13:49:21 +01:00
1 parent f27fbf6a30
commit 2c03fcf248
18 files changed
+430 -920

No files matched your search

+1 -1
View File
@@ -285,7 +285,7 @@ pub fn router(state: Arc<App>) -> Router {
.route("/api/metrics/summary", get(metrics_summary))
.fallback(|| async { reject(StatusCode::NOT_FOUND, "not_found") })
.layer(DefaultBodyLimit::max(
crate::config::MAX_CONFIG_BYTES + 1024,
crate::config::MAX_CONFIG_BYTES + 65_536,
))
.layer(middleware::from_fn_with_state(state.clone(), guard))
.layer(middleware::from_fn_with_state(state.clone(), observe_admin))
+44
View File
@@ -832,6 +832,50 @@ mod tests {
store.close().await;
}
#[tokio::test]
async fn vhosts_section_accepts_large_config_and_persists_it() {
let config = config::demo_config("127.0.0.1:9000".parse().unwrap());
let (router, store, _dir, secret, csrf) = fixture_with_config(config.clone()).await;
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
let mut vhosts = config.vhosts;
vhosts[0].routes = (0..5)
.map(|index| crate::vhost::PathRoute {
id: format!("large-{index}"),
path_prefix: format!("/large-{index}"),
response: Some(crate::vhost::LocationResponse {
body: "x".repeat(60_000),
..Default::default()
}),
..Default::default()
})
.collect();
let body = serde_json::to_vec(&json!({"expected_revision": 0, "value": vhosts})).unwrap();
assert!(body.len() > 262_144);
let response = router
.clone()
.oneshot(
request("/api/config/vhosts")
.method("PUT")
.header("content-type", "application/json")
.header("cookie", &cookie)
.header("origin", "http://127.0.0.1:9090")
.header("x-rustproxy-request", "1")
.header("x-csrf-token", &csrf)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(json_body(response).await["revision"], 1);
let saved = store.load().await.unwrap();
assert_eq!(saved.revision, 1);
assert_eq!(saved.config.vhosts[0].routes.len(), 5);
let history = store.configuration_revision(1).await.unwrap().unwrap();
assert_eq!(history.config.vhosts[0].routes.len(), 5);
store.close().await;
}
#[tokio::test]
async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() {
let (router, store, _dir, secret, csrf) = fixture().await;
+6 -27
View File
@@ -58,10 +58,7 @@ pub struct Choice {
}
impl Choice {
pub fn validate(self) -> Result<()> {
ensure!(
self.provider.supports(self.method),
"unsupported authentication provider and integration method combination"
);
ensure!(self.provider.supports(self.method), "unsupported authentication provider and integration method combination");
Ok(())
}
}
@@ -72,11 +69,7 @@ mod tests {
#[test]
fn supported_methods_are_explicit_and_reject_invalid_pairs() {
for provider in [
IdentityProvider::Tinyauth,
IdentityProvider::Authelia,
IdentityProvider::Authentik,
] {
for provider in [IdentityProvider::Tinyauth, IdentityProvider::Authelia, IdentityProvider::Authentik] {
assert!(provider.supports(IntegrationMethod::ForwardAuth));
assert!(provider.supports(IntegrationMethod::Oidc));
}
@@ -91,25 +84,11 @@ mod tests {
#[test]
fn selection_serializes_as_independent_provider_and_method() {
let selection = Choice {
provider: IdentityProvider::Authelia,
method: IntegrationMethod::Oidc,
};
let selection = Choice { provider: IdentityProvider::Authelia, method: IntegrationMethod::Oidc };
let json = serde_json::to_value(selection).unwrap();
assert_eq!(
json,
serde_json::json!({"provider":"authelia","method":"oidc"})
);
assert_eq!(json, serde_json::json!({"provider":"authelia","method":"oidc"}));
assert_eq!(serde_json::from_value::<Choice>(json).unwrap(), selection);
assert!(serde_json::from_value::<Choice>(
serde_json::json!({"provider":"pocket_id","method":"forward_auth"})
)
.unwrap()
.validate()
.is_err());
assert!(serde_json::from_value::<Choice>(
serde_json::json!({"provider":"unknown","method":"oidc"})
)
.is_err());
assert!(serde_json::from_value::<Choice>(serde_json::json!({"provider":"pocket_id","method":"forward_auth"})).unwrap().validate().is_err());
assert!(serde_json::from_value::<Choice>(serde_json::json!({"provider":"unknown","method":"oidc"})).is_err());
}
}
+30 -76
View File
@@ -130,9 +130,7 @@ impl Selection<'_> {
if let Some(choice) = self.choice {
choice.validate()?;
let matches_configuration = match choice.method {
IntegrationMethod::Basic => {
self.basic_enabled && choice.provider == IdentityProvider::Basic
}
IntegrationMethod::Basic => self.basic_enabled && choice.provider == IdentityProvider::Basic,
IntegrationMethod::ForwardAuth => match choice.provider {
IdentityProvider::Tinyauth => self.tinyauth.enabled,
IdentityProvider::Authelia => self.authelia.enabled,
@@ -141,10 +139,7 @@ impl Selection<'_> {
},
IntegrationMethod::Oidc => self.oidc.enabled,
};
ensure!(
matches_configuration,
"authentication selection does not match the enabled settings"
);
ensure!(matches_configuration, "authentication selection does not match the enabled settings");
}
Ok(())
}
@@ -164,12 +159,9 @@ impl Selection<'_> {
return Ok(self.authentik.compile(policy, bindings)?.map(Compiled::new));
}
if self.oidc.enabled {
let (id, label) = self.choice.map_or(("oidc", "OIDC"), |choice| {
(choice.provider.id(), choice.provider.display_name())
});
return Ok(self
.oidc
.compile(policy, bindings)?
let (id, label) = self.choice.map_or(("oidc", "OIDC"),
|choice| (choice.provider.id(), choice.provider.display_name()));
return Ok(self.oidc.compile(policy, bindings)?
.map(|provider| Compiled::new(provider.with_provider(id, label))));
}
Ok(None)
@@ -258,69 +250,47 @@ mod tests {
tinyauth.url = "http://127.0.0.1:3000".into();
tinyauth.login_url = "https://auth.example.net".into();
let oidc = oidc::Settings {
enabled: true,
issuer_url: "https://auth.test.com".into(),
enabled: true, issuer_url: "https://auth.gruszczynski.cc".into(),
backend_url: "http://127.0.0.1:9091".into(),
client_id: "test-client".into(),
client_secret: "test-secret".into(),
redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(),
client_id: "test-client".into(), client_secret: "test-secret".into(),
redirect_url: "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(),
session_secret: "long-session-secret-at-least-32-characters".into(),
..oidc::Settings::default()
};
assert!(Selection {
choice: None,
basic_enabled: false,
tinyauth: &tinyauth,
basic_enabled: false, tinyauth: &tinyauth,
authelia: &authelia::Settings::default(),
authentik: &authentik::Settings::default(),
oidc: &oidc,
}
.validate()
.is_err());
authentik: &authentik::Settings::default(), oidc: &oidc,
}.validate().is_err());
}
#[test]
fn oidc_identity_provider_is_independent_of_integration_method() {
let mut oidc = oidc::Settings::default();
oidc.enabled = true;
oidc.issuer_url = "https://auth.test.com".into();
oidc.issuer_url = "https://auth.gruszczynski.cc".into();
oidc.client_id = "rustproxy-test".into();
oidc.client_secret = "test-client-secret".into();
oidc.session_secret = "a-long-random-value-for-testing-oidc-sessions".into();
oidc.redirect_url = "https://app.example.com/_rustproxy/oidc/callback".into();
for provider in [
IdentityProvider::Tinyauth,
IdentityProvider::Authelia,
IdentityProvider::Authentik,
IdentityProvider::PocketId,
IdentityProvider::Custom,
] {
oidc.redirect_url = "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into();
for provider in [IdentityProvider::Tinyauth, IdentityProvider::Authelia,
IdentityProvider::Authentik, IdentityProvider::PocketId,
IdentityProvider::Custom] {
assert!(Selection {
choice: Some(Choice {
provider,
method: IntegrationMethod::Oidc
}),
choice: Some(Choice { provider, method: IntegrationMethod::Oidc }),
basic_enabled: false,
tinyauth: &tinyauth::Settings::default(),
authelia: &authelia::Settings::default(),
authentik: &authentik::Settings::default(),
oidc: &oidc,
}
.validate()
.is_ok());
}.validate().is_ok());
}
assert!(Selection {
choice: Some(Choice {
provider: IdentityProvider::Authelia,
method: IntegrationMethod::ForwardAuth
}),
basic_enabled: false,
tinyauth: &tinyauth::Settings::default(),
authelia: &authelia::Settings::default(),
authentik: &authentik::Settings::default(),
choice: Some(Choice { provider: IdentityProvider::Authelia, method: IntegrationMethod::ForwardAuth }),
basic_enabled: false, tinyauth: &tinyauth::Settings::default(),
authelia: &authelia::Settings::default(), authentik: &authentik::Settings::default(),
oidc: &oidc,
}
.validate()
.is_err());
}.validate().is_err());
}
#[test]
@@ -333,32 +303,16 @@ mod tests {
let authentik = authentik::Settings::default();
let oidc = oidc::Settings::default();
let selection = |choice| Selection {
choice,
basic_enabled: false,
tinyauth: &tinyauth,
authelia: &authelia,
authentik: &authentik,
oidc: &oidc,
choice, basic_enabled: false, tinyauth: &tinyauth,
authelia: &authelia, authentik: &authentik, oidc: &oidc,
};
assert!(selection(None).validate().is_ok());
assert!(selection(Some(Choice {
provider: IdentityProvider::Tinyauth,
method: IntegrationMethod::ForwardAuth
}))
.validate()
.is_ok());
assert!(selection(Some(Choice {
provider: IdentityProvider::Authelia,
method: IntegrationMethod::ForwardAuth
}))
.validate()
.is_err());
assert!(selection(Some(Choice {
provider: IdentityProvider::Tinyauth,
method: IntegrationMethod::Oidc
}))
.validate()
.is_err());
assert!(selection(Some(Choice { provider: IdentityProvider::Tinyauth,
method: IntegrationMethod::ForwardAuth })).validate().is_ok());
assert!(selection(Some(Choice { provider: IdentityProvider::Authelia,
method: IntegrationMethod::ForwardAuth })).validate().is_err());
assert!(selection(Some(Choice { provider: IdentityProvider::Tinyauth,
method: IntegrationMethod::Oidc })).validate().is_err());
}
#[derive(Debug)]
+3 -2
View File
@@ -208,9 +208,10 @@ pub fn compile_with_active(
<= MAX_ROUTES,
"too many rules across all redirect hosts (max 256)"
);
let config_bytes = serde_json::to_vec(&config)?.len();
ensure!(
serde_json::to_vec(&config)?.len() <= MAX_CONFIG_BYTES,
"configuration too large"
config_bytes <= MAX_CONFIG_BYTES,
"configuration too large ({config_bytes} bytes, maximum {MAX_CONFIG_BYTES} bytes)"
);
let listeners = crate::vhost::listener_plan(&config, &desired_policy)?;
crate::stream::validate(
+1 -1
View File
@@ -13,7 +13,7 @@ use std::{
};
use url::{Host, Url};
pub const MAX_CONFIG_BYTES: usize = 262_144;
pub const MAX_CONFIG_BYTES: usize = 2_097_152;
pub const MAX_BACKENDS: usize = 128;
pub const MAX_ROUTES: usize = 256;
+38 -5
View File
@@ -9,6 +9,42 @@ mod tests {
admin_addr: "127.0.0.1:9090".parse().unwrap(),
}
}
fn config_with_large_responses(count: usize) -> Config {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].routes = (0..count)
.map(|index| crate::vhost::PathRoute {
id: format!("large-{index}"),
path_prefix: format!("/large-{index}"),
response: Some(crate::vhost::LocationResponse {
body: "x".repeat(60_000),
..Default::default()
}),
..Default::default()
})
.collect();
config
}
#[test]
fn configuration_above_legacy_limit_compiles() {
let config = config_with_large_responses(5);
let bytes = serde_json::to_vec(&config).unwrap().len();
assert!(bytes > 262_144);
assert!(bytes <= MAX_CONFIG_BYTES);
assert!(compile(config, 1, &policy()).is_ok());
}
#[test]
fn configuration_above_new_limit_is_rejected() {
let config = config_with_large_responses(36);
assert!(serde_json::to_vec(&config).unwrap().len() > MAX_CONFIG_BYTES);
let error = match compile(config, 1, &policy()) {
Ok(_) => panic!("oversized configuration should be rejected"),
Err(error) => error,
};
assert!(error.to_string().contains("configuration too large"));
}
#[test]
fn route_handles_reuse_snapshot_ownership_without_cloning_compiled_routes() {
let snapshot =
@@ -80,17 +116,14 @@ mod tests {
method: crate::auth_provider::IntegrationMethod::Oidc,
});
host.oidc.enabled = true;
host.oidc.issuer_url = "https://auth.test.com".into();
host.oidc.issuer_url = "https://auth.gruszczynski.cc".into();
host.oidc.client_id = "app".into();
host.oidc.client_secret = "client-secret".into();
host.oidc.session_secret = "a-unique-testing-session-secret-32-bytes".into();
host.oidc.redirect_url = "https://localhost/_rustproxy/oidc/callback".into();
assert!(host.validate().is_ok());
let encoded = serde_json::to_value(&host).unwrap();
assert_eq!(
encoded["authentication"],
serde_json::json!({"provider":"authelia","method":"oidc"})
);
assert_eq!(encoded["authentication"], serde_json::json!({"provider":"authelia","method":"oidc"}));
let restored: crate::vhost::Vhost = serde_json::from_value(encoded.clone()).unwrap();
assert_eq!(restored.authentication, host.authentication);
assert!(restored.validate().is_ok());
+2 -2
View File
@@ -398,7 +398,7 @@ async fn api(
async fn read_config_file(file: PathBuf) -> Result<Config> {
ensure!(
tokio::fs::metadata(&file).await?.len() <= config::MAX_CONFIG_BYTES as u64,
"config file exceeds 256 KiB"
"config file exceeds size limit"
);
let data = tokio::task::spawn_blocking(move || {
let file = std::fs::File::open(file)?;
@@ -407,7 +407,7 @@ async fn read_config_file(file: PathBuf) -> Result<Config> {
.read_to_end(&mut bytes)?;
anyhow::ensure!(
bytes.len() <= config::MAX_CONFIG_BYTES,
"config file exceeds 256 KiB"
"config file exceeds size limit"
);
Ok::<_, anyhow::Error>(bytes)
})
+155 -509
View File
@@ -5,24 +5,13 @@ use crate::{
};
use anyhow::{ensure, Context as _, Result};
use axum::{body::Body, response::Response};
use base64::{
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
Engine as _,
};
use base64::{engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, Engine as _};
use http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use http_body_util::{BodyExt, Limited};
use ring::{
aead, digest, hmac,
rand::{SecureRandom, SystemRandom},
};
use ring::{aead, digest, hmac, rand::{SecureRandom, SystemRandom}};
use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::Value;
use std::{
fmt,
net::SocketAddr,
sync::Arc,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
use std::{fmt, net::SocketAddr, sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
use url::{form_urlencoded, Url};
@@ -53,183 +42,87 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
enabled: false,
issuer_url: String::new(),
backend_url: String::new(),
client_id: String::new(),
client_secret: String::new(),
scopes: "openid profile email".into(),
session_secret: String::new(),
redirect_url: String::new(),
allow_private: false,
address_allowlist: Vec::new(),
tls_ca_pem: None,
connect_timeout_seconds: 5,
timeout_seconds: 10,
session_ttl_seconds: 3600,
enabled: false, issuer_url: String::new(), backend_url: String::new(), client_id: String::new(),
client_secret: String::new(), scopes: "openid profile email".into(), session_secret: String::new(), redirect_url: String::new(),
allow_private: false, address_allowlist: Vec::new(), tls_ca_pem: None,
connect_timeout_seconds: 5, timeout_seconds: 10, session_ttl_seconds: 3600,
}
}
}
fn https_url(raw: &str) -> Result<Url> {
ensure!(
!raw.is_empty() && raw.len() <= 2048,
"OIDC URL must be 1..2048 bytes"
);
ensure!(!raw.is_empty() && raw.len() <= 2048, "OIDC URL must be 1..2048 bytes");
let url = Url::parse(raw)?;
ensure!(
url.scheme() == "https" && url.host_str().is_some(),
"OIDC requires HTTPS URLs"
);
ensure!(
url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none(),
"OIDC URL cannot contain credentials, queries or fragments"
);
ensure!(url.scheme() == "https" && url.host_str().is_some(), "OIDC requires HTTPS URLs");
ensure!(url.username().is_empty() && url.password().is_none() && url.query().is_none() && url.fragment().is_none(), "OIDC URL cannot contain credentials, queries or fragments");
Ok(url)
}
impl Settings {
pub fn validate(&self) -> Result<()> {
ensure!(
(1..=30).contains(&self.connect_timeout_seconds)
&& (1..=60).contains(&self.timeout_seconds)
&& self.connect_timeout_seconds <= self.timeout_seconds,
"OIDC timeouts must be 1..30s connect <= 1..60s total"
);
ensure!(
(60..=86400).contains(&self.session_ttl_seconds),
"OIDC session lifetime must be 60..86400 seconds"
);
ensure!((1..=30).contains(&self.connect_timeout_seconds)
&& (1..=60).contains(&self.timeout_seconds)
&& self.connect_timeout_seconds <= self.timeout_seconds,
"OIDC timeouts must be 1..30s connect <= 1..60s total");
ensure!((60..=86400).contains(&self.session_ttl_seconds), "OIDC session lifetime must be 60..86400 seconds");
if self.enabled {
let issuer = https_url(&self.issuer_url)?;
ensure!(!issuer.path().contains(".."), "OIDC issuer path is invalid");
if !self.backend_url.is_empty() {
let internal =
crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?;
ensure!(
internal.path() == "/" && internal.query().is_none(),
"OIDC internal backend URL must be an origin without a path or query"
);
let internal = crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?;
ensure!(internal.path() == "/" && internal.query().is_none(),
"OIDC internal backend URL must be an origin without a path or query");
}
ensure!(
!self.client_id.is_empty()
&& self.client_id.len() <= 256
&& self
.client_id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)),
"OIDC client ID must contain 1..256 URL-safe characters"
);
ensure!(
!self.client_secret.is_empty() && self.client_secret.len() <= 2048,
"OIDC client secret required"
);
ensure!(
self.session_secret.len() >= 32 && self.session_secret.len() <= 4096,
"OIDC session secret must be at least 32 bytes"
);
ensure!(
self.scopes.len() <= 1024
&& self
.scopes
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b" -_:.".contains(&c)),
"OIDC scopes cannot contain control characters"
);
ensure!(!self.client_id.is_empty() && self.client_id.len() <= 256
&& self.client_id.bytes().all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)),
"OIDC client ID must contain 1..256 URL-safe characters");
ensure!(!self.client_secret.is_empty() && self.client_secret.len() <= 2048, "OIDC client secret required");
ensure!(self.session_secret.len() >= 32 && self.session_secret.len() <= 4096, "OIDC session secret must be at least 32 bytes");
ensure!(self.scopes.len() <= 1024 && self.scopes.bytes().all(|c| c.is_ascii_alphanumeric() || b" -_:.".contains(&c)),
"OIDC scopes cannot contain control characters");
let scopes: Vec<_> = self.scopes.split_whitespace().collect();
ensure!(
scopes.len() <= 16
&& scopes.contains(&"openid")
&& scopes.iter().all(|item| item.len() <= 64
&& item
.bytes()
.all(|c| c.is_ascii_alphanumeric() || b"-_:.".contains(&c))),
"OIDC scopes must contain openid and valid space-separated names"
);
ensure!(scopes.len() <= 16 && scopes.contains(&"openid")
&& scopes.iter().all(|item| item.len() <= 64 && item.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_:.".contains(&c))),
"OIDC scopes must contain openid and valid space-separated names");
let redirect = https_url(&self.redirect_url)?;
ensure!(
redirect.path() == CALLBACK,
"OIDC redirect URL must end with {CALLBACK}"
);
ensure!(redirect.path() == CALLBACK, "OIDC redirect URL must end with {CALLBACK}");
}
Ok(())
}
pub fn compile(
&self,
policy: &ValidationPolicy,
bindings: &[SocketAddr],
) -> Result<Option<Compiled>> {
pub fn compile(&self, policy: &ValidationPolicy, bindings: &[SocketAddr]) -> Result<Option<Compiled>> {
self.validate()?;
if !self.enabled {
return Ok(None);
}
if !self.enabled { return Ok(None); }
let issuer = https_url(&self.issuer_url)?;
let redirect = https_url(&self.redirect_url)?;
let upstream_origin = if self.backend_url.is_empty() {
issuer.origin().ascii_serialization()
} else {
crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?
.origin()
.ascii_serialization()
crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?.origin().ascii_serialization()
};
let public_host: Uri = issuer.as_str().parse()?;
let host_header = HeaderValue::from_str(
public_host
.authority()
.context("OIDC issuer authority missing")?
.as_str(),
)?;
let host_header = HeaderValue::from_str(public_host.authority().context("OIDC issuer authority missing")?.as_str())?;
let definition = Backend {
id: "oidc".into(),
url: upstream_origin.clone(),
allow_private: self.allow_private,
accept_local_listener_risk: false,
preserve_host: false,
protocol: UpstreamProtocol::Http1,
id: "oidc".into(), url: upstream_origin.clone(),
allow_private: self.allow_private, accept_local_listener_risk: false,
preserve_host: false, protocol: UpstreamProtocol::Http1,
address_allowlist: self.address_allowlist.clone(),
tls_ca_pem: self.tls_ca_pem.clone(),
tls_server_name: None,
tls_verify: true,
tls_ca_pem: self.tls_ca_pem.clone(), tls_server_name: None, tls_verify: true,
};
let keepalive = crate::balance::Keepalive::default();
let mut backend =
crate::config::compile_backend(&definition, policy, bindings, &keepalive)?;
let mut backend = crate::config::compile_backend(&definition, policy, bindings, &keepalive)?;
backend.client = crate::upstream::client_with_timeout(
&definition,
backend.address_policy.clone(),
false,
&keepalive,
&definition, backend.address_policy.clone(), false, &keepalive,
Duration::from_secs(self.connect_timeout_seconds),
)?;
let fingerprint = digest::digest(
&digest::SHA256,
format!("{}:{}", self.client_id, self.redirect_url).as_bytes(),
);
let suffix = fingerprint.as_ref()[..8]
.iter()
.map(|c| format!("{c:02x}"))
.collect::<Vec<_>>()
.join("");
let fingerprint = digest::digest(&digest::SHA256,
format!("{}:{}", self.client_id, self.redirect_url).as_bytes());
let suffix = fingerprint.as_ref()[..8].iter().map(|c| format!("{c:02x}")).collect::<Vec<_>>().join("");
Ok(Some(Compiled {
provider_id: "oidc",
provider_label: "OIDC",
issuer: self.issuer_url.clone(),
client_id: self.client_id.clone(),
scopes: self.scopes.clone(),
provider_id: "oidc", provider_label: "OIDC",
issuer: self.issuer_url.clone(), client_id: self.client_id.clone(), scopes: self.scopes.clone(),
client_secret: self.client_secret.clone(),
session_secret: scoped_secret(
&self.session_secret,
&self.issuer_url,
&self.client_id,
&self.redirect_url,
),
redirect,
backend: Arc::new(backend),
cache: Mutex::new(None),
upstream_origin: Url::parse(&upstream_origin)?,
host_header,
session_secret: scoped_secret(&self.session_secret, &self.issuer_url, &self.client_id, &self.redirect_url),
redirect, backend: Arc::new(backend), cache: Mutex::new(None),
upstream_origin: Url::parse(&upstream_origin)?, host_header,
internal_override: !self.backend_url.is_empty(),
timeout: Duration::from_secs(self.timeout_seconds),
session_ttl: self.session_ttl_seconds,
@@ -279,10 +172,8 @@ impl Compiled {
}
impl fmt::Debug for Compiled {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OidcProvider")
.field("issuer", &self.issuer)
.field("redirect", &self.redirect)
.finish_non_exhaustive()
f.debug_struct("OidcProvider").field("issuer", &self.issuer)
.field("redirect", &self.redirect).finish_non_exhaustive()
}
}
#[derive(Serialize, Deserialize)]
@@ -308,9 +199,7 @@ fn now() -> Result<u64> {
fn random_b64(bytes: usize) -> Result<String> {
ensure!(bytes <= 128, "random input too long");
let mut random = vec![0u8; bytes];
SystemRandom::new()
.fill(&mut random)
.map_err(|_| anyhow::anyhow!("random source unavailable"))?;
SystemRandom::new().fill(&mut random).map_err(|_| anyhow::anyhow!("random source unavailable"))?;
Ok(URL_SAFE_NO_PAD.encode(random))
}
fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) -> String {
@@ -320,25 +209,17 @@ fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) ->
}
fn crypt_key(secret: &str) -> Result<aead::LessSafeKey> {
let hash = digest::digest(&digest::SHA256, secret.as_bytes());
Ok(aead::LessSafeKey::new(
aead::UnboundKey::new(&aead::AES_256_GCM, hash.as_ref())
.map_err(|_| anyhow::anyhow!("OIDC cipher initialization failed"))?,
))
Ok(aead::LessSafeKey::new(aead::UnboundKey::new(&aead::AES_256_GCM, hash.as_ref()).map_err(|_| anyhow::anyhow!("OIDC cipher initialization failed"))?))
}
fn seal<T: Serialize>(secret: &str, value: &T) -> Result<String> {
let mut nonce_bytes = [0u8; 12];
SystemRandom::new()
.fill(&mut nonce_bytes)
.map_err(|_| anyhow::anyhow!("random source unavailable"))?;
SystemRandom::new().fill(&mut nonce_bytes).map_err(|_| anyhow::anyhow!("random source unavailable"))?;
let mut bytes = serde_json::to_vec(value)?;
ensure!(bytes.len() <= 3072, "OIDC cookie contents too large");
crypt_key(secret)?
.seal_in_place_append_tag(
aead::Nonce::assume_unique_for_key(nonce_bytes),
aead::Aad::from("rustproxy-oidc-v1"),
&mut bytes,
)
.map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?;
crypt_key(secret)?.seal_in_place_append_tag(
aead::Nonce::assume_unique_for_key(nonce_bytes),
aead::Aad::from("rustproxy-oidc-v1"), &mut bytes,
).map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?;
let mut data = nonce_bytes.to_vec();
data.extend_from_slice(&bytes);
let encoded = URL_SAFE_NO_PAD.encode(data);
@@ -351,13 +232,10 @@ fn open<T: DeserializeOwned>(secret: &str, encoded: &str) -> Result<T> {
ensure!(bytes.len() > 28, "invalid OIDC cookie");
let mut nonce = [0u8; 12];
nonce.copy_from_slice(&bytes[..12]);
let plaintext = crypt_key(secret)?
.open_in_place(
aead::Nonce::assume_unique_for_key(nonce),
aead::Aad::from("rustproxy-oidc-v1"),
&mut bytes[12..],
)
.map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?;
let plaintext = crypt_key(secret)?.open_in_place(
aead::Nonce::assume_unique_for_key(nonce),
aead::Aad::from("rustproxy-oidc-v1"), &mut bytes[12..],
).map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?;
Ok(serde_json::from_slice(plaintext)?)
}
fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
@@ -366,9 +244,7 @@ fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
for entry in line.to_str().ok()?.split(';') {
if let Some((key, value)) = entry.trim().split_once('=') {
if key == name {
if selected.is_some() {
return None;
}
if selected.is_some() { return None; }
selected = Some(value);
}
}
@@ -380,27 +256,19 @@ fn cookie(name: &str, value: &str, age: u64) -> Result<HeaderValue> {
Ok(format!("{name}={value}; Path=/; Max-Age={age}; HttpOnly; Secure; SameSite=Lax").parse()?)
}
fn clear_cookie(name: &str) -> HeaderValue {
format!("{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax")
.parse()
.expect("static valid cookie name")
format!("{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax").parse().expect("static valid cookie name")
}
fn redirect(location: &str, status: StatusCode) -> Response {
let mut response = auth_provider::deny(status);
match location.parse() {
Ok(value) => {
response.headers_mut().insert(header::LOCATION, value);
}
Err(_) => {
return auth_provider::deny(StatusCode::BAD_GATEWAY);
}
Ok(value) => { response.headers_mut().insert(header::LOCATION, value); }
Err(_) => { return auth_provider::deny(StatusCode::BAD_GATEWAY); }
}
response
}
fn sanitize_header(headers: &mut HeaderMap, name: &'static str, value: &str) {
if value.len() <= 2048 {
if let Ok(value) = value.parse() {
headers.insert(name, value);
}
if let Ok(value) = value.parse() { headers.insert(name, value); }
}
}
fn identity(session: &Session, issuer: &str) -> Identity {
@@ -408,31 +276,19 @@ fn identity(session: &Session, issuer: &str) -> Identity {
sanitize_header(&mut headers, "remote-sub", &session.subject);
sanitize_header(&mut headers, "remote-issuer", issuer);
sanitize_header(&mut headers, "remote-user", &session.username);
if let Some(email) = &session.email {
sanitize_header(&mut headers, "remote-email", email);
}
if let Some(name) = &session.name {
sanitize_header(&mut headers, "remote-name", name);
}
if !session.groups.is_empty() {
sanitize_header(&mut headers, "remote-groups", &session.groups.join(","));
}
if let Some(email) = &session.email { sanitize_header(&mut headers, "remote-email", email); }
if let Some(name) = &session.name { sanitize_header(&mut headers, "remote-name", name); }
if !session.groups.is_empty() { sanitize_header(&mut headers, "remote-groups", &session.groups.join(",")); }
Identity { headers }
}
impl Compiled {
fn url_for_endpoint(&self, raw: &str) -> Result<Url> {
let url = https_url(raw)?;
ensure!(
url.origin() == https_url(&self.issuer)?.origin(),
"OIDC endpoints must use the configured issuer origin"
);
ensure!(url.origin() == https_url(&self.issuer)?.origin(), "OIDC endpoints must use the configured issuer origin");
Ok(url)
}
fn wire_uri(&self, public: &Url) -> Result<Uri> {
ensure!(
public.origin() == https_url(&self.issuer)?.origin(),
"OIDC request not on issuer origin"
);
ensure!(public.origin() == https_url(&self.issuer)?.origin(), "OIDC request not on issuer origin");
let mut wire = self.upstream_origin.clone();
wire.set_path(public.path());
wire.set_query(public.query());
@@ -445,145 +301,64 @@ impl Compiled {
}
}
async fn get_json(&self, url: &Url) -> Result<Value> {
let mut request = Request::builder()
.method(Method::GET)
.uri(self.wire_uri(url)?)
let mut request = Request::builder().method(Method::GET).uri(self.wire_uri(url)?)
.body(Body::empty())?;
self.wire_headers(request.headers_mut());
request
.headers_mut()
.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json"));
let response = self.backend.client.request(request).await?;
ensure!(
response.status() == StatusCode::OK,
"OIDC endpoint returned an error"
);
let body = Limited::new(response.into_body(), 65536)
.collect()
.await
.map_err(|error| anyhow::anyhow!("invalid OIDC endpoint response: {error}"))?
.to_bytes();
ensure!(response.status() == StatusCode::OK, "OIDC endpoint returned an error");
let body = Limited::new(response.into_body(), 65536).collect().await
.map_err(|error| anyhow::anyhow!("invalid OIDC endpoint response: {error}"))?.to_bytes();
Ok(serde_json::from_slice(&body)?)
}
async fn discovery(&self) -> Result<Discovery> {
let mut guard = self.cache.lock().await;
if let Some(saved) = guard.as_ref() {
if saved.until > Instant::now() {
return Ok(saved.discovery.clone());
}
if saved.until > Instant::now() { return Ok(saved.discovery.clone()); }
}
let discovery_url = self.url_for_endpoint(&format!(
"{}/.well-known/openid-configuration",
self.issuer.trim_end_matches('/')
))?;
let discovery_url = self.url_for_endpoint(&format!("{}/.well-known/openid-configuration", self.issuer.trim_end_matches('/')))?;
let metadata = self.get_json(&discovery_url).await?;
ensure!(
metadata.get("issuer").and_then(Value::as_str) == Some(self.issuer.as_str()),
"OIDC discovery issuer mismatch"
);
let authorization = self.url_for_endpoint(
metadata
.get("authorization_endpoint")
.and_then(Value::as_str)
.context("missing OIDC authorization endpoint")?,
)?;
let token = self.url_for_endpoint(
metadata
.get("token_endpoint")
.and_then(Value::as_str)
.context("missing OIDC token endpoint")?,
)?;
let userinfo = metadata
.get("userinfo_endpoint")
.and_then(Value::as_str)
.map(|url| self.url_for_endpoint(url))
.transpose()?;
let jwks_url = self.url_for_endpoint(
metadata
.get("jwks_uri")
.and_then(Value::as_str)
.context("missing OIDC JWKS URI")?,
)?;
ensure!(metadata.get("issuer").and_then(Value::as_str) == Some(self.issuer.as_str()), "OIDC discovery issuer mismatch");
let authorization = self.url_for_endpoint(metadata.get("authorization_endpoint").and_then(Value::as_str).context("missing OIDC authorization endpoint")?)?;
let token = self.url_for_endpoint(metadata.get("token_endpoint").and_then(Value::as_str).context("missing OIDC token endpoint")?)?;
let userinfo = metadata.get("userinfo_endpoint").and_then(Value::as_str)
.map(|url| self.url_for_endpoint(url)).transpose()?;
let jwks_url = self.url_for_endpoint(metadata.get("jwks_uri").and_then(Value::as_str).context("missing OIDC JWKS URI")?)?;
let jwks = self.get_json(&jwks_url).await?;
ensure!(
jwks.get("keys").and_then(Value::as_array).is_some(),
"invalid OIDC signing keys"
);
let discovery = Discovery {
authorization,
token,
userinfo,
jwks,
};
*guard = Some(Cached {
until: Instant::now() + Duration::from_secs(300),
discovery: discovery.clone(),
});
ensure!(jwks.get("keys").and_then(Value::as_array).is_some(), "invalid OIDC signing keys");
let discovery = Discovery { authorization, token, userinfo, jwks };
*guard = Some(Cached { until: Instant::now() + Duration::from_secs(300), discovery: discovery.clone() });
Ok(discovery)
}
fn is_redirect_host(&self, context: &forwarding::Context) -> bool {
context.scheme == "https"
&& Url::parse(&context.original_url)
.is_ok_and(|url| url.origin() == self.redirect.origin())
context.scheme == "https" && Url::parse(&context.original_url)
.is_ok_and(|url| url.origin() == self.redirect.origin())
}
async fn initiate(
&self,
context: &forwarding::Context,
cookies: &CookieSink,
) -> Result<Response> {
ensure!(
self.is_redirect_host(context),
"OIDC request origin does not match callback"
);
async fn initiate(&self, context: &forwarding::Context, cookies: &CookieSink) -> Result<Response> {
ensure!(self.is_redirect_host(context), "OIDC request origin does not match callback");
let destination = Url::parse(&context.original_url)?;
let mut return_to = destination.path().to_owned();
if let Some(query) = destination.query() {
return_to.push('?');
return_to.push_str(query);
}
ensure!(
return_to.len() <= 1800 && return_to.starts_with('/') && !return_to.starts_with("//"),
"invalid OIDC return URL"
);
if let Some(query) = destination.query() { return_to.push('?'); return_to.push_str(query); }
ensure!(return_to.len() <= 1800 && return_to.starts_with('/') && !return_to.starts_with("//"), "invalid OIDC return URL");
let transaction = Pending {
state: random_b64(32)?,
nonce: random_b64(32)?,
verifier: random_b64(32)?,
return_to,
issued: now()?,
state: random_b64(32)?, nonce: random_b64(32)?, verifier: random_b64(32)?,
return_to, issued: now()?,
};
let mut authorization = self.discovery().await?.authorization;
let challenge = digest::digest(&digest::SHA256, transaction.verifier.as_bytes());
authorization
.query_pairs_mut()
.append_pair("response_type", "code")
authorization.query_pairs_mut().append_pair("response_type", "code")
.append_pair("client_id", &self.client_id)
.append_pair("redirect_uri", self.redirect.as_str())
.append_pair("scope", &self.scopes)
.append_pair("state", &transaction.state)
.append_pair("nonce", &transaction.nonce)
.append_pair("code_challenge_method", "S256")
.append_pair(
"code_challenge",
&URL_SAFE_NO_PAD.encode(challenge.as_ref()),
);
.append_pair("code_challenge", &URL_SAFE_NO_PAD.encode(challenge.as_ref()));
let mut response = redirect(authorization.as_str(), StatusCode::FOUND);
let mut set_cookie = HeaderMap::new();
set_cookie.append(
header::SET_COOKIE,
cookie(
&self.state_cookie,
&seal(&self.session_secret, &transaction)?,
300,
)?,
);
ensure!(
cookies.collect(&set_cookie),
"OIDC transaction cookie too large"
);
response
.headers_mut()
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
set_cookie.append(header::SET_COOKIE, cookie(&self.state_cookie, &seal(&self.session_secret, &transaction)?, 300)?);
ensure!(cookies.collect(&set_cookie), "OIDC transaction cookie too large");
response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer"));
Ok(response)
}
async fn exchange(&self, code: &str, pending: &Pending) -> Result<token::Claims> {
@@ -594,89 +369,38 @@ impl Compiled {
.append_pair("redirect_uri", self.redirect.as_str())
.append_pair("code_verifier", &pending.verifier)
.finish();
let id_encoded: String =
form_urlencoded::byte_serialize(self.client_id.as_bytes()).collect();
let secret_encoded: String =
form_urlencoded::byte_serialize(self.client_secret.as_bytes()).collect();
let id_encoded: String = form_urlencoded::byte_serialize(self.client_id.as_bytes()).collect();
let secret_encoded: String = form_urlencoded::byte_serialize(self.client_secret.as_bytes()).collect();
let authorization = STANDARD.encode(format!("{id_encoded}:{secret_encoded}"));
let mut request = Request::builder()
.method(Method::POST)
.uri(self.wire_uri(&discovery.token)?)
let mut request = Request::builder().method(Method::POST).uri(self.wire_uri(&discovery.token)?)
.body(Body::from(body))?;
self.wire_headers(request.headers_mut());
request.headers_mut().insert(
header::AUTHORIZATION,
format!("Basic {authorization}").parse()?,
);
request
.headers_mut()
.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
request.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/x-www-form-urlencoded"),
);
request.headers_mut().insert(header::AUTHORIZATION, format!("Basic {authorization}").parse()?);
request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json"));
request.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-www-form-urlencoded"));
let response = self.backend.client.request(request).await?;
ensure!(
response.status() == StatusCode::OK,
"OIDC token endpoint returned an error"
);
let body = Limited::new(response.into_body(), 65536)
.collect()
.await
.map_err(|error| anyhow::anyhow!("invalid OIDC token response: {error}"))?
.to_bytes();
ensure!(response.status() == StatusCode::OK, "OIDC token endpoint returned an error");
let body = Limited::new(response.into_body(), 65536).collect().await
.map_err(|error| anyhow::anyhow!("invalid OIDC token response: {error}"))?.to_bytes();
let value: Value = serde_json::from_slice(&body)?;
let id_token = value
.get("id_token")
.and_then(Value::as_str)
.context("missing OIDC ID token")?;
let mut claims = token::verify_id_token(
id_token,
&discovery.jwks,
&self.issuer,
&self.client_id,
&pending.nonce,
)?;
if let (Some(userinfo_url), Some(access_token)) = (
discovery.userinfo.as_ref(),
value.get("access_token").and_then(Value::as_str),
) {
ensure!(
access_token.len() <= 8192 && !access_token.is_empty(),
"invalid OIDC access token"
);
let mut request = Request::builder()
.method(Method::GET)
.uri(self.wire_uri(userinfo_url)?)
let id_token = value.get("id_token").and_then(Value::as_str).context("missing OIDC ID token")?;
let mut claims = token::verify_id_token(id_token, &discovery.jwks, &self.issuer, &self.client_id, &pending.nonce)?;
if let (Some(userinfo_url), Some(access_token)) = (discovery.userinfo.as_ref(), value.get("access_token").and_then(Value::as_str)) {
ensure!(access_token.len() <= 8192 && !access_token.is_empty(), "invalid OIDC access token");
let mut request = Request::builder().method(Method::GET).uri(self.wire_uri(userinfo_url)?)
.body(Body::empty())?;
self.wire_headers(request.headers_mut());
request.headers_mut().insert(
header::AUTHORIZATION,
format!("Bearer {access_token}").parse()?,
);
request
.headers_mut()
.insert(header::ACCEPT, HeaderValue::from_static("application/json"));
request.headers_mut().insert(header::AUTHORIZATION, format!("Bearer {access_token}").parse()?);
request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json"));
let response = self.backend.client.request(request).await?;
ensure!(
response.status() == StatusCode::OK,
"OIDC UserInfo endpoint returned an error"
);
let data = Limited::new(response.into_body(), 65536)
.collect()
.await
.map_err(|error| anyhow::anyhow!("invalid OIDC UserInfo response: {error}"))?
.to_bytes();
ensure!(response.status() == StatusCode::OK, "OIDC UserInfo endpoint returned an error");
let data = Limited::new(response.into_body(), 65536).collect().await
.map_err(|error| anyhow::anyhow!("invalid OIDC UserInfo response: {error}"))?.to_bytes();
token::merge_userinfo(&mut claims, &serde_json::from_slice::<Value>(&data)?)?;
}
Ok(claims)
}
async fn callback(
&self,
request: Request<Body>,
context: &forwarding::Context,
shutdown: &CancellationToken,
) -> Response {
async fn callback(&self, request: Request<Body>, context: &forwarding::Context, shutdown: &CancellationToken) -> Response {
if request.method() != Method::GET || !self.is_redirect_host(context) {
return auth_provider::deny(StatusCode::BAD_REQUEST);
}
@@ -684,20 +408,14 @@ impl Compiled {
let mut valid_state = None;
let mut error = false;
if let Some(query) = request.uri().query() {
if query.len() > 8192 {
return auth_provider::deny(StatusCode::BAD_REQUEST);
}
if query.len() > 8192 { return auth_provider::deny(StatusCode::BAD_REQUEST); }
for (key, value) in form_urlencoded::parse(query.as_bytes()) {
match key.as_ref() {
"code" if valid_code.is_none() && value.len() <= 4096 => {
valid_code = Some(value.into_owned())
}
"state" if valid_state.is_none() && value.len() <= 256 => {
valid_state = Some(value.into_owned())
}
"code" if valid_code.is_none() && value.len() <= 4096 => valid_code = Some(value.into_owned()),
"state" if valid_state.is_none() && value.len() <= 256 => valid_state = Some(value.into_owned()),
"error" => error = true,
"code" | "state" => error = true,
_ => {}
_ => {},
}
}
}
@@ -708,18 +426,11 @@ impl Compiled {
Ok(value) => value,
Err(_) => return auth_provider::deny(StatusCode::UNAUTHORIZED),
};
let current = match now() {
Ok(now) => now,
Err(_) => return auth_provider::deny(StatusCode::BAD_GATEWAY),
};
if error
|| current < pending.issued
|| current.saturating_sub(pending.issued) > 300
let current = match now() { Ok(now) => now, Err(_) => return auth_provider::deny(StatusCode::BAD_GATEWAY) };
if error || current < pending.issued || current.saturating_sub(pending.issued) > 300
|| valid_state.as_deref() != Some(pending.state.as_str())
|| valid_code.as_deref().is_none_or(str::is_empty)
{
return auth_provider::deny(StatusCode::UNAUTHORIZED);
}
{ return auth_provider::deny(StatusCode::UNAUTHORIZED); }
let code = valid_code.expect("validated authorization code");
let result = tokio::select! {
_ = shutdown.cancelled() => return auth_provider::deny(StatusCode::SERVICE_UNAVAILABLE),
@@ -729,90 +440,44 @@ impl Compiled {
Ok(Ok(claims)) => claims,
Ok(Err(error)) => {
tracing::warn!(provider = self.provider_id, %error, "OIDC callback verification failed");
return auth_provider::gateway_failure(
self.provider_label,
GatewayFailure::Verification,
);
}
Err(_) => {
return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout)
return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification);
}
Err(_) => return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout),
};
let session = Session {
subject: claims.subject,
username: claims.username,
email: claims.email,
name: claims.name,
groups: claims.groups,
subject: claims.subject, username: claims.username, email: claims.email,
name: claims.name, groups: claims.groups,
expires: current.saturating_add(self.session_ttl),
};
let value = match seal(&self.session_secret, &session) {
Ok(value) => value,
Err(_) => {
return auth_provider::gateway_failure(
self.provider_label,
GatewayFailure::Verification,
)
}
Err(_) => return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification),
};
let mut response = redirect(&pending.return_to, StatusCode::SEE_OTHER);
response
.headers_mut()
.append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
response
.headers_mut()
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer"));
if let Ok(cookie) = cookie(&self.session_cookie, &value, self.session_ttl) {
response.headers_mut().append(header::SET_COOKIE, cookie);
} else {
return auth_provider::deny(StatusCode::BAD_GATEWAY);
}
} else { return auth_provider::deny(StatusCode::BAD_GATEWAY); }
response
}
}
impl auth_provider::Provider for Compiled {
fn id(&self) -> &'static str {
self.provider_id
}
fn method(&self) -> auth_provider::IntegrationMethod {
auth_provider::IntegrationMethod::Oidc
}
fn id(&self) -> &'static str { self.provider_id }
fn method(&self) -> auth_provider::IntegrationMethod { auth_provider::IntegrationMethod::Oidc }
fn strip_identity(&self, headers: &mut HeaderMap) {
let names: Vec<_> = headers
.keys()
.filter(|name| {
let name = name.as_str();
name.starts_with("remote-")
|| name.starts_with("x-authentik-")
|| name.starts_with("x-auth-request-")
|| matches!(
name,
"x-forwarded-user"
| "x-forwarded-email"
| "x-remote-user"
| "x-remote-email"
| "x-user"
| "x-email"
)
})
.cloned()
.collect();
for name in names {
headers.remove(name);
}
let names: Vec<_> = headers.keys().filter(|name| {
let name = name.as_str();
name.starts_with("remote-") || name.starts_with("x-authentik-") || name.starts_with("x-auth-request-")
|| matches!(name, "x-forwarded-user" | "x-forwarded-email" | "x-remote-user" | "x-remote-email" | "x-user" | "x-email")
}).cloned().collect();
for name in names { headers.remove(name); }
}
fn authorize<'a>(
&'a self,
incoming: &'a HeaderMap,
method: &'a Method,
context: &'a forwarding::Context,
shutdown: &'a CancellationToken,
cookies: &'a CookieSink,
) -> auth_provider::Authorization<'a> {
fn authorize<'a>(&'a self, incoming: &'a HeaderMap, method: &'a Method,
context: &'a forwarding::Context, shutdown: &'a CancellationToken,
cookies: &'a CookieSink) -> auth_provider::Authorization<'a> {
Box::pin(async move {
if !self.is_redirect_host(context) {
return Err(auth_provider::deny(StatusCode::FORBIDDEN));
}
if !self.is_redirect_host(context) { return Err(auth_provider::deny(StatusCode::FORBIDDEN)); }
if let Some(encoded) = cookie_value(incoming, &self.session_cookie) {
if let Ok(session) = open::<Session>(&self.session_secret, encoded) {
if now().is_ok_and(|current| session.expires > current) {
@@ -831,44 +496,25 @@ impl auth_provider::Provider for Compiled {
Ok(Ok(response)) => response,
Ok(Err(error)) => {
tracing::warn!(provider = self.provider_id, %error, "OIDC login initiation failed");
auth_provider::gateway_failure(
self.provider_label,
GatewayFailure::Verification,
)
}
Err(_) => {
auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout)
auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification)
}
Err(_) => auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout),
})
})
}
fn reserved_path(&self, path: &str) -> bool {
matches!(path, CALLBACK | LOGOUT)
}
fn serve_reserved<'a>(
&'a self,
request: Request<Body>,
context: &'a forwarding::Context,
shutdown: &'a CancellationToken,
) -> Option<auth_provider::Route<'a>> {
fn reserved_path(&self, path: &str) -> bool { matches!(path, CALLBACK | LOGOUT) }
fn serve_reserved<'a>(&'a self, request: Request<Body>, context: &'a forwarding::Context,
shutdown: &'a CancellationToken) -> Option<auth_provider::Route<'a>> {
match request.uri().path() {
CALLBACK => Some(Box::pin(async move {
self.callback(request, context, shutdown).await
})),
CALLBACK => Some(Box::pin(async move { self.callback(request, context, shutdown).await })),
LOGOUT => Some(Box::pin(async move {
if request.method() != Method::GET || !self.is_redirect_host(context) {
return auth_provider::deny(StatusCode::BAD_REQUEST);
}
let mut response = redirect("/", StatusCode::SEE_OTHER);
response
.headers_mut()
.append(header::SET_COOKIE, clear_cookie(&self.session_cookie));
response
.headers_mut()
.append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
response
.headers_mut()
.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.session_cookie));
response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.state_cookie));
response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer"));
response
})),
_ => None,
+37 -120
View File
@@ -6,11 +6,11 @@ mod tests {
fn enabled() -> Settings {
Settings {
enabled: true,
issuer_url: "https://auth.test.com".into(),
issuer_url: "https://auth.gruszczynski.cc".into(),
client_id: "rustproxy-app".into(),
client_secret: "super-secret-client".into(),
session_secret: "an-uncorrelated-high-entropy-session-secret-value".into(),
redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(),
redirect_url: "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(),
..Settings::default()
}
}
@@ -20,14 +20,8 @@ mod tests {
assert!(!Settings::default().enabled);
let settings = enabled();
assert!(settings.validate().is_ok());
assert_eq!(
https_url(&settings.issuer_url).unwrap().host_str(),
Some("https://auth.test.com")
);
assert_eq!(
https_url(&settings.redirect_url).unwrap().host_str(),
Some("app.example.com")
);
assert_eq!(https_url(&settings.issuer_url).unwrap().host_str(), Some("auth.gruszczynski.cc"));
assert_eq!(https_url(&settings.redirect_url).unwrap().host_str(), Some("app.linuxiarz.pl"));
}
#[test]
@@ -36,7 +30,7 @@ mod tests {
settings.issuer_url = "http://auth.example.com".into();
assert!(settings.validate().is_err());
settings = enabled();
settings.redirect_url = "https://app.example.com/other".into();
settings.redirect_url = "https://app.linuxiarz.pl/other".into();
assert!(settings.validate().is_err());
settings = enabled();
settings.client_secret.clear();
@@ -56,12 +50,9 @@ mod tests {
fn encrypted_cookies_are_authenticated_and_not_readable() {
let secret = "session-secret-at-least-32-bytes-unique";
let session = Session {
subject: "a-stable-id".into(),
username: "alice".into(),
email: Some("alice@example.com".into()),
name: None,
groups: vec!["member".into()],
expires: now().unwrap() + 3600,
subject: "a-stable-id".into(), username: "alice".into(),
email: Some("alice@example.com".into()), name: None,
groups: vec!["member".into()], expires: now().unwrap() + 3600,
};
let encoded = seal(secret, &session).unwrap();
assert!(!encoded.contains("alice"));
@@ -82,18 +73,8 @@ mod tests {
#[test]
fn cookies_cannot_be_reused_across_oidc_clients_or_redirect_hosts() {
let shared = "same-admin-configured-secret-for-multiple-applications";
let a = scoped_secret(
shared,
"https://auth.example.com",
"app1",
"https://app1.example.org/_rustproxy/oidc/callback",
);
let b = scoped_secret(
shared,
"https://auth.example.com",
"app2",
"https://app2.example.org/_rustproxy/oidc/callback",
);
let a = scoped_secret(shared, "https://auth.example.com", "app1", "https://app1.example.org/_rustproxy/oidc/callback");
let b = scoped_secret(shared, "https://auth.example.com", "app2", "https://app2.example.org/_rustproxy/oidc/callback");
assert_ne!(a, b);
let encrypted = seal(&a, &serde_json::json!({"sub": "alice"})).unwrap();
assert!(open::<Value>(&b, &encrypted).is_err());
@@ -102,10 +83,7 @@ mod tests {
#[test]
fn duplicate_cookies_cannot_override_a_valid_cookie() {
let mut headers = HeaderMap::new();
headers.insert(
header::COOKIE,
"a=1; login=ok; login=malicious".parse().unwrap(),
);
headers.insert(header::COOKIE, "a=1; login=ok; login=malicious".parse().unwrap());
assert!(cookie_value(&headers, "login").is_none());
headers.insert(header::COOKIE, "a=1; login=ok".parse().unwrap());
assert_eq!(cookie_value(&headers, "login"), Some("ok"));
@@ -114,28 +92,17 @@ mod tests {
#[test]
fn userinfo_must_match_signed_subject_and_does_not_trust_unverified_email() {
let mut claims = token::Claims {
subject: "id-42".into(),
username: "id-42".into(),
email: None,
name: None,
groups: vec![],
subject: "id-42".into(), username: "id-42".into(), email: None,
name: None, groups: vec![],
};
assert!(token::merge_userinfo(
&mut claims,
&serde_json::json!({
"sub":"other-user", "preferred_username":"admin"
})
)
.is_err());
assert!(token::merge_userinfo(&mut claims, &serde_json::json!({
"sub":"other-user", "preferred_username":"admin"
})).is_err());
assert_eq!(claims.username, "id-42");
token::merge_userinfo(
&mut claims,
&serde_json::json!({
"sub":"id-42", "preferred_username":"alice", "email":"alice@example.com",
"email_verified":false, "groups":["users"]
}),
)
.unwrap();
token::merge_userinfo(&mut claims, &serde_json::json!({
"sub":"id-42", "preferred_username":"alice", "email":"alice@example.com",
"email_verified":false, "groups":["users"]
})).unwrap();
assert_eq!(claims.username, "alice");
assert_eq!(claims.email, None);
assert_eq!(claims.groups, vec!["users".to_string()]);
@@ -145,16 +112,9 @@ mod tests {
fn id_token_signature_issuer_audience_nonce_and_expiry_are_checked() {
let rng = SystemRandom::new();
let pkcs8 = signature::EcdsaKeyPair::generate_pkcs8(
&signature::ECDSA_P256_SHA256_FIXED_SIGNING,
&rng,
)
.unwrap();
&signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap();
let keypair = signature::EcdsaKeyPair::from_pkcs8(
&signature::ECDSA_P256_SHA256_FIXED_SIGNING,
pkcs8.as_ref(),
&rng,
)
.unwrap();
&signature::ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap();
let public = keypair.public_key().as_ref();
let jwks = serde_json::json!({ "keys": [{
"kid":"key1", "kty":"EC", "use":"sig", "alg":"ES256", "crv":"P-256",
@@ -168,75 +128,32 @@ mod tests {
});
let header = serde_json::json!({"alg":"ES256","kid":"key1","typ":"JWT"});
let token = |claims: &Value| {
let data = format!(
"{}.{}",
let data = format!("{}.{}",
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()),
URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap())
);
URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()));
let sig = keypair.sign(&rng, data.as_bytes()).unwrap();
format!("{}.{}", data, URL_SAFE_NO_PAD.encode(sig.as_ref()))
};
let jwt = token(&claims);
let verified = token::verify_id_token(
&jwt,
&jwks,
"https://auth.example.com",
"rustproxy",
"session-nonce",
)
.unwrap();
let verified = token::verify_id_token(&jwt, &jwks,
"https://auth.example.com", "rustproxy", "session-nonce").unwrap();
assert_eq!(verified.subject, "id-42");
assert_eq!(verified.username, "alice");
assert!(token::verify_id_token(
&jwt,
&jwks,
"https://other.example.com",
"rustproxy",
"session-nonce"
)
.is_err());
assert!(token::verify_id_token(
&jwt,
&jwks,
"https://auth.example.com",
"other-client",
"session-nonce"
)
.is_err());
assert!(token::verify_id_token(
&jwt,
&jwks,
"https://auth.example.com",
"rustproxy",
"wrong-nonce"
)
.is_err());
assert!(token::verify_id_token(&jwt, &jwks,
"https://other.example.com", "rustproxy", "session-nonce").is_err());
assert!(token::verify_id_token(&jwt, &jwks,
"https://auth.example.com", "other-client", "session-nonce").is_err());
assert!(token::verify_id_token(&jwt, &jwks,
"https://auth.example.com", "rustproxy", "wrong-nonce").is_err());
let mut expired = claims.clone();
expired["exp"] = serde_json::json!(now().unwrap() - 1);
assert!(token::verify_id_token(
&token(&expired),
&jwks,
"https://auth.example.com",
"rustproxy",
"session-nonce"
)
.is_err());
assert!(token::verify_id_token(&token(&expired), &jwks,
"https://auth.example.com", "rustproxy", "session-nonce").is_err());
let mut wrong = claims.clone();
wrong["sub"] = serde_json::json!("another-user");
let forged_parts: Vec<_> = token(&wrong).split('.').map(str::to_string).collect();
let modified = format!(
"{}.{}.{}",
forged_parts[0],
forged_parts[1],
jwt.split('.').last().unwrap()
);
assert!(token::verify_id_token(
&modified,
&jwks,
"https://auth.example.com",
"rustproxy",
"session-nonce"
)
.is_err());
let modified = format!("{}.{}.{}", forged_parts[0], forged_parts[1], jwt.split('.').last().unwrap());
assert!(token::verify_id_token(&modified, &jwks,
"https://auth.example.com", "rustproxy", "session-nonce").is_err());
}
}
+42 -158
View File
@@ -14,10 +14,7 @@ pub(super) struct Claims {
}
fn field<'a>(value: &'a Value, name: &str) -> Result<&'a str> {
value
.get(name)
.and_then(Value::as_str)
.context("missing or invalid OIDC claim")
value.get(name).and_then(Value::as_str).context("missing or invalid OIDC claim")
}
fn key_bytes(value: &Value, key: &str) -> Result<Vec<u8>> {
@@ -27,42 +24,22 @@ fn key_bytes(value: &Value, key: &str) -> Result<Vec<u8>> {
}
fn validate_signature(key: &Value, alg: &str, signed: &[u8], signature_bytes: &[u8]) -> Result<()> {
ensure!(
key.get("use")
.and_then(Value::as_str)
.is_none_or(|usage| usage == "sig"),
"OIDC JWK is not a signing key"
);
ensure!(
key.get("alg")
.and_then(Value::as_str)
.is_none_or(|declared| declared == alg),
"OIDC JWK algorithm mismatch"
);
ensure!(key.get("use").and_then(Value::as_str).is_none_or(|usage| usage == "sig"), "OIDC JWK is not a signing key");
ensure!(key.get("alg").and_then(Value::as_str).is_none_or(|declared| declared == alg), "OIDC JWK algorithm mismatch");
match alg {
"RS256" => {
ensure!(field(key, "kty")? == "RSA", "OIDC key type mismatch");
let n = key_bytes(key, "n")?;
let e = key_bytes(key, "e")?;
signature::RsaPublicKeyComponents { n: &n, e: &e }
.verify(
&signature::RSA_PKCS1_2048_8192_SHA256,
signed,
signature_bytes,
)
.verify(&signature::RSA_PKCS1_2048_8192_SHA256, signed, signature_bytes)
.map_err(|_| anyhow::anyhow!("invalid OIDC signature"))?;
}
"ES256" => {
ensure!(
field(key, "kty")? == "EC" && field(key, "crv")? == "P-256",
"OIDC key type mismatch"
);
ensure!(field(key, "kty")? == "EC" && field(key, "crv")? == "P-256", "OIDC key type mismatch");
let x = key_bytes(key, "x")?;
let y = key_bytes(key, "y")?;
ensure!(
x.len() == 32 && y.len() == 32,
"invalid OIDC curve coordinates"
);
ensure!(x.len() == 32 && y.len() == 32, "invalid OIDC curve coordinates");
let mut public = Vec::with_capacity(65);
public.push(4);
public.extend_from_slice(&x);
@@ -92,172 +69,79 @@ pub(super) fn verify_id_token(
let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(head)?)?;
let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?;
let alg = field(&header, "alg")?;
ensure!(
matches!(alg, "RS256" | "ES256"),
"OIDC JWT algorithm not allowed"
);
ensure!(matches!(alg, "RS256" | "ES256"), "OIDC JWT algorithm not allowed");
let kid = header.get("kid").and_then(Value::as_str);
let keys = jwks
.get("keys")
.and_then(Value::as_array)
.context("invalid OIDC JWKS")?;
let keys = jwks.get("keys").and_then(Value::as_array).context("invalid OIDC JWKS")?;
ensure!(keys.len() <= 32, "too many OIDC signing keys");
let candidates: Vec<_> = keys
.iter()
.filter(|key| {
kid.map_or(true, |id| {
key.get("kid").and_then(Value::as_str) == Some(id)
}) && key
.get("alg")
.and_then(Value::as_str)
.is_none_or(|configured| configured == alg)
&& key
.get("use")
.and_then(Value::as_str)
.is_none_or(|configured| configured == "sig")
})
.collect();
ensure!(
candidates.len() == 1,
"OIDC signing key missing or ambiguous"
);
let candidates: Vec<_> = keys.iter().filter(|key| {
kid.map_or(true, |id| key.get("kid").and_then(Value::as_str) == Some(id))
&& key.get("alg").and_then(Value::as_str).is_none_or(|configured| configured == alg)
&& key.get("use").and_then(Value::as_str).is_none_or(|configured| configured == "sig")
}).collect();
ensure!(candidates.len() == 1, "OIDC signing key missing or ambiguous");
let signed = format!("{head}.{payload}");
validate_signature(
candidates[0],
alg,
signed.as_bytes(),
&URL_SAFE_NO_PAD.decode(signature)?,
)?;
ensure!(
field(&claims, "iss")? == expected_issuer,
"OIDC issuer mismatch"
);
validate_signature(candidates[0], alg, signed.as_bytes(), &URL_SAFE_NO_PAD.decode(signature)?)?;
ensure!(field(&claims, "iss")? == expected_issuer, "OIDC issuer mismatch");
let audience = claims.get("aud").context("missing OIDC audience")?;
let audiences: Vec<&str> = match audience {
Value::String(value) => vec![value],
Value::Array(values) => values
.iter()
.map(|value| value.as_str().context("invalid OIDC audience"))
.collect::<Result<_>>()?,
Value::Array(values) => values.iter().map(|value| value.as_str().context("invalid OIDC audience")).collect::<Result<_>>()?,
_ => bail!("invalid OIDC audience"),
};
ensure!(audiences.contains(&client_id), "OIDC audience mismatch");
if audiences.len() > 1 {
ensure!(
field(&claims, "azp")? == client_id,
"OIDC authorized party mismatch"
);
ensure!(field(&claims, "azp")? == client_id, "OIDC authorized party mismatch");
} else if let Some(azp) = claims.get("azp").and_then(Value::as_str) {
ensure!(azp == client_id, "OIDC authorized party mismatch");
}
ensure!(
field(&claims, "nonce")? == expected_nonce,
"OIDC nonce mismatch"
);
ensure!(field(&claims, "nonce")? == expected_nonce, "OIDC nonce mismatch");
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
let exp = claims
.get("exp")
.and_then(Value::as_u64)
.context("missing OIDC expiration")?;
let exp = claims.get("exp").and_then(Value::as_u64).context("missing OIDC expiration")?;
ensure!(exp > now, "OIDC ID token expired");
let issued = claims
.get("iat")
.and_then(Value::as_u64)
.context("missing OIDC issuance time")?;
ensure!(
issued <= now.saturating_add(60),
"OIDC token issued in the future"
);
let issued = claims.get("iat").and_then(Value::as_u64).context("missing OIDC issuance time")?;
ensure!(issued <= now.saturating_add(60), "OIDC token issued in the future");
if let Some(nbf) = claims.get("nbf") {
ensure!(
nbf.as_u64()
.is_some_and(|start| start <= now.saturating_add(60)),
"OIDC token is not yet valid"
);
ensure!(nbf.as_u64().is_some_and(|start| start <= now.saturating_add(60)), "OIDC token is not yet valid");
}
let subject = field(&claims, "sub")?.to_string();
ensure!(
!subject.is_empty() && subject.len() <= 256,
"invalid OIDC subject"
);
let username = claims
.get("preferred_username")
.and_then(Value::as_str)
ensure!(!subject.is_empty() && subject.len() <= 256, "invalid OIDC subject");
let username = claims.get("preferred_username").and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256)
.unwrap_or(&subject)
.to_string();
let optional = |key| {
claims
.get(key)
.and_then(Value::as_str)
.filter(|value| !value.is_empty() && value.len() <= 256)
.map(str::to_string)
};
let groups = claims
.get("groups")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.filter(|s| !s.is_empty() && s.len() <= 128)
.take(32)
.map(str::to_string)
.collect()
})
.unwrap_or(&subject).to_string();
let optional = |key| claims.get(key).and_then(Value::as_str)
.filter(|value| !value.is_empty() && value.len() <= 256).map(str::to_string);
let groups = claims.get("groups").and_then(Value::as_array)
.map(|items| items.iter().filter_map(Value::as_str).filter(|s| !s.is_empty() && s.len() <= 128).take(32).map(str::to_string).collect())
.unwrap_or_default();
let email = if claims.get("email_verified").and_then(Value::as_bool) == Some(false) {
None
} else {
optional("email")
};
Ok(Claims {
subject,
username,
email,
name: optional("name"),
groups,
})
Ok(Claims { subject, username, email, name: optional("name"), groups })
}
pub(super) fn merge_userinfo(claims: &mut Claims, value: &Value) -> Result<()> {
ensure!(
field(value, "sub")? == claims.subject,
"OIDC UserInfo subject mismatch"
);
ensure!(field(value, "sub")? == claims.subject, "OIDC UserInfo subject mismatch");
if claims.username == claims.subject {
if let Some(username) = value
.get("preferred_username")
.and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256)
{
if let Some(username) = value.get("preferred_username").and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256) {
claims.username = username.to_string();
}
}
if claims.name.is_none() {
claims.name = value
.get("name")
.and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256)
.map(str::to_string);
claims.name = value.get("name").and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256).map(str::to_string);
}
if claims.email.is_none() && value.get("email_verified").and_then(Value::as_bool) != Some(false)
{
claims.email = value
.get("email")
.and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256)
.map(str::to_string);
if claims.email.is_none() && value.get("email_verified").and_then(Value::as_bool) != Some(false) {
claims.email = value.get("email").and_then(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 256).map(str::to_string);
}
if claims.groups.is_empty() {
if let Some(groups) = value.get("groups").and_then(Value::as_array) {
claims.groups = groups
.iter()
.filter_map(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 128)
.take(32)
.map(str::to_string)
.collect();
claims.groups = groups.iter().filter_map(Value::as_str)
.filter(|name| !name.is_empty() && name.len() <= 128).take(32).map(str::to_string).collect();
}
}
Ok(())
+2 -8
View File
@@ -254,14 +254,8 @@ impl Vhost {
.validate()?;
if self.oidc.enabled {
let redirect = url::Url::parse(&self.oidc.redirect_url)?;
ensure!(
redirect.host_str().is_some_and(|host| self
.frontend
.hosts
.iter()
.any(|name| name == host)),
"OIDC callback host must be an exact Vhost hostname"
);
ensure!(redirect.host_str().is_some_and(|host| self.frontend.hosts.iter().any(|name| name == host)),
"OIDC callback host must be an exact Vhost hostname");
}
self.pages.validate()?;
self.transform