Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-nats-auth/src/introspect.rs
atlas e405c2b098 swarm-nats-auth: give issued user JWTs an exp
The user JWT the callout issues carried no exp, so nats-server armed no
expiry timer and a connection kept its grant until the client dropped it:
a deleted or re-minted agent queue secret, a removed OIDC client or an
expired access token never reached a live connection.

Every issued user JWT now expires at the presented OIDC token's exp
(read from the introspection answer) or an hour after issue, whichever
is sooner; the agent queue-secret path has no exp of its own and gets
the hour. Every queue client reconnects through an auth callback that
re-reads or re-mints its credential, so the forced reconnect presents a
current one.

Fixes #5432
2026-10-17 16:29:55 +02:00

235 lines
9.4 KiB
Rust

//! 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` 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;
use tokio::time::Instant;
/// 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. It bounds a whole request, the re-read of this
/// responder's own secret and the retry after it included, which is why
/// [`identify_caller`] takes a deadline rather than a duration.
pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_millis(1500);
/// What one introspection call said.
#[derive(Debug, PartialEq, Eq)]
pub enum Answer {
/// A 2xx: the caller, or `None` for every shape that is not an admission.
Caller(Option<Caller>),
/// A non-2xx, with its status: the `IdP` did not answer about the token
/// at all. A 400 or 401 is how it refuses this responder's own
/// credential, which `oidc_secret` re-reads and retries once before this
/// denies.
Refused(reqwest::StatusCode),
}
/// Who an introspected token belongs to, and until when.
#[derive(Debug, PartialEq, Eq)]
pub struct Caller {
/// The client that holds the token.
pub client_id: String,
/// The token's `exp`, in unix seconds, when the `IdP` names one.
pub expires: Option<i64>,
}
/// The fields this responder acts on. Authelia returns more (`sub`,
/// `scope`); 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<String>,
/// When the token expires. It admits nothing (`active` alone does) and
/// only caps the issued user JWT's `exp`, at which nats-server closes the
/// connection.
#[serde(default)]
exp: Option<i64>,
}
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<Caller> {
if !self.active {
return None;
}
let client_id = self.client_id.as_deref().filter(|id| !id.is_empty())?;
Some(Caller {
client_id: client_id.to_owned(),
expires: self.exp,
})
}
}
/// Ask the `IdP` whether `token` is currently valid, and as whom.
///
/// Returns `Ok(Answer::Caller(Some(caller)))` **only** on a 2xx whose
/// body says `active: true` and names the client. Every other 2xx is
/// `Ok(Answer::Caller(None))` with the reason logged, a non-2xx is
/// `Ok(Answer::Refused(_))`, and `Err` is a call that could not be made at all
/// or did not finish by `deadline` — callers must treat all but the first 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,
deadline: Instant,
) -> Result<Answer> {
let resp = http
.post(url)
.basic_auth(own_client_id, Some(own_client_secret))
.form(&[("token", token)])
.timeout(deadline.saturating_duration_since(Instant::now()))
.send()
.await
.context("introspection request")?;
let status = resp.status();
if !status.is_success() {
tracing::warn!(%status, "introspection returned non-2xx");
return Ok(Answer::Refused(status));
}
let body: IntrospectionResponse = match resp.json().await {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = ?e, "introspection body did not parse; denying");
return Ok(Answer::Caller(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(Answer::Caller(body.caller()))
}
#[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::<IntrospectionResponse>(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(Caller {
client_id: "hive-alpha".to_owned(),
expires: None,
})
);
}
#[test]
fn an_admitted_caller_carries_the_tokens_exp() {
assert_eq!(
parse(r#"{"active":true,"client_id":"hive-alpha","exp":1700000000}"#)
.caller()
.and_then(|c| c.expires),
Some(1_700_000_000)
);
}
#[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
);
}
}