Skip to main content

turul_a2a_aws_lambda/
auth.rs

1//! Lambda authorizer middleware — reads trusted x-authorizer-* headers.
2
3use async_trait::async_trait;
4use turul_a2a::middleware::{A2aMiddleware, AuthIdentity, MiddlewareError, RequestContext};
5
6use crate::adapter::AUTHORIZER_HEADER_PREFIX;
7
8/// Mapping configuration for Lambda authorizer context.
9#[derive(Debug, Clone)]
10pub struct AuthorizerMapping {
11    /// Authorizer field that maps to owner (default: "sub")
12    pub owner_field: String,
13    /// Whether to collect all authorizer fields as claims
14    pub include_claims: bool,
15}
16
17impl Default for AuthorizerMapping {
18    fn default() -> Self {
19        Self {
20            owner_field: "sub".into(),
21            include_claims: true,
22        }
23    }
24}
25
26/// Middleware that reads trusted authorizer context from x-authorizer-* headers.
27///
28/// These headers are injected by the Lambda adapter from `requestContext.authorizer`
29/// after stripping any client-supplied headers with the same prefix (anti-spoofing).
30pub struct LambdaAuthorizerMiddleware {
31    mapping: AuthorizerMapping,
32}
33
34impl LambdaAuthorizerMiddleware {
35    pub fn new(mapping: AuthorizerMapping) -> Self {
36        Self { mapping }
37    }
38}
39
40#[async_trait]
41impl A2aMiddleware for LambdaAuthorizerMiddleware {
42    async fn before_request(&self, ctx: &mut RequestContext) -> Result<(), MiddlewareError> {
43        let owner_header = format!("{AUTHORIZER_HEADER_PREFIX}{}", self.mapping.owner_field);
44        let owner = ctx
45            .headers
46            .get(owner_header.as_str())
47            .and_then(|v| v.to_str().ok())
48            .map(|s| s.to_string());
49
50        match owner {
51            Some(owner) if !owner.trim().is_empty() => {
52                let claims = if self.mapping.include_claims {
53                    Some(collect_authorizer_claims(&ctx.headers))
54                } else {
55                    None
56                };
57                ctx.identity = AuthIdentity::Authenticated { owner, claims };
58                Ok(())
59            }
60            _ => Err(MiddlewareError::Unauthenticated(
61                "Missing authorizer context".into(),
62            )),
63        }
64    }
65}
66
67/// Collect all x-authorizer-* headers into a JSON object.
68fn collect_authorizer_claims(headers: &http::HeaderMap) -> serde_json::Value {
69    let mut claims = serde_json::Map::new();
70    for (key, value) in headers.iter() {
71        let key_str = key.as_str();
72        if let Some(field) = key_str.strip_prefix(AUTHORIZER_HEADER_PREFIX) {
73            if let Ok(val) = value.to_str() {
74                claims.insert(
75                    field.to_string(),
76                    serde_json::Value::String(val.to_string()),
77                );
78            }
79        }
80    }
81    serde_json::Value::Object(claims)
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[tokio::test]
89    async fn authorizer_middleware_extracts_owner() {
90        let mw = LambdaAuthorizerMiddleware::new(AuthorizerMapping::default());
91        let mut ctx = RequestContext::new();
92        ctx.headers.insert(
93            http::header::HeaderName::from_static("x-authorizer-sub"),
94            "user-123".parse().unwrap(),
95        );
96
97        mw.before_request(&mut ctx).await.unwrap();
98        assert!(ctx.identity.is_authenticated());
99        assert_eq!(ctx.identity.owner(), "user-123");
100    }
101
102    #[tokio::test]
103    async fn authorizer_middleware_custom_owner_field() {
104        let mw = LambdaAuthorizerMiddleware::new(AuthorizerMapping {
105            owner_field: "userId".into(),
106            include_claims: false,
107        });
108        let mut ctx = RequestContext::new();
109        ctx.headers.insert(
110            http::header::HeaderName::from_static("x-authorizer-userid"),
111            "custom-user".parse().unwrap(),
112        );
113
114        mw.before_request(&mut ctx).await.unwrap();
115        assert_eq!(ctx.identity.owner(), "custom-user");
116    }
117
118    #[tokio::test]
119    async fn authorizer_middleware_rejects_missing_owner() {
120        let mw = LambdaAuthorizerMiddleware::new(AuthorizerMapping::default());
121        let ctx = &mut RequestContext::new();
122        let err = mw.before_request(ctx).await.unwrap_err();
123        assert!(matches!(err, MiddlewareError::Unauthenticated(_)));
124    }
125
126    #[tokio::test]
127    async fn authorizer_middleware_rejects_empty_owner() {
128        let mw = LambdaAuthorizerMiddleware::new(AuthorizerMapping::default());
129        let mut ctx = RequestContext::new();
130        ctx.headers.insert(
131            http::header::HeaderName::from_static("x-authorizer-sub"),
132            "".parse().unwrap(),
133        );
134        let err = mw.before_request(&mut ctx).await.unwrap_err();
135        assert!(matches!(err, MiddlewareError::Unauthenticated(_)));
136    }
137
138    #[tokio::test]
139    async fn authorizer_middleware_collects_claims() {
140        let mw = LambdaAuthorizerMiddleware::new(AuthorizerMapping {
141            owner_field: "sub".into(),
142            include_claims: true,
143        });
144        let mut ctx = RequestContext::new();
145        ctx.headers.insert(
146            http::header::HeaderName::from_static("x-authorizer-sub"),
147            "user-1".parse().unwrap(),
148        );
149        ctx.headers.insert(
150            http::header::HeaderName::from_static("x-authorizer-role"),
151            "admin".parse().unwrap(),
152        );
153
154        mw.before_request(&mut ctx).await.unwrap();
155        let claims = ctx.identity.claims().unwrap();
156        assert_eq!(claims["sub"], "user-1");
157        assert_eq!(claims["role"], "admin");
158    }
159}