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}