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 *nearly* 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//! *Nearly*, because the two can legitimately differ, and where they do the
42//! audience check was wrong rather than the metadata path. RFC 9728 §3.1
43//! derives the metadata URL from the resource identifier, so the resource has
44//! to be the HTTPS URL this server answers on — but the authorization server
45//! need not write that URL into `aud`. Microsoft Entra
46//! [does not](https://learn.microsoft.com/entra/identity-platform/claims-validation#validate-the-audience):
47//! a v2.0 token carries the web API's client ID, a bare GUID, while a v1.0
48//! token carries an app ID URI such as `api://<guid>`. Binding the two
49//! together refused every valid token at an Entra-fronted server.
50//! [`OAuthAuthenticator::with_expected_audience`] is how such a deployment
51//! states the audience it actually mints, and the resource identifier stays
52//! accepted either way.
53//!
54//! # Nothing here logs
55//!
56//! The token is borrowed for the length of one call and never stored, cloned
57//! into a message, or formatted. Every error from [`authenticate`] is discarded
58//! into [`AuthContext::anonymous`], which is what makes that easy to keep: there
59//! is no error value left anywhere for a log line to pick up.
60
61use std::future::Future;
62use std::pin::Pin;
63use std::sync::Arc;
64use std::time::SystemTime;
65
66use super::{AuthContext, Authenticator, Credentials};
67use crate::application::oauth::{Audience, ScopePolicy, TokenPolicy, TokenSource, authenticate};
68use crate::domain::ProtocolVersion;
69use crate::domain::authorization_model;
70use crate::domain::mcp::ResourceMetadata;
71
72/// The wall clock `exp` and `nbf` are compared against.
73///
74/// Injected rather than called from [`SystemTime::now`] because the domain
75/// rule is that time is a parameter, and a test that cannot say what "now" is
76/// cannot pin an expiry boundary. Narrow, owned by the consumer, and one
77/// method, which is what the port rule asks for.
78pub trait WallClock: Send + Sync {
79 /// The current instant.
80 fn now(&self) -> SystemTime;
81}
82
83/// Reads the real clock.
84///
85/// The only implementation a deployment should use. Every other one is a test.
86#[derive(Debug, Clone, Copy, Default)]
87pub struct SystemClock;
88
89impl WallClock for SystemClock {
90 /// The current wall-clock instant.
91 fn now(&self) -> SystemTime {
92 SystemTime::now()
93 }
94}
95
96/// The bearer credential a request presented, if it presented one.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98enum Presented<'a> {
99 /// A well-formed `Bearer <token>` per RFC 6750 §2.1.
100 Bearer(&'a str),
101 /// No `Authorization` header, or one that is not a single bearer token.
102 Nothing,
103}
104
105/// Reads the bearer token out of an `Authorization` header value.
106///
107/// RFC 6750 §2.1: the scheme is case-insensitive (RFC 7235 §2.1) and separated
108/// from the token by whitespace. The token itself is a `b64token`, which cannot
109/// contain a space, so splitting at the *first* space is correct and taking the
110/// remainder whole is safe — there is no second token hiding in it to
111/// misinterpret, and a header carrying one is a malformed request rather than a
112/// stronger credential.
113///
114/// A scheme this does not recognise is [`Presented::Nothing`] rather than an
115/// error, because it is not a bearer token this server can use: a client
116/// authenticating with `Basic` has presented nothing that answers the question.
117fn bearer(header: Option<&str>) -> Presented<'_> {
118 let Some(header) = header else {
119 return Presented::Nothing;
120 };
121 let Some((scheme, token)) = header.split_once(char::is_whitespace) else {
122 return Presented::Nothing;
123 };
124 if !scheme.eq_ignore_ascii_case("Bearer") {
125 return Presented::Nothing;
126 }
127 let token = token.trim();
128 if token.is_empty() {
129 return Presented::Nothing;
130 }
131 Presented::Bearer(token)
132}
133
134/// Authenticates bearer tokens against a [`TokenSource`].
135///
136/// Generic over the source rather than holding a `dyn`, following
137/// [`TokenSource`]'s own rule: the call site takes `impl TokenSource`, so this
138/// monomorphises and the per-request cost is a direct call rather than a boxed
139/// future behind a vtable. The scopes and the clock are `dyn` because a
140/// deployment supplies them by value at construction and they are one call each.
141pub struct OAuthAuthenticator<A> {
142 /// What establishes authenticity.
143 source: A,
144 /// The resource identifier a token must be bound to, and the document a
145 /// challenge points at.
146 metadata: Arc<ResourceMetadata>,
147 /// The revision this server negotiated, which decides what the model
148 /// requires.
149 version: ProtocolVersion,
150 /// Whether tokens are required to name this server as their issuer.
151 expected_issuer: Option<Arc<str>>,
152 /// Audiences accepted in addition to the resource, when `aud` is not the
153 /// resource identifier itself.
154 expected_audiences: Option<Arc<[Audience]>>,
155 /// The scope vocabulary this deployment recognises.
156 scopes: Arc<dyn ScopePolicy>,
157 /// What "now" means.
158 clock: Arc<dyn WallClock>,
159}
160
161impl<A> std::fmt::Debug for OAuthAuthenticator<A> {
162 /// Prints the configuration only.
163 ///
164 /// Written by hand rather than derived, and not for the usual reason of a
165 /// field that is awkward to format: the derive would demand
166 /// `A: Debug` on the source — which for a JWT source would mean formatting
167 /// its key set — and `dyn Debug` on two fields that are behaviour, not
168 /// state. What is left is what an operator actually needs when a `401`
169 /// shows up: which resource is protected, under which revision's model,
170 /// and whether an issuer was pinned. The expected issuer is printed;
171 /// nothing else on this type is secret, and no token ever reaches it.
172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173 f.debug_struct("OAuthAuthenticator")
174 .field("resource", self.metadata.resource())
175 .field("version", &self.version)
176 .field("expected_issuer", &self.expected_issuer)
177 .field("expected_audiences", &self.expected_audiences)
178 .finish_non_exhaustive()
179 }
180}
181
182impl<A> OAuthAuthenticator<A> {
183 /// An authenticator for `source`, protecting `metadata`'s resource under
184 /// the model `version` selects.
185 ///
186 /// # Examples
187 ///
188 /// ```
189 /// use protocol_droid::adapters::primary::transport::http::{
190 /// OAuthAuthenticator, SystemClock,
191 /// };
192 /// use protocol_droid::application::oauth::NoScopes;
193 /// use protocol_droid::domain::{ProtocolVersion, mcp::{ResourceMetadata, ResourceUri}};
194 ///
195 /// let metadata = ResourceMetadata::new(ResourceUri::new("https://example.com/mcp")?);
196 /// let authenticator = OAuthAuthenticator::new(
197 /// (), // a real deployment passes a `TokenSource`
198 /// metadata,
199 /// ProtocolVersion::V2025_06_18,
200 /// NoScopes,
201 /// SystemClock,
202 /// );
203 /// # let _ = authenticator;
204 /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
205 /// ```
206 #[must_use]
207 pub fn new(
208 source: A,
209 metadata: ResourceMetadata,
210 version: ProtocolVersion,
211 scopes: impl ScopePolicy + 'static,
212 clock: impl WallClock + 'static,
213 ) -> Self {
214 Self {
215 source,
216 metadata: Arc::new(metadata),
217 version,
218 expected_issuer: None,
219 expected_audiences: None,
220 scopes: Arc::new(scopes),
221 clock: Arc::new(clock),
222 }
223 }
224
225 /// Requires tokens to name `issuer`, rather than accepting any.
226 ///
227 /// Off by default because RFC 9728 publishes a *list* of authorization
228 /// servers this resource trusts, so a deployment fronted by more than one
229 /// legitimately accepts more than one issuer. Turning it on is how a
230 /// single-issuer deployment says so.
231 #[must_use]
232 pub fn with_expected_issuer(mut self, issuer: impl Into<Arc<str>>) -> Self {
233 self.expected_issuer = Some(issuer.into());
234 self
235 }
236
237 /// Accepts a token bound to `audience`, as well as to the resource.
238 ///
239 /// For an authorization server whose `aud` is not this server's RFC 9728
240 /// resource identifier. Microsoft Entra v2.0 is the motivating case: it
241 /// writes the web API's client ID — a bare GUID — into `aud`, while the
242 /// resource has to stay the HTTPS URL because RFC 9728 §3.1 derives the
243 /// metadata path from it. Without this, every valid token is refused with
244 /// [`Refusal::AudienceMismatch`](crate::application::oauth::Refusal::AudienceMismatch).
245 ///
246 /// The resource remains accepted, and comparison stays exact equality, so
247 /// this cannot be used to widen the check to a host or a path prefix.
248 /// See [`TokenPolicy::with_expected_audiences`] for the full argument.
249 ///
250 /// # Examples
251 ///
252 /// ```
253 /// use protocol_droid::adapters::primary::transport::http::{
254 /// OAuthAuthenticator, SystemClock,
255 /// };
256 /// use protocol_droid::application::oauth::NoScopes;
257 /// use protocol_droid::domain::{ProtocolVersion, mcp::{ResourceMetadata, ResourceUri}};
258 ///
259 /// let metadata = ResourceMetadata::new(ResourceUri::new("https://mcp.example.com/mcp")?);
260 /// let authenticator = OAuthAuthenticator::new(
261 /// (),
262 /// metadata,
263 /// ProtocolVersion::V2026_07_28,
264 /// NoScopes,
265 /// SystemClock,
266 /// )
267 /// // The Entra client ID of this web API — a bare GUID, which is not a
268 /// // URI, which is the point.
269 /// .with_expected_audience("8f7c1e42-0b3a-4f5d-9c6e-1a2b3c4d5e6f");
270 /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
271 /// ```
272 #[must_use]
273 pub fn with_expected_audience(mut self, audience: impl Into<Audience>) -> Self {
274 self.expected_audiences = Some(Arc::from([audience.into()]));
275 self
276 }
277
278 /// Accepts a token bound to any of `audiences`, as well as to the resource.
279 ///
280 /// The multi-identifier form of [`Self::with_expected_audience`], for a
281 /// deployment that answers to more than one — a proxying one, or one fronted
282 /// by an authorization server that mints v1.0 tokens (app ID URI, e.g.
283 /// `api://<guid>`) as well as v2.0 ones (bare client ID GUID).
284 #[must_use]
285 pub fn with_expected_audiences<I, T>(mut self, audiences: I) -> Self
286 where
287 I: IntoIterator<Item = T>,
288 T: Into<Audience>,
289 {
290 self.expected_audiences = Some(Arc::from(
291 audiences.into_iter().map(Into::into).collect::<Vec<_>>(),
292 ));
293 self
294 }
295
296 /// The resource this authenticator protects.
297 #[must_use]
298 pub fn metadata(&self) -> &ResourceMetadata {
299 &self.metadata
300 }
301
302 /// The policy this authenticator applies, built fresh for one call.
303 ///
304 /// Rebuilt per request rather than stored because [`TokenPolicy`] borrows
305 /// the resource and holds an `Option<Arc<str>>`, so a stored one would pin
306 /// a lifetime onto the struct for the sake of two field copies.
307 fn policy(&self) -> TokenPolicy<'_> {
308 let mut policy =
309 TokenPolicy::new(self.metadata.resource(), authorization_model(self.version));
310 if let Some(issuer) = &self.expected_issuer {
311 policy = policy.with_expected_issuer(Arc::clone(issuer));
312 }
313 if let Some(audiences) = &self.expected_audiences {
314 policy = policy.with_expected_audiences(audiences.iter().cloned());
315 }
316 policy
317 }
318}
319
320impl<A> Authenticator for OAuthAuthenticator<A>
321where
322 A: TokenSource,
323{
324 fn authenticate<'a>(
325 &'a self,
326 presented: &'a Credentials<'_>,
327 ) -> Pin<Box<dyn Future<Output = AuthContext> + Send + 'a>> {
328 let token = match bearer(presented.authorization()) {
329 Presented::Bearer(token) => token,
330 Presented::Nothing => {
331 return Box::pin(std::future::ready(AuthContext::anonymous()));
332 }
333 };
334 let source = &self.source;
335 let scopes = Arc::clone(&self.scopes);
336 let policy = self.policy();
337 let now = self.clock.now();
338
339 Box::pin(async move {
340 // Every failure collapses to the same answer, which is the module's
341 // whole argument. `SourceError` is deliberately not examined:
342 // distinguishing "the authorization server is unreachable" from "the
343 // token was refused" would hand a caller a liveness probe for the
344 // authorization server, and telling a bad token from a good one
345 // would hand them a probe for its keys.
346 match authenticate(source, &policy, scopes.as_ref(), token, now).await {
347 Ok(context) => context,
348 Err(_refusal) => AuthContext::anonymous(),
349 }
350 })
351 }
352}
353
354#[cfg(test)]
355#[path = "oauth/tests.rs"]
356mod tests;