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;