This commit is contained in:
Mateusz Gruszczyński committed 2026-10-27 12:18:20 +01:00
1 parent 62b7574553
commit e364392659
47 files changed
+2164 -257

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
use crate::{
auth_provider::{self, CookieSink},
config::ValidationPolicy,
forward_auth, forwarding,
};
use anyhow::{ensure, Result};
use axum::response::Response;
use http::{header, HeaderMap, Method, StatusCode};
use serde::{Deserialize, Serialize};
use std::{
net::SocketAddr,
ops::{Deref, DerefMut},
};
use tokio_util::sync::CancellationToken;
use url::Url;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Settings(forward_auth::Settings);
impl Deref for Settings {
type Target = forward_auth::Settings;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Settings {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Settings {
pub fn validate(&self) -> Result<()> {
self.0.validate::<Adapter>()
}
pub fn compile(
&self,
policy: &ValidationPolicy,
bindings: &[SocketAddr],
) -> Result<Option<Compiled>> {
Ok(self.0.compile::<Adapter>(policy, bindings)?.map(Compiled))
}
}
#[derive(Debug)]
pub struct Compiled(forward_auth::Compiled<Adapter>);
#[derive(Debug)]
pub struct Adapter;
impl forward_auth::Adapter for Adapter {
fn label() -> &'static str {
"Authelia"
}
fn verification_path() -> &'static str {
"/api/authz/forward-auth"
}
fn portal(settings: &forward_auth::Settings) -> Result<Option<Url>> {
let portal = forward_auth::http_url(&settings.login_url, "Authelia login")?;
ensure!(
forward_auth::is_portal_https(&portal),
"public Authelia login must use HTTPS"
);
Ok(Some(portal))
}
fn denied(
status: StatusCode,
response_headers: &HeaderMap,
_original_url: &str,
portal: Option<&Url>,
redirect_forbidden: bool,
) -> std::result::Result<Response, Response> {
if matches!(
status,
StatusCode::FOUND | StatusCode::SEE_OTHER | StatusCode::UNAUTHORIZED
) || (status == StatusCode::FORBIDDEN && redirect_forbidden)
{
let location = portal.and_then(|url| {
forward_auth::login_redirect(response_headers.get(header::LOCATION), url)
});
if let Some(location) = location {
let mut response = forward_auth::fail(if status == StatusCode::SEE_OTHER {
StatusCode::SEE_OTHER
} else {
StatusCode::FOUND
});
response.headers_mut().insert(header::LOCATION, location);
Ok(response)
} else if matches!(status, StatusCode::FOUND | StatusCode::SEE_OTHER) {
Err(forward_auth::invalid(
"Authelia",
"Authelia returned an untrusted login redirect",
))
} else {
Ok(forward_auth::fail(status))
}
} else if status == StatusCode::FORBIDDEN {
Ok(forward_auth::fail(status))
} else {
Err(forward_auth::invalid(
"Authelia",
"Authelia returned an unexpected verifier status",
))
}
}
}
impl auth_provider::Provider for Compiled {
fn id(&self) -> &'static str {
"authelia"
}
fn strip_identity(&self, headers: &mut HeaderMap) {
self.0.strip_identity(headers);
}
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(
self.0
.authorize(incoming, method, context, shutdown, cookies),
)
}
}
#[cfg(test)]
include!("tests.rs");
+85
View File
@@ -0,0 +1,85 @@
mod tests {
use super::*;
use crate::auth_provider::{Provider, testing::{context, policy, spawn_mock}};
use http_body_util::BodyExt;
fn settings(url: &str) -> Settings {
let mut settings = Settings::default();
settings.enabled = true;
settings.url = url.into();
settings.login_url = "https://auth.example.com".into();
settings.allow_private = true;
settings
}
fn compile(url: &str) -> Compiled { settings(url).compile(&policy(), &[]).unwrap().unwrap() }
#[test]
fn legacy_json_shape_and_provider_identity_are_preserved() {
let parsed: Settings = serde_json::from_str(r#"{"enabled":true,"url":"http://127.0.0.1:9300","login_url":"https://auth.example.com","allow_private":true}"#).unwrap();
assert!(parsed.enabled);
let serialized = serde_json::to_value(&parsed).unwrap();
assert_eq!(serialized["url"], "http://127.0.0.1:9300");
assert!(serialized.get("0").is_none());
assert_eq!(compile("http://127.0.0.1:9300").id(), "authelia");
}
#[test]
fn invalid_portal_and_cross_origin_redirects_are_rejected() {
let mut s = settings("http://127.0.0.1:9300");
s.login_url = "http://auth.example.com".into();
assert!(s.validate().is_err());
let portal = Url::parse("https://auth.example.com/").unwrap();
for bad in ["https://evil.example/", "//evil.example", "https://auth.example.com@evil.example/", "javascript:alert(1)", "https://auth.example.com/#frag"] {
assert!(forward_auth::login_redirect(Some(&bad.parse().unwrap()), &portal).is_none(), "{bad}");
}
assert_eq!(forward_auth::login_redirect(Some(&"/login?rd=1".parse().unwrap()), &portal).unwrap(), "https://auth.example.com/login?rd=1");
}
#[tokio::test]
async fn checks_forward_auth_endpoint_and_returns_only_verified_identity() {
let (url, server) = spawn_mock("200 OK", "Remote-User: trusted\r\nRemote-Email: trusted@example.com\r\n").await;
let auth = compile(&url);
let mut incoming = HeaderMap::new();
incoming.insert("remote-user", "forged".parse().unwrap());
let identity = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
&CancellationToken::new(), &CookieSink::default()).await.unwrap();
let request = server.await.unwrap().to_ascii_lowercase();
assert!(request.contains("get /api/authz/forward-auth"));
assert!(request.contains("x-forwarded-host: app.example.com"));
assert!(request.contains("x-forwarded-uri: /private?redirect=%2fhome"));
assert!(!request.contains("remote-user: forged"));
let mut headers = HeaderMap::new();
identity.apply(&mut headers);
assert_eq!(headers["remote-user"], "trusted");
}
#[tokio::test]
async fn redirects_only_to_trusted_portal_and_denies_untrusted_redirects() {
let (url, server) = spawn_mock("302 Found", "Location: https://auth.example.com/?rd=home\r\n").await;
let auth = compile(&url);
let incoming = HeaderMap::new();
let response = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
&CancellationToken::new(), &CookieSink::default()).await.unwrap_err();
assert_eq!(response.status(), StatusCode::FOUND);
assert!(response.headers()[header::LOCATION].to_str().unwrap().starts_with("https://auth.example.com/"));
server.await.unwrap();
let (url, server) = spawn_mock("302 Found", "Location: https://evil.example/\r\n").await;
let auth = compile(&url);
let response = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
&CancellationToken::new(), &CookieSink::default()).await.unwrap_err();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
server.await.unwrap();
}
#[tokio::test]
async fn unreachable_provider_fails_closed_with_no_origin_details() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
drop(listener);
let auth = compile(&url);
let incoming = HeaderMap::new();
let response = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
&CancellationToken::new(), &CookieSink::default()).await.unwrap_err();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert!(response.extensions().get::<auth_provider::AuthenticationResponse>().is_some());
let body = response.into_body().collect().await.unwrap().to_bytes();
assert!(!body.windows(url.len()).any(|chunk| chunk == url.as_bytes()));
}
}