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}