Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-nats-auth/src/oidc_secret.rs
atlas 2fc63d844b name the mint, not the publisher, as the writer of the client secrets
Finishes the sweep of prose that said the publisher unit writes or carries the OIDC client secrets: rust docs, nix comments and option descriptions, test names and docs.
2026-10-30 16:57:58 +01:00

383 lines
14 KiB
Rust

//! 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 mint, in its
//! container, writes a new one when it re-mints. 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_secret_client::client::{DEFAULT_CERT_MOUNT, Settings};
use swarm_secret_client::path::{Kind, principal_prefix};
use swarm_secret_client::stored_secret::{StoredSecret, is_credential_refusal};
use tokio::time::Instant;
use crate::introspect::{Answer, Caller};
use crate::keystore::KeyStore;
/// The path under the store's KV mount holding `client_id`'s secret.
/// Authelia's mint (`authelia-swarm-secrets`) writes it, at the path
/// `swarm-secret-publisher.nix` lists, 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<String> {
let prefix = principal_prefix(Kind::Service, client_id)?;
Ok(format!("{prefix}/oidc/client"))
}
/// The secret at `path`, with surrounding whitespace trimmed: an earlier
/// release stored 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<String> {
let stored = store.read(path).await?.with_context(|| {
format!("nothing is stored at {path}; authelia's mint, in its container, 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?)
}
}))
}
pub(crate) 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<F, Fut>(&self, deadline: Instant, attempt: F) -> Result<Option<Caller>>
where
F: Fn(String) -> Fut,
Fut: Future<Output = Result<Answer>>,
{
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<AtomicUsize>) {
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<Result<Answer>> + 'a {
move |secret| {
calls.fetch_add(1, Ordering::SeqCst);
std::future::ready(Ok(if secret == registered {
Answer::Caller(Some(Caller {
client_id: "hive-alpha".to_owned(),
expires: None,
}))
} 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.map(|c| c.client_id).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.map(|c| c.client_id).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.map(|c| c.client_id).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.map(|c| c.client_id).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","exp":1700000000}"#,
)
} 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,
Some(Caller {
client_id: "hive-alpha".to_owned(),
expires: Some(1_700_000_000),
})
);
assert_eq!(reads.load(Ordering::SeqCst), 1);
}
}