Slice 1 shipped the NATS container with an auth_callout block and no responder, which is the fail-closed state: the server answers auth_required and admits nobody. This crate is what lets it say yes. Connects as the callout-exempt user by nkey (never by name - the server refuses to start if that entry carries a username), subscribes to $SYS.REQ.USER.AUTH, validates the presented bearer token against authelia's introspection endpoint, and replies with a signed NATS user JWT. A denial is a signed response carrying an error, never silence: a server that hears nothing cannot tell a refusing responder from a dead one, so staying quiet would turn every rejection into a timeout and hide an outage inside what looks like ordinary denials. Everything that is not an explicit active:true denies - network error, timeout, non-2xx, unparseable body, no token at all. Those are exactly the conditions under which an attacker would most like this to fall open. The introspection budget is held under the server's own 2s auth_callout timeout by a test, since the two numbers live in different languages in different files. nats-jwt mints the user JWT. It cannot mint the authorization_response wrapper - its claim enum is closed and its claims carry no aud, which the response needs so a reply cannot be replayed at another server in the cluster - so that half is hand-written, and a test builds a user token both ways and requires the bytes to match. That is the only honest basis for trusting the hand-written path on the shape the crate does not model. async-nats is taken with default-features off: the default set carries jetstream, kv, object-store, websockets and service, none of which a callout responder speaks.
109 lines
4.2 KiB
Rust
109 lines
4.2 KiB
Rust
//! Validating a presented bearer token against the `IdP`.
|
|
//!
|
|
//! RFC 7662 token introspection: POST the token to authelia, which answers
|
|
//! `{"active": true|false, ...}`. `active` is the whole 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. That is not defensive coding, it is the only shape that is safe: the
|
|
//! failure modes of an HTTP call are exactly the conditions under which an
|
|
//! attacker would most like this to fall open.
|
|
//!
|
|
//! # 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;
|
|
|
|
/// 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.
|
|
pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_millis(1500);
|
|
|
|
/// The one field this responder acts on. Authelia returns more (`sub`,
|
|
/// `scope`, `exp`); modelling them would imply we check them.
|
|
#[derive(Debug, Deserialize)]
|
|
struct IntrospectionResponse {
|
|
active: bool,
|
|
}
|
|
|
|
/// Ask the `IdP` whether `token` is currently valid.
|
|
///
|
|
/// Returns `Ok(true)` **only** on a 2xx whose body says `active: true`.
|
|
/// Every other outcome is `Ok(false)` with the reason logged, or `Err` when
|
|
/// the call could not be made at all — callers must treat both as a denial.
|
|
///
|
|
/// 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 is_active(
|
|
http: &reqwest::Client,
|
|
url: &str,
|
|
client_id: &str,
|
|
client_secret: &str,
|
|
token: &str,
|
|
) -> Result<bool> {
|
|
let resp = http
|
|
.post(url)
|
|
.basic_auth(client_id, Some(client_secret))
|
|
.form(&[("token", token)])
|
|
.timeout(INTROSPECTION_TIMEOUT)
|
|
.send()
|
|
.await
|
|
.context("introspection request")?;
|
|
|
|
let status = resp.status();
|
|
if !status.is_success() {
|
|
tracing::warn!(%status, "introspection returned non-2xx; denying");
|
|
return Ok(false);
|
|
}
|
|
let body: IntrospectionResponse = match resp.json().await {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "introspection body did not parse; denying");
|
|
return Ok(false);
|
|
}
|
|
};
|
|
Ok(body.active)
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
}
|
|
|
|
#[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);
|
|
// 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());
|
|
}
|
|
}
|