1use std::collections::HashSet;
32use std::sync::Arc;
33use std::time::Duration;
34
35use jsonwebtoken::dangerous::insecure_decode;
36use jsonwebtoken::jwk::JwkSet;
37use jsonwebtoken::{DecodingKey, Validation, decode, decode_header};
38use parking_lot::RwLock;
39use serde::de::DeserializeOwned;
40
41use crate::error::{Error, Result};
42
43#[derive(Clone)]
48pub struct ExternalJwtIssuer {
49 issuer_url: String,
50 audience: HashSet<String>,
51 jwks_uri: String,
52 jwks: Arc<RwLock<JwkSet>>,
53 refresh_interval: Duration,
54}
55
56impl ExternalJwtIssuer {
57 pub async fn discover(
63 issuer_url: String,
64 audience: Vec<String>,
65 refresh_secs: u64,
66 ) -> Result<Self> {
67 let trimmed = issuer_url.trim_end_matches('/').to_string();
68 let discovery_url = format!("{trimmed}/.well-known/openid-configuration");
69
70 let client = reqwest::Client::builder()
71 .timeout(Duration::from_secs(10))
72 .build()
73 .map_err(|e| Error::Oidc(format!("build http client: {e}")))?;
74
75 let metadata: serde_json::Value = client
76 .get(&discovery_url)
77 .send()
78 .await
79 .map_err(|e| Error::Oidc(format!("discover {discovery_url}: {e}")))?
80 .error_for_status()
81 .map_err(|e| Error::Oidc(format!("discover {discovery_url}: {e}")))?
82 .json()
83 .await
84 .map_err(|e| Error::Oidc(format!("parse {discovery_url}: {e}")))?;
85
86 let jwks_uri = metadata
87 .get("jwks_uri")
88 .and_then(|v| v.as_str())
89 .ok_or_else(|| Error::Oidc(format!("{discovery_url} missing `jwks_uri` field")))?
90 .to_string();
91
92 let jwks = fetch_jwks(&client, &jwks_uri).await?;
93
94 let verifier = Self {
95 issuer_url: trimmed.clone(),
96 audience: audience.into_iter().collect(),
97 jwks_uri: jwks_uri.clone(),
98 jwks: Arc::new(RwLock::new(jwks)),
99 refresh_interval: Duration::from_secs(refresh_secs.max(60)),
100 };
101
102 verifier.spawn_refresh(client);
103 Ok(verifier)
104 }
105
106 pub fn issuer(&self) -> &str {
110 &self.issuer_url
111 }
112
113 pub fn verify<T: DeserializeOwned>(&self, token: &str) -> Result<jsonwebtoken::TokenData<T>> {
119 let header =
120 decode_header(token).map_err(|e| Error::Oidc(format!("decode jwt header: {e}")))?;
121 let kid = header
122 .kid
123 .as_ref()
124 .ok_or_else(|| Error::Oidc("jwt header missing `kid`".to_string()))?;
125
126 let jwk = {
127 let jwks = self.jwks.read();
128 jwks.find(kid).cloned()
129 };
130 let jwk = jwk.ok_or_else(|| {
131 Error::Oidc(format!(
132 "kid `{kid}` not in cached jwks for issuer `{}`",
133 self.issuer_url
134 ))
135 })?;
136
137 let key = DecodingKey::from_jwk(&jwk)
138 .map_err(|e| Error::Oidc(format!("build decoding key from jwk: {e}")))?;
139
140 let mut validation = Validation::new(header.alg);
141 validation.set_issuer(&[&self.issuer_url]);
142 if self.audience.is_empty() {
143 validation.validate_aud = false;
146 } else {
147 let aud: Vec<&str> = self.audience.iter().map(String::as_str).collect();
148 validation.set_audience(&aud);
149 }
150
151 decode::<T>(token, &key, &validation)
152 .map_err(|e| Error::Oidc(format!("verify jwt against `{}`: {e}", self.issuer_url)))
153 }
154
155 fn spawn_refresh(&self, client: reqwest::Client) {
156 let jwks = Arc::clone(&self.jwks);
157 let jwks_uri = self.jwks_uri.clone();
158 let interval = self.refresh_interval;
159 let issuer_url = self.issuer_url.clone();
160
161 tokio::spawn(async move {
162 loop {
163 tokio::time::sleep(interval).await;
164 match fetch_jwks(&client, &jwks_uri).await {
165 Ok(fresh) => {
166 *jwks.write() = fresh;
167 tracing::debug!(
168 target: "assay-auth::external_jwt",
169 issuer = %issuer_url,
170 "refreshed jwks"
171 );
172 }
173 Err(e) => {
174 tracing::warn!(
175 target: "assay-auth::external_jwt",
176 issuer = %issuer_url,
177 error = %e,
178 "failed to refresh jwks; keeping previous keys"
179 );
180 }
181 }
182 }
183 });
184 }
185}
186
187async fn fetch_jwks(client: &reqwest::Client, uri: &str) -> Result<JwkSet> {
188 let body: serde_json::Value = client
189 .get(uri)
190 .send()
191 .await
192 .map_err(|e| Error::Oidc(format!("fetch jwks {uri}: {e}")))?
193 .error_for_status()
194 .map_err(|e| Error::Oidc(format!("fetch jwks {uri}: {e}")))?
195 .json()
196 .await
197 .map_err(|e| Error::Oidc(format!("parse jwks {uri}: {e}")))?;
198 serde_json::from_value(body).map_err(|e| Error::Oidc(format!("decode jwks {uri}: {e}")))
199}
200
201pub fn verify_with_any<T: DeserializeOwned>(
208 issuers: &[ExternalJwtIssuer],
209 token: &str,
210) -> Option<Result<jsonwebtoken::TokenData<T>>> {
211 if issuers.is_empty() {
212 return None;
213 }
214
215 #[derive(serde::Deserialize)]
220 struct IssClaim {
221 iss: String,
222 }
223 let unverified = insecure_decode::<IssClaim>(token).ok()?;
224 let iss = unverified.claims.iss;
225 let trimmed = iss.trim_end_matches('/');
226
227 for issuer in issuers {
228 if issuer.issuer() == trimmed || issuer.issuer() == iss {
229 return Some(issuer.verify::<T>(token));
230 }
231 }
232 None
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
239 use serde::{Deserialize, Serialize};
240
241 #[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
242 struct TestClaims {
243 iss: String,
244 aud: String,
245 sub: String,
246 exp: usize,
247 }
248
249 fn verifier_for_tests(issuer: &str, audience: Vec<String>, jwks: JwkSet) -> ExternalJwtIssuer {
254 ExternalJwtIssuer {
255 issuer_url: issuer.trim_end_matches('/').to_string(),
256 audience: audience.into_iter().collect(),
257 jwks_uri: format!("{issuer}/jwks"),
258 jwks: Arc::new(RwLock::new(jwks)),
259 refresh_interval: Duration::from_secs(3600),
260 }
261 }
262
263 fn hs256_jwks_with_kid(kid: &str, secret: &[u8]) -> JwkSet {
264 let json = serde_json::json!({
265 "keys": [{
266 "kty": "oct",
267 "use": "sig",
268 "alg": "HS256",
269 "kid": kid,
270 "k": base64_url(secret)
271 }]
272 });
273 serde_json::from_value(json).unwrap()
274 }
275
276 fn base64_url(b: &[u8]) -> String {
277 const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
281 let mut out = Vec::with_capacity(b.len().div_ceil(3) * 4);
282 for chunk in b.chunks(3) {
283 let mut buf = [0u8; 3];
284 buf[..chunk.len()].copy_from_slice(chunk);
285 let n = u32::from_be_bytes([0, buf[0], buf[1], buf[2]]);
286 out.push(T[((n >> 18) & 0x3F) as usize]);
287 out.push(T[((n >> 12) & 0x3F) as usize]);
288 if chunk.len() >= 2 {
289 out.push(T[((n >> 6) & 0x3F) as usize]);
290 }
291 if chunk.len() == 3 {
292 out.push(T[(n & 0x3F) as usize]);
293 }
294 }
295 String::from_utf8(out).expect("ascii")
296 }
297
298 fn issue_test_token(secret: &[u8], kid: &str, claims: &TestClaims) -> String {
299 let mut header = Header::new(Algorithm::HS256);
300 header.kid = Some(kid.to_string());
301 encode(&header, claims, &EncodingKey::from_secret(secret)).unwrap()
302 }
303
304 #[test]
305 fn verifies_token_from_configured_issuer() {
306 let secret = b"unit-test-secret-key-32bytes!!!!";
307 let kid = "test-key-1";
308 let issuer = "https://hydra.example.com";
309 let aud = "test-app";
310 let claims = TestClaims {
311 iss: issuer.to_string(),
312 aud: aud.to_string(),
313 sub: "user-42".to_string(),
314 exp: (std::time::SystemTime::now()
315 .duration_since(std::time::UNIX_EPOCH)
316 .unwrap()
317 .as_secs()
318 + 3600) as usize,
319 };
320 let token = issue_test_token(secret, kid, &claims);
321
322 let v = verifier_for_tests(
323 issuer,
324 vec![aud.to_string()],
325 hs256_jwks_with_kid(kid, secret),
326 );
327 let out = v.verify::<TestClaims>(&token).unwrap();
328 assert_eq!(out.claims, claims);
329 }
330
331 #[test]
332 fn rejects_token_with_wrong_issuer() {
333 let secret = b"unit-test-secret-key-32bytes!!!!";
334 let kid = "test-key-1";
335 let claims = TestClaims {
336 iss: "https://other.example.com".to_string(),
337 aud: "test-app".to_string(),
338 sub: "user-42".to_string(),
339 exp: (std::time::SystemTime::now()
340 .duration_since(std::time::UNIX_EPOCH)
341 .unwrap()
342 .as_secs()
343 + 3600) as usize,
344 };
345 let token = issue_test_token(secret, kid, &claims);
346
347 let v = verifier_for_tests(
348 "https://hydra.example.com",
349 vec!["test-app".to_string()],
350 hs256_jwks_with_kid(kid, secret),
351 );
352 assert!(v.verify::<TestClaims>(&token).is_err());
353 }
354
355 #[test]
356 fn rejects_token_with_wrong_audience() {
357 let secret = b"unit-test-secret-key-32bytes!!!!";
358 let kid = "test-key-1";
359 let issuer = "https://hydra.example.com";
360 let claims = TestClaims {
361 iss: issuer.to_string(),
362 aud: "some-other-app".to_string(),
363 sub: "user-42".to_string(),
364 exp: (std::time::SystemTime::now()
365 .duration_since(std::time::UNIX_EPOCH)
366 .unwrap()
367 .as_secs()
368 + 3600) as usize,
369 };
370 let token = issue_test_token(secret, kid, &claims);
371
372 let v = verifier_for_tests(
373 issuer,
374 vec!["test-app".to_string()],
375 hs256_jwks_with_kid(kid, secret),
376 );
377 assert!(v.verify::<TestClaims>(&token).is_err());
378 }
379
380 #[test]
381 fn rejects_token_with_unknown_kid() {
382 let secret = b"unit-test-secret-key-32bytes!!!!";
383 let issuer = "https://hydra.example.com";
384 let claims = TestClaims {
385 iss: issuer.to_string(),
386 aud: "test-app".to_string(),
387 sub: "user-42".to_string(),
388 exp: (std::time::SystemTime::now()
389 .duration_since(std::time::UNIX_EPOCH)
390 .unwrap()
391 .as_secs()
392 + 3600) as usize,
393 };
394 let token = issue_test_token(secret, "rotated-key", &claims);
396
397 let v = verifier_for_tests(
398 issuer,
399 vec!["test-app".to_string()],
400 hs256_jwks_with_kid("current-key", secret),
401 );
402 let err = v.verify::<TestClaims>(&token).unwrap_err().to_string();
403 assert!(err.contains("kid"), "error should mention kid: {err}");
404 }
405
406 #[test]
407 fn rejects_expired_token() {
408 let secret = b"unit-test-secret-key-32bytes!!!!";
409 let kid = "test-key-1";
410 let issuer = "https://hydra.example.com";
411 let claims = TestClaims {
412 iss: issuer.to_string(),
413 aud: "test-app".to_string(),
414 sub: "user-42".to_string(),
415 exp: (std::time::SystemTime::now()
417 .duration_since(std::time::UNIX_EPOCH)
418 .unwrap()
419 .as_secs()
420 - 3600) as usize,
421 };
422 let token = issue_test_token(secret, kid, &claims);
423
424 let v = verifier_for_tests(
425 issuer,
426 vec!["test-app".to_string()],
427 hs256_jwks_with_kid(kid, secret),
428 );
429 assert!(v.verify::<TestClaims>(&token).is_err());
430 }
431
432 #[test]
433 fn empty_audience_list_skips_aud_check() {
434 let secret = b"unit-test-secret-key-32bytes!!!!";
435 let kid = "test-key-1";
436 let issuer = "https://hydra.example.com";
437 let claims = TestClaims {
438 iss: issuer.to_string(),
439 aud: "literally-anything".to_string(),
440 sub: "user-42".to_string(),
441 exp: (std::time::SystemTime::now()
442 .duration_since(std::time::UNIX_EPOCH)
443 .unwrap()
444 .as_secs()
445 + 3600) as usize,
446 };
447 let token = issue_test_token(secret, kid, &claims);
448
449 let v = verifier_for_tests(issuer, vec![], hs256_jwks_with_kid(kid, secret));
451 assert!(v.verify::<TestClaims>(&token).is_ok());
452 }
453
454 #[test]
455 fn verify_with_any_routes_by_iss() {
456 let secret_a = b"key-A-secret-32bytes-unit-tests!";
457 let secret_b = b"key-B-secret-32bytes-unit-tests!";
458 let issuer_a = "https://hydra-a.example.com";
459 let issuer_b = "https://hydra-b.example.com";
460
461 let v_a = verifier_for_tests(
462 issuer_a,
463 vec!["test-app".to_string()],
464 hs256_jwks_with_kid("a-key", secret_a),
465 );
466 let v_b = verifier_for_tests(
467 issuer_b,
468 vec!["test-app".to_string()],
469 hs256_jwks_with_kid("b-key", secret_b),
470 );
471
472 let claims_b = TestClaims {
473 iss: issuer_b.to_string(),
474 aud: "test-app".to_string(),
475 sub: "user-42".to_string(),
476 exp: (std::time::SystemTime::now()
477 .duration_since(std::time::UNIX_EPOCH)
478 .unwrap()
479 .as_secs()
480 + 3600) as usize,
481 };
482 let token_b = issue_test_token(secret_b, "b-key", &claims_b);
483
484 let result = verify_with_any::<TestClaims>(&[v_a, v_b], &token_b)
486 .expect("verifier should match issuer_b")
487 .expect("verification should succeed");
488 assert_eq!(result.claims, claims_b);
489 }
490
491 #[test]
492 fn verify_with_any_returns_none_for_unknown_issuer() {
493 let secret = b"unit-test-secret-key-32bytes!!!!";
494 let v = verifier_for_tests(
495 "https://hydra.example.com",
496 vec!["test-app".to_string()],
497 hs256_jwks_with_kid("a-key", secret),
498 );
499 let claims = TestClaims {
500 iss: "https://stranger.example.com".to_string(),
501 aud: "test-app".to_string(),
502 sub: "user-42".to_string(),
503 exp: (std::time::SystemTime::now()
504 .duration_since(std::time::UNIX_EPOCH)
505 .unwrap()
506 .as_secs()
507 + 3600) as usize,
508 };
509 let token = issue_test_token(secret, "a-key", &claims);
510
511 let result = verify_with_any::<TestClaims>(&[v], &token);
512 assert!(result.is_none(), "unknown issuer should fall through");
513 }
514
515 #[test]
516 fn verify_with_any_returns_none_for_empty_issuer_list() {
517 let secret = b"unit-test-secret-key-32bytes!!!!";
518 let claims = TestClaims {
519 iss: "https://anywhere.example.com".to_string(),
520 aud: "test-app".to_string(),
521 sub: "user-42".to_string(),
522 exp: 9999999999,
523 };
524 let token = issue_test_token(secret, "x", &claims);
525 assert!(verify_with_any::<TestClaims>(&[], &token).is_none());
526 }
527}