a2a_protocol_server/auth/
mod.rs1use std::collections::HashSet;
47use std::future::Future;
48use std::pin::Pin;
49use std::sync::Arc;
50
51use a2a_protocol_types::error::{A2aError, A2aResult, ErrorCode};
52
53use crate::call_context::CallContext;
54use crate::interceptor::ServerInterceptor;
55
56#[cfg(feature = "auth-jwt")]
57pub mod jwt;
58
59#[cfg(feature = "auth-jwt")]
60pub use jwt::{Jwks, JwtAuthInterceptor, JwtValidator};
61
62pub(crate) fn auth_rejected() -> A2aError {
67 A2aError::new(ErrorCode::InvalidRequest, "authentication required")
68}
69
70#[must_use]
77pub(crate) fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
78 if a.len() != b.len() {
79 return false;
80 }
81 let mut diff = 0u8;
82 for (x, y) in a.iter().zip(b.iter()) {
83 diff |= x ^ y;
84 }
85 diff == 0
86}
87
88fn any_constant_time_match(candidate: &[u8], allowed: &HashSet<Vec<u8>>) -> bool {
93 let mut matched = false;
94 for value in allowed {
95 matched |= constant_time_eq(candidate, value);
96 }
97 matched
98}
99
100pub struct ApiKeyAuthInterceptor {
108 header_name: String,
109 allowed: HashSet<Vec<u8>>,
110}
111
112impl ApiKeyAuthInterceptor {
113 #[must_use]
116 pub fn new<I, S>(keys: I) -> Self
117 where
118 I: IntoIterator<Item = S>,
119 S: Into<String>,
120 {
121 Self {
122 header_name: "x-api-key".to_owned(),
123 allowed: keys.into_iter().map(|k| k.into().into_bytes()).collect(),
124 }
125 }
126
127 #[must_use]
129 pub fn with_header(mut self, header_name: impl Into<String>) -> Self {
130 self.header_name = header_name.into().to_ascii_lowercase();
131 self
132 }
133}
134
135impl std::fmt::Debug for ApiKeyAuthInterceptor {
136 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 f.debug_struct("ApiKeyAuthInterceptor")
138 .field("header_name", &self.header_name)
139 .field("allowed_keys", &self.allowed.len())
140 .finish()
141 }
142}
143
144impl ServerInterceptor for ApiKeyAuthInterceptor {
145 fn before<'a>(
146 &'a self,
147 ctx: &'a CallContext,
148 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
149 Box::pin(async move {
150 let key = ctx
151 .http_headers()
152 .get(&self.header_name)
153 .ok_or_else(auth_rejected)?;
154 if any_constant_time_match(key.as_bytes(), &self.allowed) {
155 Ok(())
156 } else {
157 Err(auth_rejected())
158 }
159 })
160 }
161
162 fn after<'a>(
163 &'a self,
164 _ctx: &'a CallContext,
165 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
166 Box::pin(async move { Ok(()) })
167 }
168
169 fn authenticates(&self) -> bool {
170 true
171 }
172}
173
174pub struct BearerTokenAuthInterceptor {
182 allowed: HashSet<Vec<u8>>,
183}
184
185impl BearerTokenAuthInterceptor {
186 #[must_use]
188 pub fn new<I, S>(tokens: I) -> Self
189 where
190 I: IntoIterator<Item = S>,
191 S: Into<String>,
192 {
193 Self {
194 allowed: tokens.into_iter().map(|t| t.into().into_bytes()).collect(),
195 }
196 }
197}
198
199impl std::fmt::Debug for BearerTokenAuthInterceptor {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 f.debug_struct("BearerTokenAuthInterceptor")
202 .field("allowed_tokens", &self.allowed.len())
203 .finish()
204 }
205}
206
207pub(crate) fn extract_bearer(auth_header: &str) -> Option<&str> {
213 let rest = auth_header.strip_prefix("Bearer ").or_else(|| {
214 let (scheme, rest) = auth_header.split_once(' ')?;
216 scheme.eq_ignore_ascii_case("bearer").then_some(rest)
217 })?;
218 let token = rest.trim();
219 (!token.is_empty()).then_some(token)
220}
221
222impl ServerInterceptor for BearerTokenAuthInterceptor {
223 fn before<'a>(
224 &'a self,
225 ctx: &'a CallContext,
226 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
227 Box::pin(async move {
228 let header = ctx
229 .http_headers()
230 .get("authorization")
231 .ok_or_else(auth_rejected)?;
232 let token = extract_bearer(header).ok_or_else(auth_rejected)?;
233 if any_constant_time_match(token.as_bytes(), &self.allowed) {
234 Ok(())
235 } else {
236 Err(auth_rejected())
237 }
238 })
239 }
240
241 fn after<'a>(
242 &'a self,
243 _ctx: &'a CallContext,
244 ) -> Pin<Box<dyn Future<Output = A2aResult<()>> + Send + 'a>> {
245 Box::pin(async move { Ok(()) })
246 }
247
248 fn authenticates(&self) -> bool {
249 true
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Eq)]
260#[non_exhaustive]
261pub struct AuthenticatedPrincipal {
262 pub subject: Option<String>,
264 pub issuer: Option<String>,
266}
267
268pub type SharedPrincipal = Arc<AuthenticatedPrincipal>;
270
271#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn ctx_with(header: &str, value: &str) -> CallContext {
278 CallContext::new("message/send").with_http_header(header, value)
279 }
280
281 #[test]
284 fn constant_time_eq_matches_and_rejects() {
285 assert!(constant_time_eq(b"abc", b"abc"));
286 assert!(!constant_time_eq(b"abc", b"abd"));
287 assert!(!constant_time_eq(b"abc", b"ab"));
288 assert!(constant_time_eq(b"", b""));
289 }
290
291 #[test]
294 fn extract_bearer_variants() {
295 assert_eq!(extract_bearer("Bearer tok"), Some("tok"));
296 assert_eq!(extract_bearer("bearer tok"), Some("tok"));
297 assert_eq!(extract_bearer("BEARER tok "), Some("tok"));
298 assert_eq!(extract_bearer("Basic tok"), None);
299 assert_eq!(extract_bearer("Bearer "), None);
300 assert_eq!(extract_bearer("Bearer"), None);
301 assert_eq!(extract_bearer(""), None);
302 }
303
304 #[tokio::test]
307 async fn api_key_accepts_allowed_and_rejects_others() {
308 let i = ApiKeyAuthInterceptor::new(["key-1", "key-2"]);
309
310 assert!(i.before(&ctx_with("x-api-key", "key-1")).await.is_ok());
311 assert!(i.before(&ctx_with("x-api-key", "key-2")).await.is_ok());
312 assert!(i.before(&ctx_with("x-api-key", "nope")).await.is_err());
313 assert!(i.before(&CallContext::new("m")).await.is_err());
315 }
316
317 #[tokio::test]
318 async fn api_key_custom_header() {
319 let i = ApiKeyAuthInterceptor::new(["k"]).with_header("X-Company-Key");
320 assert!(i.before(&ctx_with("x-company-key", "k")).await.is_ok());
321 assert!(i.before(&ctx_with("x-api-key", "k")).await.is_err());
323 }
324
325 #[tokio::test]
328 async fn bearer_accepts_allowed_and_rejects_others() {
329 let i = BearerTokenAuthInterceptor::new(["tok-a", "tok-b"]);
330
331 assert!(i
332 .before(&ctx_with("authorization", "Bearer tok-a"))
333 .await
334 .is_ok());
335 assert!(i
336 .before(&ctx_with("authorization", "bearer tok-b"))
337 .await
338 .is_ok());
339 assert!(i
340 .before(&ctx_with("authorization", "Bearer wrong"))
341 .await
342 .is_err());
343 assert!(i
344 .before(&ctx_with("authorization", "Basic tok-a"))
345 .await
346 .is_err());
347 assert!(i.before(&CallContext::new("m")).await.is_err());
348 }
349
350 #[tokio::test]
351 async fn rejection_message_is_generic() {
352 let i = BearerTokenAuthInterceptor::new(["tok"]);
354 let missing = i.before(&CallContext::new("m")).await.unwrap_err();
355 let wrong = i
356 .before(&ctx_with("authorization", "Bearer nope"))
357 .await
358 .unwrap_err();
359 assert_eq!(missing.message, wrong.message);
360 assert_eq!(missing.message, "authentication required");
361 }
362
363 #[test]
364 fn debug_impls_render_type_and_redact_secrets() {
365 let api = ApiKeyAuthInterceptor::new(["super-secret-api-key"]).with_header("X-Company-Key");
369 let api_dbg = format!("{api:?}");
370 assert!(
371 api_dbg.contains("ApiKeyAuthInterceptor"),
372 "ApiKey Debug: {api_dbg}"
373 );
374 assert!(
375 api_dbg.contains("x-company-key"),
376 "header name is shown (lowercased)"
377 );
378 assert!(
379 !api_dbg.contains("super-secret-api-key"),
380 "raw API keys must never appear in Debug output"
381 );
382
383 let bearer = BearerTokenAuthInterceptor::new(["super-secret-bearer-token"]);
384 let bearer_dbg = format!("{bearer:?}");
385 assert!(
386 bearer_dbg.contains("BearerTokenAuthInterceptor"),
387 "Bearer Debug: {bearer_dbg}"
388 );
389 assert!(
390 !bearer_dbg.contains("super-secret-bearer-token"),
391 "raw bearer tokens must never appear in Debug output"
392 );
393 }
394}