Skip to main content

protocol_droid/adapters/primary/transport/http/
auth.rs

1//! Turning a request's headers into the `AuthContext` every stateful path
2//! takes.
3//!
4//! # Why this is a port and not a function
5//!
6//! Because the port belongs to the consumer, and the consumer is the router.
7//! The seam is fixed so that an implementation is a constructor argument
8//! rather than a change to the router's signature — and so that the *default*
9//! is a decision someone made rather than an absence.
10//!
11//! Two implementations ship: [`Anonymous`], which is the default, and
12//! [`OAuthAuthenticator`] behind the `oauth` feature, which validates a token
13//! presented to this server against an external authorization server. This
14//! module is the port and the anonymous default; the OAuth half lives in the
15//! [`oauth`] submodule.
16//!
17//! # Why the default is anonymous and that is still fail-secure
18//!
19//! [`Anonymous`] returns [`AuthContext::anonymous`], which holds no scopes.
20//! That looks like "no authentication by default", and the honest framing is
21//! that authentication is opt-in — a consumer wanting it passes
22//! [`OAuthAuthenticator`] or its own [`Authenticator`] to
23//! [`HttpBuilder::with_authenticator`](super::HttpBuilder::with_authenticator)
24//! and a policy to `with_policy`. What makes the default fail-secure rather
25//! than fail-open is structural:
26//!
27//! - Every stateful route resolves an `AuthContext` before it looks at the
28//!   session, the body, or the stream. There is no path that skips it, because
29//!   the route handler's only way to get a session is through a `&AuthContext`
30//!   it was handed.
31//! - The resolved context is checked against
32//!   [`ServerConfig::required_scopes`](super::ServerConfig::required_scopes)
33//!   before anything else happens. A configuration that names a scope refuses
34//!   every anonymous caller, and the refusal is `401`.
35//! - [`AuthContext::anonymous`] cannot be widened after the fact. The only
36//!   constructors that carry scopes are `new` and `unrestricted`, and neither
37//!   is reachable from this module.
38//!
39//! So the risk of this module is not that it fails open — it is that a
40//! deployment forgets to configure a scope. That is why [`AuthPolicy`] exists:
41//! it is the one place a deployment states, out loud, which of "this endpoint
42//! is public" and "this endpoint needs a credential" is true.
43
44use std::future::Future;
45use std::pin::Pin;
46
47use axum::http::HeaderMap;
48
49use crate::domain::registry::{AuthContext, ScopeSet};
50
51/// What a request presented, for an authenticator to judge.
52///
53/// Borrowed rather than owned because the only field is a header value that is
54/// already in the request, and copying an `Authorization` header into a
55/// long-lived struct is exactly the kind of retention the redaction rule is
56/// about.
57///
58/// `Debug` is written by hand below rather than derived. The derive would print
59/// the raw `Authorization` header — that is, the bearer token — into any
60/// `{:?}` this type meets: a panic message, a failed assertion, a log line
61/// somebody adds while debugging. The field's own doc comment used to say this
62/// crate never logs it, which was true and was not a property of anything.
63#[derive(Clone, Copy)]
64pub struct Credentials<'a> {
65    /// The `Authorization` header, if the client sent one.
66    authorization: Option<&'a str>,
67}
68
69impl core::fmt::Debug for Credentials<'_> {
70    /// Prints a fingerprint, never the header.
71    ///
72    /// The same shape as
73    /// [`RequestId::redacted`](crate::domain::RequestId::redacted): a short
74    /// prefix and a length, so two log lines can be correlated without either
75    /// carrying the credential. Four characters of a 256-bit token is not a
76    /// meaningful reduction in the search space, and correlation is worth it.
77    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
78        formatter
79            .debug_struct("Credentials")
80            .field("authorization", &self.authorization.map(Fingerprint))
81            .finish()
82    }
83}
84
85/// A presented header value, rendered as a fingerprint.
86///
87/// Fingerprints the *token*, not the whole header: every bearer credential
88/// starts `Bearer `, so a prefix of the header is the same four characters on
89/// every request in the world and correlates nothing at all.
90struct Fingerprint<'a>(&'a str);
91
92impl core::fmt::Debug for Fingerprint<'_> {
93    /// Renders `#prefix…length`.
94    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
95        let token = self
96            .0
97            .split_once(char::is_whitespace)
98            .map_or(self.0, |(_, rest)| rest.trim());
99        let head: String = token.chars().take(4).collect();
100        write!(formatter, "#{head}…{}", token.chars().count())
101    }
102}
103
104impl<'a> Credentials<'a> {
105    /// Reads the `Authorization` header out of a request.
106    #[must_use]
107    pub fn from_headers(headers: &'a HeaderMap) -> Self {
108        Self {
109            authorization: headers
110                .get(axum::http::header::AUTHORIZATION)
111                .and_then(|value| value.to_str().ok()),
112        }
113    }
114
115    /// The raw `Authorization` value.
116    ///
117    /// Present for an implementor that has to parse it. This crate never logs
118    /// it, and the doc comment on [`Credentials`] is the reason: the rule is
119    /// "never log tokens", and the way that is kept is by this being the only
120    /// path to the value and by nothing in this module formatting it.
121    #[must_use]
122    pub const fn authorization(&self) -> Option<&'a str> {
123        self.authorization
124    }
125}
126
127/// Resolves a request's credentials into the context its handlers run under.
128///
129/// # Why this is async
130///
131/// Because establishing authenticity is network I/O. An introspecting source
132/// asks the authorization server about every request, and a JWKS source asks
133/// it for keys on rotation; neither can be answered from what is already in
134/// the process. A synchronous signature here would have to be served by an
135/// authenticator that cannot actually check a real token.
136///
137/// The boxed-future form is deliberate rather than an `async fn` in the trait.
138/// [`McpRouter`](super::router::McpRouter) holds `Arc<dyn Authenticator>`, so
139/// the port has to stay object safe, and native `async fn` on a trait is RPITIT
140/// and is not. It is written out rather than pulled in through `async-trait`
141/// for one method, and the allocation it adds is not what is being measured
142/// here — this runs once per HTTP request, beside a session lookup and a JSON
143/// parse, and the router was already dispatching through a `dyn`.
144///
145/// This is the "boxed futures are already the norm" case that the crate's own
146/// rule carves out, and it is deliberately *not* what
147/// [`TokenSource`](crate::application::oauth::TokenSource) does: that port takes
148/// `impl TokenSource` at the call site and never pays a boxing.
149///
150/// # Implementing this
151///
152/// Return [`AuthContext::anonymous`] for a request you do not recognise. Do not
153/// return a context with scopes for a credential you could not verify, and do
154/// not treat a malformed `Authorization` value as a present-but-empty one: a
155/// token that fails to parse is not a token.
156///
157/// Reporting *why* a credential failed is deliberately not in the signature.
158/// Every unusable credential arrives as the same anonymous context, and the
159/// policy turns that into one status code. An authenticator that could
160/// distinguish "expired" from "unknown key" would hand a caller an oracle for
161/// probing which tokens exist.
162///
163/// # Example
164///
165/// ```
166/// use std::future::Future;
167/// use std::pin::Pin;
168///
169/// use protocol_droid::adapters::primary::transport::http::{
170///     Authenticator, Credentials,
171/// };
172/// use protocol_droid::domain::registry::{AuthContext, Scope, ScopeSet};
173///
174/// /// A stand-in for the real thing: it accepts exactly one well-known token
175/// /// so the test has something to exercise. The real implementation is
176/// /// [`OAuthAuthenticator`](super::auth::oauth::OAuthAuthenticator).
177/// struct StaticToken;
178///
179/// impl Authenticator for StaticToken {
180///     fn authenticate<'a>(
181///         &'a self,
182///         presented: &'a Credentials<'_>,
183///     ) -> Pin<Box<dyn Future<Output = AuthContext> + Send + 'a>> {
184///         let context = match presented.authorization() {
185///             Some("Bearer test-token") => AuthContext::new(
186///                 "test",
187///                 ScopeSet::empty().with(Scope::Read),
188///             ),
189///             _ => AuthContext::anonymous(),
190///         };
191///         Box::pin(std::future::ready(context))
192///     }
193/// }
194/// # let _ = StaticToken;
195/// ```
196pub trait Authenticator: Send + Sync {
197    /// The context `presented` is allowed to act under.
198    fn authenticate<'a>(
199        &'a self,
200        presented: &'a Credentials<'_>,
201    ) -> Pin<Box<dyn Future<Output = AuthContext> + Send + 'a>>;
202}
203
204/// The default: no credential is accepted, and no scope is ever held.
205///
206/// The `_ =>` arm is the whole design. A header this does not recognise, a
207/// header that is absent, and a header that is malformed all reach the same
208/// place, because a half-check here would be a credential check that is wrong
209/// in a way nobody can see. The future is already resolved, so awaiting it
210/// costs a poll and nothing else.
211#[derive(Debug, Clone, Copy, Default)]
212pub struct Anonymous;
213
214impl Authenticator for Anonymous {
215    fn authenticate<'a>(
216        &'a self,
217        _presented: &'a Credentials<'_>,
218    ) -> Pin<Box<dyn Future<Output = AuthContext> + Send + 'a>> {
219        Box::pin(std::future::ready(AuthContext::anonymous()))
220    }
221}
222
223/// What a deployment has decided about this endpoint's exposure.
224///
225/// Separate from [`Anonymous`] because "we have not configured anything yet"
226/// and "this endpoint is deliberately public" are different states, and
227/// conflating them is how a default becomes a decision nobody made. A
228/// deployment that means "public" writes [`AuthPolicy::Public`]; a deployment
229/// that means "authenticated" writes [`AuthPolicy::Require`] and names the
230/// scopes.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
232pub enum AuthPolicy {
233    /// Every request is served under an anonymous context.
234    ///
235    /// Correct for a loopback stdio-equivalent and for a conformance runner,
236    /// and wrong for anything reachable from another host. It is the default
237    /// because a `Router` that cannot be constructed until authentication is
238    /// wired is a router nobody constructs while T16 is unimplemented.
239    #[default]
240    Public,
241    /// Every request must hold these scopes.
242    ///
243    /// Note what an *empty* `ScopeSet` does here: it requires nothing, so
244    /// `refuse` answers `None` and the policy behaves exactly
245    /// like [`Public`](Self::Public). That is a footgun — "I require
246    /// authentication" followed by nothing to require is a configuration
247    /// mistake — and it is the kind that is best made visible by a test rather
248    /// than by a runtime check, because there is no error to return from a
249    /// `const` builder on a value that is otherwise perfectly well-formed.
250    /// `an_empty_requirement_is_public_and_says_so` pins the behaviour so it
251    /// cannot change silently: if a future dispatch wants `Require` to reject
252    /// an empty set, that test is the thing that has to be updated on purpose.
253    ///
254    /// The same applies to
255    /// [`ServerConfig::with_required_scopes`](super::ServerConfig::with_required_scopes):
256    /// it stores what it is given and does not validate it.
257    Require(ScopeSet),
258}
259
260impl AuthPolicy {
261    /// The scopes a request must hold under this policy.
262    #[must_use]
263    pub const fn required(self) -> ScopeSet {
264        match self {
265            Self::Public => ScopeSet::empty(),
266            Self::Require(scopes) => scopes,
267        }
268    }
269
270    /// The refusal this policy produces for `context`, if any.
271    ///
272    /// The `401`-before-`403` order is the point. A caller that holds nothing
273    /// is told to authenticate; a caller that holds a principal but lacks a
274    /// scope is told its credential is insufficient. Answering `403` to a
275    /// caller holding nothing tells the client to retry with more permission,
276    /// which it cannot do — it has nothing to add.
277    ///
278    /// The test for "holds nothing" is [`AuthContext::scopes`], **not**
279    /// [`AuthContext::principal`]. The domain's [`AuthContext::anonymous`] is
280    /// a *named* principal — it is called `anonymous` rather than being the
281    /// empty string, deliberately, so a trace can distinguish a peer that
282    /// presented no credential from a named principal whose credential granted
283    /// nothing. So a check on the principal being empty is not merely
284    /// inelegant here, it is unreachable: it would classify the anonymous
285    /// caller as authenticated-but-insufficient and answer `403` to a client
286    /// that has no credential at all. The scopes are the thing the policy is
287    /// actually about, and a caller holding none of them is the caller that
288    /// needs to authenticate first.
289    #[must_use]
290    pub const fn refuse(&self, context: &AuthContext) -> Option<super::Refusal> {
291        let required = self.required();
292        if required.is_empty() {
293            return None;
294        }
295        if context.scopes().is_empty() {
296            return Some(super::Refusal::Unauthenticated);
297        }
298        if !context.holds(required) {
299            return Some(super::Refusal::Forbidden);
300        }
301        None
302    }
303}
304
305/// The bearer-token authenticator — the real implementation of
306/// [`Authenticator`] behind T16's OAuth work.
307///
308/// `pub(super)` rather than `pub` or `pub(crate)`, and both of those are
309/// lints' suggestions that do not work here. `pub` is
310/// [`unreachable_pub`](clippy::unreachable_pub): the enclosing `auth` module is
311/// private, so the module itself is not reachable from outside the crate.
312/// `pub(crate)` is [`redundant_pub_crate`](clippy::redundant_pub_crate): the
313/// enclosing module has already restricted it to the crate, so the `crate` in
314/// `pub(crate)` says nothing the parent does not. What is actually needed is
315/// exactly `http` — the module that re-exports these items — which is one level
316/// up and no further.
317pub(super) mod oauth;
318
319pub use oauth::{OAuthAuthenticator, SystemClock, WallClock};
320
321#[cfg(test)]
322mod tests {
323    /// A credential of the kind a real client sends.
324    const SECRET: &str = "super-secret-bearer-token-value-0123456789";
325
326    /// The redaction boundary, for a presented credential.
327    ///
328    /// The value is untouched — an authenticator has to be able to read the
329    /// token to check it — and the *rendering* carries only a fingerprint. This
330    /// is the shape `SessionStore`'s `Debug` already has and the one
331    /// [`RequestId::redacted`](crate::domain::RequestId::redacted) serves.
332    ///
333    /// It is a test on `Debug` specifically rather than on `Display` because
334    /// `Debug` is what a panic, an `assert_eq!`, and every future log line
335    /// expand to. A type that is safe to hold and unsafe to print has no
336    /// redaction at all.
337    #[test]
338    fn the_debug_form_redacts_but_the_value_does_not() -> Result<(), TestError> {
339        let header = format!("Bearer {SECRET}");
340        let headers = header_map_with(Some(header.as_str()))?;
341        let credentials = Credentials::from_headers(&headers);
342        let rendered = format!("{credentials:?}");
343
344        assert!(!rendered.contains(SECRET), "{rendered}");
345        assert!(!rendered.contains("secret"), "{rendered}");
346        // The fingerprint still correlates: the first four characters of the
347        // token, and its length.
348        assert!(rendered.contains("#supe…42"), "{rendered}");
349        // The half the authenticator needs is untouched.
350        assert_eq!(credentials.authorization(), Some(header.as_str()));
351        Ok(())
352    }
353
354    /// A fingerprint that included the scheme would correlate nothing.
355    ///
356    /// Every bearer header starts `Bearer `, so a prefix taken from the whole
357    /// header is `Bear` on every request in the world. The test states the
358    /// prefix is of the *token* so that an implementation cannot quietly move
359    /// the split and still pass the assertion above.
360    #[test]
361    fn the_fingerprint_covers_the_token_and_not_the_scheme() -> Result<(), TestError> {
362        let header = format!("Bearer {SECRET}");
363        let headers = header_map_with(Some(header.as_str()))?;
364        let rendered = format!("{:?}", Credentials::from_headers(&headers));
365
366        assert!(rendered.contains("#supe…"), "{rendered}");
367        assert!(!rendered.contains("Bear"), "{rendered}");
368        Ok(())
369    }
370
371    /// A request that presented nothing is shown as having presented nothing.
372    ///
373    /// Redaction must not become "everything looks the same": a missing
374    /// credential and a present one are different facts for a reader of a log,
375    /// and both are safe to print.
376    #[test]
377    fn a_missing_credential_says_so() {
378        let headers = HeaderMap::new();
379        let rendered = format!("{:?}", Credentials::from_headers(&headers));
380
381        assert!(rendered.contains("None"), "{rendered}");
382    }
383    use axum::http::{HeaderMap, HeaderValue, header};
384
385    use crate::domain::registry::{AuthContext, Scope, ScopeSet};
386
387    use super::{Anonymous, AuthPolicy, Authenticator, Credentials};
388    use crate::adapters::primary::transport::http::Refusal;
389
390    type TestError = Box<dyn std::error::Error>;
391
392    fn header_map_with(authorization: Option<&str>) -> Result<HeaderMap, TestError> {
393        let mut headers = HeaderMap::new();
394        if let Some(value) = authorization {
395            headers.insert(header::AUTHORIZATION, HeaderValue::from_str(value)?);
396        }
397        Ok(headers)
398    }
399
400    /// The default authenticator grants nothing, whatever the request says.
401    ///
402    /// The property under test is the *negative* one. A test that only showed
403    /// "a good token works" would pass against an authenticator that also
404    /// accepted `Bearer anything-else`, and the failure that matters is a
405    /// credential check that is more permissive than it looks.
406    #[tokio::test]
407    async fn the_default_authenticator_holds_no_scopes_for_anything() -> Result<(), TestError> {
408        for presented in [None, Some("Bearer test-token"), Some("Basic abc"), Some("")] {
409            let headers = header_map_with(presented)?;
410            let context = Anonymous
411                .authenticate(&Credentials::from_headers(&headers))
412                .await;
413            assert_eq!(context.scopes(), ScopeSet::empty());
414            // The claim is "this credential bought nothing", and the precise
415            // spelling of that is equality with the domain's own anonymous
416            // context. Asserting the principal is *empty* would be wrong: the
417            // domain names the anonymous principal, on purpose, so a trace can
418            // tell a peer that presented nothing from a named peer that
419            // presented something worth nothing.
420            assert_eq!(context, AuthContext::anonymous());
421        }
422        Ok(())
423    }
424
425    /// A malformed credential under a required policy is refused, not passed.
426    ///
427    /// The negative case the module docs promise and the one a naive
428    /// implementation gets wrong: a header that is present but unparseable
429    /// looks like "a credential was supplied", and an implementation that
430    /// branches on presence rather than on what the credential proves will let
431    /// it through to whatever the route does next.
432    #[tokio::test]
433    async fn a_malformed_credential_is_refused_under_a_required_policy() -> Result<(), TestError> {
434        let policy = AuthPolicy::Require(ScopeSet::empty().with(Scope::Read));
435        for malformed in [Some(""), Some("Bearer"), Some("not-a-scheme x"), Some("??")] {
436            let headers = header_map_with(malformed)?;
437            let context = Anonymous
438                .authenticate(&Credentials::from_headers(&headers))
439                .await;
440            assert_eq!(
441                policy.refuse(&context),
442                Some(Refusal::Unauthenticated),
443                "malformed credential {malformed:?} was not refused"
444            );
445        }
446        Ok(())
447    }
448
449    /// A caller who presented a credential that granted nothing gets `401`.
450    ///
451    /// Separate from the anonymous case because the domain deliberately keeps
452    /// the two apart: a named principal holding nothing presented credentials
453    /// worth nothing, and a client that gets `403` for that is told to ask for
454    /// permission it has no way to obtain. There is nothing to add, so the
455    /// honest answer is "authenticate".
456    #[test]
457    fn a_credential_that_granted_nothing_is_unauthenticated() {
458        let policy = AuthPolicy::Require(ScopeSet::empty().with(Scope::Read));
459        let granted_nothing = AuthContext::new("alice", ScopeSet::empty());
460        assert_eq!(
461            policy.refuse(&granted_nothing),
462            Some(Refusal::Unauthenticated),
463        );
464    }
465
466    /// `Require` with an empty scope set requires nothing, and therefore
467    /// behaves exactly like `Public`.
468    ///
469    /// Pinned deliberately. An empty requirement is a configuration mistake
470    /// that is silent at every other layer, and the honest fix for that is to
471    /// make the behaviour *stated* rather than to leave it emergent: this test
472    /// is what a future dispatch has to update on purpose if it decides
473    /// `Require` should reject an empty set.
474    #[test]
475    fn an_empty_requirement_is_public_and_says_so() {
476        let policy = AuthPolicy::Require(ScopeSet::empty());
477        // Equivalence is *behavioural*, not structural: the two are different
478        // enum values and asserting they are equal would be asserting that
479        // `Require` stops existing. What matters is that a caller cannot tell
480        // the difference through the port, which is what these two lines say.
481        assert!(policy.required().is_empty());
482        assert_eq!(policy.refuse(&AuthContext::anonymous()), None);
483        assert_eq!(policy.required(), AuthPolicy::Public.required());
484        assert_eq!(
485            policy.refuse(&AuthContext::anonymous()),
486            AuthPolicy::Public.refuse(&AuthContext::anonymous()),
487        );
488    }
489
490    /// The `Authorization` value is read from the request, not guessed.
491    ///
492    /// Without this, a test elsewhere that asserts "an unauthenticated request
493    /// is refused" could be passing because the header was never read at all.
494    #[test]
495    fn the_credential_is_read_off_the_request() -> Result<(), TestError> {
496        let with = header_map_with(Some("Bearer abc"))?;
497        let without = header_map_with(None)?;
498        assert_eq!(
499            Credentials::from_headers(&with).authorization(),
500            Some("Bearer abc"),
501        );
502        assert_eq!(Credentials::from_headers(&without).authorization(), None);
503        Ok(())
504    }
505
506    /// A scope-bearing policy refuses an anonymous caller with `401`.
507    ///
508    /// This is the fail-secure claim the module docs make, and it is the one
509    /// that distinguishes a transport that checks from one that merely has an
510    /// `AuthContext` parameter nobody reads.
511    #[test]
512    fn a_requiring_policy_refuses_an_anonymous_caller() {
513        let policy = AuthPolicy::Require(ScopeSet::empty().with(Scope::Read));
514        let context = AuthContext::anonymous();
515        assert_eq!(policy.refuse(&context), Some(Refusal::Unauthenticated));
516    }
517
518    /// A caller with a principal but too little scope gets `403`, not `401`.
519    ///
520    /// The two are different recoveries — "authenticate" against "you are
521    /// authenticated but not allowed" — and collapsing them sends a client
522    /// looking for a credential it already has.
523    #[test]
524    fn an_insufficient_scope_is_403_and_not_401() {
525        let policy = AuthPolicy::Require(ScopeSet::empty().with(Scope::Write));
526        let read_only = AuthContext::new("alice", ScopeSet::empty().with(Scope::Read));
527        assert_eq!(policy.refuse(&read_only), Some(Refusal::Forbidden));
528        // The same policy lets a caller with the scope through.
529        let writer = AuthContext::new("alice", ScopeSet::empty().with(Scope::Write));
530        assert_eq!(policy.refuse(&writer), None);
531    }
532
533    /// The public policy serves an anonymous caller.
534    ///
535    /// The counterpart to the refusal test, and the reason the enum exists
536    /// rather than a bare `ScopeSet` field: "no scopes required" has to be
537    /// something a deployment says on purpose.
538    #[test]
539    fn the_public_policy_serves_everyone() {
540        let policy = AuthPolicy::Public;
541        assert!(policy.required().is_empty());
542        assert_eq!(policy.refuse(&AuthContext::anonymous()), None);
543        assert_eq!(policy.required(), AuthPolicy::default().required());
544    }
545}