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}