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//! The rule is equality against a *set of named servers*, not equality against
19//! one. A deployment can add audiences the authorization server actually mints
20//! into [`TokenPolicy::with_expected_audiences`]; the resource identifier
21//! remains accepted throughout, and an identifier nobody configured is still a
22//! mismatch. See that method for why the resource identifier and `aud` are
23//! frequently two different strings.
24//!
25//! **No token means no context.** [`Refusal`] has no variant that means "let it
26//! through unprivileged", because the route's only way to reach a session is
27//! through the `&AuthContext` it was handed, and a context nobody can widen is
28//! the fail-secure default.
29//!
30//! # On scope names
31//!
32//! The claims carry whatever scope strings the authorization server put there,
33//! as strings. The vendored spec names none of them — the only scope sentence in
34//! any revision's changelog is `2025-11-25` §3 (SEP-835) on incremental consent
35//! via `WWW-Authenticate` — so there is no specified vocabulary to map onto
36//! [`Scope`](crate::domain::Scope). Turning `mcp:tools` into
37//! [`Read`](crate::domain::Scope::Read) is a *deployment's* policy, so it lives
38//! behind [`ScopePolicy`] as configuration rather than being hardcoded here as
39//! if the specification had said so.
40
41use std::sync::Arc;
42use std::time::SystemTime;
43
44use crate::domain::mcp::ResourceUri;
45use crate::domain::mcp::authorization::AuthorizationModel;
46use crate::domain::registry::{AuthContext, Scope, ScopeSet};
47
48/// One entry of a token's `aud` claim.
49///
50/// Deliberately *not* a [`ResourceUri`]. RFC 9728 §2 requires a protected-
51/// resource identifier to be an absolute URI, and [`ResourceUri`] enforces
52/// that, but RFC 7519 §4.1.3 puts no such requirement on `aud`: it is defined
53/// as a `StringOrURI`, and an authorization server is entitled to mint any
54/// string it likes. Microsoft Entra's v2.0 tokens carry the web API's client ID
55/// — a bare GUID with no scheme at all — which `ResourceUri::new` refuses.
56///
57/// Modelling `aud` as `ResourceUri` therefore loses two things at once. A
58/// policy cannot name a GUID, so there is nowhere to say "this server accepts
59/// this client ID"; and [`TokenClaims::with_audience_string`] drops every entry
60/// that does not parse, so such a token arrives with an empty audience and is
61/// refused no matter how the policy is configured. One type that holds the
62/// string fixes both.
63///
64/// Comparison is exact, case-sensitive, and never canonicalised — RFC 7519
65/// §4.1.3 defines `aud` as case-sensitive strings, and a value that canonicalises
66/// to something else would be a different identifier to the server that minted
67/// it.
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
69pub struct Audience(Arc<str>);
70
71impl Audience {
72    /// An audience entry from `value`, kept verbatim apart from trimming
73    /// surrounding whitespace.
74    ///
75    /// Never fails. Anything that is not empty is a legal `aud`, and refusing a
76    /// value the authorization server chose to mint is this type's whole reason
77    /// for existing.
78    #[must_use]
79    pub fn new(value: impl AsRef<str>) -> Self {
80        Self(Arc::from(value.as_ref().trim()))
81    }
82
83    /// The entry as the authorization server wrote it.
84    #[must_use]
85    pub fn as_str(&self) -> &str {
86        &self.0
87    }
88}
89
90impl From<&str> for Audience {
91    fn from(value: &str) -> Self {
92        Self::new(value)
93    }
94}
95
96impl From<String> for Audience {
97    fn from(value: String) -> Self {
98        Self::new(value)
99    }
100}
101
102impl From<&ResourceUri> for Audience {
103    /// The canonical form, so a resource identifier and the audience a token
104    /// carries for it compare equal however the token spelled it.
105    fn from(value: &ResourceUri) -> Self {
106        Self::new(value.as_str())
107    }
108}
109
110impl From<ResourceUri> for Audience {
111    fn from(value: ResourceUri) -> Self {
112        Self::new(value.as_str())
113    }
114}
115
116impl std::fmt::Display for Audience {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        f.write_str(&self.0)
119    }
120}
121
122/// What an adapter established about a presented token, before this crate's
123/// own rules ran.
124///
125/// "Established" means the adapter verified the signature (JWT) or asked the
126/// authorization server (introspection). It is deliberately *not* a type that can
127/// be built from a raw string in this crate: there is no path from an
128/// `Authorization` header to a `TokenClaims` that skips the adapter, because
129/// that is the property that keeps a decode from becoming a verify.
130#[derive(Debug, Clone, PartialEq, Eq, Default)]
131pub struct TokenClaims {
132    subject: Option<Arc<str>>,
133    issuer: Option<Arc<str>>,
134    audience: Vec<Audience>,
135    expires_at: Option<SystemTime>,
136    not_before: Option<SystemTime>,
137    scopes: Vec<Arc<str>>,
138}
139
140impl TokenClaims {
141    /// Claims with nothing in them, for an adapter to fill in.
142    #[must_use]
143    pub fn new() -> Self {
144        Self::default()
145    }
146
147    /// Sets the subject — who the token is about.
148    #[must_use]
149    pub fn with_subject(mut self, subject: impl Into<Arc<str>>) -> Self {
150        self.subject = Some(subject.into());
151        self
152    }
153
154    /// Sets the issuer that minted the token.
155    #[must_use]
156    pub fn with_issuer(mut self, issuer: impl Into<Arc<str>>) -> Self {
157        self.issuer = Some(issuer.into());
158        self
159    }
160
161    /// Adds an audience entry.
162    ///
163    /// A builder rather than a setter because RFC 7519 §4.1.3 allows `aud` to
164    /// be either a single string or an array, and both have to land in the same
165    /// place.
166    #[must_use]
167    pub fn with_audience(mut self, audience: impl Into<Audience>) -> Self {
168        self.audience.push(audience.into());
169        self
170    }
171
172    /// Adds an audience entry that arrives as a string.
173    ///
174    /// Retains every value, including one that is not an absolute URI. That is
175    /// the change from the behaviour this replaced: an entry that could not
176    /// parse as a [`ResourceUri`] was dropped on the floor, which silently
177    /// emptied the audience of every token whose `aud` is a bare identifier.
178    /// Microsoft Entra v2.0 does exactly that
179    /// ([per Microsoft](https://learn.microsoft.com/entra/identity-platform/claims-validation#validate-the-audience)),
180    /// so such a token arrived here with nothing to compare against and was
181    /// refused however the policy was configured — and the refusal named a
182    /// mismatch rather than a decode that had thrown the answer away.
183    ///
184    /// Nothing is lost by keeping it: the audience check is exact equality
185    /// against values the deployment named, so an entry nobody configured
186    /// cannot make a token verify.
187    #[must_use]
188    pub fn with_audience_string(mut self, audience: impl AsRef<str>) -> Self {
189        self.audience.push(Audience::new(audience));
190        self
191    }
192
193    /// Sets when the token stops being valid.
194    #[must_use]
195    pub const fn with_expiry(mut self, at: SystemTime) -> Self {
196        self.expires_at = Some(at);
197        self
198    }
199
200    /// Sets when the token starts being valid.
201    #[must_use]
202    pub const fn with_not_before(mut self, at: SystemTime) -> Self {
203        self.not_before = Some(at);
204        self
205    }
206
207    /// Declares a scope the token carries.
208    #[must_use]
209    pub fn with_scope(mut self, scope: impl Into<Arc<str>>) -> Self {
210        self.scopes.push(scope.into());
211        self
212    }
213
214    /// The subject, if the token named one.
215    #[must_use]
216    pub fn subject(&self) -> Option<&str> {
217        self.subject.as_deref()
218    }
219
220    /// The issuer, if the token named one.
221    #[must_use]
222    pub fn issuer(&self) -> Option<&str> {
223        self.issuer.as_deref()
224    }
225
226    /// Every audience entry, as the authorization server wrote it.
227    #[must_use]
228    pub fn audience(&self) -> &[Audience] {
229        &self.audience
230    }
231
232    /// The scope strings, untranslated.
233    #[must_use]
234    pub fn scopes(&self) -> &[Arc<str>] {
235        &self.scopes
236    }
237
238    /// Whether the token is bound to `audience`.
239    ///
240    /// Exact, case-sensitive equality against a canonicalised
241    /// [`ResourceUri`], and equality against *any* element of `aud` — which is
242    /// what RFC 7519 §4.1.3 means by an array audience. "Contains the audience"
243    /// is the same predicate; what it is *not* is "contains the audience's
244    /// host", which is the check that would let a sibling server's token
245    /// through.
246    #[must_use]
247    pub fn is_audience(&self, audience: &ResourceUri) -> bool {
248        self.audience
249            .iter()
250            .any(|entry| entry.as_str() == audience.as_str())
251    }
252
253    /// Whether the token is bound to the audience `audience` names.
254    ///
255    /// The string form, for an `aud` that is not an absolute URI — a bare
256    /// GUID, which is what a Microsoft Entra v2.0 token carries. Same exact
257    /// equality as [`Self::is_audience`]; there is deliberately no prefix or
258    /// host matching anywhere in this check.
259    #[must_use]
260    pub fn is_audience_str(&self, audience: &str) -> bool {
261        self.audience.iter().any(|entry| entry.as_str() == audience)
262    }
263}
264
265/// Why a presented token was refused.
266///
267/// Every variant is a distinct reason a `401` might be correct, and the HTTP
268/// layer does not get to choose between them on the way out: an expired token
269/// and a wrong-audience token produce the same status and the same challenge,
270/// because telling a caller *which* failed is telling it something about the
271/// token it should not learn.
272#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
273pub enum Refusal {
274    /// The token had expired by `now`.
275    #[error("the access token has expired")]
276    Expired,
277
278    /// The token's `nbf` is in the future.
279    #[error("the access token is not valid yet")]
280    NotYetValid,
281
282    /// The token is bound to some other resource.
283    ///
284    /// The one refusal worth naming in a log rather than in a response body.
285    #[error("the access token is not bound to this resource")]
286    AudienceMismatch,
287
288    /// The token named an issuer, and it is not the one expected.
289    ///
290    /// Only reachable when the deployment configured an expected issuer.
291    #[error("the access token was issued by an unexpected issuer")]
292    IssuerMismatch,
293
294    /// The token had no subject, so it names nobody to attribute an action to.
295    #[error("the access token names no subject")]
296    NoSubject,
297
298    /// The token carried no scope this deployment recognises.
299    ///
300    /// A refusal rather than an empty [`ScopeSet`], so that "valid token, no
301    /// permissions" and "valid token, permissions the operator forgot to map"
302    /// are distinguishable in a log even though both answer `401`.
303    #[error("the access token carries no recognised scope")]
304    Unscoped,
305
306    /// The token was not a well-formed JWS at all.
307    ///
308    /// Added when the JWT source arrived, which is the first
309    /// [`TokenSource`] that can meet a token it cannot even parse. It is a
310    /// refusal rather than [`SourceError::Unavailable`] because nothing was
311    /// unavailable: the bytes arrived, and they were not a token. Filing it
312    /// under `Unavailable` would send a caller to re-authenticate against an
313    /// authorization server that answered perfectly well — and would report a
314    /// client's credentials as bad when the real fault was a middlebox
315    /// mangling a header.
316    #[error("the access token is not a well-formed JWS")]
317    Malformed,
318
319    /// The token parsed, but its signature could not be established as genuine.
320    ///
321    /// Distinct from `Malformed` and from every rule in `verify`, and both
322    /// distinctions are load-bearing. A malformed token is the caller's bug and
323    /// can be reported as one; an unverifiable one is either a token this
324    /// server should not accept or a key it could not fetch, and a log that
325    /// collapses the two sends an on-call engineer to the wrong system.
326    ///
327    /// Also the landing place for a bad signature. Folding that into
328    /// `AudienceMismatch` would be tempting and wrong: an expired token and a
329    /// forged one would then look identical in a log, which is the one
330    /// distinction a forgery attack is trying to hide.
331    #[error("the access token's signature could not be verified")]
332    Unverified,
333}
334
335/// What a deployment decides its own OAuth scope strings mean.
336///
337/// Not in `domain/`, and deliberately not a hardcoded table: the vendored spec
338/// names no scope strings, so any mapping written here would be this crate
339/// inventing a vocabulary and presenting it as policy. A deployment supplies the
340/// vocabulary it actually issues, and the default maps nothing — which means an
341/// unconfigured deployment refuses every token rather than granting one a scope
342/// nobody chose.
343pub trait ScopePolicy: Send + Sync {
344    /// The scope one authorization-server string confers, if any.
345    fn resolve(&self, scope: &str) -> Option<Scope>;
346
347    /// The principal name to record, given the token's subject and every scope
348    /// it carries.
349    ///
350    /// Separate from [`Self::resolve`] because the two are separate decisions:
351    /// which scopes a token has is the authorization server's statement, while
352    /// what to call the principal is entirely local. A deployment that
353    /// authenticates every user as `"user"` is not contradicting the token.
354    fn principal(&self, subject: &str, scopes: &[Arc<str>]) -> String {
355        let _ = scopes;
356        subject.to_owned()
357    }
358}
359
360/// The fail-secure default: no scope string means anything.
361///
362/// Every token is refused with [`Refusal::Unscoped`] until a deployment supplies
363/// a real policy. That is the whole design — a missing configuration must deny,
364/// and it must deny *loudly* with a reason that says it was a configuration
365/// rather than a forged token.
366#[derive(Debug, Clone, Copy, Default)]
367pub struct NoScopes;
368
369impl ScopePolicy for NoScopes {
370    fn resolve(&self, _scope: &str) -> Option<Scope> {
371        None
372    }
373}
374
375/// What the request is allowed to be, given the resource it reached and the
376/// revision it negotiated.
377///
378/// Two deployment facts and one spec fact, all of which change whether a token
379/// is checked at all. Bundled because passing three arguments to every call site
380/// invites two of them being forgotten.
381#[derive(Debug, Clone)]
382pub struct TokenPolicy<'a> {
383    /// The resource identifier a token must be bound to.
384    ///
385    /// Always accepted, whether or not [`Self::expected_audiences`] names
386    /// anything else, because it is the identifier RFC 9728 derives the
387    /// metadata path from.
388    resource: &'a ResourceUri,
389
390    /// The revision's authorization model, from
391    /// `authorization_model`.
392    model: AuthorizationModel,
393
394    /// The one issuer this server accepts, if it has been configured with one.
395    expected_issuer: Option<Arc<str>>,
396
397    /// Audiences accepted *in addition to* [`Self::resource`], if any.
398    expected_audiences: Option<Arc<[Audience]>>,
399}
400
401impl<'a> TokenPolicy<'a> {
402    /// A policy that accepts any issuer.
403    ///
404    /// # Examples
405    ///
406    /// ```
407    /// use protocol_droid::domain::{ProtocolVersion, authorization_model};
408    /// use protocol_droid::domain::mcp::ResourceUri;
409    /// use protocol_droid::application::oauth::TokenPolicy;
410    ///
411    /// let resource = ResourceUri::new("https://example.com/mcp")?;
412    /// let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
413    /// assert!(policy.accepts_any_issuer());
414    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
415    /// ```
416    #[must_use]
417    pub const fn new(resource: &'a ResourceUri, model: AuthorizationModel) -> Self {
418        Self {
419            resource,
420            model,
421            expected_issuer: None,
422            expected_audiences: None,
423        }
424    }
425
426    /// Requires tokens to name `issuer`, refusing any minted by anyone else.
427    #[must_use]
428    pub fn with_expected_issuer(mut self, issuer: impl Into<Arc<str>>) -> Self {
429        self.expected_issuer = Some(issuer.into());
430        self
431    }
432
433    /// Accepts tokens bound to `audience` as well as to the resource.
434    ///
435    /// The single-audience form of [`Self::with_expected_audiences`]; see that
436    /// method for what this is for and why the resource stays accepted.
437    ///
438    /// # Examples
439    ///
440    /// ```
441    /// use protocol_droid::application::oauth::TokenPolicy;
442    /// use protocol_droid::domain::mcp::ResourceUri;
443    /// use protocol_droid::domain::{ProtocolVersion, authorization_model};
444    ///
445    /// let resource = ResourceUri::new("https://example.com/mcp")?;
446    /// // A Microsoft Entra v2.0 token carries the web API's client ID in `aud`.
447    /// let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28))
448    ///     .with_expected_audience("8f7c1e42-0b3a-4f5d-9c6e-1a2b3c4d5e6f");
449    ///
450    /// assert_eq!(policy.expected_audiences().map(<[Audience]>::len), Some(1));
451    /// # use protocol_droid::application::oauth::Audience;
452    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
453    /// ```
454    #[must_use]
455    pub fn with_expected_audience(mut self, audience: impl Into<Audience>) -> Self {
456        self.expected_audiences = Some(Arc::from([audience.into()]));
457        self
458    }
459
460    /// Accepts tokens bound to any of `audiences`, as well as to the resource.
461    ///
462    /// # Why this exists
463    ///
464    /// The resource identifier and the `aud` claim are frequently two different
465    /// strings, and binding them together refuses valid tokens. RFC 9728 §3.1
466    /// derives the metadata URL *from* the resource identifier, so the resource
467    /// must be the HTTPS URL this server answers on — but that URL need not be
468    /// what the authorization server writes into `aud`.
469    ///
470    /// Microsoft Entra is the case that forced it.
471    /// [Validate the audience](https://learn.microsoft.com/entra/identity-platform/claims-validation#validate-the-audience)
472    /// states that for **v2.0** tokens the audience is the **client ID** of the
473    /// web API — a bare GUID — while for v1.0 tokens it is one of the app ID
474    /// URIs, such as `api://{ApplicationID}`. So three identifiers are in play,
475    /// and conflating any two of them breaks:
476    ///
477    /// | identifier | example | role |
478    /// | --- | --- | --- |
479    /// | Application ID URI | `api://<guid>` | what a client asks for as `scope` |
480    /// | client ID | `<guid>` | `aud` in a v2.0 token |
481    /// | RFC 9728 resource | `https://host/mcp` | derives the metadata path; RFC 8707 indicator |
482    ///
483    /// A deployment fronted by Entra therefore configures the GUID here and keeps
484    /// the HTTPS URL as the resource, and both verify.
485    ///
486    /// # What this does not change
487    ///
488    /// Comparison is still exact equality against each canonicalised
489    /// [`ResourceUri`], never "contains" and never a host or path prefix, so a
490    /// sibling server's token still fails. The resource identifier stays
491    /// accepted, so adding audiences can only widen what this deployment already
492    /// trusted — it never withdraws the RFC 8707 binding. Accepting an audience
493    /// is a deliberate act by whoever holds the configuration; the default is
494    /// `None`, which leaves behaviour exactly as it was.
495    ///
496    /// A repeated call replaces the set rather than extending it, so the
497    /// configured audiences are always the ones the last call named.
498    ///
499    /// # Examples
500    ///
501    /// ```
502    /// use protocol_droid::application::oauth::TokenPolicy;
503    /// use protocol_droid::domain::mcp::ResourceUri;
504    /// use protocol_droid::domain::{ProtocolVersion, authorization_model};
505    ///
506    /// let resource = ResourceUri::new("https://mcp.example.com/mcp")?;
507    /// let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28))
508    ///     .with_expected_audiences([
509    ///         // A v2.0 client ID, and a v1.0 app ID URI.
510    ///         "8f7c1e42-0b3a-4f5d-9c6e-1a2b3c4d5e6f",
511    ///         "api://8f7c1e42-0b3a-4f5d-9c6e-1a2b3c4d5e6f",
512    ///     ]);
513    ///
514    /// assert_eq!(policy.expected_audiences().map(<[Audience]>::len), Some(2));
515    /// # use protocol_droid::application::oauth::Audience;
516    /// # Ok::<(), protocol_droid::domain::mcp::ResourceUriError>(())
517    /// ```
518    #[must_use]
519    pub fn with_expected_audiences<I, T>(mut self, audiences: I) -> Self
520    where
521        I: IntoIterator<Item = T>,
522        T: Into<Audience>,
523    {
524        self.expected_audiences = Some(Arc::from(
525            audiences.into_iter().map(Into::into).collect::<Vec<_>>(),
526        ));
527        self
528    }
529
530    /// The audiences accepted in addition to the resource, if any were set.
531    #[must_use]
532    pub fn expected_audiences(&self) -> Option<&[Audience]> {
533        self.expected_audiences.as_deref()
534    }
535
536    /// Whether this policy accepts a token naming the resource, or — when the
537    /// deployment configured any — one of those audiences instead.
538    ///
539    /// The resource is checked first so the common case costs one comparison
540    /// and never walks the additional set.
541    #[must_use]
542    fn accepts_audience(&self, claims: &TokenClaims) -> bool {
543        if claims.is_audience(self.resource) {
544            return true;
545        }
546        self.expected_audiences
547            .as_deref()
548            .is_some_and(|audiences| audiences.iter().any(|a| claims.is_audience_str(a.as_str())))
549    }
550
551    /// Whether an issuer check will be made.
552    #[must_use]
553    pub const fn accepts_any_issuer(&self) -> bool {
554        self.expected_issuer.is_none()
555    }
556}
557
558/// Turns claims an adapter authenticated into an [`AuthContext`], or refuses.
559///
560/// # Errors
561///
562/// The [`Refusal`] naming the first rule `claims` fails. Order is deliberate:
563/// time is checked before audience so a stale token does not report a mismatch,
564/// and audience before issuer because the audience is the binding that keeps a
565/// token from crossing between servers, which is the one this crate must not get
566/// wrong.
567///
568/// # Examples
569///
570/// ```
571/// use std::time::{Duration, SystemTime};
572///
573/// use protocol_droid::application::oauth::{ScopePolicy, TokenClaims, TokenPolicy, verify};
574/// use protocol_droid::domain::mcp::ResourceUri;
575/// use protocol_droid::domain::registry::{Scope, ScopeSet};
576/// use protocol_droid::domain::{ProtocolVersion, authorization_model};
577///
578/// struct Permissive;
579/// impl ScopePolicy for Permissive {
580///     fn resolve(&self, scope: &str) -> Option<Scope> {
581///         match scope {
582///             "mcp:read" => Some(Scope::Read),
583///             _ => None,
584///         }
585///     }
586/// }
587///
588/// let resource = ResourceUri::new("https://example.com/mcp")?;
589/// let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000);
590/// let policy = TokenPolicy::new(&resource, authorization_model(ProtocolVersion::V2026_07_28));
591///
592/// let good = TokenClaims::new()
593///     .with_subject("alice")
594///     .with_audience(resource.clone())
595///     .with_expiry(now + Duration::from_secs(60))
596///     .with_scope("mcp:read");
597///
598/// let context = verify(&good, &policy, &Permissive, now)?;
599/// assert_eq!(context.principal(), "alice");
600/// assert!(context.holds(ScopeSet::empty().with(Scope::Read)));
601///
602/// // A token minted for a different server is refused.
603/// let elsewhere = TokenClaims::new()
604///     .with_subject("alice")
605///     .with_audience(ResourceUri::new("https://evil.example/mcp")?)
606///     .with_expiry(now + Duration::from_secs(60));
607/// assert!(verify(&elsewhere, &policy, &Permissive, now).is_err());
608/// # Ok::<(), Box<dyn std::error::Error>>(())
609/// ```
610pub fn verify(
611    claims: &TokenClaims,
612    policy: &TokenPolicy<'_>,
613    scopes: &dyn ScopePolicy,
614    now: SystemTime,
615) -> Result<AuthContext, Refusal> {
616    // A revision with no authorization framework has no audience concept to
617    // bind against, so there is no correct way to accept a token for it. Refuse
618    // rather than accept one that cannot be checked.
619    if !policy.model.audience_bound() {
620        return Err(Refusal::AudienceMismatch);
621    }
622
623    if claims.expires_at.is_some_and(|expiry| expiry <= now) {
624        return Err(Refusal::Expired);
625    }
626    if claims.not_before.is_some_and(|start| start > now) {
627        return Err(Refusal::NotYetValid);
628    }
629    if !policy.accepts_audience(claims) {
630        return Err(Refusal::AudienceMismatch);
631    }
632    if let Some(expected) = &policy.expected_issuer
633        && claims
634            .issuer()
635            .is_some_and(|issuer| issuer != expected.as_ref())
636    {
637        return Err(Refusal::IssuerMismatch);
638    }
639
640    let subject = claims.subject().ok_or(Refusal::NoSubject)?;
641
642    // Union, not intersection: RFC 8693-style tokens can carry several scope
643    // strings and a deployment may map more than one onto the same `Scope`.
644    let mut resolved = ScopeSet::empty();
645    for scope in claims.scopes() {
646        if let Some(scope) = scopes.resolve(scope) {
647            resolved = resolved.with(scope);
648        }
649    }
650    if resolved.is_empty() {
651        return Err(Refusal::Unscoped);
652    }
653
654    Ok(AuthContext::new(
655        scopes.principal(subject, &claims.scopes),
656        resolved,
657    ))
658}
659
660/// Why no claims could be established for a presented token.
661///
662/// Two kinds, and the distinction is the one a client cannot recover from
663/// either: a token that was *refused* is refused, and a token whose validity
664/// could not be *determined* is in a different position. Reporting the second
665/// as the first sends a caller to re-authenticate against an authorization
666/// server that was merely unreachable, and reports a client's credentials as
667/// bad when they were never wrong.
668#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
669pub enum SourceError {
670    /// The authorization server, or this crate's own rules, refused the token.
671    #[error(transparent)]
672    Refused(#[from] Refusal),
673
674    /// The authorization server could not be asked, or its answer was not
675    /// readable.
676    ///
677    /// Carries a reason for the log and nothing else. It must never carry the
678    /// token: this type is the first place a token could end up in a message
679    /// somebody pastes into a bug report.
680    #[error("the authorization server could not be asked: {0}")]
681    Unavailable(String),
682}
683
684/// Where verified claims come from.
685///
686/// The consumer is [`authenticate`], and the line this port draws is the whole
687/// design: an adapter establishes *authenticity* — the signature checked, or the
688/// authorization server asked — and this crate applies *authorization*: the
689/// audience, the expiry, the issuer, the scopes. Neither half can do the other's
690/// job, and in particular a decode is not a verify, which is why
691/// [`TokenClaims`] has no constructor that takes a raw string.
692///
693/// # Object safety
694///
695/// An `async fn` on a trait is native RPITIT and is not object safe. That is
696/// deliberate: the call site takes `impl TokenSource`, so this monomorphises and
697/// pays nothing per call. A deployment that must choose a source at runtime
698/// holds an enum over two variants rather than a `dyn` — which costs less than
699/// the per-request boxing that a trait object here would need.
700pub trait TokenSource: Send + Sync {
701    /// Establishes what `presented` says, or refuses it.
702    ///
703    /// `presented` is the bare token — not an `Authorization` header. Stripping
704    /// the scheme is the transport's job, so that an adapter never has to know
705    /// which header the credential arrived in.
706    fn claims(
707        &self,
708        presented: &str,
709    ) -> impl Future<Output = Result<TokenClaims, SourceError>> + Send;
710}
711
712/// Authenticates a presented bearer token end to end.
713///
714/// [`TokenSource::claims`] for the authenticity, then [`verify`] for the
715/// authorization rules, in that order and with no other path between them.
716///
717/// The token is borrowed and never stored, cloned into a message, or logged —
718/// it exists for the length of this call and then only as whatever the
719/// authorization server chose to say about it.
720///
721/// # Errors
722///
723/// [`SourceError::Refused`] for a token that was refused, and
724/// [`SourceError::Unavailable`] when the authorization server could not be
725/// asked. The two mean different things to a caller and are not merged.
726pub async fn authenticate(
727    source: &impl TokenSource,
728    policy: &TokenPolicy<'_>,
729    scopes: &dyn ScopePolicy,
730    bearer: &str,
731    now: SystemTime,
732) -> Result<AuthContext, SourceError> {
733    let claims = source.claims(bearer).await?;
734    Ok(verify(&claims, policy, scopes, now)?)
735}
736
737#[cfg(test)]
738#[path = "token/tests.rs"]
739mod tests;