//! 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 { 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::(r#"{"sub":"someone"}"#).is_err()); } }