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:
parent
de4871cab1
commit
e405c2b098
9 changed files with 245 additions and 69 deletions
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue