Skip to main content

protocol_droid/application/oauth/
token.rs

1//! Turning a presented access token into the [`AuthContext`] a request runs under.
2//!
3//! The security-relevant part of T16 lives here and is a pure function:
4//! [`verify`] takes claims an adapter has already authenticated (signature
5//! checked, issuer confirmed), the resource the request arrived at, and a clock,
6//! and it either produces an [`AuthContext`] or a [`Refusal`] that names why.
7//! It reads no clock and opens no socket, so every branch in it — wrong audience,
8//! expired, not yet valid, wrong issuer — is testable without a network.
9//!
10//! # Two rules that are worth stating plainly
11//!
12//! **The audience must be exactly this server.** RFC 8707 resource indicators
13//! exist so a token minted for one resource cannot be presented at another, and
14//! `2025-06-18` changelog §4 (PR #734) makes them a client `MUST`. Comparing
15//! for "contains" rather than equality would defeat the whole mechanism: a token
16//! with a two-element `aud` naming a malicious server and ours would pass.
17//!
18//! **No token means no context.** [`Refusal`] has no variant that means "let it
19//! through unprivileged", because the route's only way to reach a session is
20//! through the `&AuthContext` it was handed, and a context nobody can widen is
21//! the fail-secure default.
22//!
23//! # On scope names
24//!
25//! The claims carry whatever scope strings the authorization server put there,
26//! as strings. The vendored spec names none of them — the only scope sentence in
27//! any revision's changelog is `2025-11-25` §3 (SEP-835) on incremental consent
28//! via `WWW-Authenticate` — so there is no specified vocabulary to map onto
29//! [`Scope`](crate::domain::Scope). Turning `mcp:tools` into
30//! [`Read`](crate::domain::Scope::Read) is a *deployment's* policy, so it lives
31//! behind [`ScopePolicy`] as configuration rather than being hardcoded here as
32//! if the specification had said so.
33
34use std::sync::Arc;
35use std::time::SystemTime;
36
37use crate::domain::mcp::ResourceUri;
38use crate::domain::mcp::authorization::AuthorizationModel;
39use crate::domain::registry::{AuthContext, Scope, ScopeSet};
40
41/// What an adapter established about a presented token, before this crate's
42/// own rules ran.
43///
44/// "Established" means the adapter verified the signature (JWT) or asked the
45/// authorization server (introspection). It is deliberately *not* a type that can
46/// be built from a raw string in this crate: there is no path from an
47/// `Authorization` header to a `TokenClaims` that skips the adapter, because
48/// that is the property that keeps a decode from becoming a verify.
49#[derive(Debug, Clone, PartialEq, Eq, Default)]
50pub struct TokenClaims {
51    subject: Option<Arc<str>>,
52    issuer: Option<Arc<str>>,
53    audience: Vec<ResourceUri>,
54    expires_at: Option<SystemTime>,
55    not_before: Option<SystemTime>,
56    scopes: Vec<Arc<str>>,
57}
58
59impl TokenClaims {
60    /// Claims with nothing in them, for an adapter to fill in.
61    #[must_use]
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// Sets the subject — who the token is about.
67    #[must_use]
68    pub fn with_subject(mut self, subject: impl Into<Arc<str>>) -> Self {
69        self.subject = Some(subject.into());
70        self
71    }
72
73    /// Sets the issuer that minted the token.
74    #[must_use]
75    pub fn with_issuer(mut self, issuer: impl Into<Arc<str>>) -> Self {
76        self.issuer = Some(issuer.into());
77        self
78    }
79
80    /// Adds an audience entry.
81    ///
82    /// A builder rather than a setter because RFC 7519 §4.1.3 allows `aud` to
83    /// be either a single string or an array, and both have to land in the same
84    /// place.
85    #[must_use]
86    pub fn with_audience(mut self, audience: ResourceUri) -> Self {
87        self.audience.push(audience);
88        self
89    }
90
91    /// Adds an audience entry that arrives as a string.
92    ///
93    /// Skipped rather than fatal when it does not parse. An `aud` entry that is
94    /// not an absolute URI cannot equal this server's resource, so ignoring it
95    /// and letting the audience check decide is the same answer as refusing —
96    /// and a server that names one malformed audience *alongside* a valid one
97    /// has still issued a usable token, which refusing outright would throw
98    /// away.
99    #[must_use]
100    pub fn with_audience_string(mut self, audience: impl AsRef<str>) -> Self {
101        if let Ok(parsed) = ResourceUri::new(audience.as_ref()) {
102            self.audience.push(parsed);
103        }
104        self
105    }
106
107    /// Sets when the token stops being valid.
108    #[must_use]
109    pub const fn with_expiry(mut self, at: SystemTime) -> Self {
110        self.expires_at = Some(at);
111        self
112    }
113
114    /// Sets when the token starts being valid.
115    #[must_use]
116    pub const fn with_not_before(mut self, at: SystemTime) -> Self {
117        self.not_before = Some(at);
118        self
119    }
120
121    /// Declares a scope the token carries.
122    #[must_use]
123    pub fn with_scope(mut self, scope: impl Into<Arc<str>>) -> Self {
124        self.scopes.push(scope.into());
125        self
126    }
127
128    /// The subject, if the token named one.
129    #[must_use]
130    pub fn subject(&self) -> Option<&str> {
131        self.subject.as_deref()
132    }
133
134    /// The issuer, if the token named one.
135    #[must_use]
136    pub fn issuer(&self) -> Option<&str> {
137        self.issuer.as_deref()
138    }
139
140    /// Every audience entry, already canonicalised.
141    #[must_use]
142    pub fn audience(&self) -> &[ResourceUri] {
143        &self.audience
144    }
145
146    /// The scope strings, untranslated.
147    #[must_use]
148    pub fn scopes(&self) -> &[Arc<str>] {
149        &self.scopes
150    }
151
152    /// Whether the token is bound to `resource`.
153    ///
154    /// Exact equality against a canonicalised [`ResourceUri`], and equality
155    /// against *any* element of `aud` — which is what RFC 7519 §4.1.3 means by
156    /// an array audience. "Contains the resource" is the same predicate; what it
157    /// is *not* is "contains the resource's host", which is the check that would
158    /// let a sibling server's token through.
159    #[must_use]
160    pub fn is_audience(&self, resource: &ResourceUri) -> bool {
161        self.audience.iter().any(|audience| audience == resource)
162    }
163}
164
165/// Why a presented token was refused.
166///
167/// Every variant is a distinct reason a `401` might be correct, and the HTTP
168/// layer does not get to choose between them on the way out: an expired token
169/// and a wrong-audience token produce the same status and the same challenge,
170/// because telling a caller *which* failed is telling it something about the
171/// token it should not learn.
172#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
173pub enum Refusal {
174    /// The token had expired by `now`.
175    #[error("the access token has expired")]
176    Expired,
177
178    /// The token's `nbf` is in the future.
179    #[error("the access token is not valid yet")]
180    NotYetValid,
181
182    /// The token is bound to some other resource.
183    ///
184    /// The one refusal worth naming in a log rather than in a response body.
185    #[error("the access token is not bound to this resource")]
186    AudienceMismatch,
187
188    /// The token named an issuer, and it is not the one expected.
189    ///
190    /// Only reachable when the deployment configured an expected issuer.
191    #[error("the access token was issued by an unexpected issuer")]
192    IssuerMismatch,
193
194    /// The token had no subject, so it names nobody to attribute an action to.
195    #[error("the access token names no subject")]
196    NoSubject,
197
198    /// The token carried no scope this deployment recognises.
199    ///
200    /// A refusal rather than an empty [`ScopeSet`], so that "valid token, no
201    /// permissions" and "valid token, permissions the operator forgot to map"
202    /// are distinguishable in a log even though both answer `401`.
203    #[error("the access token carries no recognised scope")]
204    Unscoped,
205
206    /// The token was not a well-formed JWS at all.
207    ///
208    /// Added when the JWT source arrived, which is the first
209    /// [`TokenSource`] that can meet a token it cannot even parse. It is a
210    /// refusal rather than [`SourceError::Unavailable`] because nothing was
211    /// unavailable: the bytes arrived, and they were not a token. Filing it
212    /// under `Unavailable` would send a caller to re-authenticate against an
213    /// authorization server that answered perfectly well — and would report a
214    /// client's credentials as bad when the real fault was a middlebox
215    /// mangling a header.
216    #[error("the access token is not a well-formed JWS")]
217    Malformed,
218
219    /// The token parsed, but its signature could not be established as genuine.
220    ///
221    /// Distinct from `Malformed` and from every rule in `verify`, and both
222    /// distinctions are load-bearing. A malformed token is the caller's bug and
223    /// can be reported as one; an unverifiable one is either a token this
224    /// server should not accept or a key it could not fetch, and a log that
225    /// collapses the two sends an on-call engineer to the wrong system.
226    ///
227    /// Also the landing place for a bad signature. Folding that into
228    /// `AudienceMismatch` would be tempting and wrong: an expired token and a
229    /// forged one would then look identical in a log, which is the one
230    /// distinction a forgery attack is trying to hide.
231    #[error("the access token's signature could not be verified")]
232    Unverified,
233}
234
235/// What a deployment decides its own OAuth scope strings mean.
236///
237/// Not in `domain/`, and deliberately not a hardcoded table: the vendored spec
238/// names no scope strings, so any mapping written here would be this crate
239/// inventing a vocabulary and presenting it as policy. A deployment supplies the
240/// vocabulary it actually issues, and the default maps nothing — which means an
241/// unconfigured deployment refuses every token rather than granting one a scope
242/// nobody chose.
243pub trait ScopePolicy: Send + Sync {
244    /// The scope one authorization-server string confers, if any.
245    fn resolve(&self, scope: &str) -> Option<Scope>;
246
247    /// The principal name to record, given the token's subject and every scope
248    /// it carries.
249    ///
250    /// Separate from [`Self::resolve`] because the two are separate decisions:
251    /// which scopes a token has is the authorization server's statement, while
252    /// what to call the principal is entirely local. A deployment that
253    /// authenticates every user as `"user"` is not contradicting the token.
254    fn principal(&self, subject: &str, scopes: &[Arc<str>]) -> String {
255        let _ = scopes;
256        subject.to_owned()
257    }
258}
259
260/// The fail-secure default: no scope string means anything.
261///
262/// Every token is refused with [`Refusal::Unscoped`] until a deployment supplies
263/// a real policy. That is the whole design — a missing configuration must deny,
264/// and it must deny *loudly* with a reason that says it was a configuration
265/// rather than a forged token.
266#[derive(Debug, Clone, Copy, Default)]
267pub struct NoScopes;
268
269impl ScopePolicy for NoScopes {
270    fn resolve(&self, _scope: &str) -> Option<Scope> {
271        None
272    }
273}
274
275/// What the request is allowed to be, given the resource it reached and the
276/// revision it negotiated.
277///
278/// Two deployment facts and one spec fact, all of which change whether a token
279/// is checked at all. Bundled because passing three arguments to every call site
280/// invites two of them being forgotten.
281#[derive(Debug, Clone)]
282pub struct TokenPolicy<'a> {
283    /// The resource identifier a token must be bound to.
284    resource: &'a ResourceUri,
285
286    /// The revision's authorization model, from
287    /// `authorization_model`.
288    model: AuthorizationModel,
289
290    /// The one issuer this server accepts, if it has been configured with one.
291    expected_issuer: Option<Arc<str>>,
292}
293
294impl<'a> TokenPolicy<'a> {
295    /// A policy that accepts any issuer.
296    ///
297    /// # Examples
298    ///
299    /// ```
300    /// use protocol_droid::domain::{ProtocolVersion, authorization_model};
301    /// use protocol_droid::domain::mcp::ResourceUri;
302    /// use protocol_droid::application::oauth::TokenPolicy;
303    ///
304    /// let resource = ResourceUri::new("https://example.com/mcp")?;
305    /// let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
306    /// assert!(policy.accepts_any_issuer());
307    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
308    /// ```
309    #[must_use]
310    pub const fn new(resource: &'a ResourceUri, model: AuthorizationModel) -> Self {
311        Self {
312            resource,
313            model,
314            expected_issuer: None,
315        }
316    }
317
318    /// Requires tokens to name `issuer`, refusing any minted by anyone else.
319    #[must_use]
320    pub fn with_expected_issuer(mut self, issuer: impl Into<Arc<str>>) -> Self {
321        self.expected_issuer = Some(issuer.into());
322        self
323    }
324
325    /// Whether an issuer check will be made.
326    #[must_use]
327    pub const fn accepts_any_issuer(&self) -> bool {
328        self.expected_issuer.is_none()
329    }
330}
331
332/// Turns claims an adapter authenticated into an [`AuthContext`], or refuses.
333///
334/// # Errors
335///
336/// The [`Refusal`] naming the first rule `claims` fails. Order is deliberate:
337/// time is checked before audience so a stale token does not report a mismatch,
338/// and audience before issuer because the audience is the binding that keeps a
339/// token from crossing between servers, which is the one this crate must not get
340/// wrong.
341///
342/// # Examples
343///
344/// ```
345/// use std::time::{Duration, SystemTime};
346///
347/// use protocol_droid::application::oauth::{ScopePolicy, TokenClaims, TokenPolicy, verify};
348/// use protocol_droid::domain::mcp::ResourceUri;
349/// use protocol_droid::domain::registry::{Scope, ScopeSet};
350/// use protocol_droid::domain::{ProtocolVersion, authorization_model};
351///
352/// struct Permissive;
353/// impl ScopePolicy for Permissive {
354///     fn resolve(&self, scope: &str) -> Option<Scope> {
355///         match scope {
356///             "mcp:read" => Some(Scope::Read),
357///             _ => None,
358///         }
359///     }
360/// }
361///
362/// let resource = ResourceUri::new("https://example.com/mcp")?;
363/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
364/// let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
365///
366/// let good = TokenClaims::new()
367///     .with_subject("alice")
368///     .with_audience(resource.clone())
369///     .with_expiry(now + Duration::from_secs(60))
370///     .with_scope("mcp:read");
371///
372/// let context = verify(&good, &policy, &Permissive, now)?;
373/// assert_eq!(context.principal(), "alice");
374/// assert!(context.holds(ScopeSet::empty().with(Scope::Read)));
375///
376/// // A token minted for a different server is refused.
377/// let elsewhere = TokenClaims::new()
378///     .with_subject("alice")
379///     .with_audience(ResourceUri::new("https://evil.example/mcp")?)
380///     .with_expiry(now + Duration::from_secs(60));
381/// assert!(verify(&elsewhere, &policy, &Permissive, now).is_err());
382/// # Ok::<(), Box<dyn std::error::Error>>(())
383/// ```
384pub fn verify(
385    claims: &TokenClaims,
386    policy: &TokenPolicy<'_>,
387    scopes: &dyn ScopePolicy,
388    now: SystemTime,
389) -> Result<AuthContext, Refusal> {
390    // A revision with no authorization framework has no audience concept to
391    // bind against, so there is no correct way to accept a token for it. Refuse
392    // rather than accept one that cannot be checked.
393    if !policy.model.audience_bound() {
394        return Err(Refusal::AudienceMismatch);
395    }
396
397    if claims.expires_at.is_some_and(|expiry| expiry <= now) {
398        return Err(Refusal::Expired);
399    }
400    if claims.not_before.is_some_and(|start| start > now) {
401        return Err(Refusal::NotYetValid);
402    }
403    if !claims.is_audience(policy.resource) {
404        return Err(Refusal::AudienceMismatch);
405    }
406    if let Some(expected) = &policy.expected_issuer
407        && claims
408            .issuer()
409            .is_some_and(|issuer| issuer != expected.as_ref())
410    {
411        return Err(Refusal::IssuerMismatch);
412    }
413
414    let subject = claims.subject().ok_or(Refusal::NoSubject)?;
415
416    // Union, not intersection: RFC 8693-style tokens can carry several scope
417    // strings and a deployment may map more than one onto the same `Scope`.
418    let mut resolved = ScopeSet::empty();
419    for scope in claims.scopes() {
420        if let Some(scope) = scopes.resolve(scope) {
421            resolved = resolved.with(scope);
422        }
423    }
424    if resolved.is_empty() {
425        return Err(Refusal::Unscoped);
426    }
427
428    Ok(AuthContext::new(
429        scopes.principal(subject, &claims.scopes),
430        resolved,
431    ))
432}
433
434/// Why no claims could be established for a presented token.
435///
436/// Two kinds, and the distinction is the one a client cannot recover from
437/// either: a token that was *refused* is refused, and a token whose validity
438/// could not be *determined* is in a different position. Reporting the second
439/// as the first sends a caller to re-authenticate against an authorization
440/// server that was merely unreachable, and reports a client's credentials as
441/// bad when they were never wrong.
442#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
443pub enum SourceError {
444    /// The authorization server, or this crate's own rules, refused the token.
445    #[error(transparent)]
446    Refused(#[from] Refusal),
447
448    /// The authorization server could not be asked, or its answer was not
449    /// readable.
450    ///
451    /// Carries a reason for the log and nothing else. It must never carry the
452    /// token: this type is the first place a token could end up in a message
453    /// somebody pastes into a bug report.
454    #[error("the authorization server could not be asked: {0}")]
455    Unavailable(String),
456}
457
458/// Where verified claims come from.
459///
460/// The consumer is [`authenticate`], and the line this port draws is the whole
461/// design: an adapter establishes *authenticity* — the signature checked, or the
462/// authorization server asked — and this crate applies *authorization*: the
463/// audience, the expiry, the issuer, the scopes. Neither half can do the other's
464/// job, and in particular a decode is not a verify, which is why
465/// [`TokenClaims`] has no constructor that takes a raw string.
466///
467/// # Object safety
468///
469/// An `async fn` on a trait is native RPITIT and is not object safe. That is
470/// deliberate: the call site takes `impl TokenSource`, so this monomorphises and
471/// pays nothing per call. A deployment that must choose a source at runtime
472/// holds an enum over two variants rather than a `dyn` — which costs less than
473/// the per-request boxing that a trait object here would need.
474pub trait TokenSource: Send + Sync {
475    /// Establishes what `presented` says, or refuses it.
476    ///
477    /// `presented` is the bare token — not an `Authorization` header. Stripping
478    /// the scheme is the transport's job, so that an adapter never has to know
479    /// which header the credential arrived in.
480    fn claims(
481        &self,
482        presented: &str,
483    ) -> impl Future<Output = Result<TokenClaims, SourceError>> + Send;
484}
485
486/// Authenticates a presented bearer token end to end.
487///
488/// [`TokenSource::claims`] for the authenticity, then [`verify`] for the
489/// authorization rules, in that order and with no other path between them.
490///
491/// The token is borrowed and never stored, cloned into a message, or logged —
492/// it exists for the length of this call and then only as whatever the
493/// authorization server chose to say about it.
494///
495/// # Errors
496///
497/// [`SourceError::Refused`] for a token that was refused, and
498/// [`SourceError::Unavailable`] when the authorization server could not be
499/// asked. The two mean different things to a caller and are not merged.
500pub async fn authenticate(
501    source: &impl TokenSource,
502    policy: &TokenPolicy<'_>,
503    scopes: &dyn ScopePolicy,
504    bearer: &str,
505    now: SystemTime,
506) -> Result<AuthContext, SourceError> {
507    let claims = source.claims(bearer).await?;
508    Ok(verify(&claims, policy, scopes, now)?)
509}
510
511#[cfg(test)]
512#[path = "token/tests.rs"]
513mod tests;