//! Validating a presented bearer token against the `IdP`, and learning who //! presented it. //! //! RFC 7662 token introspection: POST the token to authelia, which answers //! `{"active": true|false, "client_id": "...", ...}`. `active` is the whole //! **admission** 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. Not defensive coding but the only safe shape: the failure modes of //! an HTTP call are exactly when an attacker would like this to fall open. //! //! # `client_id` is modelled, and is not a second admission check //! //! `sub`/`scope`/`exp` stay unmodelled: modelling a field implies checking //! it. `client_id` differs in kind — `active` decides **whether** to admit, //! `client_id` decides **as whom**, which is what lets a grant be scoped to //! one hive. Admission stays one decision in one place; the identity is //! returned rather than a bool so "admitted but unscoped" cannot exist. //! //! # 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 two fields this responder acts on — one per decision. Authelia returns /// more (`sub`, `scope`, `exp`); modelling them would imply we check them. #[derive(Debug, Deserialize)] struct IntrospectionResponse { /// Whether to admit at all. active: bool, /// Which client presented the token, i.e. which hive to scope its grant /// to. Optional on the wire (RFC 7662 makes every field but `active` /// optional), and its absence is a denial rather than a default — see /// [`IntrospectionResponse::caller`]. #[serde(default)] client_id: Option, } impl IntrospectionResponse { /// The identity to admit this token as, or `None` for every shape that is /// not an admission. /// /// An inactive token has no identity here even when the body names one: a /// `client_id` on an expired token says who it *was* minted for, which is /// not a statement that the bearer may connect. /// /// An empty `client_id` is treated as absent. It would otherwise become an /// empty component in a subject the grant is scoped to, and a scope /// assembled from a blank is not a narrower permission, it is a different /// one nobody reviewed. fn caller(&self) -> Option<&str> { if !self.active { return None; } self.client_id.as_deref().filter(|id| !id.is_empty()) } } /// Ask the `IdP` whether `token` is currently valid, and as whom. /// /// Returns `Ok(Some(client_id))` **only** on a 2xx whose body says `active: /// true` and names the client. Every other outcome is `Ok(None)` with the /// reason logged, or `Err` when the call could not be made at all — callers /// must treat both as a denial. /// /// `own_client_id` / `own_client_secret` are *this responder's* credential for /// the introspection endpoint. They are not the caller's identity and must /// never be used as one; the caller's is the return value. /// /// 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 identify_caller( http: &reqwest::Client, url: &str, own_client_id: &str, own_client_secret: &str, token: &str, ) -> Result> { let resp = http .post(url) .basic_auth(own_client_id, Some(own_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(None); } let body: IntrospectionResponse = match resp.json().await { Ok(b) => b, Err(e) => { tracing::warn!(error = ?e, "introspection body did not parse; denying"); return Ok(None); } }; if body.active && body.caller().is_none() { // Loud on purpose: this is a token the `IdP` says is good, refused by // us. Without a line here the operator sees a working credential // rejected for no visible reason. tracing::warn!("introspection said active but named no client; denying"); } Ok(body.caller().map(str::to_owned)) } #[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" ); } fn parse(body: &str) -> IntrospectionResponse { serde_json::from_str(body).expect("introspection body") } #[test] fn only_active_true_deserializes_to_a_grant() { assert!(parse(r#"{"active":true,"client_id":"hive-alpha"}"#).active); assert!(!parse(r#"{"active":false}"#).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()); } #[test] fn an_active_token_is_admitted_as_the_client_that_holds_it() { assert_eq!( parse(r#"{"active":true,"client_id":"hive-alpha","scope":"x"}"#).caller(), Some("hive-alpha") ); } #[test] fn active_without_a_client_id_is_a_denial_not_an_unscoped_grant() { // The invariant the whole scoping design rests on. If this ever // returns `Some`, a token the `IdP` could not attribute would be // admitted with whatever scope the caller assembles from nothing - // which is how a per-hive permission becomes a swarm-wide one. assert_eq!(parse(r#"{"active":true}"#).caller(), None); assert_eq!(parse(r#"{"active":true,"client_id":""}"#).caller(), None); } #[test] fn an_inactive_token_has_no_identity_even_when_the_body_names_one() { // Admission is decided by `active` alone. A `client_id` alongside // `active: false` says who the token *was* for, and reading it as an // identity would turn an expired credential into a working one. assert_eq!( parse(r#"{"active":false,"client_id":"hive-alpha"}"#).caller(), None ); } }