Skip to main content

basil_core/service/
jwks.rs

1// SPDX-FileCopyrightText: 2026 OpenBasil Contributors
2//
3// SPDX-License-Identifier: Apache-2.0
4
5//! The JWKS + OIDC-discovery HTTP surface (`basil-uce.1`, `basil-uce.2`).
6//!
7//! This is the **first and only** HTTP endpoint in an otherwise
8//! gRPC-over-unix-socket, peer-cred-attested broker, so it is **strictly
9//! opt-in**: the listener is bound only when the operator sets `[jwks] enable =
10//! true` in the daemon config (default `false`: no port is opened). See
11//! [`serve`] and the `run_daemon` wiring in `main.rs`.
12//!
13//! The endpoint serves a standard RFC 7517 JSON Web Key Set built from the
14//! **public** halves of the configured JWT-SVID issuer keys: the same keys,
15//! `kid`s, and `alg`s the SPIFFE Workload API publishes (both reuse the shared
16//! [`crate::minter::jwt_svid_jwks_grace`] generator, so the two surfaces never
17//! diverge). **No private or secret material can reach this surface by
18//! construction**: the handler only ever reads the **public** halves
19//! ([`crate::backend::Backend::public_keys`]) and serializes the public
20//! modulus/exponent. The endpoint is unauthenticated, which is correct for a JWKS:
21//! a JWKS is meant to be world-readable, and it serves public keys only.
22//!
23//! The JWK set is built from the live backend manager on each request and
24//! reflects the **rotation grace window**: one JWK per issuer key version still
25//! inside `[grace_floor ..= latest]`, so a verifier can validate a token signed
26//! by a recently rotated-away version, and a version is dropped once it falls
27//! below the floor (`basil-uce.2`).
28//!
29//! When an `issuer` (public base URL) is configured, the OIDC discovery document
30//! is served at [`OIDC_DISCOVERY_PATH`] with a `jwks_uri` consistent with
31//! `issuer` and the JWKS path. Basil-minted JWT-SVIDs carry a **SPIFFE** `iss`
32//! (`spiffe://<trust domain>`), so a verifier keyed off the discovery document
33//! validates the **signature + `kid` + `aud`** and does **not** assert `iss`
34//! against `issuer`. See [`discovery_document`].
35
36use 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
55/// `Content-Type` for a JSON Web Key Set (RFC 7517 §8.5.1).
56const JWKS_CONTENT_TYPE: &str = "application/jwk-set+json";
57
58/// `Cache-Control` max-age for the JWKS document, in seconds.
59///
60/// Five minutes balances rotation freshness against load: a rotated issuer's new
61/// `kid` is picked up by verifiers within this window, while ordinary verifiers
62/// avoid re-fetching on every token. The document is also `ETag`-tagged so a
63/// conditional refetch is cheap.
64const JWKS_MAX_AGE_SECS: u32 = 300;
65
66/// The conventional JWKS path (the `jwks_uri` the discovery doc advertises).
67pub const JWKS_PATH: &str = "/jwks.json";
68
69/// The well-known JWKS path, served identically.
70pub const JWKS_WELL_KNOWN_PATH: &str = "/.well-known/jwks.json";
71
72/// The OIDC discovery document path (`RFC 8414` / `OpenID` Connect Discovery).
73pub const OIDC_DISCOVERY_PATH: &str = "/.well-known/openid-configuration";
74
75/// `Content-Type` for the OIDC discovery document (a plain JSON object).
76const OIDC_CONTENT_TYPE: &str = "application/json";
77
78/// JWS algorithms this build can mint and validate for JWT-SVID issuer keys.
79const ID_TOKEN_SIGNING_ALGS_SUPPORTED: [&str; 3] = ["RS256", "ES256", "ES384"];
80
81/// Resolved HTTP-surface settings beyond the bind address: currently the OIDC
82/// discovery `issuer` (public base URL). Cheap to clone into the axum state.
83///
84/// When `issuer` is `None` the `/.well-known/openid-configuration` route is not
85/// mounted (the bare JWKS endpoints are always served). This keeps the discovery
86/// document honest: it is only published when the operator has told the broker the
87/// public URL it is reachable at, so `issuer`/`jwks_uri` are real and consistent.
88#[derive(Debug, Clone, Default)]
89pub struct JwksHttpConfig {
90    /// Public base URL the surface is reachable at (no trailing slash).
91    pub issuer: Option<String>,
92    /// Optional native TLS configuration for direct HTTPS exposure.
93    pub tls: Option<JwksTlsConfig>,
94}
95
96/// Native rustls settings for the opt-in JWKS listener.
97#[derive(Debug, Clone, Default)]
98pub struct JwksTlsConfig {
99    /// PEM certificate chain file served by the JWKS listener.
100    pub cert_file: PathBuf,
101    /// PEM private key file served by the JWKS listener.
102    pub key_file: PathBuf,
103}
104
105/// The axum state shared by the JWKS + discovery handlers: the broker state plus
106/// the resolved HTTP config (the discovery `issuer`).
107#[derive(Clone)]
108struct HttpState {
109    broker: Arc<BrokerState>,
110    config: Arc<JwksHttpConfig>,
111}
112
113/// Build the read-only JWKS + OIDC-discovery router over the shared broker state.
114///
115/// Both [`JWKS_PATH`] and [`JWKS_WELL_KNOWN_PATH`] serve the same JWK set. When
116/// `config.issuer` is set, [`OIDC_DISCOVERY_PATH`] serves the OIDC discovery
117/// document; with no issuer that route is omitted. The router is `GET`-only; any
118/// other method/path falls through to axum's default `405`/`404`.
119pub 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
133/// Bind `listen` and serve the JWKS router until `shutdown` resolves.
134///
135/// A bind failure is returned as a clean `Err` (the caller turns it into a
136/// fail-closed startup error); this function never panics. On `shutdown` the
137/// server drains in-flight requests and returns.
138///
139/// # Errors
140///
141/// Returns an error if the TCP listener cannot bind `listen`, or if the server
142/// loop terminates with an I/O error.
143pub 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// The stub matches the real (`http-tls`) signature (both are `async` and the
167// caller awaits this future unconditionally), so it never awaits; that is the
168// point of the cfg-gated fail-closed stub.
169#[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
314/// The `GET /jwks.json` handler: serialize the live issuer JWK set.
315///
316/// Reads the **current** set of JWT-SVID issuers off the backend manager and
317/// their **public** halves fresh, so the response tracks issuer rotation. On any
318/// backend error it returns `503 Service Unavailable` with a non-secret body,
319/// never a panic, never any key material in the error.
320async 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
357/// The `GET /.well-known/openid-configuration` handler: serve the OIDC discovery
358/// document built from the configured `issuer`.
359///
360/// Only mounted when an `issuer` is configured, so `config.issuer` is always
361/// `Some` here; the `None` arm is a defensive `503` (never a panic). The document
362/// is a static, public, cacheable JSON object: no backend I/O, no key material.
363async 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
375/// Build the OIDC discovery document (a minimal, spec-valid JSON object).
376///
377/// `issuer` is the configured public base URL (no trailing slash). `jwks_uri` is
378/// the `issuer` concatenated with [`JWKS_PATH`], so it is **consistent** with
379/// `issuer` (same scheme/host/base) and points at the JWKS this same surface
380/// actually serves.
381///
382/// Basil-minted JWT-SVIDs carry a **SPIFFE** `iss` claim (`spiffe://<trust
383/// domain>`), not this URL (a SPIFFE-compatibility requirement), so a verifier
384/// keyed off this document validates the **signature + `kid` + `aud`** and does
385/// **not** assert `iss` against `issuer`. The discovery `issuer` exists to make
386/// the document self-consistent (it is the base its own well-known path is served
387/// from) and to advertise `jwks_uri`; it is not asserted against token `iss`.
388fn 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        // Honest minimal values: Basil mints signed JWTs, not OIDC ID tokens via
394        // an authorization endpoint, but these fields are required by the spec.
395        "response_types_supported": ["id_token"],
396        "subject_types_supported": ["public"],
397    })
398}
399
400/// The cacheable OIDC discovery response: body + `Content-Type` + `Cache-Control`
401/// + a content-addressed `ETag`. A `200`.
402fn 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
420/// Construct the cacheable JWKS response: body + `Content-Type` + `Cache-Control`
421/// + a content-addressed `ETag` (SHA-256 of the body, base16). A `200`.
422fn 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
449/// A strong `ETag` derived from the response body (`"<hex sha256>"`).
450fn 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        // Writing to a String never fails; ignore the formatter Result.
457        let _ = write!(hex, "{byte:02x}");
458    }
459    hex.push('"');
460    hex
461}
462
463/// Whether a catalog entry is a JWT-SVID **issuer** whose public key belongs in
464/// the JWKS: an `Asymmetric` key labelled `svid_kind = jwt` with a SPIFFE
465/// JWT-SVID profile algorithm (RSA → RS256, P-256 → ES256). This mirrors the SPIFFE service's
466/// `is_jwt_svid_issuer` predicate so the JWKS and the Workload API agree on the
467/// published key set. A `trust_domain` label is **not** required here: the JWKS
468/// is a flat key set keyed only by `kid`, with no per-trust-domain partition.
469fn 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
477/// Map an issuer's key algorithm to its JWS `alg`. Only the SPIFFE JWT-SVID
478/// profile algorithms reach here (the [`is_jwks_issuer`] filter excludes the
479/// rest); a non-profile type returns `None` so it is skipped rather than panics.
480const 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
489/// Build the combined JWK set body from every configured JWT-SVID issuer,
490/// reflecting the rotation grace window.
491///
492/// Each issuer publishes one JWK **per key version still inside the grace
493/// window** (`[grace_floor ..= latest]`) via the shared
494/// [`jwt_svid_jwks_grace`] generator, the same generator the gRPC Workload-API
495/// JWKS uses, so the two surfaces are byte-identical for one issuer. A
496/// recently-rotated-away version stays published (a verifier can still validate a
497/// token it signed) until it drops below the floor. The per-issuer sets are
498/// merged into one `{"keys":[...]}`, de-duplicated by `kid` (the `kid` is
499/// content-derived). An empty issuer set yields a valid empty key set
500/// (`{"keys":[]}`) rather than an error.
501async fn build_jwks(state: &BrokerState) -> Result<Vec<u8>, String> {
502    // Snapshot the issuer (name, alg) list first so we don't hold a borrow of the
503    // manager's keys across the `await` on each backend.
504    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    // Build each issuer's grace-window JWK set fresh from its backend (public
513    // material only: `public_keys` returns the per-version public halves, never
514    // any private material) and merge them, de-duplicated by `kid`.
515    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
534/// Merge the JWK set `bytes` (as produced by the shared
535/// [`jwt_svid_jwks_grace`] generator) into `keys`, skipping any `kid` already in
536/// `seen_kids`.
537fn 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    /// Merge already-fetched issuer `(public_key, alg)` pairs into one JWK set
567    /// body (the merge the HTTP handler does, minus the backend I/O). A test-only
568    /// helper so the JWK-assembly path can be exercised against the RSA fixtures
569    /// without standing up a backend manager.
570    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        // 32-byte digest -> 64 hex chars + 2 quotes.
591        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    /// The acceptance bar (`basil-uce.1`): a standard `jsonwebtoken` verifier
664    /// validates a **Basil-minted** JWT-SVID signature against a key parsed from
665    /// the served JWKS. We build the JWKS from the issuer's RSA public half, mint
666    /// a JWT-SVID through the crate's own minter, then decode the token with a
667    /// `DecodingKey` reconstructed from the JWK's `n`/`e`, proving an ordinary
668    /// verifier needs only the JWKS (no SPIFFE plumbing) to validate the token.
669    #[tokio::test]
670    #[allow(clippy::too_many_lines)] // inline RSA-backend fixture + full mint→verify loop
671    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        /// An RS256 signing backend (the same shape the minter tests use): signs
683        /// the JWS signing input with the RSA private key and returns the issuer's
684        /// SPKI DER public half from `public_key`.
685        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        // RSA-2048 issuer (jsonwebtoken's ring backend requires >= 2048 bits).
738        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        // Build the JWKS the HTTP surface would serve, from the public half only.
754        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        // The published JWK is the public RSA key with the SPIFFE-profile metadata.
762        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        // Mint a JWT-SVID through Basil's own minter.
768        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        // The token's `kid` selects this JWK (a verifier picks the key by `kid`).
782        let header = jsonwebtoken::decode_header(&token).expect("decode header");
783        assert_eq!(header.kid.as_deref(), Some(jwk_kid));
784
785        // Reconstruct a DecodingKey from the JWK's n/e (exactly what a standard
786        // verifier does with a fetched JWKS) and validate the signature.
787        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        // jwks_uri MUST be issuer + the real JWKS path (same scheme/host/base).
804        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    /// Live-path tests for the REAL [`build_jwks`] + the axum handler: the
846    /// issuer-**selection** seam ([`is_jwks_issuer`]/[`issuer_alg`]) and the
847    /// backend fan-out, which the [`assemble_jwks`] helper above deliberately
848    /// bypasses. These pin two security invariants by construction:
849    ///
850    /// 1. **Selection**: only an `Asymmetric` + `svid_kind=jwt` + SPIFFE-profile
851    ///    key is published; value/symmetric/sealing, a non-`jwt` asymmetric key,
852    ///    and a non-profile (`ed25519`) `svid_kind=jwt` key are all excluded.
853    /// 2. **Public-keys-only**: the live path reads **only** [`Backend::public_keys`],
854    ///    never a private/secret read (`public_key`/`kv_get`/`kv_get_secret`/
855    ///    `sign`). The mock fails the test (poisons a flag and errs) on any such
856    ///    call, so "no secret material reaches JWKS" is enforced, not assumed.
857    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        /// A public-only mock backend: `public_keys` returns a seeded
884        /// version→SPKI-DER map (the only sanctioned read on the JWKS path).
885        /// **Every** material-bearing read (`public_key`, `kv_get`,
886        /// `kv_get_secret`, `sign`) trips `forbidden_called` and returns an error,
887        /// so a test can assert the live path NEVER touched a private/secret seam.
888        struct PubKeysBackend {
889            /// version → SPKI-DER public-key bytes (RSA), served by `public_keys`.
890            versions: Mutex<BTreeMap<u32, Vec<u8>>>,
891            /// Count of `public_keys` calls (asserts the live path read the
892            /// public map and how many issuers it fanned out to).
893            public_keys_calls: AtomicUsize,
894            /// Set true if ANY private/secret read was attempted. Must stay false.
895            forbidden_called: AtomicBool,
896            /// When true, `public_keys` errors (drives the handler's 503 arm).
897            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        /// Box an `Arc<PubKeysBackend>` into the manager's `Box<dyn Backend>` map
929        /// while the test keeps the `Arc` to inspect the call counters.
930        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            // ---- forbidden on the JWKS path: any call is a leak of intent. ----
951            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        /// A real RSA-2048 SPKI-DER public key (`jwk_for_public_key` parses it into
991        /// the `n`/`e` JWK; the minter's ring backend also wants >= 2048 bits).
992        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        /// Deserialize a catalog from camelCase JSON **without** the loader's
1003        /// validation, so a test can place keys the loader would reject at load
1004        /// (e.g. an `ed25519` `svid_kind=jwt` key; the loader's fail-closed
1005        /// guardrail is exercised in `catalog::loader`'s own tests). The selection
1006        /// predicate under test here lives in [`build_jwks`], not the loader.
1007        fn catalog_from_json(json: &str) -> Catalog {
1008            serde_json::from_str(json).expect("catalog json")
1009        }
1010
1011        /// Get a real `ResolvedPolicy` + `Config` (neither is trivially
1012        /// `Default`-constructible) from the loader, then thread the test's own
1013        /// (possibly loader-rejected) catalog through `BrokerState`. `build_jwks`
1014        /// reads `state.manager().keys()`, which is the manager's catalog, so the
1015        /// manager and `BrokerState` are both built from `catalog`.
1016        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        /// Like [`state_with`] but with an explicit rotation grace window (in key
1028        /// versions), so a test can drive the real [`build_jwks`] grace floor
1029        /// (`latest - grace_versions`) across different window widths.
1030        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        /// Distinct `kid`s in a JWK set body.
1060        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        /// The `is_jwks_issuer`/`issuer_alg` predicates accept exactly the
1071        /// SPIFFE profile + `svid_kind=jwt` + `Asymmetric` shape. A direct unit
1072        /// check of the selection seam, independent of I/O.
1073        #[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        /// A mix of keys: only the RSA `svid_kind=jwt` asymmetric issuer's key is
1106        /// served; the ed25519-jwt, the plain (non-jwt) asymmetric, the value, and
1107        /// the sealing keys are all excluded. Asserts EXACTLY the RSA issuer's one
1108        /// version is published, AND that `public_keys` was the only read (no
1109        /// private/secret seam touched on any backend).
1110        #[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            // The excluded keys route to a backend that ERRS on `public_keys`: if
1115            // selection were wrong and one of them were fanned out, the build would
1116            // fail: a second guard on top of the kid-count assertion.
1117            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            // Public-keys-only: the RSA issuer was read once via `public_keys`, the
1127            // excluded keys' backend was never fanned out, and NO private/secret
1128            // read happened on either backend.
1129            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        /// Two RSA `svid_kind=jwt` issuers → the served set carries BOTH issuers'
1143        /// keys, deduplicated by `kid` (distinct public keys → distinct kids; none
1144        /// dropped, none duplicated).
1145        #[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        /// Two issuers whose public keys are IDENTICAL collapse to ONE JWK: the
1170        /// `kid` is content-derived, so the duplicate is dropped (not double-served).
1171        #[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        /// The rotation grace window over the REAL [`build_jwks`] (not the
1191        /// `jwt_svid_jwks_grace` generator in isolation): a multi-version issuer
1192        /// backend publishes one JWK per version inside `[grace_floor ..= latest]`
1193        /// and DROPS every version below the floor. Drives the manager-resolve +
1194        /// `state.limits()` grace-floor + merge path end to end across three window
1195        /// widths, asserting kids for out-of-window versions are absent (and that
1196        /// the live path stays public-keys-only).
1197        #[tokio::test]
1198        async fn build_jwks_grace_window_drops_versions_below_the_floor() {
1199            // Content-derived `kid` for one issuer public half, via the single-key
1200            // generator, so a version's presence/absence in the set is checkable.
1201            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            // Distinct public keys → distinct content-derived kids, so an absent
1214            // kid genuinely means that version was dropped, not merely deduped.
1215            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            // Build the issuer JWKS at a given grace window and return its sorted
1222            // kids, re-asserting public-keys-only each time.
1223            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            // Default window (grace_versions = 1): latest = 3, floor = 2 → v2+v3
1256            // publish, v1 drops below the floor.
1257            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            // Wider window (grace_versions = 2): floor = 1 → all three publish.
1267            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            // Panic/compromise window (grace_versions = 0): floor = latest = 3 →
1276            // only the newest version publishes; both older versions drop.
1277            assert_eq!(
1278                served_kids(0).await,
1279                vec![kid_v3],
1280                "grace=0 publishes only the latest version"
1281            );
1282        }
1283
1284        /// The axum handler maps a backend error to `503 Service Unavailable` with a
1285        /// stable, non-secret text body, never a panic, never a `500`, never any
1286        /// key material in the response.
1287        #[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            // The failing backend's `public_keys` was the only seam touched: no
1317            // material read even on the error path.
1318            assert!(!rsa.forbidden_called.load(Ordering::SeqCst));
1319        }
1320
1321        /// A `BrokerState` with one healthy RSA issuer (one published version), so
1322        /// the JWKS routes serve a `200` with a non-empty key set. Used by the
1323        /// router-shape tests below.
1324        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        /// `method path` against `router(state, config)`.
1332        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        /// `GET path` against `router(state, config)`, returning the response status.
1351        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        /// Both JWKS paths serve the key set; an unknown path is `404` and a `POST`
1368        /// to a `GET`-only route is `405` (axum's method-not-allowed fall-through).
1369        #[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        /// Adversarial request targets and methods stay local to the static axum
1406        /// router. The surface never follows a supplied URL, decodes traversal into
1407        /// a real route, or reflects request metadata into a backend lookup.
1408        #[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        /// Host and forwarding headers are untrusted request metadata. OIDC
1462        /// discovery must publish only the configured issuer, never caller-supplied
1463        /// authority, proto, query, or absolute-form target data.
1464        #[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        /// Oversized headers and cache validators cannot influence the response
1509        /// body or secret-read behavior. They are treated as ordinary request
1510        /// metadata by the in-process router.
1511        #[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        /// Out-of-band backend rotation must be visible on the very next JWKS
1551        /// fetch. The catalog generation does not change in this scenario, so a
1552        /// generation-keyed in-process cache would stale-serve v1 here.
1553        #[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        /// The discovery route is mounted IFF an `issuer` is configured: present
1623        /// (`200`) when `issuer` is `Some`, `404` when `issuer` is `None` (the route
1624        /// is not mounted, so the defensive `503` arm is structurally unreachable).
1625        #[tokio::test]
1626        async fn discovery_route_mounted_iff_issuer_configured() {
1627            use super::super::OIDC_DISCOVERY_PATH;
1628
1629            // issuer = None: the route is not mounted -> 404 (not the 503 arm, which
1630            // is unreachable because the route only exists when issuer is Some).
1631            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            // issuer = Some: the route is mounted and serves the discovery doc.
1644            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        /// One RSA `svid_kind=jwt` asymmetric issuer [INCLUDED]; an `ed25519`
1656        /// `svid_kind=jwt` asymmetric key [EXCLUDED: non-profile]; a plain asymmetric
1657        /// signer with NO `svid_kind` [EXCLUDED]; a `value` key and a `sealing` key
1658        /// [EXCLUDED, wrong class]. The ed25519-jwt key is loader-rejected, so this
1659        /// catalog is deserialized directly (see `catalog_from_json`).
1660        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        /// Two RSA `svid_kind=jwt` asymmetric issuers on separate backends.
1694        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    /// The acceptance bar (`basil-uce.2`): a standard `jsonwebtoken` verifier
1714    /// validates Basil JWTs **across a key rotation** using ONLY a key selected
1715    /// from the published JWKS by `kid`.
1716    ///
1717    /// Issuer is at v1 → mint JWT-A; rotate to v2 → mint JWT-B. While both are in
1718    /// grace (grace floor = `latest - 1`): the JWKS publishes BOTH v1+v2 JWKs
1719    /// (distinct kids) and the verifier validates BOTH tokens by selecting the JWK
1720    /// matching each token's `kid`. Then the grace floor advances past v1 (latest
1721    /// jumps to v3, floor = 2): the JWKS DROPS v1 and a v1-keyed token no longer
1722    /// resolves.
1723    #[tokio::test]
1724    #[allow(clippy::too_many_lines)] // multi-version RSA fixture + full rotate→verify loop
1725    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        /// A multi-version RSA issuer: each `rotate` adds a fresh RSA-2048
1740        /// version; `public_keys` returns the SPKI-DER public half of every
1741        /// version, and `sign` signs with the **latest** version (what the minter
1742        /// targets), so a token always carries the latest version's `kid`.
1743        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        /// Mint a JWT-SVID through Basil's minter against the issuer's latest
1850        /// version.
1851        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        /// Resolve a token against the served JWKS by `kid`, then verify the
1867        /// signature + `aud` (NOT `iss`: Basil JWT-SVIDs carry a SPIFFE `iss`,
1868        /// per the discovery-doc decision). Returns `true` only if a JWK matched
1869        /// the `kid` AND the signature validated.
1870        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; // kid not published → cannot resolve
1883            };
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        /// Number of distinct `kid`s in a JWK set.
1898        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        // grace floor = latest - 1 (the broker default), clamped to >= 1.
1908        let grace_floor = |latest: u32| latest.saturating_sub(1).max(1);
1909        let backend = RotatingRsaBackend::new();
1910
1911        // v1: mint JWT-A.
1912        let jwt_a = mint(&backend).await;
1913        // rotate to v2: mint JWT-B.
1914        backend.rotate("rsa-issuer").await.expect("rotate to v2");
1915        let jwt_b = mint(&backend).await;
1916
1917        // (a) JWKS publishes BOTH v1 and v2 while both are in grace (floor = 1).
1918        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        // (b) the standard verifier validates BOTH tokens via the JWKS by kid.
1929        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        // (c) advance the grace floor past v1: rotate to v3 (floor = 2). The JWKS
1933        // drops v1's JWK and JWT-A (keyed to v1) no longer resolves; JWT-B (v2)
1934        // still does (still in grace), and a freshly minted JWT-C (v3) does too.
1935        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}