Skip to main content

dynoxide/
auth_material.rs

1//! Transport-neutral validation of AWS authentication material.
2//!
3//! Dynoxide never verifies signatures, but it mirrors DynamoDB's validation of
4//! the auth material itself: header-based and query-string SigV4 are checked for
5//! presence and completeness, with the same error types and messages DynamoDB
6//! returns when parts are missing or conflicting.
7//!
8//! This lives outside `server/` because dynoxide now has two HTTP surfaces: the
9//! native axum server and the wasm engine's [`dispatch_http`](crate::wasm_api),
10//! which is fronted by a transport shim. Both call this, so neither can drift
11//! from the other. Nothing here knows about axum or about wasm.
12
13/// The auth material one request carries, lifted out of whatever transport
14/// delivered it.
15#[derive(Debug, Default, Clone, Copy)]
16pub struct AuthMaterial<'a> {
17    /// The `Authorization` header, if present.
18    pub authorization: Option<&'a str>,
19    /// The raw query string, without a leading `?`. Empty when there is none.
20    pub query: &'a str,
21    /// Whether the request carried an `X-Amz-Date` or a `Date` header.
22    pub has_date_header: bool,
23}
24
25/// Validate the auth material, returning the DynamoDB error envelope to send
26/// when it is missing or incomplete, or `None` when the request may proceed.
27///
28/// DynamoDB checks auth after resolving the target operation, so callers run
29/// this once the operation is known.
30pub fn validate(material: AuthMaterial<'_>) -> Option<String> {
31    let AuthMaterial {
32        authorization,
33        query,
34        has_date_header,
35    } = material;
36
37    let has_algorithm_query = query.split('&').any(|p| {
38        let key = p.split('=').next().unwrap_or("");
39        key == "X-Amz-Algorithm"
40    });
41
42    // Both header and query-string auth: DynamoDB rejects the ambiguity.
43    if authorization.is_some() && has_algorithm_query {
44        return Some(envelope(
45            "InvalidSignatureException",
46            "Found both 'X-Amz-Algorithm' as a query-string param and 'Authorization' as HTTP header.",
47        ));
48    }
49
50    if has_algorithm_query {
51        return validate_query_auth(query);
52    }
53
54    validate_header_auth(authorization, has_date_header)
55}
56
57fn validate_query_auth(query: &str) -> Option<String> {
58    let mut missing = Vec::new();
59    let query_params: Vec<&str> = query
60        .split('&')
61        .map(|p| p.split('=').next().unwrap_or(""))
62        .collect();
63
64    // Present-but-empty counts as missing for the algorithm itself.
65    let algo_has_value = query.split('&').any(|p| {
66        let mut parts = p.splitn(2, '=');
67        let key = parts.next().unwrap_or("");
68        let val = parts.next().unwrap_or("");
69        key == "X-Amz-Algorithm" && !val.is_empty()
70    });
71
72    if !algo_has_value {
73        missing.push("'X-Amz-Algorithm'");
74    }
75    for (param, label) in [
76        ("X-Amz-Credential", "'X-Amz-Credential'"),
77        ("X-Amz-Signature", "'X-Amz-Signature'"),
78        ("X-Amz-SignedHeaders", "'X-Amz-SignedHeaders'"),
79        ("X-Amz-Date", "'X-Amz-Date'"),
80    ] {
81        if !query_params.contains(&param) {
82            missing.push(label);
83        }
84    }
85
86    if missing.is_empty() {
87        return None;
88    }
89
90    let parts: Vec<String> = missing
91        .iter()
92        .map(|p| format!("AWS query-string parameters must include {p}. "))
93        .collect();
94    Some(envelope(
95        "IncompleteSignatureException",
96        &format!("{}Re-examine the query-string parameters.", parts.join("")),
97    ))
98}
99
100fn validate_header_auth(authorization: Option<&str>, has_date_header: bool) -> Option<String> {
101    const MISSING_TOKEN: &str = "Request is missing Authentication Token";
102
103    let Some(auth) = authorization else {
104        return Some(envelope(
105            "MissingAuthenticationTokenException",
106            MISSING_TOKEN,
107        ));
108    };
109
110    if !auth.starts_with("AWS4-") {
111        return Some(envelope(
112            "MissingAuthenticationTokenException",
113            MISSING_TOKEN,
114        ));
115    }
116
117    // Parts may be separated by spaces or commas, so look for the keys rather
118    // than parsing a grammar. Signatures are never verified.
119    let has_credential = auth.contains("Credential=") || auth.contains("credential=");
120    let has_signature = auth.contains("Signature=") || auth.contains("signature=");
121    let has_signed_headers = auth.contains("SignedHeaders=") || auth.contains("signedheaders=");
122
123    let mut missing = Vec::new();
124    if !has_credential {
125        missing.push("'Credential'");
126    }
127    if !has_signature {
128        missing.push("'Signature'");
129    }
130    if !has_signed_headers {
131        missing.push("'SignedHeaders'");
132    }
133    if !has_date_header {
134        missing.push("existence of either a 'X-Amz-Date' or a 'Date' header.");
135    }
136
137    if missing.is_empty() {
138        return None;
139    }
140
141    let mut parts: Vec<String> = missing
142        .iter()
143        .map(|p| {
144            if p.contains("existence of") {
145                format!("Authorization header requires {p}")
146            } else {
147                format!("Authorization header requires {p} parameter.")
148            }
149        })
150        .collect();
151    parts.push(format!("Authorization={auth}"));
152    Some(envelope("IncompleteSignatureException", &parts.join(" ")))
153}
154
155fn envelope(error_type: &str, message: &str) -> String {
156    serde_json::json!({
157        "__type": format!("com.amazon.coral.service#{error_type}"),
158        "message": message,
159    })
160    .to_string()
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    const SIGNED: &str = "AWS4-HMAC-SHA256 Credential=fake/20260724/eu-west-2/dynamodb/aws4_request, SignedHeaders=host;x-amz-date, Signature=abc";
168
169    fn header(auth: &str) -> AuthMaterial<'_> {
170        AuthMaterial {
171            authorization: Some(auth),
172            query: "",
173            has_date_header: true,
174        }
175    }
176
177    #[test]
178    fn a_well_formed_signed_request_passes() {
179        assert!(validate(header(SIGNED)).is_none());
180    }
181
182    #[test]
183    fn a_missing_authorization_header_is_a_missing_token() {
184        let out = validate(AuthMaterial::default()).expect("should reject");
185        assert!(out.contains("MissingAuthenticationTokenException"), "{out}");
186    }
187
188    #[test]
189    fn a_non_sigv4_authorization_header_is_a_missing_token() {
190        let out = validate(header("Basic abc123")).expect("should reject");
191        assert!(out.contains("MissingAuthenticationTokenException"), "{out}");
192    }
193
194    #[test]
195    fn an_incomplete_authorization_header_names_each_missing_part() {
196        let out = validate(header("AWS4-HMAC-SHA256 Credential=fake")).expect("should reject");
197        assert!(out.contains("IncompleteSignatureException"), "{out}");
198        assert!(out.contains("'Signature'"), "{out}");
199        assert!(out.contains("'SignedHeaders'"), "{out}");
200    }
201
202    #[test]
203    fn a_signed_request_without_a_date_header_is_incomplete() {
204        let material = AuthMaterial {
205            authorization: Some(SIGNED),
206            query: "",
207            has_date_header: false,
208        };
209        let out = validate(material).expect("should reject");
210        assert!(out.contains("X-Amz-Date"), "{out}");
211    }
212
213    #[test]
214    fn complete_query_string_auth_passes() {
215        let material = AuthMaterial {
216            authorization: None,
217            query: "X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=c&X-Amz-Signature=s&X-Amz-SignedHeaders=host&X-Amz-Date=d",
218            has_date_header: false,
219        };
220        assert!(validate(material).is_none());
221    }
222
223    #[test]
224    fn incomplete_query_string_auth_names_the_missing_parameters() {
225        let material = AuthMaterial {
226            authorization: None,
227            query: "X-Amz-Algorithm=AWS4-HMAC-SHA256",
228            has_date_header: false,
229        };
230        let out = validate(material).expect("should reject");
231        assert!(out.contains("IncompleteSignatureException"), "{out}");
232        assert!(out.contains("'X-Amz-Signature'"), "{out}");
233    }
234
235    #[test]
236    fn mixing_header_and_query_auth_is_an_invalid_signature() {
237        let material = AuthMaterial {
238            authorization: Some(SIGNED),
239            query: "X-Amz-Algorithm=AWS4-HMAC-SHA256",
240            has_date_header: true,
241        };
242        let out = validate(material).expect("should reject");
243        assert!(out.contains("InvalidSignatureException"), "{out}");
244    }
245}