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, AuthFailureKind, 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                AuthFailureKind::MissingCredential,
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(
77                    field.to_string(),
78                    serde_json::Value::String(val.to_string()),
79                );
80            }
81        }
82    }
83    serde_json::Value::Object(claims)
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[tokio::test]
91    async fn authorizer_middleware_extracts_owner() {
92        let mw = LambdaAuthorizerMiddleware::new(AuthorizerMapping::default());
93        let mut ctx = RequestContext::new();
94        ctx.headers.insert(
95            http::header::HeaderName::from_static("x-authorizer-sub"),
96            "user-123".parse().unwrap(),
97        );
98
99        mw.before_request(&mut ctx).await.unwrap();
100        assert!(ctx.identity.is_authenticated());
101        assert_eq!(ctx.identity.owner(), "user-123");
102    }
103
104    #[tokio::test]
105    async fn authorizer_middleware_custom_owner_field() {
106        let mw = LambdaAuthorizerMiddleware::new(AuthorizerMapping {
107            owner_field: "userId".into(),
108            include_claims: false,
109        });
110        let mut ctx = RequestContext::new();
111        ctx.headers.insert(
112            http::header::HeaderName::from_static("x-authorizer-userid"),
113            "custom-user".parse().unwrap(),
114        );
115
116        mw.before_request(&mut ctx).await.unwrap();
117        assert_eq!(ctx.identity.owner(), "custom-user");
118    }
119
120    #[tokio::test]
121    async fn authorizer_middleware_rejects_missing_owner() {
122        let mw = LambdaAuthorizerMiddleware::new(AuthorizerMapping::default());
123        let ctx = &mut RequestContext::new();
124        let err = mw.before_request(ctx).await.unwrap_err();
125        assert!(matches!(err, MiddlewareError::Unauthenticated(_)));
126    }
127
128    #[tokio::test]
129    async fn authorizer_middleware_rejects_empty_owner() {
130        let mw = LambdaAuthorizerMiddleware::new(AuthorizerMapping::default());
131        let mut ctx = RequestContext::new();
132        ctx.headers.insert(
133            http::header::HeaderName::from_static("x-authorizer-sub"),
134            "".parse().unwrap(),
135        );
136        let err = mw.before_request(&mut ctx).await.unwrap_err();
137        assert!(matches!(err, MiddlewareError::Unauthenticated(_)));
138    }
139
140    #[tokio::test]
141    async fn authorizer_middleware_collects_claims() {
142        let mw = LambdaAuthorizerMiddleware::new(AuthorizerMapping {
143            owner_field: "sub".into(),
144            include_claims: true,
145        });
146        let mut ctx = RequestContext::new();
147        ctx.headers.insert(
148            http::header::HeaderName::from_static("x-authorizer-sub"),
149            "user-1".parse().unwrap(),
150        );
151        ctx.headers.insert(
152            http::header::HeaderName::from_static("x-authorizer-role"),
153            "admin".parse().unwrap(),
154        );
155
156        mw.before_request(&mut ctx).await.unwrap();
157        let claims = ctx.identity.claims().unwrap();
158        assert_eq!(claims["sub"], "user-1");
159        assert_eq!(claims["role"], "admin");
160    }
161}