Skip to main content

canton_core/
auth.rs

1//! Request authentication for gRPC channels.
2//!
3//! A tonic interceptor that injects a bearer token into request metadata, plus
4//! [`intercepted`] which wraps a [`Channel`] with a freshly-resolved token for
5//! a single call. The token itself comes from [`crate::Auth`] (static or a
6//! dynamic [`crate::TokenSource`]).
7
8use tonic::metadata::MetadataValue;
9use tonic::service::Interceptor;
10use tonic::service::interceptor::InterceptedService;
11use tonic::transport::Channel;
12use tonic::{Request, Status};
13
14use crate::Result;
15use crate::config::Auth;
16
17/// A gRPC channel wrapped with a bearer-token interceptor.
18pub type Intercepted = InterceptedService<Channel, AuthInterceptor>;
19
20/// Injects an `authorization: Bearer <token>` header when a token is present.
21#[derive(Clone, Debug, Default)]
22pub struct AuthInterceptor {
23    token: Option<String>,
24}
25
26impl AuthInterceptor {
27    /// Create an interceptor that injects `token` if `Some`, or is a no-op if `None`.
28    #[must_use]
29    pub fn new(token: Option<String>) -> Self {
30        Self { token }
31    }
32}
33
34impl Interceptor for AuthInterceptor {
35    fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
36        if let Some(token) = &self.token {
37            let value: MetadataValue<_> = format!("Bearer {token}")
38                .parse()
39                .map_err(|_| Status::unauthenticated("invalid bearer token"))?;
40            request.metadata_mut().insert("authorization", value);
41        }
42        // Propagate W3C trace context (no-op without an active OTel context).
43        #[cfg(feature = "otel")]
44        crate::telemetry::otel::inject_trace_context_metadata(request.metadata_mut());
45        Ok(request)
46    }
47}
48
49/// Wrap `channel` with a fresh bearer token resolved from `auth`, ready to back
50/// a generated gRPC client for one call.
51///
52/// # Errors
53/// Propagates token-resolution errors from the [`Auth`] source.
54pub async fn intercepted(channel: &Channel, auth: &Auth) -> Result<Intercepted> {
55    let token = auth.bearer().await?;
56    Ok(InterceptedService::new(
57        channel.clone(),
58        AuthInterceptor::new(token),
59    ))
60}
61
62#[cfg(test)]
63#[allow(clippy::unwrap_used)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn injects_bearer_header_when_token_present() {
69        let mut interceptor = AuthInterceptor::new(Some("tok-123".to_string()));
70        let req = interceptor.call(Request::new(())).unwrap();
71        let value = req.metadata().get("authorization").unwrap();
72        assert_eq!(value.to_str().unwrap(), "Bearer tok-123");
73    }
74
75    #[test]
76    fn no_header_when_token_absent() {
77        let mut interceptor = AuthInterceptor::new(None);
78        let req = interceptor.call(Request::new(())).unwrap();
79        assert!(req.metadata().get("authorization").is_none());
80    }
81
82    #[test]
83    fn rejects_a_token_with_illegal_header_bytes() {
84        let mut interceptor = AuthInterceptor::new(Some("bad\ntoken".to_string()));
85        let result = interceptor.call(Request::new(()));
86        assert!(result.is_err(), "a token with a newline must be rejected");
87    }
88}