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