Skip to main content

secrets_auth_oidc/
lib.rs

1//! OIDC auth: interactive authorization-code login for humans and
2//! JWT-bearer validation for machine-to-machine callers. Both modes share
3//! the same discovery/JWKS cache and claims-to-policies mapping — the only
4//! difference is where the JWT to verify comes from (a token-endpoint
5//! response vs. handed to us directly).
6
7use std::collections::HashMap;
8use std::time::{Duration, Instant};
9
10use async_trait::async_trait;
11use base64::Engine;
12use base64::engine::general_purpose::URL_SAFE_NO_PAD;
13use chrono::Utc;
14use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet};
15use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
16use rand::Rng;
17use secrets_core::auth::{AuthError, AuthMethod, AuthOutcome, AuthResult, LoginRequest};
18use secrets_core::storage::{StorageBackend, StorageEntry};
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21use sha2::{Digest, Sha256};
22use tokio::sync::RwLock;
23
24const CONFIG_PATH: &str = "auth/oidc/config";
25const STATE_PREFIX: &str = "auth/oidc/state/";
26const STATE_TTL_SECONDS: i64 = 300;
27const DISCOVERY_CACHE_TTL: Duration = Duration::from_secs(600);
28const JWKS_CACHE_TTL: Duration = Duration::from_secs(600);
29const DEFAULT_TTL_SECONDS: i64 = 3600;
30
31/// Operator-supplied IdP registration. One config for the whole server at
32/// v1 — multiple OIDC providers would mean keying this (and `STATE_PREFIX`)
33/// by provider name, deferred until actually needed.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct OidcConfig {
36    pub issuer_url: String,
37    pub client_id: String,
38    #[serde(default)]
39    pub client_secret: Option<String>,
40    pub redirect_url: String,
41    /// Claim (array-of-strings or space-delimited string) mapped to policy
42    /// names, mirroring Vault's "claims map to policies" model.
43    #[serde(default = "default_policy_claim")]
44    pub policy_claim: String,
45    #[serde(default)]
46    pub default_policies: Vec<String>,
47}
48
49fn default_policy_claim() -> String {
50    "policies".to_string()
51}
52
53#[derive(Debug, Deserialize)]
54struct Discovery {
55    authorization_endpoint: String,
56    token_endpoint: String,
57    jwks_uri: String,
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61struct PendingState {
62    nonce: String,
63    pkce_verifier: String,
64}
65
66#[derive(Debug, Deserialize)]
67struct TokenResponse {
68    id_token: String,
69}
70
71/// Shared by both OIDC login modes: fetches and caches discovery documents
72/// and JWKS per issuer so every login doesn't round-trip to the IdP.
73pub struct OidcAuthMethod {
74    http: reqwest::Client,
75    discovery_cache: RwLock<HashMap<String, (Instant, Discovery)>>,
76    jwks_cache: RwLock<HashMap<String, (Instant, JwkSet)>>,
77}
78
79impl Default for OidcAuthMethod {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85impl OidcAuthMethod {
86    pub fn new() -> Self {
87        Self {
88            http: reqwest::Client::new(),
89            discovery_cache: RwLock::new(HashMap::new()),
90            jwks_cache: RwLock::new(HashMap::new()),
91        }
92    }
93
94    pub async fn load_config(storage: &dyn StorageBackend) -> AuthResult<Option<OidcConfig>> {
95        let Some(entry) = storage.get(CONFIG_PATH).await? else {
96            return Ok(None);
97        };
98        Ok(Some(
99            serde_json::from_slice(&entry.value).map_err(|e| AuthError::Other(e.to_string()))?,
100        ))
101    }
102
103    pub async fn save_config(storage: &dyn StorageBackend, config: &OidcConfig) -> AuthResult<()> {
104        let value = serde_json::to_vec(config).map_err(|e| AuthError::Other(e.to_string()))?;
105        storage
106            .put(
107                CONFIG_PATH,
108                StorageEntry {
109                    value,
110                    expires_at: None,
111                },
112            )
113            .await?;
114        Ok(())
115    }
116
117    async fn discovery_for(&self, config: &OidcConfig) -> AuthResult<Discovery> {
118        if let Some((fetched_at, discovery)) = self.discovery_cache.read().await.get(&config.issuer_url)
119            && fetched_at.elapsed() < DISCOVERY_CACHE_TTL
120        {
121            return Ok(Discovery {
122                authorization_endpoint: discovery.authorization_endpoint.clone(),
123                token_endpoint: discovery.token_endpoint.clone(),
124                jwks_uri: discovery.jwks_uri.clone(),
125            });
126        }
127
128        let url = format!(
129            "{}/.well-known/openid-configuration",
130            config.issuer_url.trim_end_matches('/')
131        );
132        let discovery: Discovery = self
133            .http
134            .get(&url)
135            .send()
136            .await
137            .map_err(|e| AuthError::Other(e.to_string()))?
138            .json()
139            .await
140            .map_err(|e| AuthError::Other(e.to_string()))?;
141
142        let clone = Discovery {
143            authorization_endpoint: discovery.authorization_endpoint.clone(),
144            token_endpoint: discovery.token_endpoint.clone(),
145            jwks_uri: discovery.jwks_uri.clone(),
146        };
147        self.discovery_cache
148            .write()
149            .await
150            .insert(config.issuer_url.clone(), (Instant::now(), discovery));
151        Ok(clone)
152    }
153
154    async fn fetch_jwks(&self, jwks_uri: &str) -> AuthResult<JwkSet> {
155        self.http
156            .get(jwks_uri)
157            .send()
158            .await
159            .map_err(|e| AuthError::Other(e.to_string()))?
160            .json::<JwkSet>()
161            .await
162            .map_err(|e| AuthError::Other(e.to_string()))
163    }
164
165    async fn decoding_key_for(&self, config: &OidcConfig, kid: &str) -> AuthResult<(DecodingKey, Algorithm)> {
166        let jwks_uri = self.discovery_for(config).await?.jwks_uri;
167
168        let cached = self.jwks_cache.read().await.get(&config.issuer_url).cloned();
169        let needs_fetch = match &cached {
170            Some((fetched_at, jwks)) => {
171                fetched_at.elapsed() >= JWKS_CACHE_TTL || jwks.find(kid).is_none()
172            }
173            None => true,
174        };
175
176        let jwks = if needs_fetch {
177            let jwks = self.fetch_jwks(&jwks_uri).await?;
178            self.jwks_cache
179                .write()
180                .await
181                .insert(config.issuer_url.clone(), (Instant::now(), jwks.clone()));
182            jwks
183        } else {
184            cached.unwrap().1
185        };
186
187        let jwk = jwks.find(kid).ok_or_else(|| AuthError::Other(format!("unknown signing key '{kid}'")))?;
188        let algorithm = algorithm_for(jwk)?;
189        let decoding_key =
190            DecodingKey::from_jwk(jwk).map_err(|e| AuthError::Other(format!("invalid JWK: {e}")))?;
191        Ok((decoding_key, algorithm))
192    }
193
194    async fn verify_jwt(&self, config: &OidcConfig, token: &str) -> AuthResult<Value> {
195        let header = decode_header(token).map_err(|e| AuthError::Other(e.to_string()))?;
196        let kid = header.kid.ok_or_else(|| AuthError::Other("jwt is missing a 'kid' header".into()))?;
197        let (decoding_key, algorithm) = self.decoding_key_for(config, &kid).await?;
198
199        let mut validation = Validation::new(algorithm);
200        validation.set_audience(std::slice::from_ref(&config.client_id));
201        validation.set_issuer(std::slice::from_ref(&config.issuer_url));
202
203        let data = decode::<Value>(token, &decoding_key, &validation)
204            .map_err(|_| AuthError::InvalidCredentials)?;
205        Ok(data.claims)
206    }
207
208    /// Builds the URL to redirect a human to for interactive login,
209    /// stashing the CSRF state / nonce / PKCE verifier so `login()` can
210    /// validate them when the IdP calls back.
211    pub async fn authorize_url(&self, storage: &dyn StorageBackend) -> AuthResult<String> {
212        let config = Self::load_config(storage)
213            .await?
214            .ok_or_else(|| AuthError::Other("OIDC is not configured".into()))?;
215        let discovery = self.discovery_for(&config).await?;
216
217        let state = random_url_safe(24);
218        let nonce = random_url_safe(24);
219        let pkce_verifier = random_url_safe(32);
220        let code_challenge = code_challenge_s256(&pkce_verifier);
221
222        let pending = PendingState {
223            nonce: nonce.clone(),
224            pkce_verifier,
225        };
226        let value = serde_json::to_vec(&pending).map_err(|e| AuthError::Other(e.to_string()))?;
227        storage
228            .put(
229                &format!("{STATE_PREFIX}{state}"),
230                StorageEntry {
231                    value,
232                    expires_at: Some(Utc::now() + chrono::Duration::seconds(STATE_TTL_SECONDS)),
233                },
234            )
235            .await?;
236
237        Ok(format!(
238            "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&nonce={}&code_challenge={}&code_challenge_method=S256",
239            discovery.authorization_endpoint,
240            urlencoding::encode(&config.client_id),
241            urlencoding::encode(&config.redirect_url),
242            urlencoding::encode("openid profile email"),
243            urlencoding::encode(&state),
244            urlencoding::encode(&nonce),
245            urlencoding::encode(&code_challenge),
246        ))
247    }
248
249    async fn login_auth_code(
250        &self,
251        storage: &dyn StorageBackend,
252        code: String,
253        state: String,
254    ) -> AuthResult<AuthOutcome> {
255        let config = Self::load_config(storage)
256            .await?
257            .ok_or_else(|| AuthError::Other("OIDC is not configured".into()))?;
258
259        let state_path = format!("{STATE_PREFIX}{state}");
260        let entry = storage.get(&state_path).await?.ok_or(AuthError::InvalidCredentials)?;
261        storage.delete(&state_path).await?;
262        let pending: PendingState =
263            serde_json::from_slice(&entry.value).map_err(|e| AuthError::Other(e.to_string()))?;
264
265        let discovery = self.discovery_for(&config).await?;
266        let mut form = vec![
267            ("grant_type", "authorization_code"),
268            ("code", code.as_str()),
269            ("redirect_uri", config.redirect_url.as_str()),
270            ("client_id", config.client_id.as_str()),
271            ("code_verifier", pending.pkce_verifier.as_str()),
272        ];
273        if let Some(secret) = &config.client_secret {
274            form.push(("client_secret", secret.as_str()));
275        }
276
277        let response: TokenResponse = self
278            .http
279            .post(&discovery.token_endpoint)
280            .form(&form)
281            .send()
282            .await
283            .map_err(|e| AuthError::Other(e.to_string()))?
284            .json()
285            .await
286            .map_err(|e| AuthError::Other(e.to_string()))?;
287
288        let claims = self.verify_jwt(&config, &response.id_token).await?;
289        if claims.get("nonce").and_then(Value::as_str) != Some(pending.nonce.as_str()) {
290            return Err(AuthError::InvalidCredentials);
291        }
292
293        Ok(outcome_from_claims(&claims, &config))
294    }
295
296    async fn login_bearer_jwt(&self, storage: &dyn StorageBackend, jwt: String) -> AuthResult<AuthOutcome> {
297        let config = Self::load_config(storage)
298            .await?
299            .ok_or_else(|| AuthError::Other("OIDC is not configured".into()))?;
300        let claims = self.verify_jwt(&config, &jwt).await?;
301        Ok(outcome_from_claims(&claims, &config))
302    }
303}
304
305#[async_trait]
306impl AuthMethod for OidcAuthMethod {
307    async fn login(&self, storage: &dyn StorageBackend, request: LoginRequest) -> AuthResult<AuthOutcome> {
308        match request {
309            LoginRequest::OidcAuthCodeCallback { code, state } => {
310                self.login_auth_code(storage, code, state).await
311            }
312            LoginRequest::OidcBearerJwt { jwt } => self.login_bearer_jwt(storage, jwt).await,
313            LoginRequest::UserPass { .. } => {
314                Err(AuthError::InvalidRequest("expected an OIDC login request".into()))
315            }
316        }
317    }
318}
319
320fn outcome_from_claims(claims: &Value, config: &OidcConfig) -> AuthOutcome {
321    let display_name = claims
322        .get("email")
323        .or_else(|| claims.get("preferred_username"))
324        .or_else(|| claims.get("sub"))
325        .and_then(Value::as_str)
326        .unwrap_or("oidc-user")
327        .to_string();
328
329    let ttl_seconds = claims
330        .get("exp")
331        .and_then(Value::as_i64)
332        .map(|exp| (exp - Utc::now().timestamp()).max(1))
333        .unwrap_or(DEFAULT_TTL_SECONDS);
334
335    AuthOutcome {
336        policies: policies_from_claims(claims, config),
337        display_name,
338        ttl_seconds: Some(ttl_seconds),
339    }
340}
341
342fn policies_from_claims(claims: &Value, config: &OidcConfig) -> Vec<String> {
343    let policies = match claims.get(&config.policy_claim) {
344        Some(Value::Array(values)) => values.iter().filter_map(Value::as_str).map(String::from).collect(),
345        Some(Value::String(s)) => s.split_whitespace().map(String::from).collect(),
346        _ => Vec::new(),
347    };
348    if policies.is_empty() {
349        config.default_policies.clone()
350    } else {
351        policies
352    }
353}
354
355fn algorithm_for(jwk: &jsonwebtoken::jwk::Jwk) -> AuthResult<Algorithm> {
356    match &jwk.algorithm {
357        AlgorithmParameters::RSA(_) => Ok(Algorithm::RS256),
358        AlgorithmParameters::EllipticCurve(params) => match params.curve {
359            jsonwebtoken::jwk::EllipticCurve::P256 => Ok(Algorithm::ES256),
360            jsonwebtoken::jwk::EllipticCurve::P384 => Ok(Algorithm::ES384),
361            _ => Err(AuthError::Other("unsupported elliptic curve JWK".into())),
362        },
363        _ => Err(AuthError::Other("unsupported JWK key type".into())),
364    }
365}
366
367fn random_url_safe(len_bytes: usize) -> String {
368    let mut bytes = vec![0u8; len_bytes];
369    rand::rng().fill_bytes(&mut bytes);
370    URL_SAFE_NO_PAD.encode(bytes)
371}
372
373fn code_challenge_s256(verifier: &str) -> String {
374    URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    fn config() -> OidcConfig {
382        OidcConfig {
383            issuer_url: "https://idp.example.com".to_string(),
384            client_id: "client-1".to_string(),
385            client_secret: None,
386            redirect_url: "https://app.example.com/callback".to_string(),
387            policy_claim: "policies".to_string(),
388            default_policies: vec!["default".to_string()],
389        }
390    }
391
392    #[test]
393    fn maps_array_claim_to_policies() {
394        let claims = serde_json::json!({ "policies": ["a", "b"] });
395        assert_eq!(policies_from_claims(&claims, &config()), vec!["a", "b"]);
396    }
397
398    #[test]
399    fn maps_space_delimited_claim_to_policies() {
400        let claims = serde_json::json!({ "policies": "a b" });
401        assert_eq!(policies_from_claims(&claims, &config()), vec!["a", "b"]);
402    }
403
404    #[test]
405    fn falls_back_to_default_policies_when_claim_missing() {
406        let claims = serde_json::json!({});
407        assert_eq!(policies_from_claims(&claims, &config()), vec!["default"]);
408    }
409
410    #[test]
411    fn pkce_challenge_is_deterministic_and_url_safe() {
412        let verifier = "test-verifier-value";
413        let challenge_a = code_challenge_s256(verifier);
414        let challenge_b = code_challenge_s256(verifier);
415        assert_eq!(challenge_a, challenge_b);
416        assert!(!challenge_a.contains('+'));
417        assert!(!challenge_a.contains('/'));
418        assert!(!challenge_a.contains('='));
419    }
420
421    #[test]
422    fn random_url_safe_values_are_unique() {
423        assert_ne!(random_url_safe(16), random_url_safe(16));
424    }
425}