dpop_verifier/
actix_helpers.rs

1#[cfg(feature = "actix-web")]
2use actix_web::HttpRequest;
3
4#[cfg(feature = "actix-web")]
5use crate::DpopError;
6
7#[cfg(feature = "actix-web")]
8/// Return the single DPoP header as &str; error if missing or multiple.
9pub fn dpop_header_str(req: &HttpRequest) -> Result<&str, DpopError> {
10    // Reject more than one header field
11    let mut it = req.headers().get_all("DPoP");
12    let first = it.next().ok_or(DpopError::MalformedJws)?;
13    if it.next().is_some() {
14        return Err(DpopError::MalformedJws);
15    }
16
17    // Parse the single header field as a single token68-like value (compact JWS),
18    // not a comma-separated list. Also reject padding and whitespace.
19    let s = first.to_str().map_err(|_| DpopError::MalformedJws)?;
20    if s.contains(',') || s.contains(' ') || s.contains('\t') || s.contains('=') {
21        return Err(DpopError::MalformedJws);
22    }
23    Ok(s)
24}
25
26#[cfg(feature = "actix-web")]
27/// Construct the expected HTU from an Actix request, considering proxy headers if trust_proxies is true.
28pub fn expected_htu_from_actix(req: &actix_web::HttpRequest, trust_proxies: bool) -> String {
29    let scheme = if trust_proxies {
30        req.headers()
31            .get("x-forwarded-proto")
32            .and_then(|v| v.to_str().ok())
33            .map(|s| s.trim().to_ascii_lowercase())
34    } else {
35        None
36    }
37    .unwrap_or_else(|| req.connection_info().scheme().to_ascii_lowercase());
38
39    // host (take first in X-Forwarded-Host if present)
40    let host = if trust_proxies {
41        req.headers()
42            .get("x-forwarded-host")
43            .and_then(|v| v.to_str().ok())
44            .and_then(|s| s.split(',').next())
45            .map(|s| s.trim().to_string())
46    } else {
47        None
48    }
49    .unwrap_or_else(|| req.connection_info().host().to_string());
50
51    // optional explicit port from X-Forwarded-Port
52    let port_opt = if trust_proxies {
53        req.headers()
54            .get("x-forwarded-port")
55            .and_then(|v| v.to_str().ok())
56            .and_then(|s| s.parse::<u16>().ok())
57    } else {
58        None
59    };
60
61    let path = {
62        let p = req.uri().path();
63        if p.is_empty() {
64            "/"
65        } else {
66            p
67        }
68    };
69
70    // Drop default ports; otherwise include
71    let is_default =
72        (scheme == "http" && port_opt == Some(80)) || (scheme == "https" && port_opt == Some(443));
73    let host_lc = host.to_ascii_lowercase();
74    if let Some(p) = port_opt {
75        if !is_default {
76            return format!("{scheme}://{host_lc}:{p}{path}");
77        }
78    }
79    format!("{scheme}://{host_lc}{path}")
80}
81
82#[cfg(all(test, feature = "actix-web"))]
83mod actix_helper_tests {
84    use super::{dpop_header_str, expected_htu_from_actix};
85    use actix_web::test;
86    use actix_web::test::TestRequest;
87
88    // ---- dpop_header_str ------------------------------------------------------
89
90    #[actix_web::test]
91    async fn dpop_header_ok() {
92        let req = test::TestRequest::default()
93            .insert_header(("DPoP", "abc.def.ghi"))
94            .to_http_request();
95        assert_eq!(dpop_header_str(&req).unwrap(), "abc.def.ghi");
96    }
97
98    #[actix_web::test]
99    async fn dpop_header_missing() {
100        let req = test::TestRequest::default().to_http_request();
101        assert!(dpop_header_str(&req).is_err());
102    }
103
104    #[test]
105    async fn dpop_header_multiple() {
106        // Multiple header fields -> must be rejected
107        let req = TestRequest::default()
108            .append_header(("DPoP", "v1.header.payload")) // dummy token-ish
109            .append_header(("DPoP", "v2.header.payload"))
110            .to_http_request();
111        assert!(dpop_header_str(&req).is_err());
112    }
113
114    #[test]
115    async fn dpop_header_single_field_comma_list_rejected() {
116        // Single field with comma-separated values -> must be rejected
117        let req = TestRequest::default()
118            .insert_header(("DPoP", "a.b.c,d.e.f"))
119            .to_http_request();
120        assert!(dpop_header_str(&req).is_err());
121    }
122
123    #[test]
124    async fn dpop_header_invalid_token68_ascii() {
125        // Padding '=' is not allowed in base64url(no-pad) segments, and spaces are illegal.
126        let req = TestRequest::default()
127            .insert_header(("DPoP", "abc==")) // ASCII, but invalid for our rules
128            .to_http_request();
129        assert!(dpop_header_str(&req).is_err());
130
131        let req2 = TestRequest::default()
132            .insert_header(("DPoP", "abc def")) // space
133            .to_http_request();
134        assert!(dpop_header_str(&req2).is_err());
135
136        let req3 = TestRequest::default()
137            .insert_header(("DPoP", "abc,def")) // comma (list)
138            .to_http_request();
139        assert!(dpop_header_str(&req3).is_err());
140    }
141
142    // ---- expected_htu_from_actix --------------------------------------------
143
144    #[actix_web::test]
145    async fn canonicalize_basic_no_proxy() {
146        // Explicit host + non-default port to make the expectation deterministic
147        let req = test::TestRequest::default()
148            .insert_header(("Host", "api.example.com:8080"))
149            .uri("/a")
150            .to_http_request();
151
152        let got = expected_htu_from_actix(&req, false);
153        assert_eq!(got, "http://api.example.com:8080/a");
154    }
155
156    #[actix_web::test]
157    async fn canonicalize_uses_x_forwarded_and_drops_default_port() {
158        // With X-Forwarded headers present, helper should prefer them,
159        // lowercase scheme/host, drop default 443, and ignore query/fragment.
160        let req = test::TestRequest::default()
161            .insert_header(("Host", "ignored.local:1234"))
162            .insert_header(("X-Forwarded-Proto", "HTTPS"))
163            .insert_header(("X-Forwarded-Host", "EXAMPLE.COM"))
164            .insert_header(("X-Forwarded-Port", "443"))
165            .uri("/a/../b?x=1#frag")
166            .to_http_request();
167
168        let got = expected_htu_from_actix(&req, true);
169        // Note: actix helper does NOT resolve dot-segments (that's done later by normalize_htu).
170        assert_eq!(got, "https://example.com/a/../b");
171    }
172}