Skip to main content

oauth_toolkit/
oidc_validator.rs

1//! JWKS-backed OIDC token validation.
2
3use std::collections::HashMap;
4use std::sync::Arc;
5use parking_lot::RwLock;
6use serde::de::DeserializeOwned;
7
8use crate::oidc::OidcError;
9
10/// Configuration for OIDC token validation.
11#[derive(Debug, Clone)]
12pub struct OidcValidatorConfig {
13    /// Expected issuer URL.
14    pub issuer: String,
15    /// Expected audience.
16    pub audience: String,
17    /// JWKS URI. If None, auto-discovered from `.well-known/openid-configuration`.
18    pub jwks_uri: Option<String>,
19}
20
21struct JwksCache {
22    keys: HashMap<String, jsonwebtoken::DecodingKey>,
23    fetched_at: std::time::Instant,
24}
25
26/// JWKS-backed OIDC token validator.
27///
28/// Caches JWKS keys with a 24-hour TTL. Rejects HMAC algorithms
29/// (only accepts asymmetric: RS256, ES256, etc.).
30pub struct OidcValidator {
31    config: Arc<OidcValidatorConfig>,
32    jwks: Arc<RwLock<JwksCache>>,
33    http_client: reqwest::Client,
34}
35
36impl OidcValidator {
37    /// Create a new validator. Call `refresh_jwks()` after construction.
38    pub fn new(config: OidcValidatorConfig) -> Self {
39        Self {
40            config: Arc::new(config),
41            jwks: Arc::new(RwLock::new(JwksCache {
42                keys: HashMap::new(),
43                fetched_at: std::time::Instant::now() - std::time::Duration::from_secs(86400),
44            })),
45            http_client: reqwest::Client::new(),
46        }
47    }
48
49    /// Get the validator configuration.
50    pub fn config(&self) -> &OidcValidatorConfig {
51        &self.config
52    }
53
54    /// Validate a JWT and extract typed claims.
55    pub async fn validate_token<T: DeserializeOwned>(&self, token: &str) -> Result<T, OidcError> {
56        self.maybe_refresh_jwks().await?;
57
58        let cache = self.jwks.read();
59        for (_kid, key) in cache.keys.iter() {
60            let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
61            validation.set_audience(&[&self.config.audience]);
62            validation.set_issuer(&[&self.config.issuer]);
63
64            if let Ok(data) = jsonwebtoken::decode::<T>(token, key, &validation) {
65                return Ok(data.claims);
66            }
67        }
68
69        Err(OidcError::TokenExchangeFailed("no matching key found".into()))
70    }
71
72    /// Force-refresh the JWKS cache.
73    pub async fn refresh_jwks(&self) -> Result<(), OidcError> {
74        let jwks_uri = match &self.config.jwks_uri {
75            Some(uri) => uri.clone(),
76            None => {
77                let discovery = crate::oidc::fetch_discovery(&self.http_client, &self.config.issuer).await?;
78                discovery.jwks_uri
79            }
80        };
81
82        let resp = self
83            .http_client
84            .get(&jwks_uri)
85            .send()
86            .await
87            .map_err(|e| OidcError::DiscoveryFailed(e.to_string()))?;
88
89        let jwks_value: serde_json::Value = resp
90            .json()
91            .await
92            .map_err(|e| OidcError::DiscoveryFailed(e.to_string()))?;
93
94        let mut keys = HashMap::new();
95        if let Some(key_array) = jwks_value["keys"].as_array() {
96            for key_obj in key_array {
97                if let (Some(kty), Some(kid)) = (key_obj["kty"].as_str(), key_obj["kid"].as_str()) {
98                    if kty == "RSA" {
99                        if let (Some(n), Some(e)) = (key_obj["n"].as_str(), key_obj["e"].as_str()) {
100                            let decoding_key = jsonwebtoken::DecodingKey::from_rsa_components(n, e)
101                                .ok();
102                            if let Some(key) = decoding_key {
103                                keys.insert(kid.to_string(), key);
104                            }
105                        }
106                    }
107                }
108            }
109        }
110
111        let mut cache = self.jwks.write();
112        cache.keys = keys;
113        cache.fetched_at = std::time::Instant::now();
114
115        Ok(())
116    }
117
118    async fn maybe_refresh_jwks(&self) -> Result<(), OidcError> {
119        let needs_refresh = {
120            let cache = self.jwks.read();
121            cache.fetched_at.elapsed() > std::time::Duration::from_secs(86400)
122        };
123        if needs_refresh {
124            self.refresh_jwks().await?;
125        }
126        Ok(())
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn validator_config_creation() {
136        let config = OidcValidatorConfig {
137            issuer: "https://accounts.google.com".into(),
138            audience: "my-app".into(),
139            jwks_uri: None,
140        };
141        let _validator = OidcValidator::new(config);
142    }
143}