Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-nats-auth/src/oidc_secret.rs
atlas 70c13aee7a swarm-nats-auth: re-read the OIDC client secret only on a 400 or 401
The responder re-read its secret on any non-2xx introspection answer.
With the re-read backoff added in the previous commit, an IdP outage
(503s, 502s from nginx, 429s) made each re-read find the unchanged
secret, so the delay grew to minutes. A real 401 after the rotation
then landed inside that window, and the responder denied every new
NATS connect until it ran out.

`Answer::Refused` now carries the status. `OidcSecret::introspect`
re-reads only when `stored_secret::is_credential_refusal` says the
status is 400 or 401, the same predicate `ClientSecret::refused` uses on
the client path. Every other non-2xx is a denial with no re-read.

The client-path test for a 429 now runs the mint against a loopback
endpoint answering 429 and 503, instead of calling `refused()` directly.
swarm-queue-client's README no longer names `ClientSecret::Value`.

Part of #5017 (slice Q).
2026-10-13 19:16:04 +02:00

373 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 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<String> {
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<String> {
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<F, Fut>(&self, deadline: Instant, attempt: F) -> Result<Option<String>>
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("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);
}
}