Skip to main content

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;