Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-authelia-bridge/src/introspect.rs
atlas 9e7fdb55ae swarm-controller: make authelia's operators matrix homeserver admins
swarm-authelia-bridge gains ListOperators, the usernames in authelia's
operator group (`operatorGroup = "admins"`, passed in from
swarm-authelia.nix). Unlike the two agent verbs, which admit any active
token, it answers only the client named by
SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID (swarm-controller's
queueClientId) and returns 403 to every other active token: authelia
issues machine tokens to every hive and its agents as well. The bridge's
introspection now models `client_id` to make that check, the same way
swarm-nats-auth's does.

swarm-controller runs a grant-only pass at start and every five minutes:
read the group, map each username to the account an SSO login lands on
(tuwunel lowercases `preferred_username`), skip anyone already joined to
`#admins:<server>` (tuwunel's definition of admin) or without a matrix
account, and send `!admin users make-user-admin <user>` there as @swarm
with the swarm appservice token. Nothing is ever revoked: tuwunel has no
command for it (#4799).

The admin-room calls (alias, joined members, profile, send) go into
swarm-matrix-client next to whoami, keeping its no-body-in-errors rule.

Closes #4585
2026-10-04 16:45:08 +02:00

183 lines
7 KiB
Rust

//! 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 admission
//! 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);
/// `active` decides whether to admit; `client_id` says as whom, which is
/// what lets one verb be scoped to a single client. Same two fields, for the
/// same reasons, as `swarm-nats-auth::introspect`.
#[derive(Debug, Deserialize)]
struct IntrospectionResponse {
active: bool,
#[serde(default)]
client_id: Option<String>,
}
impl IntrospectionResponse {
fn verdict(self) -> Verdict {
if !self.active {
return Verdict::Inactive;
}
Verdict::Active {
client_id: self.client_id.filter(|id| !id.is_empty()),
}
}
}
/// 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, PartialEq, Eq)]
pub enum Verdict {
/// 2xx with `active: true`. The only admitting outcome.
Active {
/// The client the token was issued to. `None` when authelia named
/// none, or named an empty one: a verb scoped to one client refuses
/// such a caller, it does not match it to a blank.
client_id: Option<String>,
},
/// 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::<IntrospectionResponse>().await {
Ok(body) => body.verdict(),
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::<IntrospectionResponse>(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"
);
}
fn parse(body: &str) -> Verdict {
serde_json::from_str::<IntrospectionResponse>(body)
.expect("introspection body")
.verdict()
}
#[test]
fn an_active_token_names_the_client_that_holds_it() {
assert_eq!(
parse(r#"{"active":true,"client_id":"swarm-controller"}"#),
Verdict::Active {
client_id: Some("swarm-controller".to_owned())
}
);
}
#[test]
fn a_missing_or_empty_client_id_names_no_client() {
for body in [r#"{"active":true}"#, r#"{"active":true,"client_id":""}"#] {
assert_eq!(parse(body), Verdict::Active { client_id: None }, "{body}");
}
}
#[test]
fn an_inactive_token_names_no_client_even_when_the_body_does() {
assert_eq!(
parse(r#"{"active":false,"client_id":"swarm-controller"}"#),
Verdict::Inactive
);
}
}