Skip to main content

toolkit_security/
authenticator.rs

1//! Transport-agnostic bearer-token authentication abstraction.
2//!
3//! [`BearerAuthenticator`] decouples the HTTP/gRPC transport layers from the
4//! concrete `AuthN` Resolver client. The transport only needs to hand a raw
5//! bearer token to an implementation and receive a reconstructed
6//! [`SecurityContext`] back. The concrete `AuthNResolverClient` adapter is
7//! injected at the gear/bootstrap layer so neither `toolkit-http` nor
8//! `toolkit-transport-grpc` need to depend on the full `ToolKit` framework.
9//!
10//! This lives in `toolkit-security` (not `toolkit-http`) so it stays
11//! transport-agnostic and reusable by the gRPC path — it returns
12//! [`SecurityContext`], which `toolkit-security` already owns, and
13//! `toolkit-security` has no dependency on any transport crate.
14
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18
19use crate::context::SecurityContext;
20use crate::internal_auth::{InternalAuthNError, InternalAuthenticator, PlatformIdentity};
21
22/// Neutral authentication error returned by a [`BearerAuthenticator`].
23///
24/// Intentionally coarse-grained and transport-agnostic: it never carries the
25/// token or any provider-specific detail so it is safe to surface at a trust
26/// boundary. Concrete adapters map their own error types into these variants.
27#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum AuthNError {
30    /// The token was syntactically present but failed validation
31    /// (invalid signature, expired, malformed claims, etc.).
32    #[error("invalid or expired token")]
33    InvalidToken,
34    /// The authentication backend could not be reached or returned a
35    /// transient failure. Callers may choose to retry or surface a 503.
36    #[error("authentication backend unavailable")]
37    Unavailable,
38    /// Any other authentication failure. The message must not contain the
39    /// token or other sensitive material.
40    #[error("authentication failed: {0}")]
41    Other(String),
42}
43
44/// Re-validates a raw bearer token and reconstructs a [`SecurityContext`].
45///
46/// Implementations perform a full validation on every call — there is no
47/// trusted-peer fast path (zero-trust; see `cpt-cf-adr-two-plane-auth`). The transport layer
48/// stays generic over this trait; the concrete `AuthNResolverClient` adapter
49/// is supplied at the gear/bootstrap layer.
50///
51/// The returned future is `Send` so the trait can be used from Axum/Tower
52/// middleware running on a multi-threaded runtime.
53pub trait BearerAuthenticator: Send + Sync {
54    /// Validate `token` and reconstruct the corresponding [`SecurityContext`].
55    ///
56    /// cancel-safe: this future is dropped mid-flight whenever a client
57    /// disconnects, because it runs from Axum/Tower middleware on a task the
58    /// server aborts. An implementation must therefore hold no state across the
59    /// await that would be corrupted by never resuming — an abandoned call must
60    /// leave the authenticator exactly as it found it. Losing in-flight work
61    /// (a cache insert, a single-flight slot) is fine; a half-applied mutation
62    /// is not.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`AuthNError`] if the token is invalid, the backend is
67    /// unavailable, or authentication otherwise fails.
68    fn authenticate(
69        &self,
70        token: &str,
71    ) -> impl Future<Output = Result<SecurityContext, AuthNError>> + Send;
72}
73
74type BearerFuture<'a> =
75    Pin<Box<dyn Future<Output = Result<SecurityContext, AuthNError>> + Send + 'a>>;
76
77/// Object-safe erasure of [`BearerAuthenticator`].
78///
79/// [`BearerAuthenticator::authenticate`] returns `impl Future`, so the trait is
80/// not `dyn`-compatible. This trait boxes the future so a concrete authenticator
81/// can be stored behind an `Arc` and shared/injected as a trait object.
82trait ErasedBearer: Send + Sync {
83    fn authenticate<'a>(&'a self, token: &'a str) -> BearerFuture<'a>;
84}
85
86impl<A: BearerAuthenticator> ErasedBearer for A {
87    fn authenticate<'a>(&'a self, token: &'a str) -> BearerFuture<'a> {
88        Box::pin(BearerAuthenticator::authenticate(self, token))
89    }
90}
91
92/// Injectable, object-safe tenant-plane authenticator.
93///
94/// Wraps a concrete [`BearerAuthenticator`] (e.g. an `AuthNResolverClient`
95/// adapter) so it can be stored behind an `Arc` and registered in a
96/// `ClientHub` / handed to the `OoP` HTTP runtime. Lives in `toolkit-security`
97/// (a leaf crate, always available) so any gear can register the bridge without
98/// depending on the bootstrap-gated `toolkit` runtime.
99#[derive(Clone)]
100pub struct DynBearerAuthenticator(Arc<dyn ErasedBearer>);
101
102impl DynBearerAuthenticator {
103    /// Wrap a concrete [`BearerAuthenticator`] in the object-safe adapter.
104    #[must_use]
105    pub fn new<A: BearerAuthenticator + 'static>(authenticator: A) -> Self {
106        Self(Arc::new(authenticator))
107    }
108
109    /// Wrap an already-`Arc`'d [`BearerAuthenticator`] in the object-safe adapter.
110    #[must_use]
111    pub fn from_arc<A: BearerAuthenticator + 'static>(authenticator: Arc<A>) -> Self {
112        // The blanket `impl<A: BearerAuthenticator> ErasedBearer for A` means
113        // `Arc<A>` unsizes to `Arc<dyn ErasedBearer>` directly. An adapter
114        // struct here would allocate a second `Arc` purely to hold the first,
115        // and cost an extra pointer hop on every authenticate call.
116        Self(authenticator)
117    }
118}
119
120impl std::fmt::Debug for DynBearerAuthenticator {
121    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122        f.debug_struct("DynBearerAuthenticator")
123            .finish_non_exhaustive()
124    }
125}
126
127impl BearerAuthenticator for DynBearerAuthenticator {
128    async fn authenticate(&self, token: &str) -> Result<SecurityContext, AuthNError> {
129        self.0.authenticate(token).await
130    }
131}
132
133type InternalFuture<'a> =
134    Pin<Box<dyn Future<Output = Result<PlatformIdentity, InternalAuthNError>> + Send + 'a>>;
135
136/// Object-safe erasure of [`InternalAuthenticator`] (same rationale as
137/// [`ErasedBearer`]).
138trait ErasedInternal: Send + Sync {
139    fn authenticate<'a>(&'a self, token: &'a str) -> InternalFuture<'a>;
140}
141
142impl<A: InternalAuthenticator> ErasedInternal for A {
143    fn authenticate<'a>(&'a self, token: &'a str) -> InternalFuture<'a> {
144        Box::pin(InternalAuthenticator::authenticate(self, token))
145    }
146}
147
148/// Injectable, object-safe platform-plane authenticator.
149///
150/// Wraps a concrete [`InternalAuthenticator`] (e.g. the K8s `TokenReview`
151/// validator) so it can be stored behind an `Arc` and registered in a
152/// `ClientHub` / handed to the `OoP` HTTP runtime. Lives in `toolkit-security`
153/// (a leaf crate, always available) so any gear can register the bridge without
154/// depending on the bootstrap-gated `toolkit` runtime — the platform-plane
155/// mirror of [`DynBearerAuthenticator`].
156#[derive(Clone)]
157pub struct DynInternalAuthenticator(Arc<dyn ErasedInternal>);
158
159impl DynInternalAuthenticator {
160    /// Wrap a concrete [`InternalAuthenticator`] in the object-safe adapter.
161    #[must_use]
162    pub fn new<A: InternalAuthenticator + 'static>(authenticator: A) -> Self {
163        Self(Arc::new(authenticator))
164    }
165
166    /// Wrap an already-`Arc`'d [`InternalAuthenticator`] in the object-safe adapter.
167    #[must_use]
168    pub fn from_arc<A: InternalAuthenticator + 'static>(authenticator: Arc<A>) -> Self {
169        // As in `DynBearerAuthenticator::from_arc`: the blanket impl lets
170        // `Arc<A>` unsize directly, so no adapter allocation is needed.
171        Self(authenticator)
172    }
173}
174
175impl std::fmt::Debug for DynInternalAuthenticator {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        f.debug_struct("DynInternalAuthenticator")
178            .finish_non_exhaustive()
179    }
180}
181
182impl InternalAuthenticator for DynInternalAuthenticator {
183    async fn authenticate(&self, token: &str) -> Result<PlatformIdentity, InternalAuthNError> {
184        self.0.authenticate(token).await
185    }
186}
187
188#[cfg(test)]
189#[cfg_attr(coverage_nightly, coverage(off))]
190mod tests {
191    use super::*;
192    use uuid::Uuid;
193
194    // A tenant-plane authenticator that echoes the token into the subject_type
195    // on success, or fails for the token "bad".
196    struct FakeBearer;
197
198    impl BearerAuthenticator for FakeBearer {
199        async fn authenticate(&self, token: &str) -> Result<SecurityContext, AuthNError> {
200            if token == "bad" {
201                return Err(AuthNError::InvalidToken);
202            }
203            SecurityContext::builder()
204                .subject_id(Uuid::from_u128(1))
205                .subject_tenant_id(Uuid::from_u128(2))
206                .subject_type(token)
207                .build()
208                .map_err(|e| AuthNError::Other(e.to_string()))
209        }
210    }
211
212    struct FakeInternal;
213
214    impl InternalAuthenticator for FakeInternal {
215        async fn authenticate(&self, token: &str) -> Result<PlatformIdentity, InternalAuthNError> {
216            if token == "bad" {
217                return Err(InternalAuthNError::InvalidToken);
218            }
219            Ok(PlatformIdentity::Shared {
220                name: token.to_owned(),
221            })
222        }
223    }
224
225    #[tokio::test]
226    async fn dyn_bearer_new_delegates_to_inner() {
227        let auth = DynBearerAuthenticator::new(FakeBearer);
228        let ctx = BearerAuthenticator::authenticate(&auth, "alice")
229            .await
230            .expect("authenticates");
231        assert_eq!(ctx.subject_type(), Some("alice"));
232
233        let err = BearerAuthenticator::authenticate(&auth, "bad")
234            .await
235            .expect_err("rejects");
236        assert!(matches!(err, AuthNError::InvalidToken));
237    }
238
239    #[tokio::test]
240    async fn dyn_bearer_from_arc_and_clone_delegate_to_inner() {
241        let auth = DynBearerAuthenticator::from_arc(Arc::new(FakeBearer));
242        let cloned = auth.clone();
243        let ctx = BearerAuthenticator::authenticate(&cloned, "bob")
244            .await
245            .expect("authenticates");
246        assert_eq!(ctx.subject_type(), Some("bob"));
247    }
248
249    #[test]
250    fn dyn_bearer_debug_is_non_exhaustive() {
251        let auth = DynBearerAuthenticator::new(FakeBearer);
252        // The whole rendering, not just the struct name: the name alone can
253        // only fail on a rename, while what this is guarding is that the
254        // wrapped authenticator -- which may hold a secret -- stays out of the
255        // output, and that the `..` marker says so.
256        assert_eq!(format!("{auth:?}"), "DynBearerAuthenticator { .. }");
257    }
258
259    #[tokio::test]
260    async fn dyn_internal_new_delegates_to_inner() {
261        let auth = DynInternalAuthenticator::new(FakeInternal);
262        let identity = InternalAuthenticator::authenticate(&auth, "gear-a")
263            .await
264            .expect("authenticates");
265        assert_eq!(identity.peer_name(), "gear-a");
266
267        let err = InternalAuthenticator::authenticate(&auth, "bad")
268            .await
269            .expect_err("rejects");
270        assert!(matches!(err, InternalAuthNError::InvalidToken));
271    }
272
273    #[tokio::test]
274    async fn dyn_internal_from_arc_and_clone_delegate_to_inner() {
275        let auth = DynInternalAuthenticator::from_arc(Arc::new(FakeInternal));
276        let cloned = auth.clone();
277        let identity = InternalAuthenticator::authenticate(&cloned, "gear-b")
278            .await
279            .expect("authenticates");
280        assert_eq!(identity.peer_name(), "gear-b");
281    }
282
283    #[test]
284    fn dyn_internal_debug_is_non_exhaustive() {
285        let auth = DynInternalAuthenticator::new(FakeInternal);
286        // As above: the platform-plane authenticator this wraps holds the
287        // shared secret, so the assertion is that nothing of it is rendered.
288        assert_eq!(format!("{auth:?}"), "DynInternalAuthenticator { .. }");
289    }
290}