protocol_droid/adapters/primary/transport/http/auth/oauth.rs
1//! The [`Authenticator`] that turns a bearer token into an identity.
2//!
3//! # What this is for
4//!
5//! The router's port asks one question — what may this request act as — and
6//! until now the only implementations were a stand-in that knew one hardcoded
7//! token and the [`Anonymous`](super::Anonymous) default that knows none. This
8//! is the real one: it strips `Bearer`, asks a [`TokenSource`] what the token
9//! says, applies the version's authorization model, and hands back a context.
10//!
11//! # Everything unusable becomes anonymous, and that is the point
12//!
13//! A request with no header, a request with a non-bearer scheme, a request
14//! whose token the authorization server refuses, and a request whose token is
15//! perfectly valid but names the wrong audience all arrive at the same place:
16//! [`AuthContext::anonymous`]. They are not collapsed because they are
17//! equivalent — they are collapsed because telling them apart would be an
18//! oracle. RFC 6750 §3.1 defines `invalid_token` with an `error_description`
19//! precisely so a client can react, and a server that obliged would hand anyone
20//! with a token a way to learn which keys the authorization server has and
21//! whether a given token is merely expired. The client loses a little
22//! diagnosability; the attacker loses a probe. That is the trade, and it is
23//! made in the port's signature rather than left to each implementor.
24//!
25//! What the caller gets instead is [`AuthPolicy`](super::AuthPolicy) reading
26//! the resulting context: anonymous under a [`Require`](super::AuthPolicy) is
27//! a `401`, which is RFC 6750's `invalid_token` status, and a context that
28//! holds a principal but not the scope is a `403`, which is
29//! `insufficient_scope`. The distinction that RFC 6750 actually cares about —
30//! no credential, or an insufficient one — survives. Only the reason does not.
31//!
32//! # The resource identifier comes from the metadata document
33//!
34//! The audience a token must carry and the `resource_metadata` URL in the
35//! challenge are the *same* fact about the server, so this takes one
36//! [`ResourceMetadata`] rather than a resource and a metadata URL separately.
37//! Configuring them twice is how a deployment ends up checking tokens against
38//! one audience while telling clients to fetch metadata for another, which
39//! fails only in production and only for clients that follow the link.
40//!
41//! # Nothing here logs
42//!
43//! The token is borrowed for the length of one call and never stored, cloned
44//! into a message, or formatted. Every error from [`authenticate`] is discarded
45//! into [`AuthContext::anonymous`], which is what makes that easy to keep: there
46//! is no error value left anywhere for a log line to pick up.
47
48use std::future::Future;
49use std::pin::Pin;
50use std::sync::Arc;
51use std::time::SystemTime;
52
53use super::{AuthContext, Authenticator, Credentials};
54use crate::application::oauth::{ScopePolicy, TokenPolicy, TokenSource, authenticate};
55use crate::domain::ProtocolVersion;
56use crate::domain::authorization_model;
57use crate::domain::mcp::ResourceMetadata;
58
59/// The wall clock `exp` and `nbf` are compared against.
60///
61/// Injected rather than called from [`SystemTime::now`] because the domain
62/// rule is that time is a parameter, and a test that cannot say what "now" is
63/// cannot pin an expiry boundary. Narrow, owned by the consumer, and one
64/// method, which is what the port rule asks for.
65pub trait WallClock: Send + Sync {
66 /// The current instant.
67 fn now(&self) -> SystemTime;
68}
69
70/// Reads the real clock.
71///
72/// The only implementation a deployment should use. Every other one is a test.
73#[derive(Debug, Clone, Copy, Default)]
74pub struct SystemClock;
75
76impl WallClock for SystemClock {
77 /// The current wall-clock instant.
78 fn now(&self) -> SystemTime {
79 SystemTime::now()
80 }
81}
82
83/// The bearer credential a request presented, if it presented one.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum Presented<'a> {
86 /// A well-formed `Bearer <token>` per RFC 6750 §2.1.
87 Bearer(&'a str),
88 /// No `Authorization` header, or one that is not a single bearer token.
89 Nothing,
90}
91
92/// Reads the bearer token out of an `Authorization` header value.
93///
94/// RFC 6750 §2.1: the scheme is case-insensitive (RFC 7235 §2.1) and separated
95/// from the token by whitespace. The token itself is a `b64token`, which cannot
96/// contain a space, so splitting at the *first* space is correct and taking the
97/// remainder whole is safe — there is no second token hiding in it to
98/// misinterpret, and a header carrying one is a malformed request rather than a
99/// stronger credential.
100///
101/// A scheme this does not recognise is [`Presented::Nothing`] rather than an
102/// error, because it is not a bearer token this server can use: a client
103/// authenticating with `Basic` has presented nothing that answers the question.
104fn bearer(header: Option<&str>) -> Presented<'_> {
105 let Some(header) = header else {
106 return Presented::Nothing;
107 };
108 let Some((scheme, token)) = header.split_once(char::is_whitespace) else {
109 return Presented::Nothing;
110 };
111 if !scheme.eq_ignore_ascii_case("Bearer") {
112 return Presented::Nothing;
113 }
114 let token = token.trim();
115 if token.is_empty() {
116 return Presented::Nothing;
117 }
118 Presented::Bearer(token)
119}
120
121/// Authenticates bearer tokens against a [`TokenSource`].
122///
123/// Generic over the source rather than holding a `dyn`, following
124/// [`TokenSource`]'s own rule: the call site takes `impl TokenSource`, so this
125/// monomorphises and the per-request cost is a direct call rather than a boxed
126/// future behind a vtable. The scopes and the clock are `dyn` because a
127/// deployment supplies them by value at construction and they are one call each.
128pub struct OAuthAuthenticator<A> {
129 /// What establishes authenticity.
130 source: A,
131 /// The resource identifier a token must be bound to, and the document a
132 /// challenge points at.
133 metadata: Arc<ResourceMetadata>,
134 /// The revision this server negotiated, which decides what the model
135 /// requires.
136 version: ProtocolVersion,
137 /// Whether tokens are required to name this server as their issuer.
138 expected_issuer: Option<Arc<str>>,
139 /// The scope vocabulary this deployment recognises.
140 scopes: Arc<dyn ScopePolicy>,
141 /// What "now" means.
142 clock: Arc<dyn WallClock>,
143}
144
145impl<A> std::fmt::Debug for OAuthAuthenticator<A> {
146 /// Prints the configuration only.
147 ///
148 /// Written by hand rather than derived, and not for the usual reason of a
149 /// field that is awkward to format: the derive would demand
150 /// `A: Debug` on the source — which for a JWT source would mean formatting
151 /// its key set — and `dyn Debug` on two fields that are behaviour, not
152 /// state. What is left is what an operator actually needs when a `401`
153 /// shows up: which resource is protected, under which revision's model,
154 /// and whether an issuer was pinned. The expected issuer is printed;
155 /// nothing else on this type is secret, and no token ever reaches it.
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.debug_struct("OAuthAuthenticator")
158 .field("resource", self.metadata.resource())
159 .field("version", &self.version)
160 .field("expected_issuer", &self.expected_issuer)
161 .finish_non_exhaustive()
162 }
163}
164
165impl<A> OAuthAuthenticator<A> {
166 /// An authenticator for `source`, protecting `metadata`'s resource under
167 /// the model `version` selects.
168 ///
169 /// # Examples
170 ///
171 /// ```
172 /// use protocol_droid::adapters::primary::transport::http::{
173 /// OAuthAuthenticator, SystemClock,
174 /// };
175 /// use protocol_droid::application::oauth::NoScopes;
176 /// use protocol_droid::domain::{ProtocolVersion, mcp::{ResourceMetadata, ResourceUri}};
177 ///
178 /// let metadata = ResourceMetadata::new(ResourceUri::new("https://example.com/mcp")?);
179 /// let authenticator = OAuthAuthenticator::new(
180 /// (), // a real deployment passes a `TokenSource`
181 /// metadata,
182 /// ProtocolVersion::V2025_06_18,
183 /// NoScopes,
184 /// SystemClock,
185 /// );
186 /// # let _ = authenticator;
187 /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
188 /// ```
189 #[must_use]
190 pub fn new(
191 source: A,
192 metadata: ResourceMetadata,
193 version: ProtocolVersion,
194 scopes: impl ScopePolicy + 'static,
195 clock: impl WallClock + 'static,
196 ) -> Self {
197 Self {
198 source,
199 metadata: Arc::new(metadata),
200 version,
201 expected_issuer: None,
202 scopes: Arc::new(scopes),
203 clock: Arc::new(clock),
204 }
205 }
206
207 /// Requires tokens to name `issuer`, rather than accepting any.
208 ///
209 /// Off by default because RFC 9728 publishes a *list* of authorization
210 /// servers this resource trusts, so a deployment fronted by more than one
211 /// legitimately accepts more than one issuer. Turning it on is how a
212 /// single-issuer deployment says so.
213 #[must_use]
214 pub fn with_expected_issuer(mut self, issuer: impl Into<Arc<str>>) -> Self {
215 self.expected_issuer = Some(issuer.into());
216 self
217 }
218
219 /// The resource this authenticator protects.
220 #[must_use]
221 pub fn metadata(&self) -> &ResourceMetadata {
222 &self.metadata
223 }
224
225 /// The policy this authenticator applies, built fresh for one call.
226 ///
227 /// Rebuilt per request rather than stored because [`TokenPolicy`] borrows
228 /// the resource and holds an `Option<Arc<str>>`, so a stored one would pin
229 /// a lifetime onto the struct for the sake of two field copies.
230 fn policy(&self) -> TokenPolicy<'_> {
231 let mut policy =
232 TokenPolicy::new(self.metadata.resource(), authorization_model(self.version));
233 if let Some(issuer) = &self.expected_issuer {
234 policy = policy.with_expected_issuer(Arc::clone(issuer));
235 }
236 policy
237 }
238}
239
240impl<A> Authenticator for OAuthAuthenticator<A>
241where
242 A: TokenSource,
243{
244 fn authenticate<'a>(
245 &'a self,
246 presented: &'a Credentials<'_>,
247 ) -> Pin<Box<dyn Future<Output = AuthContext> + Send + 'a>> {
248 let token = match bearer(presented.authorization()) {
249 Presented::Bearer(token) => token,
250 Presented::Nothing => {
251 return Box::pin(std::future::ready(AuthContext::anonymous()));
252 }
253 };
254 let source = &self.source;
255 let scopes = Arc::clone(&self.scopes);
256 let policy = self.policy();
257 let now = self.clock.now();
258
259 Box::pin(async move {
260 // Every failure collapses to the same answer, which is the module's
261 // whole argument. `SourceError` is deliberately not examined:
262 // distinguishing "the authorization server is unreachable" from "the
263 // token was refused" would hand a caller a liveness probe for the
264 // authorization server, and telling a bad token from a good one
265 // would hand them a probe for its keys.
266 match authenticate(source, &policy, scopes.as_ref(), token, now).await {
267 Ok(context) => context,
268 Err(_refusal) => AuthContext::anonymous(),
269 }
270 })
271 }
272}
273
274#[cfg(test)]
275#[path = "oauth/tests.rs"]
276mod tests;