//! This responder's own OIDC client secret, the credential it introspects //! with. Read from the secret store as the responder starts and held in //! memory, never in a file, `argv` or the environment. //! //! The secret can change under a running responder: authelia's host //! re-publishes it when it is re-minted. So when the `IdP` refuses an //! introspection as a credential (a 400 or 401), the secret is read from the //! store again ([`StoredSecret::after_refusal`]) and, when it changed, the //! introspection is tried once more. Both fit in the one request's //! [`INTROSPECTION_TIMEOUT`](crate::introspect::INTROSPECTION_TIMEOUT). A //! re-read that does not is left running: that request is denied, and the //! next one introspects with what the re-read landed. Any other non-2xx is //! denied without a re-read. use std::future::Future; use std::sync::Arc; use anyhow::{Context, Result}; use swarm_queue_client::stored_secret::{StoredSecret, is_credential_refusal}; use swarm_secret_client::client::{DEFAULT_CERT_MOUNT, Settings}; use swarm_secret_client::path::{Kind, principal_prefix}; use tokio::time::Instant; use crate::introspect::Answer; use crate::keystore::KeyStore; /// The path under the store's KV mount holding `client_id`'s secret. /// `swarm-secret-publisher.nix` writes it, and `natsAuthReaders` in /// `swarm-bao.nix` grants this responder's role the read. /// /// # Errors /// When `client_id` is not a single path segment. pub fn path(client_id: &str) -> Result { let prefix = principal_prefix(Kind::Service, client_id)?; Ok(format!("{prefix}/oidc/client")) } /// The secret at `path`, with surrounding whitespace trimmed: the publisher /// stores the minted file as is, and a trailing newline is not part of the /// secret. Errors name the path, never the value. /// /// # Errors /// When the store cannot be read, or holds nothing or only whitespace there. pub async fn read(store: &impl KeyStore, path: &str) -> Result { let stored = store.read(path).await?.with_context(|| { format!("nothing is stored at {path}; swarm-secret-publish on authelia's host writes it") })?; let trimmed = stored.trim(); if trimmed.is_empty() { anyhow::bail!("{path} holds an empty secret"); } Ok(trimmed.to_owned()) } /// The secret in use, and how it is re-read. pub struct OidcSecret { secret: StoredSecret, } impl OidcSecret { /// `client_id`'s secret, `initial` until a re-read replaces it. A re-read /// logs in to the store at `settings` as `cert_role` and reads `path`: it /// can come long after the start's token has expired. pub fn from_store( client_id: &str, initial: String, settings: Settings, cert_role: String, path: String, ) -> Self { let source = Arc::new((settings, cert_role, path)); Self::new(StoredSecret::new(client_id, initial, move || { let source = Arc::clone(&source); async move { let (settings, cert_role, path) = &*source; let store = swarm_secret_client::SecretStore::connect( settings, cert_role, DEFAULT_CERT_MOUNT, ) .await .context("logging in to the secret store")?; Ok(read(&store, path).await?) } })) } fn new(secret: StoredSecret) -> Self { Self { secret } } /// The caller `attempt` names, introspecting with the current secret. /// When the `IdP` refuses that as a credential (a 400 or 401), the secret /// is re-read and, if it changed and `deadline` has not passed, `attempt` /// runs once more with the new one. Any other refusal is a denial. /// `attempt` bounds itself by the same `deadline`. /// /// # Errors /// When an attempt could not be made at all; the caller denies. pub async fn introspect(&self, deadline: Instant, attempt: F) -> Result> where F: Fn(String) -> Fut, Fut: Future>, { let used = self.secret.current(); match attempt(used.clone()).await? { Answer::Caller(caller) => return Ok(caller), Answer::Refused(status) if !is_credential_refusal(status) => return Ok(None), Answer::Refused(_) => {} } let Ok(fresh) = tokio::time::timeout_at(deadline, self.secret.after_refusal(&used)).await else { tracing::warn!( "re-reading the OIDC client secret did not finish inside the introspection budget; \ denying, and the next request introspects with what it reads" ); return Ok(None); }; let Some(fresh) = fresh else { return Ok(None); }; match attempt(fresh).await? { Answer::Caller(caller) => Ok(caller), Answer::Refused(_) => Ok(None), } } } #[cfg(test)] mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use super::*; use crate::introspect::INTROSPECTION_TIMEOUT; use crate::keystore::tests::Fake as FakeKeys; /// `initial` in use, re-read from a store holding `stored` that takes /// `delay` to answer; and the count of its reads. fn secret( initial: &str, stored: &'static str, delay: Duration, ) -> (OidcSecret, Arc) { let reads = Arc::new(AtomicUsize::new(0)); let counted = Arc::clone(&reads); let secret = StoredSecret::new("swarm-nats", initial.to_owned(), move || { counted.fetch_add(1, Ordering::SeqCst); async move { tokio::time::sleep(delay).await; Ok(stored.to_owned()) } }); (OidcSecret::new(secret), reads) } /// An `IdP` that admits as `hive-alpha` only when introspected with /// `registered`, and refuses every other secret, counting its calls. fn idp<'a>( registered: &'static str, calls: &'a AtomicUsize, ) -> impl Fn(String) -> std::future::Ready> + 'a { move |secret| { calls.fetch_add(1, Ordering::SeqCst); std::future::ready(Ok(if secret == registered { Answer::Caller(Some("hive-alpha".to_owned())) } else { Answer::Refused(reqwest::StatusCode::UNAUTHORIZED) })) } } fn budget() -> Instant { Instant::now() + INTROSPECTION_TIMEOUT } #[test] fn the_path_is_the_one_the_publisher_writes() { assert_eq!( path("swarm-nats").expect("path"), "swarm/services/swarm-nats/oidc/client" ); assert!(path("../swarm-grafana").is_err()); } #[tokio::test] async fn the_stored_secret_is_trimmed_and_an_empty_or_absent_one_refused() { let at = "swarm/services/swarm-nats/oidc/client"; let keys = FakeKeys::holding(&[(at, "s3cret\n")]); assert_eq!(read(&keys, at).await.expect("stored"), "s3cret"); keys.put(at, " \n"); assert!(read(&keys, at).await.is_err()); assert!(read(&FakeKeys::default(), at).await.is_err()); } #[tokio::test] async fn an_answered_introspection_reads_nothing() { let (secret, reads) = secret("old", "new", Duration::ZERO); let calls = AtomicUsize::new(0); let caller = secret .introspect(budget(), idp("old", &calls)) .await .expect("answered"); assert_eq!(caller.as_deref(), Some("hive-alpha")); assert_eq!(calls.load(Ordering::SeqCst), 1); assert_eq!(reads.load(Ordering::SeqCst), 0); } /// The rotation the re-read exists for: the `IdP` refuses the secret held /// since start, the store holds the new one, and the same request is /// admitted with it. #[tokio::test] async fn a_refused_secret_is_re_read_and_the_request_retried_once() { let (secret, reads) = secret("old", "new", Duration::ZERO); let calls = AtomicUsize::new(0); let caller = secret .introspect(budget(), idp("new", &calls)) .await .expect("answered"); assert_eq!(caller.as_deref(), Some("hive-alpha")); assert_eq!(calls.load(Ordering::SeqCst), 2); assert_eq!(reads.load(Ordering::SeqCst), 1); } #[tokio::test] async fn a_refusal_after_the_re_read_is_a_denial_with_no_third_attempt() { let (secret, reads) = secret("old", "newer", Duration::ZERO); let calls = AtomicUsize::new(0); let caller = secret .introspect(budget(), idp("unknown", &calls)) .await .expect("answered"); assert_eq!(caller, None); assert_eq!(calls.load(Ordering::SeqCst), 2); assert_eq!(reads.load(Ordering::SeqCst), 1); } #[tokio::test] async fn an_unchanged_secret_is_not_retried() { let (secret, _) = secret("old", "old", Duration::ZERO); let calls = AtomicUsize::new(0); let caller = secret .introspect(budget(), idp("unknown", &calls)) .await .expect("answered"); assert_eq!(caller, None); assert_eq!(calls.load(Ordering::SeqCst), 1); } /// An `IdP` outage answers 503s and 429s with the new secret already in /// the store: none of them reads it, so none pushes the re-read back, and /// the first 401 once the `IdP` is back re-reads and is admitted. #[tokio::test] async fn only_a_credential_refusal_re_reads_the_secret() { use reqwest::StatusCode; let (secret, reads) = secret("old", "new", Duration::ZERO); let calls = AtomicUsize::new(0); for status in [ StatusCode::SERVICE_UNAVAILABLE, StatusCode::BAD_GATEWAY, StatusCode::TOO_MANY_REQUESTS, ] { let caller = secret .introspect(budget(), |_| { calls.fetch_add(1, Ordering::SeqCst); std::future::ready(Ok(Answer::Refused(status))) }) .await .expect("answered"); assert_eq!(caller, None, "{status}"); } assert_eq!(calls.load(Ordering::SeqCst), 3); assert_eq!(reads.load(Ordering::SeqCst), 0); let caller = secret .introspect(budget(), idp("new", &calls)) .await .expect("answered"); assert_eq!(caller.as_deref(), Some("hive-alpha")); assert_eq!(calls.load(Ordering::SeqCst), 5); assert_eq!(reads.load(Ordering::SeqCst), 1); } /// A store slower than the budget: the request is denied at its /// deadline, the re-read finishes behind it, and the next request is /// admitted on its first attempt without reading again. #[tokio::test(start_paused = true)] async fn a_re_read_past_the_budget_serves_the_next_request() { let (secret, reads) = secret("old", "new", Duration::from_secs(3)); let calls = AtomicUsize::new(0); let started = Instant::now(); let deadline = started + INTROSPECTION_TIMEOUT; let first = secret .introspect(deadline, idp("new", &calls)) .await .expect("answered"); assert_eq!(first, None); assert_eq!(Instant::now(), deadline); assert_eq!(calls.load(Ordering::SeqCst), 1); tokio::time::sleep(Duration::from_secs(2)).await; let next = secret .introspect(budget(), idp("new", &calls)) .await .expect("answered"); assert_eq!(next.as_deref(), Some("hive-alpha")); assert_eq!(calls.load(Ordering::SeqCst), 2); assert_eq!(reads.load(Ordering::SeqCst), 1); } /// An `IdP` on a loopback port that admits as `hive-alpha` a request /// whose basic auth is `swarm-nats:{registered}`, and answers any other /// with a 401. async fn stub_idp(registered: &str) -> String { use axum::http::{HeaderMap, StatusCode, header::AUTHORIZATION}; let expected = format!( "Basic {}", data_encoding::BASE64.encode(format!("swarm-nats:{registered}").as_bytes()) ); let app = axum::Router::new().fallback(move |headers: HeaderMap| async move { if headers.get(AUTHORIZATION).and_then(|v| v.to_str().ok()) == Some(&*expected) { ( StatusCode::OK, r#"{"active":true,"client_id":"hive-alpha"}"#, ) } else { (StatusCode::UNAUTHORIZED, "") } }); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let url = format!("http://{}/introspect", listener.local_addr().expect("addr")); tokio::spawn(async move { axum::serve(listener, app).await }); url } /// The `IdP`'s 401 to the secret held since start is what starts the /// re-read: driven through the real HTTP introspection, not an [`Answer`] /// handed in. #[tokio::test] async fn an_idp_401_re_reads_the_secret_and_admits_with_the_new_one() { let url = stub_idp("new").await; let http = reqwest::Client::builder() .no_proxy() .build() .expect("client"); let (secret, reads) = secret("old", "new", Duration::ZERO); let deadline = budget(); let caller = secret .introspect(deadline, |own| { let (http, url) = (&http, &url); async move { crate::introspect::identify_caller( http, url, "swarm-nats", &own, "a-token", deadline, ) .await } }) .await .expect("answered"); assert_eq!(caller.as_deref(), Some("hive-alpha")); assert_eq!(reads.load(Ordering::SeqCst), 1); } }