The auth-callout responder learned whether a token was valid, never whose it was, so every admitted client got the same unscoped grant. Scoping a grant to one hive's subjects needs an identity to scope it to. `is_active` becomes `identify_caller` and returns the identity rather than a boolean: `Ok(Some(client_id))` admits as that client, `Ok(None)` denies. `active: true` with no `client_id` is a denial, and returning an identity is what makes that unrepresentable instead of remembered -- there is no admitted-but-unscoped value to construct, so there is no branch a later edit can forget to handle. The module deliberately ignores `sub`/`scope`/`exp` on the grounds that modelling a field implies checking it. That still holds: `active` remains the whole admission decision, made in one place. `client_id` answers a different question -- as whom -- and is used downstream of an admission that has already happened, not as a second gate. The module docs say so, next to the paragraph that would otherwise argue for deleting the field. An inactive token has no identity even when the body names one, and an empty `client_id` counts as absent: it would become a blank component in a subject the grant is scoped to, which is not a narrower permission but a different one. The two credential parameters are renamed `own_*` -- this responder's introspection credential is not the caller's identity, and the function now takes one and returns the other. The grant itself is still unscoped; narrowing it is the next slice.
186 lines
7.6 KiB
Rust
186 lines
7.6 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`/`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<String>,
|
|
}
|
|
|
|
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<Option<String>> {
|
|
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::<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("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
|
|
);
|
|
}
|
|
}
|