Skip to main content

turul_a2a_aws_lambda/
adapter.rs

1//! Request/response conversion between Lambda and axum.
2
3use axum::body::Body;
4use http_body_util::BodyExt;
5
6/// Header prefix for authorizer context. Client-supplied headers with this
7/// prefix are stripped (anti-spoofing per ADR-008).
8pub const AUTHORIZER_HEADER_PREFIX: &str = "x-authorizer-";
9
10/// Convert a Lambda HTTP request to an axum request.
11///
12/// - Strips any client-supplied x-authorizer-* headers (anti-spoofing)
13/// - Injects authorizer context from Lambda requestContext as x-authorizer-* headers
14/// - Preserves all other headers, method, URI, body
15pub fn lambda_to_axum_request(
16    event: lambda_http::Request,
17) -> Result<http::Request<Body>, lambda_http::Error> {
18    let (mut parts, body) = event.into_parts();
19
20    // Anti-spoofing: strip ALL client-supplied x-authorizer-* headers
21    let keys_to_remove: Vec<http::header::HeaderName> = parts
22        .headers
23        .keys()
24        .filter(|k| k.as_str().starts_with(AUTHORIZER_HEADER_PREFIX))
25        .cloned()
26        .collect();
27    for key in keys_to_remove {
28        parts.headers.remove(&key);
29    }
30
31    // Extract authorizer context from Lambda extensions and inject as headers.
32    // The exact structure depends on the API Gateway type (v1, v2, ALB).
33    // We use a generic approach: look for the RequestContext extension and
34    // extract authorizer fields from it.
35    if let Some(ctx) = parts
36        .extensions
37        .get::<lambda_http::request::RequestContext>()
38    {
39        match ctx {
40            lambda_http::request::RequestContext::ApiGatewayV2(apigw) => {
41                if let Some(ref authorizer) = apigw.authorizer {
42                    // JWT authorizer: claims are in jwt.claims (HashMap<String, String>)
43                    if let Some(ref jwt) = authorizer.jwt {
44                        for (key, value) in &jwt.claims {
45                            inject_authorizer_header(&mut parts.headers, key, value);
46                        }
47                    }
48                    // Lambda authorizer: context fields
49                    // In lambda_http v1, these may be in authorizer.fields or similar
50                    // For now, JWT claims are the primary path
51                }
52            }
53            lambda_http::request::RequestContext::ApiGatewayV1(apigw) => {
54                // V1 authorizer context fields
55                for (key, value) in &apigw.authorizer.fields {
56                    if let serde_json::Value::String(s) = value {
57                        inject_authorizer_header(&mut parts.headers, key, s);
58                    }
59                }
60            }
61            _ => {} // ALB, etc. — no authorizer extraction
62        }
63    }
64
65    // Convert body
66    let body_bytes: bytes::Bytes = match body {
67        lambda_http::Body::Empty => bytes::Bytes::new(),
68        lambda_http::Body::Text(s) => bytes::Bytes::from(s),
69        lambda_http::Body::Binary(b) => bytes::Bytes::from(b),
70        _ => bytes::Bytes::new(),
71    };
72
73    let req = http::Request::from_parts(parts, Body::from(body_bytes));
74    Ok(req)
75}
76
77fn inject_authorizer_header(headers: &mut http::HeaderMap, key: &str, value: &str) {
78    let header_name = format!("{AUTHORIZER_HEADER_PREFIX}{}", key.to_lowercase());
79    if let Ok(name) = http::header::HeaderName::from_bytes(header_name.as_bytes()) {
80        if let Ok(val) = http::header::HeaderValue::from_str(value) {
81            headers.insert(name, val);
82        }
83    }
84}
85
86/// Convert an axum response to a Lambda HTTP response.
87pub async fn axum_to_lambda_response(
88    resp: http::Response<Body>,
89) -> Result<lambda_http::Response<lambda_http::Body>, lambda_http::Error> {
90    let (parts, body) = resp.into_parts();
91    let body_bytes = body
92        .collect()
93        .await
94        .map_err(|e| lambda_http::Error::from(format!("Body collect error: {e}")))?
95        .to_bytes();
96
97    let lambda_body = if body_bytes.is_empty() {
98        lambda_http::Body::Empty
99    } else {
100        lambda_http::Body::Text(String::from_utf8_lossy(&body_bytes).into_owned())
101    };
102
103    let resp = http::Response::from_parts(parts, lambda_body);
104    Ok(resp)
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn strips_client_supplied_authorizer_headers() {
113        let req: lambda_http::Request = http::Request::builder()
114            .method("POST")
115            .uri("/message:send")
116            .header("content-type", "application/json")
117            .header("a2a-version", "1.0")
118            .header("x-authorizer-userid", "forged-admin")
119            .header("x-authorizer-role", "superuser")
120            .header("x-real-header", "keep-this")
121            .body(lambda_http::Body::Text("{}".into()))
122            .unwrap();
123
124        let axum_req = lambda_to_axum_request(req).unwrap();
125
126        // Forged headers must be stripped
127        assert!(
128            axum_req.headers().get("x-authorizer-userid").is_none(),
129            "Forged x-authorizer-userid must be stripped"
130        );
131        assert!(
132            axum_req.headers().get("x-authorizer-role").is_none(),
133            "Forged x-authorizer-role must be stripped"
134        );
135
136        // Legitimate headers preserved
137        assert_eq!(
138            axum_req.headers().get("x-real-header").unwrap(),
139            "keep-this"
140        );
141        assert_eq!(
142            axum_req.headers().get("content-type").unwrap(),
143            "application/json"
144        );
145    }
146
147    #[test]
148    fn inject_authorizer_header_works() {
149        let mut headers = http::HeaderMap::new();
150        inject_authorizer_header(&mut headers, "userId", "user-123");
151        assert_eq!(headers.get("x-authorizer-userid").unwrap(), "user-123");
152    }
153}