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}