Watch
0
0
Fork
You've already forked hyperhive
0

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
This commit is contained in:
atlas 2026-10-17 15:09:06 +02:00 • committed by mara
commit e405c2b098
9 changed files with 245 additions and 69 deletions

View file

@ -15,8 +15,8 @@
//!
//! # `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,
//! `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.
@ -46,7 +46,7 @@ pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_millis(1500);
#[derive(Debug, PartialEq, Eq)]
pub enum Answer {
/// A 2xx: the caller, or `None` for every shape that is not an admission.
Caller(Option<String>),
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
@ -54,8 +54,17 @@ pub enum Answer {
Refused(reqwest::StatusCode),
}
/// The two fields this responder acts on — one per decision. Authelia returns
/// more (`sub`, `scope`, `exp`); modelling them would imply we check them.
/// 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.
@ -66,6 +75,11 @@ struct IntrospectionResponse {
/// [`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 {
@ -80,17 +94,21 @@ impl IntrospectionResponse {
/// 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> {
fn caller(&self) -> Option<Caller> {
if !self.active {
return None;
}
self.client_id.as_deref().filter(|id| !id.is_empty())
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(client_id)))` **only** on a 2xx whose
/// 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
@ -139,7 +157,7 @@ pub async fn identify_caller(
// rejected for no visible reason.
tracing::warn!("introspection said active but named no client; denying");
}
Ok(Answer::Caller(body.caller().map(str::to_owned)))
Ok(Answer::Caller(body.caller()))
}
#[cfg(test)]
@ -177,7 +195,20 @@ mod tests {
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")
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)
);
}