Skip to main content

rune_axum_redirect_https/
layer.rs

1use http::{
2    Request, Response, StatusCode,
3    header::{HOST, HeaderValue, LOCATION},
4};
5use std::future::Future;
6use std::pin::Pin;
7use std::task::{Context, Poll};
8use tower::{Layer, Service};
9
10/// Configuration for the HTTP-to-HTTPS redirect middleware.
11///
12/// Build with [`RedirectHttps::new()`] and chain methods to adjust the redirect
13/// status code or target HTTPS port, then pass to [`RedirectHttpsLayer::new()`].
14///
15/// # Examples
16///
17/// ```rust
18/// use http::StatusCode;
19/// use rune_axum_redirect_https::RedirectHttps;
20///
21/// // HTTP on :8080 → HTTPS on :8443, 301 for legacy compatibility
22/// let config = RedirectHttps::new()
23///     .status(StatusCode::MOVED_PERMANENTLY)
24///     .https_port(8443);
25/// ```
26#[derive(Clone, Debug)]
27pub struct RedirectHttps {
28    status: StatusCode,
29    https_port: Option<u16>,
30}
31
32impl Default for RedirectHttps {
33    fn default() -> Self {
34        Self {
35            status: StatusCode::PERMANENT_REDIRECT,
36            https_port: None,
37        }
38    }
39}
40
41impl RedirectHttps {
42    /// Creates a `RedirectHttps` with defaults: `308 Permanent Redirect`, standard HTTPS port.
43    #[must_use]
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// Sets the redirect status code.
49    ///
50    /// Defaults to `308 Permanent Redirect`, which preserves the HTTP method.
51    /// Use `301 Moved Permanently` when you need compatibility with older
52    /// clients that do not support 308.
53    ///
54    /// > [!WARNING]
55    /// > `301` converts POST to GET in many browsers and HTTP clients. Prefer
56    /// > `308` unless you have a specific reason to use `301`.
57    #[must_use]
58    pub const fn status(mut self, status: StatusCode) -> Self {
59        self.status = status;
60        self
61    }
62
63    /// Sets the HTTPS port in the redirect `Location` URL.
64    ///
65    /// When set, any port in the `Host` header is stripped and replaced with
66    /// this value. Useful when HTTP and HTTPS run on non-standard ports (e.g.
67    /// `8080` → `8443`). When unset the port is omitted from the URL, which
68    /// directs clients to the standard HTTPS port (443).
69    #[must_use]
70    pub const fn https_port(mut self, port: u16) -> Self {
71        self.https_port = Some(port);
72        self
73    }
74
75    fn is_http<B>(req: &Request<B>) -> bool {
76        if let Some(proto) = req.headers().get("x-forwarded-proto") {
77            return proto.as_bytes().eq_ignore_ascii_case(b"http");
78        }
79        req.uri().scheme() == Some(&http::uri::Scheme::HTTP)
80    }
81
82    fn location<B>(&self, req: &Request<B>) -> Option<HeaderValue> {
83        let host = req.headers().get(HOST)?.to_str().ok()?;
84
85        let hostname = host
86            .rsplit_once(':')
87            .filter(|(_, port)| port.parse::<u16>().is_ok())
88            .map_or(host, |(h, _)| h);
89
90        let authority = self
91            .https_port
92            .map_or_else(|| hostname.to_owned(), |port| format!("{hostname}:{port}"));
93
94        let path_and_query = req
95            .uri()
96            .path_and_query()
97            .map_or("/", http::uri::PathAndQuery::as_str);
98
99        HeaderValue::from_str(&format!("https://{authority}{path_and_query}")).ok()
100    }
101}
102
103/// Tower [`Layer`] that redirects HTTP requests to HTTPS.
104///
105/// Apply with Axum's `.layer()` call. Use [`RedirectHttpsLayer::default()`] for
106/// a `308` redirect on standard ports, or [`RedirectHttpsLayer::new()`] to
107/// supply a custom [`RedirectHttps`] configuration.
108///
109/// # Examples
110///
111/// ```rust,no_run
112/// use axum::{routing::get, Router};
113/// use rune_axum_redirect_https::RedirectHttpsLayer;
114///
115/// let app: Router = Router::new()
116///     .route("/", get(|| async { "ok" }))
117///     .layer(RedirectHttpsLayer::default());
118/// ```
119#[derive(Clone, Debug, Default)]
120pub struct RedirectHttpsLayer {
121    config: RedirectHttps,
122}
123
124impl RedirectHttpsLayer {
125    /// Creates a `RedirectHttpsLayer` from a custom [`RedirectHttps`] configuration.
126    #[must_use]
127    pub const fn new(config: RedirectHttps) -> Self {
128        Self { config }
129    }
130}
131
132impl<S> Layer<S> for RedirectHttpsLayer {
133    type Service = RedirectHttpsService<S>;
134
135    fn layer(&self, inner: S) -> Self::Service {
136        RedirectHttpsService {
137            inner,
138            config: self.config.clone(),
139        }
140    }
141}
142
143/// Tower [`Service`] produced by [`RedirectHttpsLayer`].
144#[derive(Clone, Debug)]
145pub struct RedirectHttpsService<S> {
146    inner: S,
147    config: RedirectHttps,
148}
149
150impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for RedirectHttpsService<S>
151where
152    S: Service<Request<ReqBody>, Response = Response<ResBody>>,
153    S::Future: Send + 'static,
154    S::Error: Send + 'static,
155    ResBody: Default + Send + 'static,
156{
157    type Response = Response<ResBody>;
158    type Error = S::Error;
159    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
160
161    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
162        self.inner.poll_ready(cx)
163    }
164
165    fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
166        if RedirectHttps::is_http(&req)
167            && let Some(location) = self.config.location(&req)
168        {
169            let status = self.config.status;
170            return Box::pin(async move {
171                let mut response = Response::new(ResBody::default());
172                *response.status_mut() = status;
173                response.headers_mut().insert(LOCATION, location);
174                Ok(response)
175            });
176        }
177        Box::pin(self.inner.call(req))
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use axum::{Router, body::Body, routing::get};
185    use http::StatusCode;
186    use tower::ServiceExt;
187
188    fn build_app(config: RedirectHttps) -> Router {
189        Router::new()
190            .route("/", get(|| async { "ok" }))
191            .layer(RedirectHttpsLayer::new(config))
192    }
193
194    async fn send(app: Router, req: http::Request<Body>) -> http::Response<Body> {
195        app.oneshot(req).await.unwrap()
196    }
197
198    fn forwarded_request(proto: &str, uri: &str) -> http::Request<Body> {
199        http::Request::builder()
200            .uri(uri)
201            .header(HOST, "example.com")
202            .header("x-forwarded-proto", proto)
203            .body(Body::empty())
204            .unwrap()
205    }
206
207    #[tokio::test]
208    async fn redirects_on_x_forwarded_proto_http() {
209        let response = send(
210            build_app(RedirectHttps::new()),
211            forwarded_request("http", "/path?q=1"),
212        )
213        .await;
214
215        assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
216        assert_eq!(
217            response.headers()["location"],
218            "https://example.com/path?q=1"
219        );
220    }
221
222    #[tokio::test]
223    async fn passes_through_on_x_forwarded_proto_https() {
224        let response = send(
225            build_app(RedirectHttps::new()),
226            forwarded_request("https", "/"),
227        )
228        .await;
229        assert_eq!(response.status(), StatusCode::OK);
230    }
231
232    #[tokio::test]
233    async fn redirects_on_http_uri_scheme() {
234        let req = http::Request::builder()
235            .uri("http://example.com/page")
236            .header(HOST, "example.com")
237            .body(Body::empty())
238            .unwrap();
239        let response = send(build_app(RedirectHttps::new()), req).await;
240
241        assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
242        assert_eq!(response.headers()["location"], "https://example.com/page");
243    }
244
245    #[tokio::test]
246    async fn passes_through_when_no_scheme_indicator() {
247        let req = http::Request::builder()
248            .uri("/")
249            .header(HOST, "example.com")
250            .body(Body::empty())
251            .unwrap();
252        let response = send(build_app(RedirectHttps::new()), req).await;
253        assert_eq!(response.status(), StatusCode::OK);
254    }
255
256    #[tokio::test]
257    async fn passes_through_when_no_host_header() {
258        let req = http::Request::builder()
259            .uri("/")
260            .header("x-forwarded-proto", "http")
261            .body(Body::empty())
262            .unwrap();
263        let response = send(build_app(RedirectHttps::new()), req).await;
264        assert_eq!(response.status(), StatusCode::OK);
265    }
266
267    #[tokio::test]
268    async fn custom_status_301() {
269        let config = RedirectHttps::new().status(StatusCode::MOVED_PERMANENTLY);
270        let response = send(build_app(config), forwarded_request("http", "/")).await;
271        assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY);
272    }
273
274    #[tokio::test]
275    async fn strips_http_port_from_host() {
276        let req = http::Request::builder()
277            .uri("/path")
278            .header(HOST, "example.com:80")
279            .header("x-forwarded-proto", "http")
280            .body(Body::empty())
281            .unwrap();
282        let response = send(build_app(RedirectHttps::new()), req).await;
283        assert_eq!(response.headers()["location"], "https://example.com/path");
284    }
285
286    #[tokio::test]
287    async fn custom_https_port() {
288        let config = RedirectHttps::new().https_port(8443);
289        let req = http::Request::builder()
290            .uri("/path")
291            .header(HOST, "example.com:8080")
292            .header("x-forwarded-proto", "http")
293            .body(Body::empty())
294            .unwrap();
295        let response = send(build_app(config), req).await;
296        assert_eq!(
297            response.headers()["location"],
298            "https://example.com:8443/path"
299        );
300    }
301
302    #[tokio::test]
303    async fn default_layer_uses_308() {
304        let app = Router::new()
305            .route("/", get(|| async { "ok" }))
306            .layer(RedirectHttpsLayer::default());
307        let response = send(app, forwarded_request("http", "/")).await;
308        assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
309    }
310}