fix(#3394): distinguish the caller's bad token from the bridge failing to validate

swarm-authelia-bridge answered one 401 for four causes, including its own
client credentials being rejected by authelia — telling a caller its token
was invalid when the fault was ours. That cost a real diagnostic round-trip,
because the only thing separating the two was a log line inside a container.

Introspection now returns a three-way Verdict, and one classify() maps it to
either admission, a 401 (the caller's credential) or a 503 (ours). Admission
is unchanged: Ok is reachable from exactly one variant.
This commit is contained in:
atlas 2026-08-18 00:11:32 +02:00 committed by mara
commit 266e8ac96b
2 changed files with 212 additions and 40 deletions

View file

@ -9,6 +9,15 @@
//! HTTP call are exactly the conditions an attacker would like this to
//! fall open under.
//!
//! [`Verdict`] has three variants where admission has two: the extra one
//! carries **whose fault a denial was**, a different question from who is
//! admitted (`main`'s `classify` is the single place that answers the
//! latter). Collapsing them answered `401 missing or invalid bearer token`
//! when *this bridge's own* credentials were the problem — a false
//! accusation pointing away from the fault, in the case most likely right
//! after a config change. `Unavailable` is **not a softer denial**: it
//! denies exactly as hard and only changes the report.
//!
//! Authenticates the introspection call itself with **this bridge's own**
//! client credentials (a resource server introspecting a token proves its
//! own identity to the `IdP`, per RFC 7662) — a separate authelia machine
@ -16,7 +25,6 @@
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Deserialize;
/// Generous relative to `swarm-nats-auth`'s 1.5s (that one is bounded by
@ -30,39 +38,71 @@ struct IntrospectionResponse {
active: bool,
}
/// Ask authelia whether `token` is currently valid. `Ok(true)` only on a
/// 2xx body with `active: true`; every other outcome is `Ok(false)`
/// (logged) or `Err` when the call could not be made at all — callers
/// must treat both as a denial.
pub async fn is_active(
/// The outcome of asking authelia about a token.
///
/// Total by construction — there is no `Result` wrapper, because every way
/// the call can go wrong is already a variant. A caller that forgets a case
/// does not compile, and there is no error path that could be `?`-ed past
/// into an accidental admission.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verdict {
/// 2xx with `active: true`. The only admitting outcome.
Active,
/// 2xx with `active: false` — authelia evaluated the token and said no.
/// The **caller's** credential is the problem.
Inactive,
/// The question could not be answered: the request failed, authelia
/// answered non-2xx (which for RFC 7662 means *the introspection call
/// itself* was rejected — an invalid token is a 200 with
/// `active: false`), or the body did not parse. In every case the fault
/// is on **this bridge's** side of the exchange, not the caller's.
Unavailable,
}
/// Ask authelia whether `token` is currently valid.
///
/// Never fails: see [`Verdict`]. Each non-admitting outcome is logged at
/// `warn` with the detail that distinguishes it, since the response the
/// caller gets is deliberately vague.
pub async fn introspect(
http: &reqwest::Client,
url: &str,
client_id: &str,
client_secret: &str,
token: &str,
) -> Result<bool> {
let resp = http
) -> Verdict {
let resp = match http
.post(url)
.basic_auth(client_id, Some(client_secret))
.form(&[("token", token)])
.timeout(INTROSPECTION_TIMEOUT)
.send()
.await
.context("introspection request")?;
{
Ok(resp) => resp,
Err(e) => {
tracing::warn!(error = ?e, "introspection request failed; cannot validate");
return Verdict::Unavailable;
}
};
let status = resp.status();
if !status.is_success() {
tracing::warn!(%status, "introspection returned non-2xx; denying");
return Ok(false);
// Most often: this bridge's own client credentials are being
// rejected by authelia. Logged with the status because that is the
// one thing separating "my secret is stale" from "authelia is
// unwell", and neither is the caller's business.
tracing::warn!(%status, "introspection returned non-2xx; cannot validate");
return Verdict::Unavailable;
}
let body: IntrospectionResponse = match resp.json().await {
Ok(b) => b,
match resp.json::<IntrospectionResponse>().await {
Ok(body) if body.active => Verdict::Active,
Ok(_) => Verdict::Inactive,
Err(e) => {
tracing::warn!(error = ?e, "introspection body did not parse; denying");
return Ok(false);
tracing::warn!(error = ?e, "introspection body did not parse; cannot validate");
Verdict::Unavailable
}
};
Ok(body.active)
}
}
#[cfg(test)]
@ -77,4 +117,16 @@ mod tests {
assert!(!no.active);
assert!(serde_json::from_str::<IntrospectionResponse>(r#"{"sub":"someone"}"#).is_err());
}
/// A 2xx body whose `active` is false is a *verdict*, not an error —
/// the distinction `Verdict` exists to preserve, asserted at the one
/// place the two are told apart from the wire.
#[test]
fn an_inactive_token_is_a_verdict_not_a_failure() {
let body: IntrospectionResponse = serde_json::from_str(r#"{"active":false}"#).unwrap();
assert!(
!body.active,
"authelia answering `active: false` is authelia working, not authelia failing"
);
}
}