1use std::future::Future;
37use std::io;
38use std::net::SocketAddr;
39use std::path::PathBuf;
40use std::sync::Arc;
41
42use axum::Router;
43use axum::extract::State;
44use axum::http::{HeaderMap, StatusCode, header};
45use axum::response::{IntoResponse, Response};
46use axum::routing::get;
47use sha2::{Digest as _, Sha256};
48use tracing::{info, warn};
49
50use crate::catalog::{Class, KeyAlgorithm};
51use crate::core::catalog::schema::KeyEntry;
52use crate::minter::{SvidAlg, jwt_svid_jwks_grace};
53use crate::state::BrokerState;
54
55const JWKS_CONTENT_TYPE: &str = "application/jwk-set+json";
57
58const JWKS_MAX_AGE_SECS: u32 = 300;
65
66pub const JWKS_PATH: &str = "/jwks.json";
68
69pub const JWKS_WELL_KNOWN_PATH: &str = "/.well-known/jwks.json";
71
72pub const OIDC_DISCOVERY_PATH: &str = "/.well-known/openid-configuration";
74
75const OIDC_CONTENT_TYPE: &str = "application/json";
77
78const ID_TOKEN_SIGNING_ALGS_SUPPORTED: [&str; 3] = ["RS256", "ES256", "ES384"];
80
81#[derive(Debug, Clone, Default)]
89pub struct JwksHttpConfig {
90 pub issuer: Option<String>,
92 pub tls: Option<JwksTlsConfig>,
94}
95
96#[derive(Debug, Clone, Default)]
98pub struct JwksTlsConfig {
99 pub cert_file: PathBuf,
101 pub key_file: PathBuf,
103}
104
105#[derive(Clone)]
108struct HttpState {
109 broker: Arc<BrokerState>,
110 config: Arc<JwksHttpConfig>,
111}
112
113pub fn router(state: Arc<BrokerState>, config: JwksHttpConfig) -> Router {
120 let http = HttpState {
121 broker: state,
122 config: Arc::new(config),
123 };
124 let mut router = Router::new()
125 .route(JWKS_PATH, get(jwks_handler))
126 .route(JWKS_WELL_KNOWN_PATH, get(jwks_handler));
127 if http.config.issuer.is_some() {
128 router = router.route(OIDC_DISCOVERY_PATH, get(discovery_handler));
129 }
130 router.with_state(http)
131}
132
133pub async fn serve(
144 state: Arc<BrokerState>,
145 listen: SocketAddr,
146 config: JwksHttpConfig,
147 shutdown: impl Future<Output = ()> + Send + 'static,
148) -> std::io::Result<()> {
149 let listener = tokio::net::TcpListener::bind(listen).await?;
150 let local = listener.local_addr().unwrap_or(listen);
151 let tls = config.tls.clone();
152 let scheme = if tls.is_some() { "https" } else { "http" };
153 info!(addr = %local, path = JWKS_PATH, scheme, "JWKS HTTP surface listening");
154 let app = router(state, config);
155 let result = if let Some(tls) = tls {
156 serve_tls(listener, app, tls, shutdown).await
157 } else {
158 axum::serve(listener, app)
159 .with_graceful_shutdown(shutdown)
160 .await
161 };
162 info!(addr = %local, "JWKS HTTP surface stopped");
163 result
164}
165
166#[cfg(not(feature = "http-tls"))]
170#[allow(clippy::unused_async)]
171async fn serve_tls(
172 _listener: tokio::net::TcpListener,
173 _app: Router,
174 _tls: JwksTlsConfig,
175 _shutdown: impl Future<Output = ()> + Send + 'static,
176) -> io::Result<()> {
177 Err(io::Error::new(
178 io::ErrorKind::InvalidInput,
179 "jwks tls requires the http-tls cargo feature",
180 ))
181}
182
183#[cfg(feature = "http-tls")]
184async fn serve_tls(
185 listener: tokio::net::TcpListener,
186 app: Router,
187 tls: JwksTlsConfig,
188 shutdown: impl Future<Output = ()> + Send + 'static,
189) -> io::Result<()> {
190 use hyper_util::rt::{TokioExecutor, TokioIo};
191 use hyper_util::server::conn::auto::Builder;
192 use hyper_util::service::TowerToHyperService;
193 use tokio::sync::watch;
194
195 let acceptor = tls_acceptor_from_files(&tls)?;
196 let (signal_tx, signal_rx) = watch::channel(());
197 let signal_tx = Arc::new(signal_tx);
198 tokio::spawn(async move {
199 shutdown.await;
200 drop(signal_rx);
201 });
202 let (close_tx, close_rx) = watch::channel(());
203
204 loop {
205 let (tcp_stream, remote_addr) = tokio::select! {
206 conn = listener.accept() => conn?,
207 () = signal_tx.closed() => break,
208 };
209 if let Err(err) = tcp_stream.set_nodelay(true) {
210 warn!(%remote_addr, %err, "could not set TCP_NODELAY on JWKS TLS connection");
211 }
212 let acceptor = acceptor.clone();
213 let service = app.clone().into_service::<hyper::body::Incoming>();
214 let signal_tx = Arc::clone(&signal_tx);
215 let close_rx = close_rx.clone();
216 tokio::spawn(async move {
217 let Ok(tls_stream) = acceptor.accept(tcp_stream).await else {
218 drop(close_rx);
219 return;
220 };
221 let hyper_service = TowerToHyperService::new(service);
222 let builder = Builder::new(TokioExecutor::new());
223 let conn =
224 builder.serve_connection_with_upgrades(TokioIo::new(tls_stream), hyper_service);
225 tokio::pin!(conn);
226 let mut signal_closed = std::pin::pin!(signal_tx.closed());
227 loop {
228 tokio::select! {
229 result = conn.as_mut() => {
230 if let Err(err) = result {
231 warn!(%remote_addr, %err, "JWKS TLS connection failed");
232 }
233 break;
234 }
235 () = &mut signal_closed => {
236 conn.as_mut().graceful_shutdown();
237 }
238 }
239 }
240 drop(close_rx);
241 });
242 }
243
244 drop(close_rx);
245 drop(listener);
246 close_tx.closed().await;
247 Ok(())
248}
249
250#[cfg(feature = "http-tls")]
251fn tls_acceptor_from_files(tls: &JwksTlsConfig) -> io::Result<tokio_rustls::TlsAcceptor> {
252 use rustls::ServerConfig as RustlsServerConfig;
253 use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject as _};
254
255 let cert_pem = std::fs::read(&tls.cert_file).map_err(|err| {
256 io::Error::new(
257 err.kind(),
258 format!(
259 "reading jwks.tls.cert-file {}: {err}",
260 tls.cert_file.display()
261 ),
262 )
263 })?;
264 let key_pem = std::fs::read(&tls.key_file).map_err(|err| {
265 io::Error::new(
266 err.kind(),
267 format!(
268 "reading jwks.tls.key-file {}: {err}",
269 tls.key_file.display()
270 ),
271 )
272 })?;
273 let certs = CertificateDer::pem_reader_iter(&mut std::io::Cursor::new(cert_pem))
274 .collect::<Result<Vec<_>, _>>()
275 .map_err(|err| {
276 io::Error::new(
277 io::ErrorKind::InvalidInput,
278 format!("parsing jwks tls certificate chain: {err}"),
279 )
280 })?;
281 if certs.is_empty() {
282 return Err(io::Error::new(
283 io::ErrorKind::InvalidInput,
284 "jwks.tls.cert-file did not contain a certificate",
285 ));
286 }
287 let key =
288 PrivateKeyDer::from_pem_reader(&mut std::io::Cursor::new(key_pem)).map_err(|err| {
289 io::Error::new(
290 io::ErrorKind::InvalidInput,
291 format!("parsing jwks tls private key: {err}"),
292 )
293 })?;
294 let server_config =
295 RustlsServerConfig::builder_with_provider(rustls::crypto::ring::default_provider().into())
296 .with_safe_default_protocol_versions()
297 .map_err(|err| {
298 io::Error::new(
299 io::ErrorKind::InvalidInput,
300 format!("selecting jwks tls protocol versions: {err}"),
301 )
302 })?
303 .with_no_client_auth()
304 .with_single_cert(certs, key)
305 .map_err(|err| {
306 io::Error::new(
307 io::ErrorKind::InvalidInput,
308 format!("building jwks tls config: {err}"),
309 )
310 })?;
311 Ok(tokio_rustls::TlsAcceptor::from(Arc::new(server_config)))
312}
313
314async fn jwks_handler(headers: HeaderMap, State(state): State<HttpState>) -> Response {
321 match build_jwks_document(&state.broker).await {
322 Ok(document) => {
323 if headers
324 .get(header::IF_NONE_MATCH)
325 .and_then(|value| value.to_str().ok())
326 .is_some_and(|tag| {
327 tag.split(',')
328 .any(|candidate| candidate.trim() == document.etag)
329 })
330 {
331 return (
332 StatusCode::NOT_MODIFIED,
333 [
334 (
335 header::CACHE_CONTROL,
336 format!("public, max-age={JWKS_MAX_AGE_SECS}"),
337 ),
338 (header::ETAG, document.etag),
339 ],
340 )
341 .into_response();
342 }
343 jwks_response(document.body, document.etag)
344 }
345 Err(reason) => {
346 warn!(%reason, "JWKS request failed");
347 (
348 StatusCode::SERVICE_UNAVAILABLE,
349 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
350 "jwks temporarily unavailable\n",
351 )
352 .into_response()
353 }
354 }
355}
356
357async fn discovery_handler(State(state): State<HttpState>) -> Response {
364 let Some(issuer) = state.config.issuer.as_deref() else {
365 return (
366 StatusCode::SERVICE_UNAVAILABLE,
367 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
368 "discovery not configured\n",
369 )
370 .into_response();
371 };
372 discovery_response(&discovery_document(issuer))
373}
374
375fn discovery_document(issuer: &str) -> serde_json::Value {
389 serde_json::json!({
390 "issuer": issuer,
391 "jwks_uri": format!("{issuer}{JWKS_PATH}"),
392 "id_token_signing_alg_values_supported": ID_TOKEN_SIGNING_ALGS_SUPPORTED,
393 "response_types_supported": ["id_token"],
396 "subject_types_supported": ["public"],
397 })
398}
399
400fn discovery_response(doc: &serde_json::Value) -> Response {
403 let body = serde_json::to_vec(doc).unwrap_or_else(|_| b"{}".to_vec());
404 let etag = etag_for(&body);
405 (
406 StatusCode::OK,
407 [
408 (header::CONTENT_TYPE, OIDC_CONTENT_TYPE.to_string()),
409 (
410 header::CACHE_CONTROL,
411 format!("public, max-age={JWKS_MAX_AGE_SECS}"),
412 ),
413 (header::ETAG, etag),
414 ],
415 body,
416 )
417 .into_response()
418}
419
420fn jwks_response(body: Vec<u8>, etag: String) -> Response {
423 (
424 StatusCode::OK,
425 [
426 (header::CONTENT_TYPE, JWKS_CONTENT_TYPE.to_string()),
427 (
428 header::CACHE_CONTROL,
429 format!("public, max-age={JWKS_MAX_AGE_SECS}"),
430 ),
431 (header::ETAG, etag),
432 ],
433 body,
434 )
435 .into_response()
436}
437
438struct JwksDocument {
439 body: Vec<u8>,
440 etag: String,
441}
442
443async fn build_jwks_document(state: &BrokerState) -> Result<JwksDocument, String> {
444 let body = build_jwks(state).await?;
445 let etag = etag_for(&body);
446 Ok(JwksDocument { body, etag })
447}
448
449fn etag_for(body: &[u8]) -> String {
451 let digest = Sha256::digest(body);
452 let mut hex = String::with_capacity(2 + digest.len() * 2 + 1);
453 hex.push('"');
454 for byte in digest {
455 use std::fmt::Write as _;
456 let _ = write!(hex, "{byte:02x}");
458 }
459 hex.push('"');
460 hex
461}
462
463fn is_jwks_issuer(entry: &KeyEntry) -> bool {
470 entry.class == Class::Asymmetric
471 && entry.labels.get("svid_kind") == Some("jwt")
472 && entry
473 .key_type
474 .is_some_and(KeyAlgorithm::is_spiffe_jwt_svid_profile)
475}
476
477const fn issuer_alg(key_type: Option<KeyAlgorithm>) -> Option<SvidAlg> {
481 match key_type {
482 Some(KeyAlgorithm::Rsa2048) => Some(SvidAlg::Rs256),
483 Some(KeyAlgorithm::EcdsaP256) => Some(SvidAlg::Es256),
484 Some(KeyAlgorithm::EcdsaP384) => Some(SvidAlg::Es384),
485 _ => None,
486 }
487}
488
489async fn build_jwks(state: &BrokerState) -> Result<Vec<u8>, String> {
502 let issuers: Vec<(String, SvidAlg)> = state
505 .manager()
506 .keys()
507 .filter(|(_, entry)| is_jwks_issuer(entry))
508 .filter_map(|(name, entry)| issuer_alg(entry.key_type).map(|alg| (name.clone(), alg)))
509 .collect();
510
511 let limits = state.limits();
512 let mut keys: Vec<serde_json::Value> = Vec::with_capacity(issuers.len());
516 let mut seen_kids: Vec<String> = Vec::with_capacity(issuers.len());
517 for (name, alg) in issuers {
518 let routed = state
519 .manager()
520 .resolve(&name)
521 .map_err(|e| format!("resolving issuer: {e}"))?;
522 let bytes = jwt_svid_jwks_grace(routed.backend, routed.path(), alg, |latest| {
523 limits.grace_floor(latest)
524 })
525 .await
526 .map_err(|e| format!("building issuer jwks: {e}"))?;
527 merge_jwk_set(&bytes, &mut keys, &mut seen_kids)?;
528 }
529
530 serde_json::to_vec(&serde_json::json!({ "keys": keys }))
531 .map_err(|e| format!("serializing jwks: {e}"))
532}
533
534fn merge_jwk_set(
538 bytes: &[u8],
539 keys: &mut Vec<serde_json::Value>,
540 seen_kids: &mut Vec<String>,
541) -> Result<(), String> {
542 let parsed: serde_json::Value =
543 serde_json::from_slice(bytes).map_err(|e| format!("parsing jwk set: {e}"))?;
544 let Some(arr) = parsed.get("keys").and_then(serde_json::Value::as_array) else {
545 return Err("jwk set has no `keys` array".to_string());
546 };
547 for jwk in arr {
548 let kid = jwk
549 .get("kid")
550 .and_then(serde_json::Value::as_str)
551 .unwrap_or_default()
552 .to_string();
553 if seen_kids.iter().any(|seen| seen == &kid) {
554 continue;
555 }
556 seen_kids.push(kid);
557 keys.push(jwk.clone());
558 }
559 Ok(())
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 fn assemble_jwks(public_keys: &[(Vec<u8>, SvidAlg)]) -> Result<Vec<u8>, String> {
571 use crate::minter::jwt_svid_jwks_from_public_key;
572 let mut keys: Vec<serde_json::Value> = Vec::new();
573 let mut seen_kids: Vec<String> = Vec::new();
574 for (public_key, alg) in public_keys {
575 let bytes = jwt_svid_jwks_from_public_key(public_key, *alg)
576 .map_err(|e| format!("building jwk: {e}"))?;
577 merge_jwk_set(&bytes, &mut keys, &mut seen_kids)?;
578 }
579 serde_json::to_vec(&serde_json::json!({ "keys": keys }))
580 .map_err(|e| format!("serializing jwks: {e}"))
581 }
582
583 #[test]
584 fn etag_is_quoted_hex_and_stable() {
585 let a = etag_for(b"hello");
586 let b = etag_for(b"hello");
587 assert_eq!(a, b);
588 assert!(a.starts_with('"') && a.ends_with('"'));
589 assert_ne!(a, etag_for(b"world"));
590 assert_eq!(a.len(), 66);
592 }
593
594 #[test]
595 fn merge_dedups_by_kid_and_collects() {
596 let mut keys = Vec::new();
597 let mut seen = Vec::new();
598 let one = br#"{"keys":[{"kid":"a","kty":"RSA"}]}"#;
599 let dup = br#"{"keys":[{"kid":"a","kty":"RSA"}]}"#;
600 let two = br#"{"keys":[{"kid":"b","kty":"RSA"}]}"#;
601 merge_jwk_set(one, &mut keys, &mut seen).expect("merge one");
602 merge_jwk_set(dup, &mut keys, &mut seen).expect("merge dup");
603 merge_jwk_set(two, &mut keys, &mut seen).expect("merge two");
604 assert_eq!(keys.len(), 2);
605 assert_eq!(seen, vec!["a".to_string(), "b".to_string()]);
606 }
607
608 #[test]
609 fn empty_issuer_set_yields_a_valid_empty_jwk_set() {
610 let body = assemble_jwks(&[]).expect("assemble empty");
611 let parsed: serde_json::Value = serde_json::from_slice(&body).expect("json");
612 assert!(parsed["keys"].as_array().expect("keys array").is_empty());
613 }
614
615 #[test]
616 fn jwks_response_carries_cache_headers_and_content_type() {
617 let body = assemble_jwks(&[]).expect("assemble");
618 let etag = etag_for(&body);
619 let resp = jwks_response(body, etag);
620 assert_eq!(resp.status(), StatusCode::OK);
621 let headers = resp.headers();
622 assert_eq!(
623 headers
624 .get(header::CONTENT_TYPE)
625 .and_then(|v| v.to_str().ok()),
626 Some(JWKS_CONTENT_TYPE)
627 );
628 let cache = headers
629 .get(header::CACHE_CONTROL)
630 .and_then(|v| v.to_str().ok())
631 .expect("cache-control");
632 assert!(cache.contains("max-age=300"), "cache-control: {cache}");
633 let etag = headers
634 .get(header::ETAG)
635 .and_then(|v| v.to_str().ok())
636 .expect("etag");
637 assert!(etag.starts_with('"') && etag.ends_with('"'));
638 }
639
640 #[cfg(feature = "http-tls")]
641 #[test]
642 fn jwks_tls_acceptor_builds_from_pem_files() {
643 let dir = std::env::temp_dir().join(format!("basil-jwks-tls-{}", uuid::Uuid::new_v4()));
644 std::fs::create_dir(&dir).expect("create temp dir");
645 let cert_file = dir.join("cert.pem");
646 let key_file = dir.join("key.pem");
647 std::fs::write(&cert_file, include_str!("../../testdata/jwks_tls_cert.pem"))
648 .expect("write cert");
649 std::fs::write(&key_file, include_str!("../../testdata/jwks_tls_key.pem"))
650 .expect("write key");
651
652 let config = JwksTlsConfig {
653 cert_file: cert_file.clone(),
654 key_file: key_file.clone(),
655 };
656 tls_acceptor_from_files(&config).expect("build tls acceptor");
657
658 std::fs::remove_file(cert_file).expect("remove cert");
659 std::fs::remove_file(key_file).expect("remove key");
660 std::fs::remove_dir(dir).expect("remove temp dir");
661 }
662
663 #[tokio::test]
670 #[allow(clippy::too_many_lines)] async fn served_jwks_verifies_a_basil_minted_jwt_svid() {
672 use async_trait::async_trait;
673 use base64::Engine as _;
674 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
675 use rsa::pkcs1::EncodeRsaPrivateKey as _;
676 use rsa::pkcs8::{EncodePublicKey as _, LineEnding};
677
678 use crate::backend::{Backend, BackendError, NewKey, SignOptions};
679 use crate::minter::mint_svid;
680 use basil_proto::KeyType;
681
682 struct RsaBackend {
686 encoding_key: jsonwebtoken::EncodingKey,
687 public_der: Vec<u8>,
688 }
689 impl RsaBackend {
690 fn rs256_sign(&self, input: &[u8]) -> Result<Vec<u8>, BackendError> {
691 let b64 = jsonwebtoken::crypto::sign(
692 input,
693 &self.encoding_key,
694 jsonwebtoken::Algorithm::RS256,
695 )
696 .map_err(|e| BackendError::Backend(e.to_string()))?;
697 URL_SAFE_NO_PAD
698 .decode(b64)
699 .map_err(|e| BackendError::Backend(e.to_string()))
700 }
701 }
702 #[async_trait]
703 impl Backend for RsaBackend {
704 fn kind(&self) -> &'static str {
705 "rsa-test"
706 }
707 async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
708 Err(BackendError::Unsupported("new_key"))
709 }
710 async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
711 Ok(self.public_der.clone())
712 }
713 async fn sign(&self, _key_id: &str, input: &[u8]) -> Result<Vec<u8>, BackendError> {
714 self.rs256_sign(input)
715 }
716 async fn sign_with_options(
717 &self,
718 _key_id: &str,
719 input: &[u8],
720 options: SignOptions,
721 ) -> Result<Vec<u8>, BackendError> {
722 if options != SignOptions::Rs256Pkcs1v15Sha256 {
723 return Err(BackendError::Unsupported("rsa test sign options"));
724 }
725 self.rs256_sign(input)
726 }
727 async fn verify(
728 &self,
729 _key_id: &str,
730 _message: &[u8],
731 _signature: &[u8],
732 ) -> Result<bool, BackendError> {
733 Err(BackendError::Unsupported("verify"))
734 }
735 }
736
737 let mut rng = rand::thread_rng();
739 let private = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("rsa keygen");
740 let public = rsa::RsaPublicKey::from(&private);
741 let private_pem = private.to_pkcs1_pem(LineEnding::LF).expect("pkcs1 pem");
742 let public_der = public
743 .to_public_key_der()
744 .expect("spki der")
745 .as_bytes()
746 .to_vec();
747 let backend = RsaBackend {
748 encoding_key: jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
749 .expect("encoding key"),
750 public_der: public_der.clone(),
751 };
752
753 let body = assemble_jwks(&[(public_der.clone(), SvidAlg::Rs256)]).expect("assemble jwks");
755 let jwks: serde_json::Value = serde_json::from_slice(&body).expect("jwks json");
756 let key = jwks["keys"]
757 .as_array()
758 .and_then(|a| a.first())
759 .expect("one jwk");
760
761 assert_eq!(key["kty"], "RSA");
763 assert_eq!(key["alg"], "RS256");
764 assert_eq!(key["use"], "sig");
765 let jwk_kid = key["kid"].as_str().expect("kid");
766
767 let token = mint_svid(
769 &backend,
770 "rsa-issuer",
771 "spiffe://example.org",
772 SvidAlg::Rs256,
773 "spiffe://example.org/db-01",
774 "vault",
775 Some(300),
776 &serde_json::Value::Null,
777 )
778 .await
779 .expect("mint svid");
780
781 let header = jsonwebtoken::decode_header(&token).expect("decode header");
783 assert_eq!(header.kid.as_deref(), Some(jwk_kid));
784
785 let n = key["n"].as_str().expect("n");
788 let e = key["e"].as_str().expect("e");
789 let decoding_key = jsonwebtoken::DecodingKey::from_rsa_components(n, e).expect("decoding");
790 let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
791 validation.set_issuer(&["spiffe://example.org"]);
792 validation.set_audience(&["vault"]);
793 validation.set_required_spec_claims(&["exp", "iss", "sub", "aud"]);
794 let data = jsonwebtoken::decode::<serde_json::Value>(&token, &decoding_key, &validation)
795 .expect("verify against jwks-derived key");
796 assert_eq!(data.claims["sub"], "spiffe://example.org/db-01");
797 }
798
799 #[test]
800 fn discovery_document_is_consistent_and_minimal() {
801 let doc = discovery_document("https://basil.example.com");
802 assert_eq!(doc["issuer"], "https://basil.example.com");
803 assert_eq!(
805 doc["jwks_uri"],
806 format!("https://basil.example.com{JWKS_PATH}")
807 );
808 let jwks_uri = doc["jwks_uri"].as_str().expect("jwks_uri str");
809 assert!(
810 jwks_uri.starts_with(doc["issuer"].as_str().expect("issuer str")),
811 "jwks_uri must share the issuer base"
812 );
813 assert_eq!(
814 doc["id_token_signing_alg_values_supported"],
815 serde_json::json!(ID_TOKEN_SIGNING_ALGS_SUPPORTED)
816 );
817 let algs = doc["id_token_signing_alg_values_supported"]
818 .as_array()
819 .expect("alg list");
820 assert!(!algs.iter().any(|alg| alg == "ES512"));
821 assert!(!algs.iter().any(|alg| alg == "PS256"));
822 assert!(doc["response_types_supported"].is_array());
823 assert!(doc["subject_types_supported"].is_array());
824 }
825
826 #[test]
827 fn discovery_response_carries_cache_headers_and_json_content_type() {
828 let resp = discovery_response(&discovery_document("https://b.example"));
829 assert_eq!(resp.status(), StatusCode::OK);
830 let headers = resp.headers();
831 assert_eq!(
832 headers
833 .get(header::CONTENT_TYPE)
834 .and_then(|v| v.to_str().ok()),
835 Some(OIDC_CONTENT_TYPE)
836 );
837 let cache = headers
838 .get(header::CACHE_CONTROL)
839 .and_then(|v| v.to_str().ok())
840 .expect("cache-control");
841 assert!(cache.contains("max-age=300"), "cache-control: {cache}");
842 assert!(headers.get(header::ETAG).is_some());
843 }
844
845 mod live_path {
858 use std::collections::BTreeMap;
859 use std::sync::Arc;
860 use std::sync::Mutex;
861 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
862
863 use async_trait::async_trait;
864 use axum::body::Body;
865 use axum::http::{Method, Request, StatusCode, header};
866 use rsa::pkcs8::EncodePublicKey as _;
867 use tower::ServiceExt as _;
868
869 use super::super::{
870 JWKS_PATH, JWKS_WELL_KNOWN_PATH, JwksHttpConfig, build_jwks, is_jwks_issuer,
871 issuer_alg, router,
872 };
873 use crate::backend::{Backend, BackendError, NewKey};
874 use crate::catalog::{Catalog, KeyAlgorithm};
875 use crate::manager::BackendManager;
876 use crate::minter::SvidAlg;
877 use crate::state::BrokerState;
878 use basil_proto::KeyType;
879
880 const SECRET_BACKEND_ERROR: &str =
881 "backend body Authorization: Bearer vault-token-s.123 -----BEGIN PRIVATE KEY-----";
882
883 struct PubKeysBackend {
889 versions: Mutex<BTreeMap<u32, Vec<u8>>>,
891 public_keys_calls: AtomicUsize,
894 forbidden_called: AtomicBool,
896 fail: bool,
898 }
899
900 impl PubKeysBackend {
901 fn new(versions: BTreeMap<u32, Vec<u8>>) -> Arc<Self> {
902 Arc::new(Self {
903 versions: Mutex::new(versions),
904 public_keys_calls: AtomicUsize::new(0),
905 forbidden_called: AtomicBool::new(false),
906 fail: false,
907 })
908 }
909
910 fn failing() -> Arc<Self> {
911 Arc::new(Self {
912 versions: Mutex::new(BTreeMap::new()),
913 public_keys_calls: AtomicUsize::new(0),
914 forbidden_called: AtomicBool::new(false),
915 fail: true,
916 })
917 }
918
919 fn set_versions(&self, versions: BTreeMap<u32, Vec<u8>>) {
920 *self.versions.lock().expect("versions lock") = versions;
921 }
922
923 fn forbidden(&self) {
924 self.forbidden_called.store(true, Ordering::SeqCst);
925 }
926 }
927
928 struct Handle(Arc<PubKeysBackend>);
931
932 #[async_trait]
933 impl Backend for Handle {
934 fn kind(&self) -> &'static str {
935 "pubkeys-test"
936 }
937 async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
938 Err(BackendError::Unsupported("new_key"))
939 }
940 async fn public_keys(
941 &self,
942 _key_id: &str,
943 ) -> Result<BTreeMap<u32, Vec<u8>>, BackendError> {
944 self.0.public_keys_calls.fetch_add(1, Ordering::SeqCst);
945 if self.0.fail {
946 return Err(BackendError::Backend(SECRET_BACKEND_ERROR.into()));
947 }
948 Ok(self.0.versions.lock().expect("versions lock").clone())
949 }
950 async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
952 self.0.forbidden();
953 Err(BackendError::Backend(
954 "public_key must not be called".into(),
955 ))
956 }
957 async fn kv_get(
958 &self,
959 _key_id: &str,
960 _version: Option<u32>,
961 ) -> Result<crate::backend::KvValue, BackendError> {
962 self.0.forbidden();
963 Err(BackendError::Backend("kv_get must not be called".into()))
964 }
965 async fn kv_get_secret(
966 &self,
967 _key_id: &str,
968 _version: Option<u32>,
969 ) -> Result<crate::backend::KvSecret, BackendError> {
970 self.0.forbidden();
971 Err(BackendError::Backend(
972 "kv_get_secret must not be called".into(),
973 ))
974 }
975 async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
976 self.0.forbidden();
977 Err(BackendError::Backend("sign must not be called".into()))
978 }
979 async fn verify(
980 &self,
981 _key_id: &str,
982 _message: &[u8],
983 _signature: &[u8],
984 ) -> Result<bool, BackendError> {
985 self.0.forbidden();
986 Err(BackendError::Backend("verify must not be called".into()))
987 }
988 }
989
990 fn rsa_public_der() -> Vec<u8> {
993 let mut rng = rand::thread_rng();
994 let private = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("rsa keygen");
995 rsa::RsaPublicKey::from(&private)
996 .to_public_key_der()
997 .expect("spki der")
998 .as_bytes()
999 .to_vec()
1000 }
1001
1002 fn catalog_from_json(json: &str) -> Catalog {
1008 serde_json::from_str(json).expect("catalog json")
1009 }
1010
1011 fn state_with(
1017 catalog: Catalog,
1018 backends_map: Vec<(&str, Arc<PubKeysBackend>)>,
1019 ) -> BrokerState {
1020 state_with_grace(
1021 catalog,
1022 backends_map,
1023 crate::state::DEFAULT_ROTATION_GRACE_VERSIONS,
1024 )
1025 }
1026
1027 fn state_with_grace(
1031 catalog: Catalog,
1032 backends_map: Vec<(&str, Arc<PubKeysBackend>)>,
1033 grace_versions: u32,
1034 ) -> BrokerState {
1035 const MINIMAL_CATALOG: &str = r#"{
1036 "schemaVersion": 1,
1037 "backends": { "b": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1038 "keys": {}
1039 }"#;
1040 const EMPTY_POLICY: &str = r#"{
1041 "roles": {},
1042 "rules": [],
1043 "config": { "names": { "users": {}, "groups": {} }, "memberships": {} }
1044 }"#;
1045 let (_minimal, policy, config, _warnings) =
1046 crate::catalog::load(MINIMAL_CATALOG, EMPTY_POLICY).expect("load minimal");
1047 let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
1048 for (name, handle) in backends_map {
1049 backends.insert(name.to_string(), Box::new(Handle(handle)));
1050 }
1051 let manager = BackendManager::new(catalog.clone(), backends).expect("manager builds");
1052 let limits = crate::state::BrokerLimits {
1053 grace_versions,
1054 ..crate::state::BrokerLimits::default()
1055 };
1056 BrokerState::with_limits(catalog, policy, config, manager, "vault", limits)
1057 }
1058
1059 fn kids(body: &[u8]) -> Vec<String> {
1061 let parsed: serde_json::Value = serde_json::from_slice(body).expect("jwks json");
1062 parsed["keys"]
1063 .as_array()
1064 .expect("keys array")
1065 .iter()
1066 .filter_map(|k| k["kid"].as_str().map(str::to_string))
1067 .collect()
1068 }
1069
1070 #[test]
1074 fn selection_predicate_admits_only_profile_jwt_asymmetric() {
1075 let cat = catalog_from_json(SELECTION_CATALOG);
1076 let want = |name: &str| {
1077 cat.keys
1078 .get(name)
1079 .is_some_and(|e| is_jwks_issuer(e) && issuer_alg(e.key_type).is_some())
1080 };
1081 assert!(want("rsa.issuer"), "RSA jwt asymmetric is an issuer");
1082 assert!(!want("ed.issuer"), "ed25519 jwt is not RSA-profile");
1083 assert!(
1084 !want("plain.signer"),
1085 "asymmetric w/o svid_kind=jwt excluded"
1086 );
1087 assert!(!want("the.value"), "value key excluded (wrong class)");
1088 assert!(!want("the.sealing"), "sealing key excluded (wrong class)");
1089 assert_eq!(
1090 issuer_alg(Some(KeyAlgorithm::Rsa2048)),
1091 Some(SvidAlg::Rs256)
1092 );
1093 assert_eq!(
1094 issuer_alg(Some(KeyAlgorithm::EcdsaP256)),
1095 Some(SvidAlg::Es256)
1096 );
1097 assert_eq!(
1098 issuer_alg(Some(KeyAlgorithm::EcdsaP384)),
1099 Some(SvidAlg::Es384)
1100 );
1101 assert_eq!(issuer_alg(Some(KeyAlgorithm::EcdsaP521)), None);
1102 assert_eq!(issuer_alg(Some(KeyAlgorithm::Ed25519)), None);
1103 }
1104
1105 #[tokio::test]
1111 async fn build_jwks_serves_only_the_rsa_jwt_issuer() {
1112 let cat = catalog_from_json(SELECTION_CATALOG);
1113 let rsa = PubKeysBackend::new(BTreeMap::from([(1, rsa_public_der())]));
1114 let other = PubKeysBackend::failing();
1118 let state = state_with(
1119 cat,
1120 vec![("rsa", Arc::clone(&rsa)), ("other", Arc::clone(&other))],
1121 );
1122
1123 let body = build_jwks(&state).await.expect("build jwks");
1124 assert_eq!(kids(&body).len(), 1, "exactly the RSA issuer's one key");
1125
1126 assert_eq!(rsa.public_keys_calls.load(Ordering::SeqCst), 1);
1130 assert_eq!(
1131 other.public_keys_calls.load(Ordering::SeqCst),
1132 0,
1133 "excluded keys are never fanned out to a backend"
1134 );
1135 assert!(
1136 !rsa.forbidden_called.load(Ordering::SeqCst)
1137 && !other.forbidden_called.load(Ordering::SeqCst),
1138 "no private/secret read on the JWKS live path"
1139 );
1140 }
1141
1142 #[tokio::test]
1146 async fn build_jwks_merges_two_issuers_deduped_by_kid() {
1147 let cat = catalog_from_json(MULTI_ISSUER_CATALOG);
1148 let one = PubKeysBackend::new(BTreeMap::from([(1, rsa_public_der())]));
1149 let two = PubKeysBackend::new(BTreeMap::from([(1, rsa_public_der())]));
1150 let state = state_with(
1151 cat,
1152 vec![("one", Arc::clone(&one)), ("two", Arc::clone(&two))],
1153 );
1154
1155 let body = build_jwks(&state).await.expect("build jwks");
1156 let mut got = kids(&body);
1157 got.sort();
1158 got.dedup();
1159 assert_eq!(got.len(), 2, "both issuers' keys present, deduped by kid");
1160 assert_eq!(one.public_keys_calls.load(Ordering::SeqCst), 1);
1161 assert_eq!(two.public_keys_calls.load(Ordering::SeqCst), 1);
1162 assert!(
1163 !one.forbidden_called.load(Ordering::SeqCst)
1164 && !two.forbidden_called.load(Ordering::SeqCst),
1165 "merge path reads only public keys"
1166 );
1167 }
1168
1169 #[tokio::test]
1172 async fn build_jwks_dedups_identical_issuer_keys() {
1173 let cat = catalog_from_json(MULTI_ISSUER_CATALOG);
1174 let shared = rsa_public_der();
1175 let one = PubKeysBackend::new(BTreeMap::from([(1, shared.clone())]));
1176 let two = PubKeysBackend::new(BTreeMap::from([(1, shared)]));
1177 let state = state_with(
1178 cat,
1179 vec![("one", Arc::clone(&one)), ("two", Arc::clone(&two))],
1180 );
1181
1182 let body = build_jwks(&state).await.expect("build jwks");
1183 assert_eq!(
1184 kids(&body).len(),
1185 1,
1186 "identical public keys dedup to one kid"
1187 );
1188 }
1189
1190 #[tokio::test]
1198 async fn build_jwks_grace_window_drops_versions_below_the_floor() {
1199 let kid_of = |der: &[u8]| -> String {
1202 let body = crate::minter::jwt_svid_jwks_from_public_key(der, SvidAlg::Rs256)
1203 .expect("single-key jwks");
1204 kids(&body).first().cloned().expect("one kid")
1205 };
1206
1207 let der_v1 = rsa_public_der();
1208 let der_v2 = rsa_public_der();
1209 let der_v3 = rsa_public_der();
1210 let kid_v1 = kid_of(&der_v1);
1211 let kid_v2 = kid_of(&der_v2);
1212 let kid_v3 = kid_of(&der_v3);
1213 assert!(
1216 kid_v1 != kid_v2 && kid_v2 != kid_v3 && kid_v1 != kid_v3,
1217 "three distinct RSA keys yield three distinct kids"
1218 );
1219 let versions = BTreeMap::from([(1, der_v1), (2, der_v2), (3, der_v3)]);
1220
1221 let served_kids = |grace_versions: u32| {
1224 let versions = versions.clone();
1225 async move {
1226 let rsa = PubKeysBackend::new(versions);
1227 let other = PubKeysBackend::failing();
1228 let state = state_with_grace(
1229 catalog_from_json(SELECTION_CATALOG),
1230 vec![("rsa", Arc::clone(&rsa)), ("other", Arc::clone(&other))],
1231 grace_versions,
1232 );
1233 let body = build_jwks(&state).await.expect("build jwks");
1234 assert_eq!(
1235 rsa.public_keys_calls.load(Ordering::SeqCst),
1236 1,
1237 "issuer read exactly once via public_keys"
1238 );
1239 assert_eq!(
1240 other.public_keys_calls.load(Ordering::SeqCst),
1241 0,
1242 "excluded backend never fanned out"
1243 );
1244 assert!(
1245 !rsa.forbidden_called.load(Ordering::SeqCst)
1246 && !other.forbidden_called.load(Ordering::SeqCst),
1247 "grace path reads only public keys"
1248 );
1249 let mut got = kids(&body);
1250 got.sort();
1251 got
1252 }
1253 };
1254
1255 let mut want = vec![kid_v2.clone(), kid_v3.clone()];
1258 want.sort();
1259 let got = served_kids(1).await;
1260 assert_eq!(got, want, "default grace publishes v2+v3, drops v1");
1261 assert!(
1262 !got.contains(&kid_v1),
1263 "v1 is below the grace floor and absent from the JWKS"
1264 );
1265
1266 let mut want_all = vec![kid_v1.clone(), kid_v2.clone(), kid_v3.clone()];
1268 want_all.sort();
1269 assert_eq!(
1270 served_kids(2).await,
1271 want_all,
1272 "grace=2 publishes all three in-window versions"
1273 );
1274
1275 assert_eq!(
1278 served_kids(0).await,
1279 vec![kid_v3],
1280 "grace=0 publishes only the latest version"
1281 );
1282 }
1283
1284 #[tokio::test]
1288 async fn handler_returns_503_on_backend_error() {
1289 let cat = catalog_from_json(SELECTION_CATALOG);
1290 let rsa = PubKeysBackend::failing();
1291 let other = PubKeysBackend::failing();
1292 let state = state_with(cat, vec![("rsa", Arc::clone(&rsa)), ("other", other)]);
1293
1294 let app = router(Arc::new(state), JwksHttpConfig::default());
1295 let resp = app
1296 .oneshot(
1297 Request::builder()
1298 .uri(JWKS_PATH)
1299 .body(Body::empty())
1300 .expect("request"),
1301 )
1302 .await
1303 .expect("handler does not panic");
1304 assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
1305
1306 let body = axum::body::to_bytes(resp.into_body(), 64 * 1024)
1307 .await
1308 .expect("read body");
1309 let text = String::from_utf8(body.to_vec()).expect("utf8 body");
1310 assert_eq!(
1311 text, "jwks temporarily unavailable\n",
1312 "stable non-secret body"
1313 );
1314 assert!(!text.contains("vault-token-s.123"));
1315 assert!(!text.contains("PRIVATE KEY"));
1316 assert!(!rsa.forbidden_called.load(Ordering::SeqCst));
1319 }
1320
1321 fn healthy_state() -> BrokerState {
1325 let cat = catalog_from_json(SELECTION_CATALOG);
1326 let rsa = PubKeysBackend::new(BTreeMap::from([(1, rsa_public_der())]));
1327 let other = PubKeysBackend::failing();
1328 state_with(cat, vec![("rsa", rsa), ("other", other)])
1329 }
1330
1331 async fn route_request(
1333 state: BrokerState,
1334 config: JwksHttpConfig,
1335 method: &str,
1336 target: &str,
1337 ) -> axum::response::Response {
1338 let app = router(Arc::new(state), config);
1339 app.oneshot(
1340 Request::builder()
1341 .method(method)
1342 .uri(target)
1343 .body(Body::empty())
1344 .expect("request"),
1345 )
1346 .await
1347 .expect("router does not panic")
1348 }
1349
1350 async fn get_status(
1352 state: BrokerState,
1353 config: JwksHttpConfig,
1354 method: &str,
1355 path: &str,
1356 ) -> axum::http::StatusCode {
1357 route_request(state, config, method, path).await.status()
1358 }
1359
1360 async fn response_body(resp: axum::response::Response) -> String {
1361 let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024)
1362 .await
1363 .expect("read response body");
1364 String::from_utf8(bytes.to_vec()).expect("utf8 response")
1365 }
1366
1367 #[tokio::test]
1370 async fn router_serves_both_jwks_paths_and_falls_through_404_405() {
1371 assert_eq!(
1372 get_status(healthy_state(), JwksHttpConfig::default(), "GET", JWKS_PATH).await,
1373 axum::http::StatusCode::OK,
1374 "/jwks.json serves the key set"
1375 );
1376 assert_eq!(
1377 get_status(
1378 healthy_state(),
1379 JwksHttpConfig::default(),
1380 "GET",
1381 JWKS_WELL_KNOWN_PATH
1382 )
1383 .await,
1384 axum::http::StatusCode::OK,
1385 "/.well-known/jwks.json serves the same key set"
1386 );
1387 assert_eq!(
1388 get_status(healthy_state(), JwksHttpConfig::default(), "GET", "/nope").await,
1389 axum::http::StatusCode::NOT_FOUND,
1390 "unknown path is 404"
1391 );
1392 assert_eq!(
1393 get_status(
1394 healthy_state(),
1395 JwksHttpConfig::default(),
1396 "POST",
1397 JWKS_PATH
1398 )
1399 .await,
1400 axum::http::StatusCode::METHOD_NOT_ALLOWED,
1401 "POST to a GET-only route is 405"
1402 );
1403 }
1404
1405 #[tokio::test]
1409 async fn router_rejects_adversarial_targets_and_methods_locally() {
1410 for target in [
1411 "/%2e%2e/jwks.json",
1412 "/.well-known/%2e%2e/jwks.json",
1413 "/jwks.json/%2e%2e",
1414 "//evil.example/jwks.json",
1415 ] {
1416 assert_eq!(
1417 get_status(healthy_state(), JwksHttpConfig::default(), "GET", target).await,
1418 StatusCode::NOT_FOUND,
1419 "{target} is not routed"
1420 );
1421 }
1422
1423 for method in [Method::POST, Method::PUT, Method::DELETE, Method::PATCH] {
1424 assert_eq!(
1425 get_status(
1426 healthy_state(),
1427 JwksHttpConfig::default(),
1428 method.as_str(),
1429 JWKS_PATH
1430 )
1431 .await,
1432 StatusCode::METHOD_NOT_ALLOWED,
1433 "{method} is rejected on the JWKS route"
1434 );
1435 }
1436
1437 assert_eq!(
1438 get_status(
1439 healthy_state(),
1440 JwksHttpConfig::default(),
1441 "GET",
1442 "http://evil.example/jwks.json"
1443 )
1444 .await,
1445 StatusCode::OK,
1446 "absolute-form request targets are resolved only by local path"
1447 );
1448 assert_eq!(
1449 get_status(
1450 healthy_state(),
1451 JwksHttpConfig::default(),
1452 "GET",
1453 "/jwks.json?next=http%3A%2F%2F169.254.169.254%2Flatest&kid=%3Cscript%3E"
1454 )
1455 .await,
1456 StatusCode::OK,
1457 "query strings do not change the static JWKS route"
1458 );
1459 }
1460
1461 #[tokio::test]
1465 async fn discovery_does_not_reflect_host_or_forwarded_headers() {
1466 let config = JwksHttpConfig {
1467 issuer: Some("https://issuer.example/base".to_string()),
1468 tls: None,
1469 };
1470 let app = router(Arc::new(healthy_state()), config);
1471 let resp = app
1472 .oneshot(
1473 Request::builder()
1474 .method("GET")
1475 .uri("http://evil.example/.well-known/openid-configuration?issuer=evil")
1476 .header(header::HOST, "evil.example")
1477 .header("x-forwarded-host", "metadata.google.internal")
1478 .header("x-forwarded-proto", "http")
1479 .header("x-forwarded-uri", "/jwks.json?issuer=evil")
1480 .header("x-real-ip", "169.254.169.254")
1481 .body(Body::empty())
1482 .expect("request"),
1483 )
1484 .await
1485 .expect("router does not panic");
1486 assert_eq!(resp.status(), StatusCode::OK);
1487
1488 let body = response_body(resp).await;
1489 let doc: serde_json::Value = serde_json::from_str(&body).expect("discovery json");
1490 assert_eq!(doc["issuer"], "https://issuer.example/base");
1491 assert_eq!(
1492 doc["jwks_uri"],
1493 format!("https://issuer.example/base{JWKS_PATH}")
1494 );
1495 for untrusted in [
1496 "evil.example",
1497 "metadata.google.internal",
1498 "169.254.169.254",
1499 "x-forwarded",
1500 ] {
1501 assert!(
1502 !body.contains(untrusted),
1503 "discovery body reflected untrusted metadata: {untrusted}"
1504 );
1505 }
1506 }
1507
1508 #[tokio::test]
1512 async fn jwks_etag_is_stable_under_untrusted_headers() {
1513 let app = router(Arc::new(healthy_state()), JwksHttpConfig::default());
1514 let first = app
1515 .clone()
1516 .oneshot(
1517 Request::builder()
1518 .uri(JWKS_PATH)
1519 .body(Body::empty())
1520 .expect("request"),
1521 )
1522 .await
1523 .expect("router does not panic");
1524 assert_eq!(first.status(), StatusCode::OK);
1525 let first_etag = first.headers().get(header::ETAG).cloned().expect("etag");
1526
1527 let second = app
1528 .oneshot(
1529 Request::builder()
1530 .uri(format!(
1531 "{JWKS_WELL_KNOWN_PATH}?cache-bust=http://127.0.0.1"
1532 ))
1533 .header(header::IF_NONE_MATCH, first_etag.clone())
1534 .header(header::HOST, "attacker.example")
1535 .header("x-forwarded-host", "attacker.example")
1536 .header("x-oversized-adversarial", "a".repeat(16 * 1024))
1537 .body(Body::empty())
1538 .expect("request"),
1539 )
1540 .await
1541 .expect("router does not panic");
1542 assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
1543 assert_eq!(second.headers().get(header::ETAG), Some(&first_etag));
1544 assert!(
1545 response_body(second).await.is_empty(),
1546 "conditional JWKS hit returns 304 without a body"
1547 );
1548 }
1549
1550 #[tokio::test]
1554 async fn jwks_handler_rebuilds_after_backend_rotation_without_waiting() {
1555 let kid_of = |der: &[u8]| -> String {
1556 let body = crate::minter::jwt_svid_jwks_from_public_key(der, SvidAlg::Rs256)
1557 .expect("single-key jwks");
1558 kids(&body).first().cloned().expect("one kid")
1559 };
1560
1561 let der_v1 = rsa_public_der();
1562 let der_v2 = rsa_public_der();
1563 let kid_v1 = kid_of(&der_v1);
1564 let kid_v2 = kid_of(&der_v2);
1565 assert_ne!(kid_v1, kid_v2, "distinct public keys yield distinct kids");
1566
1567 let rsa = PubKeysBackend::new(BTreeMap::from([(1, der_v1.clone())]));
1568 let other = PubKeysBackend::failing();
1569 let state = state_with_grace(
1570 catalog_from_json(SELECTION_CATALOG),
1571 vec![("rsa", Arc::clone(&rsa)), ("other", Arc::clone(&other))],
1572 1,
1573 );
1574 let app = router(Arc::new(state), JwksHttpConfig::default());
1575
1576 let first = app
1577 .clone()
1578 .oneshot(
1579 Request::builder()
1580 .uri(JWKS_PATH)
1581 .body(Body::empty())
1582 .expect("request"),
1583 )
1584 .await
1585 .expect("router does not panic");
1586 assert_eq!(first.status(), StatusCode::OK);
1587 let first_etag = first.headers().get(header::ETAG).cloned().expect("etag");
1588 assert_eq!(
1589 kids(response_body(first).await.as_bytes()),
1590 vec![kid_v1.clone()]
1591 );
1592
1593 rsa.set_versions(BTreeMap::from([(1, der_v1), (2, der_v2)]));
1594
1595 let second = app
1596 .oneshot(
1597 Request::builder()
1598 .uri(JWKS_PATH)
1599 .body(Body::empty())
1600 .expect("request"),
1601 )
1602 .await
1603 .expect("router does not panic");
1604 assert_eq!(second.status(), StatusCode::OK);
1605 assert_ne!(
1606 second.headers().get(header::ETAG),
1607 Some(&first_etag),
1608 "rotated JWKS gets a new content-derived ETag"
1609 );
1610 let mut got = kids(response_body(second).await.as_bytes());
1611 got.sort();
1612 let mut want = vec![kid_v1, kid_v2];
1613 want.sort();
1614 assert_eq!(got, want, "v1 and v2 publish immediately after rotation");
1615 assert_eq!(
1616 rsa.public_keys_calls.load(Ordering::SeqCst),
1617 2,
1618 "each JWKS request reads the live public key map"
1619 );
1620 }
1621
1622 #[tokio::test]
1626 async fn discovery_route_mounted_iff_issuer_configured() {
1627 use super::super::OIDC_DISCOVERY_PATH;
1628
1629 assert_eq!(
1632 get_status(
1633 healthy_state(),
1634 JwksHttpConfig::default(),
1635 "GET",
1636 OIDC_DISCOVERY_PATH
1637 )
1638 .await,
1639 axum::http::StatusCode::NOT_FOUND,
1640 "no issuer -> discovery route absent (404)"
1641 );
1642
1643 let config = JwksHttpConfig {
1645 issuer: Some("https://basil.example.com".to_string()),
1646 tls: None,
1647 };
1648 assert_eq!(
1649 get_status(healthy_state(), config, "GET", OIDC_DISCOVERY_PATH).await,
1650 axum::http::StatusCode::OK,
1651 "issuer set -> discovery route serves 200"
1652 );
1653 }
1654
1655 const SELECTION_CATALOG: &str = r#"{
1661 "schemaVersion": 1,
1662 "backends": { "rsa": { "kind": "vault", "addr": "http://127.0.0.1:8200" },
1663 "other": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1664 "keys": {
1665 "rsa.issuer": {
1666 "class": "asymmetric", "keyType": "rsa-2048", "backend": "rsa",
1667 "path": "rsa-issuer", "writable": false,
1668 "labels": ["svid_kind=jwt"], "description": "an RSA JWT-SVID issuer"
1669 },
1670 "ed.issuer": {
1671 "class": "asymmetric", "keyType": "ed25519", "backend": "other",
1672 "path": "ed-issuer", "writable": false,
1673 "labels": ["svid_kind=jwt"], "description": "a non-profile jwt key (excluded)"
1674 },
1675 "plain.signer": {
1676 "class": "asymmetric", "keyType": "ed25519", "backend": "other",
1677 "path": "plain-signer", "writable": false,
1678 "description": "an asymmetric key with no svid_kind (excluded)"
1679 },
1680 "the.value": {
1681 "class": "value", "backend": "other", "engine": "kv2",
1682 "path": "secret/data/the/value", "writable": true,
1683 "description": "a value key (excluded)"
1684 },
1685 "the.sealing": {
1686 "class": "sealing", "keyType": "x25519", "backend": "other", "engine": "kv2",
1687 "path": "secret/data/the/sealing", "publicPath": "secret/data/the/sealing-pub",
1688 "writable": false, "description": "a sealing key (excluded)"
1689 }
1690 }
1691 }"#;
1692
1693 const MULTI_ISSUER_CATALOG: &str = r#"{
1695 "schemaVersion": 1,
1696 "backends": { "one": { "kind": "vault", "addr": "http://127.0.0.1:8200" },
1697 "two": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
1698 "keys": {
1699 "issuer.one": {
1700 "class": "asymmetric", "keyType": "rsa-2048", "backend": "one",
1701 "path": "issuer-one", "writable": false,
1702 "labels": ["svid_kind=jwt"], "description": "RSA JWT-SVID issuer one"
1703 },
1704 "issuer.two": {
1705 "class": "asymmetric", "keyType": "rsa-2048", "backend": "two",
1706 "path": "issuer-two", "writable": false,
1707 "labels": ["svid_kind=jwt"], "description": "RSA JWT-SVID issuer two"
1708 }
1709 }
1710 }"#;
1711 }
1712
1713 #[tokio::test]
1724 #[allow(clippy::too_many_lines)] async fn served_jwks_verifies_basil_jwts_across_a_rotation() {
1726 use std::collections::BTreeMap;
1727 use std::sync::Mutex;
1728
1729 use async_trait::async_trait;
1730 use base64::Engine as _;
1731 use base64::engine::general_purpose::URL_SAFE_NO_PAD;
1732 use rsa::pkcs1::EncodeRsaPrivateKey as _;
1733 use rsa::pkcs8::EncodePublicKey as _;
1734
1735 use crate::backend::{Backend, BackendError, KeyMetadata, NewKey, SignOptions};
1736 use crate::minter::{jwt_svid_jwks_grace, mint_svid};
1737 use basil_proto::KeyType;
1738
1739 struct RotatingRsaBackend {
1744 versions: Mutex<Vec<(jsonwebtoken::EncodingKey, Vec<u8>)>>,
1745 }
1746 impl RotatingRsaBackend {
1747 fn new() -> Self {
1748 let mut me = Self {
1749 versions: Mutex::new(Vec::new()),
1750 };
1751 me.add_version();
1752 me
1753 }
1754 fn add_version(&mut self) {
1755 let mut rng = rand::thread_rng();
1756 let private = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("rsa keygen");
1757 let public = rsa::RsaPublicKey::from(&private);
1758 let private_pem = private
1759 .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)
1760 .expect("pem");
1761 let public_der = public.to_public_key_der().expect("der").as_bytes().to_vec();
1762 let key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
1763 .expect("encoding key");
1764 self.versions
1765 .get_mut()
1766 .expect("lock")
1767 .push((key, public_der));
1768 }
1769 fn latest_der(&self) -> Vec<u8> {
1770 let v = self.versions.lock().expect("lock");
1771 v.last().expect("at least one version").1.clone()
1772 }
1773 }
1774 #[async_trait]
1775 impl Backend for RotatingRsaBackend {
1776 fn kind(&self) -> &'static str {
1777 "rotating-rsa-test"
1778 }
1779 async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
1780 Err(BackendError::Unsupported("new_key"))
1781 }
1782 async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
1783 Ok(self.latest_der())
1784 }
1785 async fn key_metadata(&self, _key_id: &str) -> Result<KeyMetadata, BackendError> {
1786 let n = u32::try_from(self.versions.lock().expect("lock").len()).unwrap_or(1);
1787 Ok(KeyMetadata {
1788 key_type: Some(KeyType::Rsa2048),
1789 latest_version: n,
1790 })
1791 }
1792 async fn public_keys(
1793 &self,
1794 _key_id: &str,
1795 ) -> Result<BTreeMap<u32, Vec<u8>>, BackendError> {
1796 let v = self.versions.lock().expect("lock");
1797 Ok(v.iter()
1798 .enumerate()
1799 .map(|(i, (_, der))| (u32::try_from(i + 1).unwrap_or(u32::MAX), der.clone()))
1800 .collect())
1801 }
1802 async fn rotate(&self, _key_id: &str) -> Result<u32, BackendError> {
1803 let mut v = self.versions.lock().expect("lock");
1804 let mut rng = rand::thread_rng();
1805 let private = rsa::RsaPrivateKey::new(&mut rng, 2048).expect("rsa keygen");
1806 let public = rsa::RsaPublicKey::from(&private);
1807 let private_pem = private
1808 .to_pkcs1_pem(rsa::pkcs8::LineEnding::LF)
1809 .expect("pem");
1810 let public_der = public.to_public_key_der().expect("der").as_bytes().to_vec();
1811 let key = jsonwebtoken::EncodingKey::from_rsa_pem(private_pem.as_bytes())
1812 .expect("encoding key");
1813 v.push((key, public_der));
1814 u32::try_from(v.len()).map_err(|_| BackendError::Backend("overflow".into()))
1815 }
1816 async fn sign_with_options(
1817 &self,
1818 _key_id: &str,
1819 input: &[u8],
1820 options: SignOptions,
1821 ) -> Result<Vec<u8>, BackendError> {
1822 if options != SignOptions::Rs256Pkcs1v15Sha256 {
1823 return Err(BackendError::Unsupported("rsa test sign options"));
1824 }
1825 let key = {
1826 let v = self.versions.lock().expect("lock");
1827 v.last().expect("a version").0.clone()
1828 };
1829 let b64 = jsonwebtoken::crypto::sign(input, &key, jsonwebtoken::Algorithm::RS256)
1830 .map_err(|e| BackendError::Backend(e.to_string()))?;
1831 URL_SAFE_NO_PAD
1832 .decode(b64)
1833 .map_err(|e| BackendError::Backend(e.to_string()))
1834 }
1835 async fn sign(&self, key_id: &str, input: &[u8]) -> Result<Vec<u8>, BackendError> {
1836 self.sign_with_options(key_id, input, SignOptions::Rs256Pkcs1v15Sha256)
1837 .await
1838 }
1839 async fn verify(
1840 &self,
1841 _key_id: &str,
1842 _message: &[u8],
1843 _signature: &[u8],
1844 ) -> Result<bool, BackendError> {
1845 Err(BackendError::Unsupported("verify"))
1846 }
1847 }
1848
1849 async fn mint(backend: &dyn Backend) -> String {
1852 mint_svid(
1853 backend,
1854 "rsa-issuer",
1855 "spiffe://example.org",
1856 SvidAlg::Rs256,
1857 "spiffe://example.org/db-01",
1858 "vault",
1859 Some(300),
1860 &serde_json::Value::Null,
1861 )
1862 .await
1863 .expect("mint svid")
1864 }
1865
1866 fn verify_via_jwks(token: &str, jwks: &serde_json::Value) -> bool {
1871 let Ok(header) = jsonwebtoken::decode_header(token) else {
1872 return false;
1873 };
1874 let Some(kid) = header.kid else { return false };
1875 let Some(keys) = jwks["keys"].as_array() else {
1876 return false;
1877 };
1878 let Some(jwk) = keys
1879 .iter()
1880 .find(|k| k["kid"].as_str() == Some(kid.as_str()))
1881 else {
1882 return false; };
1884 let (Some(n), Some(e)) = (jwk["n"].as_str(), jwk["e"].as_str()) else {
1885 return false;
1886 };
1887 let Ok(decoding_key) = jsonwebtoken::DecodingKey::from_rsa_components(n, e) else {
1888 return false;
1889 };
1890 let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256);
1891 validation.set_audience(&["vault"]);
1892 validation.validate_aud = true;
1893 validation.set_required_spec_claims(&["exp", "sub", "aud"]);
1894 jsonwebtoken::decode::<serde_json::Value>(token, &decoding_key, &validation).is_ok()
1895 }
1896
1897 fn published_kid_count(jwks: &serde_json::Value) -> usize {
1899 jwks["keys"]
1900 .as_array()
1901 .expect("keys array")
1902 .iter()
1903 .filter_map(|k| k["kid"].as_str())
1904 .count()
1905 }
1906
1907 let grace_floor = |latest: u32| latest.saturating_sub(1).max(1);
1909 let backend = RotatingRsaBackend::new();
1910
1911 let jwt_a = mint(&backend).await;
1913 backend.rotate("rsa-issuer").await.expect("rotate to v2");
1915 let jwt_b = mint(&backend).await;
1916
1917 let bytes = jwt_svid_jwks_grace(&backend, "rsa-issuer", SvidAlg::Rs256, grace_floor)
1919 .await
1920 .expect("grace jwks");
1921 let jwks: serde_json::Value = serde_json::from_slice(&bytes).expect("jwks json");
1922 assert_eq!(
1923 published_kid_count(&jwks),
1924 2,
1925 "both v1 and v2 published while in grace"
1926 );
1927
1928 assert!(verify_via_jwks(&jwt_a, &jwks), "JWT-A validates in grace");
1930 assert!(verify_via_jwks(&jwt_b, &jwks), "JWT-B validates in grace");
1931
1932 backend.rotate("rsa-issuer").await.expect("rotate to v3");
1936 let jwt_c = mint(&backend).await;
1937 let bytes = jwt_svid_jwks_grace(&backend, "rsa-issuer", SvidAlg::Rs256, grace_floor)
1938 .await
1939 .expect("grace jwks after second rotation");
1940 let jwks: serde_json::Value = serde_json::from_slice(&bytes).expect("jwks json");
1941 assert_eq!(
1942 published_kid_count(&jwks),
1943 2,
1944 "only v2 and v3 published (v1 dropped)"
1945 );
1946 assert!(
1947 !verify_via_jwks(&jwt_a, &jwks),
1948 "JWT-A (v1) no longer resolves after the floor advanced past v1"
1949 );
1950 assert!(verify_via_jwks(&jwt_b, &jwks), "JWT-B (v2) still in grace");
1951 assert!(verify_via_jwks(&jwt_c, &jwks), "JWT-C (v3) validates");
1952 }
1953}