Skip to main content

a2a_agents/control_plane/
auth.rs

1//! Bearer-token authentication for the control-plane HTTP API.
2//!
3//! The control plane is a privileged surface: `POST /agents` starts a process or
4//! container of the caller's choosing and hands it the allowlisted secrets the
5//! deploying process holds. Unauthenticated, that is remote code execution with
6//! the operator's credentials, and binding to loopback is a deployment accident
7//! away from being the only thing preventing it. So authentication is
8//! **required** and disabling it is an explicit, loud choice.
9//!
10//! Kept in `a2a-agents` rather than reusing `a2a_rs::adapter::auth`: that
11//! middleware is written against axum 0.8 while the control plane (like the rest
12//! of this crate's HTTP surface) is on 0.7.
13
14use axum::body::Body;
15use axum::extract::Request;
16use axum::http::{StatusCode, header};
17use axum::middleware::Next;
18use axum::response::{IntoResponse, Response};
19use axum::{Json, middleware};
20
21/// How the control-plane API authenticates callers.
22#[derive(Debug, Clone)]
23pub enum ControlPlaneAuth {
24    /// Require `Authorization: Bearer <token>` on every request.
25    BearerToken(String),
26    /// Accept every request. Only for a trusted, isolated dev loop — the caller
27    /// has to name this variant, so it can never be reached by forgetting to
28    /// configure a token.
29    Disabled,
30}
31
32impl ControlPlaneAuth {
33    /// Require this bearer token.
34    pub fn bearer(token: impl Into<String>) -> Self {
35        Self::BearerToken(token.into())
36    }
37
38    /// Wrap `router` in the authentication layer this variant describes.
39    pub(crate) fn apply(self, router: axum::Router) -> axum::Router {
40        match self {
41            Self::Disabled => router,
42            Self::BearerToken(token) => router.layer(middleware::from_fn(
43                move |req: Request<Body>, next: Next| {
44                    let token = token.clone();
45                    async move {
46                        match bearer_of(&req) {
47                            Some(presented) if constant_time_eq(presented, &token) => {
48                                next.run(req).await
49                            }
50                            _ => unauthorized(),
51                        }
52                    }
53                },
54            )),
55        }
56    }
57}
58
59/// The token from an `Authorization: Bearer <token>` header, if well-formed.
60fn bearer_of(req: &Request<Body>) -> Option<&str> {
61    req.headers()
62        .get(header::AUTHORIZATION)?
63        .to_str()
64        .ok()?
65        .strip_prefix("Bearer ")
66        .map(str::trim)
67}
68
69/// Compare two secrets without leaking *where* they differ through timing.
70///
71/// The length comparison is deliberately not constant-time: token length is not
72/// the secret, and the alternative (padding) obscures the code for no gain.
73fn constant_time_eq(a: &str, b: &str) -> bool {
74    let (a, b) = (a.as_bytes(), b.as_bytes());
75    if a.len() != b.len() {
76        return false;
77    }
78    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
79}
80
81/// A 401 that does not distinguish "no token" from "wrong token".
82fn unauthorized() -> Response {
83    (
84        StatusCode::UNAUTHORIZED,
85        Json(serde_json::json!({ "error": "missing or invalid bearer token" })),
86    )
87        .into_response()
88}
89
90#[cfg(test)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn constant_time_eq_matches_string_equality() {
96        assert!(constant_time_eq("s3cret", "s3cret"));
97        assert!(!constant_time_eq("s3cret", "s3crey"));
98        assert!(!constant_time_eq("s3cret", "s3cre"));
99        assert!(!constant_time_eq("", "x"));
100        assert!(constant_time_eq("", ""));
101    }
102
103    /// Build a request carrying `header_value` as `Authorization`.
104    fn req_with_auth(header_value: &str) -> Request<Body> {
105        Request::builder()
106            .header(header::AUTHORIZATION, header_value)
107            .body(Body::empty())
108            .unwrap()
109    }
110
111    #[test]
112    fn bearer_is_parsed_only_from_a_well_formed_header() {
113        assert_eq!(bearer_of(&req_with_auth("Bearer tok")), Some("tok"));
114        // Scheme is case-sensitive per the header's own grammar in our usage,
115        // and a bare token or another scheme is not a bearer token.
116        assert_eq!(bearer_of(&req_with_auth("bearer tok")), None);
117        assert_eq!(bearer_of(&req_with_auth("Basic dXNlcjpwdw==")), None);
118        assert_eq!(bearer_of(&req_with_auth("tok")), None);
119        assert_eq!(
120            bearer_of(&Request::builder().body(Body::empty()).unwrap()),
121            None
122        );
123    }
124}