Skip to main content

authentication_verifier/
lib.rs

1//! Offline RS256 JWT verification against the authentication-service JWKS.
2//!
3//! The [`authentication-service`] is the federation's single auth
4//! provider. It signs RS256 access tokens and publishes its public keys
5//! at `/.well-known/jwks.json`. Every other service verifies those
6//! tokens *offline*: fetch the JWKS once at boot, build a [`Verifier`],
7//! then call [`Verifier::verify`] per request. There is no shared secret
8//! and no per-request introspection call.
9//!
10//! This crate is the peer-side mirror of the auth-service's own
11//! `auth::verify_token`: same RS256 algorithm, same [`Claims`] shape,
12//! same `kid` selection — but keyed off the *published* JWKS rather than
13//! a locally held private key, so any service can embed it.
14//!
15//! ```no_run
16//! # use authentication_verifier::Verifier;
17//! let jwks: serde_json::Value = serde_json::json!({ "keys": [] });
18//! let verifier = Verifier::from_jwks_value(&jwks, "authentication-service", "main-x-service")?;
19//! let claims = verifier.verify("eyJhbGci...")?;
20//! println!("authenticated subject: {}", claims.sub);
21//! # Ok::<(), authentication_verifier::VerifyError>(())
22//! ```
23//!
24//! [`authentication-service`]: https://github.com/sixarm/authentication-service-rust-crate
25
26#![forbid(unsafe_code)]
27#![warn(clippy::pedantic)]
28#![deny(missing_docs)]
29
30use std::collections::HashMap;
31
32use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
33use serde::{Deserialize, Serialize};
34
35/// Verified token claims. Mirrors the auth-service `Claims` exactly so a
36/// token signed there round-trips here. `sub` carries the user `pid`.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Claims {
39    /// Subject — the user `pid` (UUID string).
40    pub sub: String,
41    /// User email, for convenience at the edge.
42    pub email: String,
43    /// Display name.
44    pub name: String,
45    /// Issuer (`iss`).
46    pub iss: String,
47    /// Audience (`aud`).
48    pub aud: String,
49    /// Expiry (unix seconds).
50    pub exp: i64,
51    /// Issued-at (unix seconds).
52    pub iat: i64,
53    /// JWT id — also the auth-service `sessions.jid`.
54    pub jti: String,
55}
56
57/// Failure modes for JWKS loading and token verification.
58#[derive(Debug, thiserror::Error)]
59pub enum VerifyError {
60    /// The JWKS document was missing or structurally invalid.
61    #[error("malformed jwks: {0}")]
62    Jwks(String),
63    /// The token header carried no `kid`, so no key could be selected.
64    #[error("token header has no kid")]
65    MissingKid,
66    /// No verification key matched the token's `kid`. The signer is
67    /// unknown to this JWKS (stale cache, wrong issuer, or forgery).
68    #[error("no verification key for kid {0:?}")]
69    UnknownKid(String),
70    /// Signature, issuer, audience, or expiry validation failed.
71    #[error("token verification failed: {0}")]
72    Jwt(#[from] jsonwebtoken::errors::Error),
73    /// Fetching the JWKS over HTTP failed (only with the `fetch` feature).
74    #[cfg(feature = "fetch")]
75    #[error("jwks fetch failed: {0}")]
76    Fetch(String),
77}
78
79/// A set of RSA verification keys (indexed by `kid`) plus the issuer /
80/// audience policy applied to every token. Construct once at boot, then
81/// share behind an `Arc` and call [`verify`](Verifier::verify) per
82/// request — verification is read-only and allocation-light.
83pub struct Verifier {
84    keys: HashMap<String, DecodingKey>,
85    validation: Validation,
86}
87
88impl Verifier {
89    /// Build a verifier from an in-memory JWKS document, validating
90    /// tokens against `issuer` (`iss`) and `audience` (`aud`).
91    ///
92    /// Only RSA signing keys are loaded; entries with a non-`RSA` `kty`
93    /// are skipped (the auth-service publishes RS256 only). An empty key
94    /// set is permitted — it yields a verifier that rejects every token
95    /// with [`VerifyError::UnknownKid`], which lets a service boot before
96    /// its JWKS source is reachable without panicking.
97    ///
98    /// # Errors
99    ///
100    /// [`VerifyError::Jwks`] when the document lacks a `keys` array, a key
101    /// is missing `kid` / `n` / `e`, or the modulus/exponent are not
102    /// valid RSA components.
103    pub fn from_jwks_value(
104        jwks: &serde_json::Value,
105        issuer: &str,
106        audience: &str,
107    ) -> Result<Self, VerifyError> {
108        let entries = jwks
109            .get("keys")
110            .and_then(serde_json::Value::as_array)
111            .ok_or_else(|| VerifyError::Jwks("missing \"keys\" array".to_string()))?;
112
113        let mut keys = HashMap::new();
114        for jwk in entries {
115            let kty = jwk.get("kty").and_then(serde_json::Value::as_str);
116            if kty != Some("RSA") {
117                continue;
118            }
119            let kid = jwk
120                .get("kid")
121                .and_then(serde_json::Value::as_str)
122                .ok_or_else(|| VerifyError::Jwks("rsa jwk missing \"kid\"".to_string()))?;
123            let n = jwk
124                .get("n")
125                .and_then(serde_json::Value::as_str)
126                .ok_or_else(|| VerifyError::Jwks(format!("jwk {kid} missing \"n\"")))?;
127            let e = jwk
128                .get("e")
129                .and_then(serde_json::Value::as_str)
130                .ok_or_else(|| VerifyError::Jwks(format!("jwk {kid} missing \"e\"")))?;
131            let key = DecodingKey::from_rsa_components(n, e)
132                .map_err(|err| VerifyError::Jwks(format!("jwk {kid}: {err}")))?;
133            keys.insert(kid.to_string(), key);
134        }
135
136        let mut validation = Validation::new(Algorithm::RS256);
137        validation.set_issuer(&[issuer]);
138        validation.set_audience(&[audience]);
139        Ok(Self { keys, validation })
140    }
141
142    /// Number of RSA verification keys loaded from the JWKS.
143    #[must_use]
144    pub fn key_count(&self) -> usize {
145        self.keys.len()
146    }
147
148    /// Verify an RS256 bearer token: select the key by the header `kid`,
149    /// check the signature, then enforce issuer, audience, and expiry.
150    ///
151    /// # Errors
152    ///
153    /// [`VerifyError::MissingKid`] / [`VerifyError::UnknownKid`] when no
154    /// key matches, or [`VerifyError::Jwt`] when signature/claim
155    /// validation fails.
156    pub fn verify(&self, token: &str) -> Result<Claims, VerifyError> {
157        let header = decode_header(token)?;
158        let kid = header.kid.ok_or(VerifyError::MissingKid)?;
159        let key = self.keys.get(&kid).ok_or(VerifyError::UnknownKid(kid))?;
160        let data = decode::<Claims>(token, key, &self.validation)?;
161        Ok(data.claims)
162    }
163}
164
165#[cfg(feature = "fetch")]
166impl Verifier {
167    /// Fetch the JWKS from `url` over HTTPS and build a verifier. Call
168    /// once at boot; the auth-service rotates keys rarely, so a process
169    /// can cache the result for its lifetime (or refetch on
170    /// [`VerifyError::UnknownKid`] to pick up a rotation).
171    ///
172    /// # Errors
173    ///
174    /// [`VerifyError::Fetch`] on any transport / non-2xx / decode error,
175    /// or [`VerifyError::Jwks`] when the fetched body is not a valid JWKS.
176    pub async fn from_jwks_url(
177        url: &str,
178        issuer: &str,
179        audience: &str,
180    ) -> Result<Self, VerifyError> {
181        let body = reqwest::get(url)
182            .await
183            .map_err(|e| VerifyError::Fetch(e.to_string()))?
184            .error_for_status()
185            .map_err(|e| VerifyError::Fetch(e.to_string()))?
186            .json::<serde_json::Value>()
187            .await
188            .map_err(|e| VerifyError::Fetch(e.to_string()))?;
189        Self::from_jwks_value(&body, issuer, audience)
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use base64::Engine;
197    use jsonwebtoken::{EncodingKey, Header};
198    use rsa::pkcs8::DecodePublicKey;
199    use rsa::traits::PublicKeyParts;
200    use sha2::{Digest, Sha256};
201
202    // Throwaway 2048-bit RSA keypair, used only to exercise the
203    // verifier offline (sign here with the private key, verify against a
204    // JWKS built from the public key). Not used anywhere in production.
205    const TEST_PRIVATE_PEM: &str = "-----BEGIN PRIVATE KEY-----\n\
206MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCb8A4bMels7eEL\n\
207TCFMcXyJTMdQ0k2klAxkPjPRHdAtwzV+6uLjKBlI2NTubkUl0BcKLKpX1G9mDjci\n\
208Qiga5frQ/QX9IedKGdU3aLo0BvpewAK2TmRwDe48i3d+uGWPbDcDd9DG0hEz6ugd\n\
209CXeCcXekdcsX8e8bF9E4FYK+2DaI9xiVhMQS8xSY7Mt3Os/T8GBT27/JdKkzoX2Z\n\
210ntYiOOhxwcwztS+W8y1qVD6b39hPCo2vbl+nyFdN6tz00L/qakJMV4AJjXOYhT5N\n\
211XBYII92vmlznYNWNTtBymmaGhes7RbIb9AvgC2PJZdk57Sxl0tP71CsIX2P/RjPD\n\
212AoExpOfDAgMBAAECggEAKwLNGUIslM+OJZwTiS66P3KufUPsh4sQWevwRes3uw+f\n\
213Z0jpWOd8BeRM4xEGQJZDbJqCR6SAL4GXQntF7Zlmk5NevgHGdmFmtphL18Le9xh2\n\
214BwvbVy74ebmsNYct+B/MksfPDa/ub8gIys2MKa4bZoDZCltAbNQmcJY6UGJ5tFAp\n\
215ItKFgoA8wnxJroUGcw1r7B8WRyxBGxSqjYVmwtRyUcbea1gCVfCXGwkkVgv42Miu\n\
216YY6C7y9e0zlwcAXdkZGTgfYlz/hiBWd2xAg+tGV2bwVBRlX3Io1qcNUEphOHDp+l\n\
217iTf/E8DkLvln3J9DsBD6jscWE8lK8HDzLHPpT1EkSQKBgQDQ7KW2so/ZMPMvLo+R\n\
218j61XUqvRW9sJgsQvZsukGt+dbq/YDJho6J0mu2OC8Sag2ZvLezGmGdjMkUHIvza/\n\
2193KpHau4vPG0LByqZv2sF+9XAOLo5YAVPZlZFb8egYH7X2bOmVaupyGKSsmcMUsHa\n\
220x7jZf84vIY033RcLhirjDDg6ywKBgQC/Evw1CXby8WVKBPNB8pr7VKE6oy6DK76d\n\
221TESJiirq1Z8am1WKxOzvo9OlZPibfuZKeY/XiF2fj5YHWc1xR7S7+OPMlwZdVx64\n\
222h7Iv3j9yFS7jwX1AVxmOB/b48ki+QEItTEiQJqEQXiFEGr0MgI6fTj/opKEltv5L\n\
2236cfdqOCP6QKBgQCu4LchQzvnV/Lm1nl0JSi6REfvuYyR3HRtHQVuOtRcig8EsB5P\n\
224Cg6pIgd8znBACYY//8GiQFZZfWjsKSoh1QpvN1FiFplLttbw1Oo3mwHjoVg3uGkZ\n\
225ehbSjmsxkjP6Z47ZtzI2rrXcBxr8lLURdUYEQNeMWfBEB3tHuSli3ZKfmwKBgQCn\n\
226YfdEcuUbz7H+lLWQqPlxgGK5HmhJilGyNDS6FCqii76ULU1Tgk1ZZLesZPaQKSuO\n\
227RE1o71GszLkN+XJKcRl3rYHJIOf3brE/z8edvWDxDHOGG2MgsOx3Cq0kygJFf785\n\
228NWE/vkdMMlmL8qx3vkqybXb40vdENbkxQTvQBvepuQKBgGHKxTj314X4xObeQ5BY\n\
229yteRm758Id7MoieW0dYZC62a05STaiyCB5ulVCijNa66616uxyAMhquKru9xT1Bt\n\
230zGUi4GKlyqqAH7webJPHDR58z5Jj4XqAblYzRFY3nqSAd6lxdjMdIftQi0xrG6+x\n\
231UOsbyJ6S44rLeDtZ9KGxR0gS\n\
232-----END PRIVATE KEY-----\n";
233
234    const TEST_PUBLIC_PEM: &str = "-----BEGIN PUBLIC KEY-----\n\
235MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm/AOGzHpbO3hC0whTHF8\n\
236iUzHUNJNpJQMZD4z0R3QLcM1furi4ygZSNjU7m5FJdAXCiyqV9RvZg43IkIoGuX6\n\
2370P0F/SHnShnVN2i6NAb6XsACtk5kcA3uPIt3frhlj2w3A3fQxtIRM+roHQl3gnF3\n\
238pHXLF/HvGxfROBWCvtg2iPcYlYTEEvMUmOzLdzrP0/BgU9u/yXSpM6F9mZ7WIjjo\n\
239ccHMM7UvlvMtalQ+m9/YTwqNr25fp8hXTerc9NC/6mpCTFeACY1zmIU+TVwWCCPd\n\
240r5pc52DVjU7QcppmhoXrO0WyG/QL4AtjyWXZOe0sZdLT+9QrCF9j/0YzwwKBMaTn\n\
241wwIDAQAB\n\
242-----END PUBLIC KEY-----\n";
243
244    const ISSUER: &str = "authentication-service";
245    const AUDIENCE: &str = "main-x-service";
246
247    // Build a JWKS document from the test public key, mirroring exactly
248    // how the auth-service derives (kid, n, e) in `auth::load_keys`.
249    fn test_jwks() -> (serde_json::Value, String) {
250        let pub_key = rsa::RsaPublicKey::from_public_key_pem(TEST_PUBLIC_PEM).expect("parse pub");
251        let n_bytes = pub_key.n().to_bytes_be();
252        let e_bytes = pub_key.e().to_bytes_be();
253        let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
254        let n = b64.encode(&n_bytes);
255        let e = b64.encode(&e_bytes);
256        let kid = b64.encode(Sha256::digest(&n_bytes));
257        let jwks = serde_json::json!({
258            "keys": [{
259                "kty": "RSA", "use": "sig", "alg": "RS256",
260                "kid": kid, "n": n, "e": e,
261            }]
262        });
263        (jwks, kid)
264    }
265
266    fn sign(kid: &str, claims: &Claims) -> String {
267        let mut header = Header::new(Algorithm::RS256);
268        header.kid = Some(kid.to_string());
269        let key = EncodingKey::from_rsa_pem(TEST_PRIVATE_PEM.as_bytes()).expect("encoding key");
270        jsonwebtoken::encode(&header, claims, &key).expect("encode")
271    }
272
273    fn claims(exp_offset: i64) -> Claims {
274        // Fixed iat well in the past; exp relative to a fixed "now" so the
275        // test stays deterministic without reading the clock for the body.
276        let now = 1_900_000_000; // year 2030, comfortably non-expired
277        Claims {
278            sub: "11111111-1111-1111-1111-111111111111".to_string(),
279            email: "alice@example.com".to_string(),
280            name: "Alice".to_string(),
281            iss: ISSUER.to_string(),
282            aud: AUDIENCE.to_string(),
283            exp: now + exp_offset,
284            iat: now,
285            jti: "22222222-2222-2222-2222-222222222222".to_string(),
286        }
287    }
288
289    #[test]
290    fn valid_token_round_trips_claims() {
291        let (jwks, kid) = test_jwks();
292        let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
293        assert_eq!(verifier.key_count(), 1);
294
295        let token = sign(&kid, &claims(3600));
296        let got = verifier.verify(&token).expect("verify");
297        assert_eq!(got.sub, "11111111-1111-1111-1111-111111111111");
298        assert_eq!(got.email, "alice@example.com");
299        assert_eq!(got.iss, ISSUER);
300        assert_eq!(got.aud, AUDIENCE);
301    }
302
303    #[test]
304    fn expired_token_is_rejected() {
305        let (jwks, kid) = test_jwks();
306        let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
307        // exp far in the past relative to the body's "now".
308        let token = sign(&kid, &claims(-10_000_000_000));
309        assert!(matches!(verifier.verify(&token), Err(VerifyError::Jwt(_))));
310    }
311
312    #[test]
313    fn wrong_audience_is_rejected() {
314        let (jwks, kid) = test_jwks();
315        let verifier =
316            Verifier::from_jwks_value(&jwks, ISSUER, "some-other-service").expect("build");
317        let token = sign(&kid, &claims(3600));
318        assert!(matches!(verifier.verify(&token), Err(VerifyError::Jwt(_))));
319    }
320
321    #[test]
322    fn wrong_issuer_is_rejected() {
323        // The verifier's policy demands `iss == ISSUER`. A token whose
324        // `iss` claim names a different issuer must be rejected even
325        // though the signature, kid, audience, and expiry are all valid.
326        let (jwks, kid) = test_jwks();
327        let verifier =
328            Verifier::from_jwks_value(&jwks, "some-other-issuer", AUDIENCE).expect("build");
329        let token = sign(&kid, &claims(3600));
330        assert!(matches!(verifier.verify(&token), Err(VerifyError::Jwt(_))));
331    }
332
333    #[test]
334    fn unknown_kid_is_rejected() {
335        let (jwks, kid) = test_jwks();
336        let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
337        // Sign with a kid the JWKS does not contain.
338        let token = sign("not-a-known-kid", &claims(3600));
339        assert!(matches!(
340            verifier.verify(&token),
341            Err(VerifyError::UnknownKid(_))
342        ));
343        let _ = kid;
344    }
345
346    #[test]
347    fn tampered_signature_is_rejected() {
348        let (jwks, kid) = test_jwks();
349        let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
350        let token = sign(&kid, &claims(3600));
351        let mut bytes = token.into_bytes();
352        let last = bytes.len() - 1;
353        // Flip a bit in the final signature char (avoid producing the same char).
354        bytes[last] = if bytes[last] == b'A' { b'B' } else { b'A' };
355        let tampered = String::from_utf8(bytes).unwrap();
356        assert!(verifier.verify(&tampered).is_err());
357    }
358
359    #[test]
360    fn garbage_token_is_rejected() {
361        let (jwks, _) = test_jwks();
362        let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
363        assert!(verifier.verify("not.a.jwt").is_err());
364        assert!(verifier.verify("").is_err());
365    }
366
367    #[test]
368    fn empty_jwks_builds_but_rejects_everything() {
369        let jwks = serde_json::json!({ "keys": [] });
370        let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
371        assert_eq!(verifier.key_count(), 0);
372        let (_, kid) = test_jwks();
373        let token = sign(&kid, &claims(3600));
374        assert!(matches!(
375            verifier.verify(&token),
376            Err(VerifyError::UnknownKid(_))
377        ));
378    }
379
380    #[test]
381    fn jwks_without_keys_array_errors() {
382        let jwks = serde_json::json!({ "not_keys": [] });
383        assert!(matches!(
384            Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE),
385            Err(VerifyError::Jwks(_))
386        ));
387    }
388
389    #[test]
390    fn non_rsa_keys_are_skipped() {
391        let jwks = serde_json::json!({
392            "keys": [{ "kty": "EC", "kid": "ec-1", "crv": "P-256", "x": "a", "y": "b" }]
393        });
394        let verifier = Verifier::from_jwks_value(&jwks, ISSUER, AUDIENCE).expect("build");
395        assert_eq!(verifier.key_count(), 0);
396    }
397}