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;