Skip to main content

rws_server/auth/
forward.rs

1//! Forward-auth middleware — delegates the allow/deny decision for every
2//! request to an external HTTP service (Traefik's `forwardAuth`, nginx's
3//! `auth_request`), so a policy engine (OPA, Casbin) or a centralized auth
4//! service can gate requests without embedding that logic in rws.
5//!
6//! No new dependency: makes a plain `GET` via the existing outbound
7//! [`crate::http_client::Client`].
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! # extern crate rws_server as rust_web_server;
13//! use rust_web_server::app::App;
14//! use rust_web_server::core::New;
15//! use rust_web_server::auth::forward::ForwardAuthLayer;
16//!
17//! let app = App::new()
18//!     .wrap(ForwardAuthLayer::new("http://auth.internal/verify")
19//!         .copy_header("X-User-Id")
20//!         .copy_header("X-Roles")
21//!         .timeout_ms(2000));
22//! ```
23//!
24//! # How it works
25//!
26//! 1. Every incoming request header is copied onto a `GET` request sent to
27//!    the configured auth service URL.
28//! 2. **2xx response** — the request is allowed through. Any header named in
29//!    [`.copy_header(...)`](ForwardAuthLayer::copy_header) that's present on
30//!    the *auth service's* response replaces (not appends to — see below) the
31//!    same-named header on the forwarded request. This is how an auth
32//!    service that resolves a session cookie to a user ID hands that ID to
33//!    your handler as a plain header.
34//! 3. **Any other status** — the auth service's response is returned to the
35//!    client **verbatim**: status code, all headers (except hop-by-hop and
36//!    body-framing ones), and body. This preserves `WWW-Authenticate`
37//!    challenges, `Location` redirects for OAuth flows, and custom error
38//!    bodies without rws needing to understand any of them.
39//! 4. **Auth service unreachable** (connection refused, timeout, DNS
40//!    failure) — the request is rejected with `502 Bad Gateway`. This fails
41//!    *closed*: an unreachable auth service is not the same as "access
42//!    granted."
43//!
44//! Copied headers **replace** any same-named header the client already sent,
45//! rather than being appended alongside it — otherwise a client could send
46//! its own `X-User-Id` and have it coexist with (and potentially shadow) the
47//! auth service's verified value, depending on which duplicate a downstream
48//! `get_header` call happens to match first.
49
50#[cfg(test)]
51mod tests;
52
53use crate::application::Application;
54use crate::core::New;
55use crate::header::Header;
56use crate::http_client::Client;
57use crate::middleware::Middleware;
58use crate::mime_type::MimeType;
59use crate::range::Range;
60use crate::request::Request;
61use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
62use crate::server::ConnectionInfo;
63
64/// Headers never copied verbatim onto rws's own response: hop-by-hop headers
65/// (RFC 7230 §6.1) and the two headers whose values are derived from
66/// `content_range_list` during response generation, not set directly.
67const EXCLUDED_PASSTHROUGH_HEADERS: &[&str] = &[
68    "connection",
69    "keep-alive",
70    "proxy-authenticate",
71    "proxy-authorization",
72    "te",
73    "trailer",
74    "transfer-encoding",
75    "upgrade",
76    "content-type",
77    "content-length",
78];
79
80/// Middleware that delegates the allow/deny decision for every request to
81/// an external HTTP service. See the [module docs](self) for the full flow.
82pub struct ForwardAuthLayer {
83    auth_url: String,
84    copy_headers: Vec<String>,
85    timeout_ms: u64,
86    identity_header: Option<String>,
87}
88
89impl ForwardAuthLayer {
90    /// Create a layer that calls `auth_url` (a full `http://`/`https://` URL,
91    /// e.g. `"http://auth.internal/verify"`) for every request.
92    ///
93    /// Default timeout: 5000ms. HTTPS URLs require the `http-client` or
94    /// `http2` feature (same requirement as the outbound HTTP client itself).
95    pub fn new(auth_url: impl Into<String>) -> Self {
96        ForwardAuthLayer { auth_url: auth_url.into(), copy_headers: Vec::new(), timeout_ms: 5000, identity_header: None }
97    }
98
99    /// The auth service's response header naming the caller (e.g.
100    /// `X-User-Id`): on a 2xx with that header the rest of the request runs
101    /// as that user ([`Identity::user`](crate::authz::Identity::user)).
102    /// Since 18.14.0.
103    pub fn identity_header(mut self, name: impl Into<String>) -> Self {
104        self.identity_header = Some(name.into());
105        self
106    }
107
108    /// Copy `name` from the auth service's response onto the forwarded
109    /// request when the auth service allows the request (2xx). Call
110    /// multiple times to copy multiple headers. Headers not present on the
111    /// auth service's response are left untouched.
112    pub fn copy_header(mut self, name: impl Into<String>) -> Self {
113        self.copy_headers.push(name.into());
114        self
115    }
116
117    /// Override the auth service call timeout (default: 5000ms).
118    pub fn timeout_ms(mut self, ms: u64) -> Self {
119        self.timeout_ms = ms;
120        self
121    }
122}
123
124impl Middleware for ForwardAuthLayer {
125    /// Headers only: a streamed body passes through untouched.
126    fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
127        true
128    }
129
130
131    fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
132        // max_redirects(0): a 3xx from the auth service (e.g. an OAuth
133        // login redirect) must reach passthrough_response() as-is, not be
134        // silently followed and replaced with whatever it points to.
135        let client = Client::new().timeout_ms(self.timeout_ms).max_redirects(0);
136        let mut builder = client.get(&self.auth_url);
137        // The request's headers, minus framing ones that describe this
138        // request rather than the GET sent, and minus the X-Forwarded-*
139        // set below (a client mustn't choose what the auth service sees).
140        const NOT_FORWARDED: [&str; 10] = [
141            "host", "content-length", "transfer-encoding", "connection", "expect",
142            "x-forwarded-method", "x-forwarded-proto", "x-forwarded-host", "x-forwarded-uri", "x-forwarded-for",
143        ];
144        for h in &request.headers {
145            if !NOT_FORWARDED.contains(&h.name.to_ascii_lowercase().as_str()) {
146                builder = builder.header(&h.name, &h.value);
147            }
148        }
149        // What Traefik, Authelia, Authentik and oauth2-proxy read (since 18.21.0).
150        let host = request.header("Host").map(|h| h.value.to_string()).unwrap_or_default();
151        builder = builder
152            .header("X-Forwarded-Method", &request.method)
153            .header("X-Forwarded-Proto", crate::ip_filter::request_scheme(request, &connection.client.ip, connection.tls))
154            .header("X-Forwarded-Host", &host)
155            .header("X-Forwarded-Uri", &request.request_uri)
156            .header("X-Forwarded-For", &crate::ip_filter::client_ip(request, &connection.client.ip));
157
158        let auth_response = match builder.send() {
159            Ok(r) => r,
160            Err(_) => return Ok(auth_service_unreachable()),
161        };
162
163        if !auth_response.is_success() {
164            return Ok(passthrough_response(&auth_response));
165        }
166
167        // A copied header comes only from the auth service: a client's own
168        // copy is dropped even when the service doesn't send one (since 18.21.0).
169        let mut forwarded = request.clone();
170        for name in &self.copy_headers {
171            forwarded.headers.retain(|h| !h.name.eq_ignore_ascii_case(name));
172            if let Some(value) = auth_response.header(name) {
173                forwarded.headers.push(Header { name: name.clone().into(), value: value.to_string().into() });
174            }
175        }
176
177        let identity = self
178            .identity_header
179            .as_deref()
180            .and_then(|name| auth_response.header(name))
181            .map(str::trim)
182            .and_then(|value| crate::authz::Identity::user(value, "forward"));
183        match identity {
184            Some(identity) => crate::authz::with_identity(identity, || next.execute(&forwarded, connection)),
185            None => next.execute(&forwarded, connection),
186        }
187    }
188}
189
190fn auth_service_unreachable() -> Response {
191    crate::error::problem(STATUS_CODE_REASON_PHRASE.n502_bad_gateway, "auth service unreachable")
192}
193
194fn passthrough_response(auth_response: &crate::http_client::Response) -> Response {
195    let status = auth_response.status();
196    let mut r = Response::new();
197    r.status_code = status as i16;
198    r.reason_phrase = reason_phrase_for_status(status);
199
200    for (name, value) in auth_response.headers() {
201        if EXCLUDED_PASSTHROUGH_HEADERS.contains(&name.to_lowercase().as_str()) {
202            continue;
203        }
204        r.headers.push(Header { name: name.clone().into(), value: value.clone().into() });
205    }
206
207    let body = auth_response.bytes();
208    if !body.is_empty() {
209        let content_type = auth_response
210            .header(Header::_CONTENT_TYPE)
211            .unwrap_or(MimeType::TEXT_PLAIN)
212            .to_string();
213        r.content_range_list = vec![Range::get_content_range(body.to_vec(), content_type)];
214    }
215
216    r
217}
218
219fn reason_phrase_for_status(status: u16) -> String {
220    Response::status_code_reason_phrase_list()
221        .into_iter()
222        .find(|s| *s.status_code == status as i16)
223        .map(|s| s.reason_phrase.to_string())
224        .unwrap_or_default()
225}