Skip to main content

oauth_toolkit/
oidc_validator.rs

1//! JWKS-backed OIDC token validation.
2
3use parking_lot::RwLock;
4use serde::de::DeserializeOwned;
5use std::collections::HashMap;
6use std::sync::Arc;
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 key in cache.keys.values() {
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(
70            "no matching key found".into(),
71        ))
72    }
73
74    /// Force-refresh the JWKS cache.
75    pub async fn refresh_jwks(&self) -> Result<(), OidcError> {
76        let jwks_uri = match &self.config.jwks_uri {
77            Some(uri) => uri.clone(),
78            None => {
79                let discovery =
80                    crate::oidc::fetch_discovery(&self.http_client, &self.config.issuer).await?;
81                discovery.jwks_uri
82            }
83        };
84
85        let resp = self
86            .http_client
87            .get(&jwks_uri)
88            .send()
89            .await
90            .map_err(|e| OidcError::DiscoveryFailed(e.to_string()))?;
91
92        let jwks_value: serde_json::Value = resp
93            .json()
94            .await
95            .map_err(|e| OidcError::DiscoveryFailed(e.to_string()))?;
96
97        let mut keys = HashMap::new();
98        if let Some(key_array) = jwks_value["keys"].as_array() {
99            for key_obj in key_array {
100                if let (Some(kty), Some(kid)) = (key_obj["kty"].as_str(), key_obj["kid"].as_str()) {
101                    if kty == "RSA" {
102                        if let (Some(n), Some(e)) = (key_obj["n"].as_str(), key_obj["e"].as_str()) {
103                            let decoding_key =
104                                jsonwebtoken::DecodingKey::from_rsa_components(n, e).ok();
105                            if let Some(key) = decoding_key {
106                                keys.insert(kid.to_string(), key);
107                            }
108                        }
109                    }
110                }
111            }
112        }
113
114        let mut cache = self.jwks.write();
115        cache.keys = keys;
116        cache.fetched_at = std::time::Instant::now();
117
118        Ok(())
119    }
120
121    async fn maybe_refresh_jwks(&self) -> Result<(), OidcError> {
122        let needs_refresh = {
123            let cache = self.jwks.read();
124            cache.fetched_at.elapsed() > std::time::Duration::from_secs(86400)
125        };
126        if needs_refresh {
127            self.refresh_jwks().await?;
128        }
129        Ok(())
130    }
131}
132
133#[cfg(test)]
134// Test code: unwrap/expect are the idiomatic way to assert setup success.
135#[allow(clippy::unwrap_used, clippy::expect_used)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn validator_config_creation() {
141        let config = OidcValidatorConfig {
142            issuer: "https://accounts.google.com".into(),
143            audience: "my-app".into(),
144            jwks_uri: None,
145        };
146        let _validator = OidcValidator::new(config);
147    }
148}