296 lines
11 KiB
Rust
296 lines
11 KiB
Rust
//! Validating a presented bearer token against authelia.
|
|
//!
|
|
//! Own copy of `swarm-nats-auth::introspect`'s shape (RFC 7662 token
|
|
//! introspection), not a shared dependency — `swarm-nats-auth` has no lib
|
|
//! target to import, and this is a handful of lines; factor out if a third
|
|
//! consumer shows up. Same verdict rule: `active` is the whole admission
|
|
//! answer, and everything that isn't an explicit `active: true` is a denial
|
|
//! (network error, timeout, non-2xx, unparseable body) — the failure modes of
|
|
//! an 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
|
|
//! client from the token being checked (`swarm-controller`'s).
|
|
|
|
use std::time::Duration;
|
|
|
|
use serde::Deserialize;
|
|
|
|
/// One attempt, no retry. authelia's introspection endpoint takes up to
|
|
/// ~19s when its host is overloaded, and this covers that. It must stay
|
|
/// below the controller's 30s bound on a whole bridge call
|
|
/// (`swarm-controller`'s `auth::HTTP_TIMEOUT`), or the controller gives up
|
|
/// on the bridge before the bridge has answered. A timeout is still
|
|
/// [`Verdict::Unavailable`], never an admission.
|
|
pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_secs(20);
|
|
|
|
const _: () = assert!(INTROSPECTION_TIMEOUT.as_secs() < 30);
|
|
|
|
/// `active` decides whether to admit; `client_id` says as whom, which is
|
|
/// what lets one verb be scoped to a single client. Same two fields, for the
|
|
/// same reasons, as `swarm-nats-auth::introspect`.
|
|
#[derive(Debug, Deserialize)]
|
|
struct IntrospectionResponse {
|
|
active: bool,
|
|
#[serde(default)]
|
|
client_id: Option<String>,
|
|
}
|
|
|
|
impl IntrospectionResponse {
|
|
fn verdict(self) -> Verdict {
|
|
if !self.active {
|
|
return Verdict::Inactive;
|
|
}
|
|
Verdict::Active {
|
|
client_id: self.client_id.filter(|id| !id.is_empty()),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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, PartialEq, Eq)]
|
|
pub enum Verdict {
|
|
/// 2xx with `active: true`. The only admitting outcome.
|
|
Active {
|
|
/// The client the token was issued to. `None` when authelia named
|
|
/// none, or named an empty one: a verb scoped to one client refuses
|
|
/// such a caller, it does not match it to a blank.
|
|
client_id: Option<String>,
|
|
},
|
|
/// 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,
|
|
) -> Verdict {
|
|
introspect_within(
|
|
http,
|
|
url,
|
|
client_id,
|
|
client_secret,
|
|
token,
|
|
INTROSPECTION_TIMEOUT,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// [`introspect`] with the deadline as a parameter, so a test can exercise
|
|
/// the timeout without waiting out [`INTROSPECTION_TIMEOUT`].
|
|
async fn introspect_within(
|
|
http: &reqwest::Client,
|
|
url: &str,
|
|
client_id: &str,
|
|
client_secret: &str,
|
|
token: &str,
|
|
timeout: Duration,
|
|
) -> Verdict {
|
|
let resp = match http
|
|
.post(url)
|
|
.basic_auth(client_id, Some(client_secret))
|
|
.form(&[("token", token)])
|
|
.timeout(timeout)
|
|
.send()
|
|
.await
|
|
{
|
|
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() {
|
|
// 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;
|
|
}
|
|
match resp.json::<IntrospectionResponse>().await {
|
|
Ok(body) => body.verdict(),
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "introspection body did not parse; cannot validate");
|
|
Verdict::Unavailable
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
|
|
|
/// An HTTP server on loopback. Each connection is answered with
|
|
/// `status` and `body` after `delay`; a `None` delay holds the
|
|
/// connection open and never answers.
|
|
async fn serve(delay: Option<Duration>, status: u16, body: &'static str) -> String {
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let url = format!("http://{}/introspect", listener.local_addr().unwrap());
|
|
tokio::spawn(async move {
|
|
while let Ok((mut conn, _)) = listener.accept().await {
|
|
tokio::spawn(async move {
|
|
let mut buf = vec![0u8; 8192];
|
|
let _ = conn.read(&mut buf).await;
|
|
let Some(delay) = delay else {
|
|
std::future::pending::<()>().await;
|
|
return;
|
|
};
|
|
tokio::time::sleep(delay).await;
|
|
let resp = format!(
|
|
"HTTP/1.1 {status} X\r\ncontent-type: application/json\r\n\
|
|
content-length: {}\r\nconnection: close\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
let _ = conn.write_all(resp.as_bytes()).await;
|
|
});
|
|
}
|
|
});
|
|
url
|
|
}
|
|
|
|
const ACTIVE: &str = r#"{"active":true,"client_id":"c"}"#;
|
|
|
|
fn active() -> Verdict {
|
|
Verdict::Active {
|
|
client_id: Some("c".to_owned()),
|
|
}
|
|
}
|
|
|
|
async fn ask(url: &str, timeout: Duration) -> Verdict {
|
|
introspect_within(
|
|
&reqwest::Client::new(),
|
|
url,
|
|
"bridge",
|
|
"secret",
|
|
"tok",
|
|
timeout,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Presence control for the timeout tests: the same server shape admits
|
|
/// when it answers in time.
|
|
#[tokio::test]
|
|
async fn an_active_answer_within_the_deadline_admits() {
|
|
let url = serve(Some(Duration::ZERO), 200, ACTIVE).await;
|
|
assert_eq!(ask(&url, Duration::from_secs(5)).await, active());
|
|
}
|
|
|
|
/// A slow authelia is waited for: the answer's latency does not matter
|
|
/// as long as it beats the deadline.
|
|
#[tokio::test]
|
|
async fn a_slow_answer_inside_the_deadline_still_admits() {
|
|
let url = serve(Some(Duration::from_millis(600)), 200, ACTIVE).await;
|
|
assert_eq!(ask(&url, Duration::from_secs(5)).await, active());
|
|
}
|
|
|
|
/// Fail closed: no answer by the deadline is `Unavailable`, not an
|
|
/// admission.
|
|
#[tokio::test]
|
|
async fn no_answer_by_the_deadline_is_unavailable() {
|
|
let url = serve(None, 200, "").await;
|
|
assert_eq!(
|
|
ask(&url, Duration::from_millis(300)).await,
|
|
Verdict::Unavailable
|
|
);
|
|
}
|
|
|
|
/// An answer that arrives after the deadline is not used, even though
|
|
/// it would have admitted.
|
|
#[tokio::test]
|
|
async fn an_active_answer_after_the_deadline_is_unavailable() {
|
|
let url = serve(Some(Duration::from_millis(900)), 200, ACTIVE).await;
|
|
assert_eq!(
|
|
ask(&url, Duration::from_millis(300)).await,
|
|
Verdict::Unavailable
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_active_true_deserializes_to_a_grant() {
|
|
let yes: IntrospectionResponse = serde_json::from_str(r#"{"active":true}"#).unwrap();
|
|
assert!(yes.active);
|
|
let no: IntrospectionResponse = serde_json::from_str(r#"{"active":false}"#).unwrap();
|
|
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"
|
|
);
|
|
}
|
|
|
|
fn parse(body: &str) -> Verdict {
|
|
serde_json::from_str::<IntrospectionResponse>(body)
|
|
.expect("introspection body")
|
|
.verdict()
|
|
}
|
|
|
|
#[test]
|
|
fn an_active_token_names_the_client_that_holds_it() {
|
|
assert_eq!(
|
|
parse(r#"{"active":true,"client_id":"swarm-controller"}"#),
|
|
Verdict::Active {
|
|
client_id: Some("swarm-controller".to_owned())
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_or_empty_client_id_names_no_client() {
|
|
for body in [r#"{"active":true}"#, r#"{"active":true,"client_id":""}"#] {
|
|
assert_eq!(parse(body), Verdict::Active { client_id: None }, "{body}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_inactive_token_names_no_client_even_when_the_body_does() {
|
|
assert_eq!(
|
|
parse(r#"{"active":false,"client_id":"swarm-controller"}"#),
|
|
Verdict::Inactive
|
|
);
|
|
}
|
|
}
|