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).
204 lines
8.5 KiB
Rust
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
|
|
);
|
|
}
|
|
}
|