//! 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 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; /// Generous relative to `swarm-nats-auth`'s 1.5s (that one is bounded by /// the NATS server's own 2s `auth_callout` timeout; an HTTP request here /// has no such external deadline to stay under), but still bounded — a /// hung introspection call must not wedge a `CreateIdentity` job forever. pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug, Deserialize)] struct IntrospectionResponse { active: bool, } /// 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, ) -> Verdict { let resp = match http .post(url) .basic_auth(client_id, Some(client_secret)) .form(&[("token", token)]) .timeout(INTROSPECTION_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::().await { Ok(body) if body.active => Verdict::Active, Ok(_) => Verdict::Inactive, Err(e) => { tracing::warn!(error = ?e, "introspection body did not parse; cannot validate"); Verdict::Unavailable } } } #[cfg(test)] mod tests { use super::*; #[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::(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" ); } }