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, Default)]
22pub struct AuthInterceptor {
23    token: Option<String>,
24}
25
26/// Hand-written for the reason [`crate::Auth`]'s is: the derived `Debug`
27/// printed the bearer token in full. An interceptor travels inside every
28/// client that holds a channel, so a single `{:?}` on client state — in a log
29/// line, a panic message, or an error context — put a live credential
30/// wherever that text went. Whether a token is present is the only thing a
31/// reader debugging authentication needs from here.
32impl std::fmt::Debug for AuthInterceptor {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.debug_struct("AuthInterceptor")
35            .field(
36                "token",
37                match &self.token {
38                    Some(_) => &"Some(<redacted>)",
39                    None => &"None",
40                },
41            )
42            .finish()
43    }
44}
45
46impl AuthInterceptor {
47    /// Create an interceptor that injects `token` if `Some`, or is a no-op if `None`.
48    #[must_use]
49    pub fn new(token: Option<String>) -> Self {
50        Self { token }
51    }
52}
53
54impl Interceptor for AuthInterceptor {
55    fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
56        if let Some(token) = &self.token {
57            let value: MetadataValue<_> = format!("Bearer {token}")
58                .parse()
59                .map_err(|_| Status::unauthenticated("invalid bearer token"))?;
60            request.metadata_mut().insert("authorization", value);
61        }
62        // Propagate W3C trace context (no-op without an active OTel context).
63        #[cfg(feature = "otel")]
64        crate::telemetry::otel::inject_trace_context_metadata(request.metadata_mut());
65        Ok(request)
66    }
67}
68
69/// Wrap `channel` with a fresh bearer token resolved from `auth`, ready to back
70/// a generated gRPC client for one call.
71///
72/// # Errors
73/// Propagates token-resolution errors from the [`Auth`] source.
74pub async fn intercepted(channel: &Channel, auth: &Auth) -> Result<Intercepted> {
75    let token = auth.bearer().await?;
76    Ok(InterceptedService::new(
77        channel.clone(),
78        AuthInterceptor::new(token),
79    ))
80}
81
82#[cfg(test)]
83#[allow(clippy::unwrap_used)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn injects_bearer_header_when_token_present() {
89        let mut interceptor = AuthInterceptor::new(Some("tok-123".to_string()));
90        let req = interceptor.call(Request::new(())).unwrap();
91        let value = req.metadata().get("authorization").unwrap();
92        assert_eq!(value.to_str().unwrap(), "Bearer tok-123");
93    }
94
95    #[test]
96    fn no_header_when_token_absent() {
97        let mut interceptor = AuthInterceptor::new(None);
98        let req = interceptor.call(Request::new(())).unwrap();
99        assert!(req.metadata().get("authorization").is_none());
100    }
101
102    #[test]
103    fn rejects_a_token_with_illegal_header_bytes() {
104        let mut interceptor = AuthInterceptor::new(Some("bad\ntoken".to_string()));
105        let result = interceptor.call(Request::new(()));
106        assert!(result.is_err(), "a token with a newline must be rejected");
107    }
108
109    // Applications routinely put client state in a log line or an error
110    // context, so the token must not be in the text that produces.
111    #[test]
112    fn debug_redacts_the_bearer_token() {
113        let token = "secret-bearer-token";
114        let rendered = format!("{:?}", AuthInterceptor::new(Some(token.to_string())));
115        assert!(!rendered.contains(token), "{rendered}");
116        assert!(rendered.contains("redacted"), "{rendered}");
117
118        // Absence still reads as absence: "no token" is what a reader
119        // debugging an unauthenticated call is looking for.
120        let rendered = format!("{:?}", AuthInterceptor::new(None));
121        assert!(rendered.contains("None"), "{rendered}");
122    }
123}