Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-nats-auth/src/introspect.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

204 lines
8.5 KiB
Rust

//! Validating a presented bearer token against the `IdP`, and learning who
//! presented it.
//!
//! RFC 7662 token introspection: POST the token to authelia, which answers
//! `{"active": true|false, "client_id": "...", ...}`. `active` is the whole
//! **admission** verdict — this responder does not second-guess it, and does
//! not inspect scopes, because a second place that decides who may connect is
//! a second place to get it wrong.
//!
//! # Everything that is not an explicit `active: true` is a denial
//!
//! Network error, timeout, non-2xx, unparseable body, `active: false` — all
//! deny. Not defensive coding but the only safe shape: the failure modes of
//! an HTTP call are exactly when an attacker would like this to fall open.
//!
//! # `client_id` is modelled, and is not a second admission check
//!
//! `sub`/`scope`/`exp` stay unmodelled: modelling a field implies checking
//! it. `client_id` differs in kind — `active` decides **whether** to admit,
//! `client_id` decides **as whom**, which is what lets a grant be scoped to
//! one hive. Admission stays one decision in one place; the identity is
//! returned rather than a bool so "admitted but unscoped" cannot exist.
//!
//! # The timeout is not a tuning knob
//!
//! `swarm-nats.nix` sets `authorization.timeout = "2s"`, so the server stops
//! waiting after two seconds and denies the connection anyway. An
//! introspection call allowed to run longer than that cannot produce a useful
//! answer — it can only hold a task open past the point where the result
//! matters. The budget below is deliberately under the server's.
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Deserialize;
use tokio::time::Instant;
/// Kept under `swarm-nats.nix`'s `authorization.timeout = "2s"` — see the
/// module docs. A slower answer is not a late success, it is a denial that
/// already happened. It bounds a whole request, the re-read of this
/// responder's own secret and the retry after it included, which is why
/// [`identify_caller`] takes a deadline rather than a duration.
pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_millis(1500);
/// What one introspection call said.
#[derive(Debug, PartialEq, Eq)]
pub enum Answer {
/// A 2xx: the caller, or `None` for every shape that is not an admission.
Caller(Option<String>),
/// A non-2xx, with its status: the `IdP` did not answer about the token
/// at all. A 400 or 401 is how it refuses this responder's own
/// credential, which `oidc_secret` re-reads and retries once before this
/// denies.
Refused(reqwest::StatusCode),
}
/// The two fields this responder acts on — one per decision. Authelia returns
/// more (`sub`, `scope`, `exp`); modelling them would imply we check them.
#[derive(Debug, Deserialize)]
struct IntrospectionResponse {
/// Whether to admit at all.
active: bool,
/// Which client presented the token, i.e. which hive to scope its grant
/// to. Optional on the wire (RFC 7662 makes every field but `active`
/// optional), and its absence is a denial rather than a default — see
/// [`IntrospectionResponse::caller`].
#[serde(default)]
client_id: Option<String>,
}
impl IntrospectionResponse {
/// The identity to admit this token as, or `None` for every shape that is
/// not an admission.
///
/// An inactive token has no identity here even when the body names one: a
/// `client_id` on an expired token says who it *was* minted for, which is
/// not a statement that the bearer may connect.
///
/// An empty `client_id` is treated as absent. It would otherwise become an
/// empty component in a subject the grant is scoped to, and a scope
/// assembled from a blank is not a narrower permission, it is a different
/// one nobody reviewed.
fn caller(&self) -> Option<&str> {
if !self.active {
return None;
}
self.client_id.as_deref().filter(|id| !id.is_empty())
}
}
/// Ask the `IdP` whether `token` is currently valid, and as whom.
///
/// Returns `Ok(Answer::Caller(Some(client_id)))` **only** on a 2xx whose
/// body says `active: true` and names the client. Every other 2xx is
/// `Ok(Answer::Caller(None))` with the reason logged, a non-2xx is
/// `Ok(Answer::Refused(_))`, and `Err` is a call that could not be made at all
/// or did not finish by `deadline` — callers must treat all but the first as
/// a denial.
///
/// `own_client_id` / `own_client_secret` are *this responder's* credential for
/// the introspection endpoint. They are not the caller's identity and must
/// never be used as one; the caller's is the return value.
///
/// The token is never logged, not even truncated: a prefix is enough to
/// correlate across logs, and a credential that is 80% redacted is still a
/// credential in a journal.
pub async fn identify_caller(
http: &reqwest::Client,
url: &str,
own_client_id: &str,
own_client_secret: &str,
token: &str,
deadline: Instant,
) -> Result<Answer> {
let resp = http
.post(url)
.basic_auth(own_client_id, Some(own_client_secret))
.form(&[("token", token)])
.timeout(deadline.saturating_duration_since(Instant::now()))
.send()
.await
.context("introspection request")?;
let status = resp.status();
if !status.is_success() {
tracing::warn!(%status, "introspection returned non-2xx");
return Ok(Answer::Refused(status));
}
let body: IntrospectionResponse = match resp.json().await {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = ?e, "introspection body did not parse; denying");
return Ok(Answer::Caller(None));
}
};
if body.active && body.caller().is_none() {
// Loud on purpose: this is a token the `IdP` says is good, refused by
// us. Without a line here the operator sees a working credential
// rejected for no visible reason.
tracing::warn!("introspection said active but named no client; denying");
}
Ok(Answer::Caller(body.caller().map(str::to_owned)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_timeout_stays_under_the_servers() {
// `swarm-nats.nix` sets `authorization.timeout = "2s"`. If someone
// raises this constant past that, the extra time buys nothing: the
// server has already denied the connection. This test is the only
// thing tying the two numbers together, since they live in different
// languages in different files.
assert!(
INTROSPECTION_TIMEOUT < Duration::from_secs(2),
"introspection budget must stay under the server's auth_callout timeout"
);
}
fn parse(body: &str) -> IntrospectionResponse {
serde_json::from_str(body).expect("introspection body")
}
#[test]
fn only_active_true_deserializes_to_a_grant() {
assert!(parse(r#"{"active":true,"client_id":"hive-alpha"}"#).active);
assert!(!parse(r#"{"active":false}"#).active);
// A body that omits `active` is not a grant with a default - it is a
// response we do not understand, and it must fail to parse rather
// than deserialize to `false` quietly under some future `#[serde(default)]`.
assert!(serde_json::from_str::<IntrospectionResponse>(r#"{"sub":"someone"}"#).is_err());
}
#[test]
fn an_active_token_is_admitted_as_the_client_that_holds_it() {
assert_eq!(
parse(r#"{"active":true,"client_id":"hive-alpha","scope":"x"}"#).caller(),
Some("hive-alpha")
);
}
#[test]
fn active_without_a_client_id_is_a_denial_not_an_unscoped_grant() {
// The invariant the whole scoping design rests on. If this ever
// returns `Some`, a token the `IdP` could not attribute would be
// admitted with whatever scope the caller assembles from nothing -
// which is how a per-hive permission becomes a swarm-wide one.
assert_eq!(parse(r#"{"active":true}"#).caller(), None);
assert_eq!(parse(r#"{"active":true,"client_id":""}"#).caller(), None);
}
#[test]
fn an_inactive_token_has_no_identity_even_when_the_body_names_one() {
// Admission is decided by `active` alone. A `client_id` alongside
// `active: false` says who the token *was* for, and reading it as an
// identity would turn an expired credential into a working one.
assert_eq!(
parse(r#"{"active":false,"client_id":"hive-alpha"}"#).caller(),
None
);
}
}