Skip to main content

typesec_integrations/jwt/
authenticator.rs

1//! JWT authenticator that verifies tokens against a JWKS endpoint.
2
3use std::sync::{Arc, PoisonError, RwLock};
4use std::time::Instant;
5
6use jsonwebtoken::{
7    DecodingKey, TokenData, Validation, decode, decode_header,
8    jwk::{Jwk, JwkSet},
9};
10use typesec_core::typestate::{AgentError, Authenticator, Credentials};
11
12use crate::http::{HttpClient, ReqwestHttpClient};
13
14use super::claims::{JwtClaims, VerifiedSubject};
15use super::config::OidcConfig;
16
17/// JWT authenticator that verifies tokens against a JWKS endpoint.
18pub struct JwtAuthenticator {
19    config: OidcConfig,
20    http: Arc<dyn HttpClient>,
21    jwks: RwLock<Option<CachedJwks>>,
22}
23
24#[derive(Clone)]
25struct CachedJwks {
26    keys: JwkSet,
27    fetched_at: Instant,
28}
29
30impl JwtAuthenticator {
31    /// Create an authenticator using the default reqwest HTTP client.
32    pub fn new(config: OidcConfig) -> Self {
33        Self::with_http(config, Arc::new(ReqwestHttpClient::new()))
34    }
35
36    /// Create an authenticator with an injected HTTP client.
37    pub fn with_http(config: OidcConfig, http: Arc<dyn HttpClient>) -> Self {
38        Self {
39            config,
40            http,
41            jwks: RwLock::new(None),
42        }
43    }
44
45    /// Verify a bearer token and return its Typesec subject model.
46    pub fn verify(&self, token: &str) -> Result<VerifiedSubject, JwtAuthError> {
47        let data = self.decode_claims(token)?;
48        if !data.claims.aud.contains(&self.config.audience) {
49            return Err(JwtAuthError::InvalidAudience);
50        }
51        Ok(data.claims.into())
52    }
53
54    fn decode_claims(&self, token: &str) -> Result<TokenData<JwtClaims>, JwtAuthError> {
55        let header = decode_header(token)?;
56        let key = self.resolve_key(header.kid.as_deref())?;
57
58        // Never seed the validator from the token's own `header.alg` — that would
59        // be one edit away from trusting an attacker-chosen algorithm. Start from
60        // a default validator and install only the algorithms the trusted config
61        // permits; a token whose `header.alg` is outside that set is rejected.
62        let mut validation = Validation::default();
63        validation.algorithms = self.config.algorithms.clone();
64        validation.set_issuer(&[self.config.issuer.as_str()]);
65        validation.set_audience(&[self.config.audience.as_str()]);
66
67        Ok(decode::<JwtClaims>(
68            token,
69            &DecodingKey::from_jwk(&key)?,
70            &validation,
71        )?)
72    }
73
74    /// Resolve the signing key for a token header.
75    ///
76    /// - With a `kid`: look it up in the cached JWKS; on a miss, re-fetch the
77    ///   JWKS once (the IdP may have rotated keys) before failing.
78    /// - Without a `kid`: only unambiguous key sets are accepted — if the JWKS
79    ///   holds more than one key, the token is rejected rather than verified
80    ///   against an arbitrary key.
81    fn resolve_key(&self, kid: Option<&str>) -> Result<Jwk, JwtAuthError> {
82        let jwks = self.jwks(false)?;
83        match kid {
84            Some(kid) => {
85                if let Some(key) = jwks.find(kid) {
86                    return Ok(key.clone());
87                }
88                // Unknown kid — refresh the JWKS once in case of key rotation.
89                let jwks = self.jwks(true)?;
90                jwks.find(kid).cloned().ok_or(JwtAuthError::MissingKey)
91            }
92            None => match jwks.keys.as_slice() {
93                [only] => Ok(only.clone()),
94                [] => Err(JwtAuthError::MissingKey),
95                _ => Err(JwtAuthError::MissingKid),
96            },
97        }
98    }
99
100    fn jwks(&self, force_refresh: bool) -> Result<JwkSet, JwtAuthError> {
101        // The cached JWKS is not invariant-bearing, so recover the guard if a
102        // prior holder panicked rather than poisoning every future auth (matches
103        // the gateway's lock-recovery policy).
104        if !force_refresh
105            && let Some(cached) = self
106                .jwks
107                .read()
108                .unwrap_or_else(PoisonError::into_inner)
109                .as_ref()
110            && cached.fetched_at.elapsed() < self.config.jwks_ttl
111        {
112            return Ok(cached.keys.clone());
113        }
114
115        let value = self.http.get_json(&self.config.jwks_url, &[])?;
116        let keys: JwkSet = serde_json::from_value(value)?;
117        *self.jwks.write().unwrap_or_else(PoisonError::into_inner) = Some(CachedJwks {
118            keys: keys.clone(),
119            fetched_at: Instant::now(),
120        });
121        Ok(keys)
122    }
123}
124
125impl Authenticator for JwtAuthenticator {
126    /// Verify the credential token as a JWT and return the *verified* subject.
127    ///
128    /// If the credentials claim a subject, it must match the token's `sub`
129    /// claim — a caller cannot authenticate as someone else's identity by
130    /// pairing a valid token with a different claimed subject.
131    fn verify_credentials(&self, credentials: &Credentials) -> Result<String, AgentError> {
132        let verified =
133            self.verify(credentials.token.expose())
134                .map_err(|e| AgentError::AuthFailed {
135                    reason: format!("jwt verification failed: {e}"),
136                })?;
137        if !credentials.subject.is_empty() && credentials.subject != verified.subject {
138            return Err(AgentError::AuthFailed {
139                reason: format!(
140                    "claimed subject '{}' does not match verified token subject '{}'",
141                    credentials.subject, verified.subject
142                ),
143            });
144        }
145        Ok(verified.subject)
146    }
147}
148
149/// Errors returned by [`JwtAuthenticator`].
150#[derive(Debug, thiserror::Error)]
151pub enum JwtAuthError {
152    /// Token validation failed.
153    #[error("jwt validation failed: {0}")]
154    Jwt(#[from] jsonwebtoken::errors::Error),
155    /// JWKS fetch failed.
156    #[error("jwks fetch failed: {0}")]
157    Http(#[from] Box<dyn std::error::Error + Send + Sync>),
158    /// JWKS JSON could not be parsed.
159    #[error("jwks parse failed: {0}")]
160    Json(#[from] serde_json::Error),
161    /// No matching signing key was found.
162    #[error("no matching signing key found in JWKS")]
163    MissingKey,
164    /// The token has no `kid` and the JWKS holds multiple keys.
165    #[error("token has no kid but JWKS is ambiguous (multiple keys)")]
166    MissingKid,
167    /// Token audience did not match the configured audience.
168    #[error("token audience did not match expected audience")]
169    InvalidAudience,
170}