Skip to main content

protocol_droid/adapters/secondary/oauth/
jwt.rs

1//! JWT access tokens, checked against the authorization server's published
2//! keys (RFC 7517 JWKS).
3//!
4//! The counterpart to [`introspection`](super::introspection), and the one
5//! that costs no round trip per request. The trade is the usual one: the
6//! resource server decides for itself whether a token is good, so a key the
7//! authorization server has revoked is still good here until the token expires
8//! or the key is withdrawn.
9//!
10//! # What this source checks, and what it leaves to someone else
11//!
12//! It establishes **authenticity** and nothing else. The audience, the clock,
13//! the issuer and the scopes are [`verify`](crate::application::oauth::verify)'s
14//! job, which is why [`JwtSource::claims`] returns a [`TokenClaims`] and not a
15//! [`AuthContext`](crate::domain::registry::AuthContext) — the same two halves
16//! [`IntrospectionSource`](super::introspection::IntrospectionSource) is built
17//! from, so a deployment can swap one for the other without the rules moving.
18//!
19//! The one thing this source does check beyond the signature is the JWS
20//! structure itself, because a token can fail there before any key exists: see
21//! [`JwtSource::claims`] for the `crit` and embedded-`jwk` refusals.
22//!
23//! # The token is never in an error
24//!
25//! Every [`SourceError`] this module produces carries a status, a reason or a
26//! [`Refusal`]. None of them borrows from the token, none of them formats it,
27//! and `verify` sees only claims. A bearer credential that reaches a log line
28//! through a `Display` impl is a credential that has to be rotated.
29
30mod claims;
31mod key_set;
32
33use std::sync::Arc;
34
35use jsonwebtoken::{Algorithm, Validation, decode, decode_header};
36
37use crate::application::oauth::{Refusal, SourceError, TokenClaims, TokenSource};
38
39use self::claims::JwtClaims;
40use self::key_set::{KeyFailure, KeySet};
41
42/// Verifies JWT access tokens against a published key set.
43#[derive(Debug, Clone)]
44pub struct JwtSource {
45    keys: KeySet,
46}
47
48impl JwtSource {
49    /// A source reading keys from `jwks_uri`.
50    ///
51    /// Opens no socket and reads no clock, so a source can be built at startup
52    /// and the first fetch happens on the first request that needs it. A
53    /// deployment that would rather pay that at startup has
54    /// [`warm`](Self::warm).
55    ///
56    /// `accepted` is the algorithms this deployment will verify. It is a
57    /// parameter rather than a default because the safe default is not obvious:
58    /// a list that is too long accepts a downgrade, and a list that is too short
59    /// refuses a signer that is working. `RS256` alone is the common answer,
60    /// and it is what [`default_algorithms`] returns.
61    #[must_use]
62    pub fn new(jwks_uri: impl Into<Arc<str>>, accepted: Vec<Algorithm>) -> Self {
63        Self {
64            keys: KeySet::new(jwks_uri, accepted),
65        }
66    }
67
68    /// Replaces the HTTP client, for a caller that needs a timeout or a proxy.
69    ///
70    /// There is no `with_timeout` shortcut: `reqwest`'s builder returns a
71    /// `Result`, and a shortcut would either swallow that error or force every
72    /// caller through a `Result` for a knob they can already reach.
73    #[must_use]
74    pub fn with_http_client(mut self, http: reqwest::Client) -> Self {
75        self.keys = self.keys.with_http_client(http);
76        self
77    }
78
79    /// Forgets the cached keys so the next request fetches again.
80    ///
81    /// For a deployment told out of band that the authorization server rotated
82    /// its keys, rather than waiting out the refresh floor.
83    pub fn invalidate(&self) {
84        self.keys.invalidate();
85    }
86
87    /// Fetches the key set now, so a missing authorization server is a startup
88    /// failure rather than a `401` under the first real request.
89    ///
90    /// # Errors
91    ///
92    /// A transport or parse failure. The reason is carried; nothing borrowed
93    /// from a token is, because no token has been involved yet.
94    pub async fn warm(&self) -> Result<(), SourceError> {
95        self.keys.warm().await.map_err(|failure| match failure {
96            KeyFailure::Unreadable(reason) => SourceError::Unavailable(reason),
97            // `warm` never selects a key, so this arm is unreachable here
98            // rather than impossible, and reporting it as unavailable is the
99            // conservative reading either way.
100            KeyFailure::NoKey(error) => SourceError::Unavailable(error.to_string()),
101        })
102    }
103
104    /// The algorithms this source will verify.
105    #[must_use]
106    pub fn accepted_algorithms(&self) -> &[Algorithm] {
107        self.keys.accepted()
108    }
109}
110
111/// The algorithms [`JwtSource`] verifies when a deployment expresses no
112/// preference.
113///
114/// `RS256` alone. Not because the others are unsafe, but because every entry
115/// on this list is one an attacker would try, and a deployment that genuinely
116/// signs `RS512` or `ES256` can say so and take on the review that implies.
117#[must_use]
118pub fn default_algorithms() -> Vec<Algorithm> {
119    vec![Algorithm::RS256]
120}
121
122impl TokenSource for JwtSource {
123    async fn claims(&self, presented: &str) -> Result<TokenClaims, SourceError> {
124        let header = decode_header(presented).map_err(|_| Refusal::Malformed)?;
125
126        // RFC 7515 §4.1.11: a `crit` header naming an extension the
127        // recipient does not understand makes the JWS invalid. This crate
128        // implements none, so any value here is one to refuse — and
129        // `jsonwebtoken` parses the field without acting on it, which means
130        // nothing downstream would catch it.
131        if header.crit.as_ref().is_some_and(|crit| !crit.is_empty()) {
132            return Err(Refusal::Malformed.into());
133        }
134        // RFC 7515 §4.1.3 lets the token carry its own key. It is ignored
135        // here — see `key_set` — and saying so at the point the header is
136        // in hand is what keeps it ignored if that module is ever edited.
137        if header.jwk.is_some() {
138            return Err(Refusal::Malformed.into());
139        }
140
141        // One forced refetch per unknown `kid`, which is what makes key
142        // rotation work. `KeySet` rate-limits it, so a caller naming a
143        // random `kid` on every request does not get a fetch every request.
144        let key = self
145            .keys
146            .key_for(&header, true)
147            .await
148            .map_err(map_key_failure)?;
149
150        // Every claim check is switched off here on purpose. `exp`, `nbf`
151        // and `aud` are `verify`'s to apply, and running them twice would
152        // mean the introspection source and this one disagreeing about
153        // which clock and which resource — with the JWT path holding the
154        // stricter of the two and no test saying so.
155        let mut validation = Validation::new(header.alg);
156        validation.validate_exp = false;
157        validation.validate_nbf = false;
158        validation.validate_aud = false;
159        // Default is `{exp}`, which would refuse a token with no expiry
160        // while the introspection path accepts whatever the authorization
161        // server chose to say. `verify` owns that decision.
162        validation.required_spec_claims.clear();
163
164        let payload = decode::<JwtClaims>(presented, &key, &validation)
165            .map_err(|_| Refusal::Malformed)?
166            .claims;
167
168        Ok(payload.into_token_claims())
169    }
170}
171
172/// Maps a key-selection failure onto the two errors a caller can act on.
173///
174/// Two outcomes, and the split between them is the whole point of
175/// [`SourceError`]. A key set that could not be read is
176/// [`SourceError::Unavailable`] — nothing was decided, and the caller should
177/// not be sent to re-authenticate against an authorization server that is
178/// merely unreachable. A token no published key may verify is
179/// [`Refusal::Unverified`]: it parsed, and this server declines to vouch for
180/// it.
181///
182/// The specific [`KeyError`] is deliberately not carried past this point. Every
183/// one of them answers the same question — may this token be used, no — and
184/// `Refusal` is what a caller matches on, so giving seven reasons to a two-arm
185/// decision would only move the choice of which to log to the log site.
186fn map_key_failure(failure: KeyFailure) -> SourceError {
187    match failure {
188        KeyFailure::Unreadable(reason) => SourceError::Unavailable(reason),
189        KeyFailure::NoKey(_) => Refusal::Unverified.into(),
190    }
191}
192
193#[cfg(test)]
194#[path = "jwt/tests.rs"]
195mod tests;
196
197#[cfg(test)]
198impl JwtSource {
199    /// Installs a key set without fetching, for `jwt::tests`.
200    ///
201    /// Not on the public API: a source that takes a key set would be a source
202    /// that does not fetch, which is a different type wearing this one's name.
203    pub(super) fn seed(&self, keys: jsonwebtoken::jwk::JwkSet) {
204        self.keys.seed(keys);
205    }
206}