1use 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#[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 #[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
71pub 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 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}