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

@ -35,11 +35,23 @@ use sha2::{Digest, Sha256};
/// equality test would catch it drifting.
const JWT_HEADER: &str = r#"{"typ":"JWT","alg":"ed25519-nkey"}"#;
/// The longest an issued user JWT lives. At its `exp` nats-server closes the
/// connection and the client reconnects through this responder, so this is
/// how long a deleted or re-minted queue secret keeps a live connection.
/// An hour matches the lifetime of the OIDC access tokens the other clients
/// present, which caps their JWTs anyway; the cost is one reconnect per
/// client per hour.
const MAX_USER_JWT_LIFETIME: std::time::Duration = std::time::Duration::from_hours(1);
/// A NATS JWT claim set, with `aud` — which the callout response needs and
/// `nats-jwt`'s own `Claims` lacks.
#[derive(Serialize)]
struct Claims<T: Serialize> {
iat: i64,
/// Unix seconds. nats-server closes a connection admitted with this JWT
/// when it passes.
#[serde(skip_serializing_if = "Option::is_none")]
exp: Option<i64>,
iss: String,
jti: String,
sub: String,
@ -108,6 +120,7 @@ fn response(
sign(
Claims {
iat: now,
exp: None,
iss: issuer.public_key(),
jti: String::new(),
sub: user_nkey.to_owned(),
@ -205,16 +218,25 @@ impl ResponsePermission {
/// `account` is the account **name** from the server's `accounts` block (the
/// module's `clientAccount`), not a public key — in config mode the server
/// resolves `aud` against its own config rather than against a key.
///
/// It expires at `credential_expires` (the presented credential's own
/// expiry, unix seconds) or [`MAX_USER_JWT_LIFETIME`] after `now`, whichever
/// is sooner.
fn user_jwt(
now: i64,
issuer: &KeyPair,
account: &str,
user_nkey: &str,
permissions: &crate::policy::Permissions,
credential_expires: Option<i64>,
) -> String {
let latest = now.saturating_add(
i64::try_from(MAX_USER_JWT_LIFETIME.as_secs()).expect("an hour fits in an i64"),
);
sign(
Claims {
iat: now,
exp: Some(credential_expires.map_or(latest, |t| t.min(latest))),
iss: issuer.public_key(),
jti: String::new(),
sub: user_nkey.to_owned(),
@ -242,7 +264,8 @@ fn user_jwt(
}
/// Grant: mint a user JWT placing the client in `account`, scoped to
/// `permissions`, and wrap it.
/// `permissions` and expiring no later than `credential_expires` (see
/// [`user_jwt`]), and wrap it.
///
/// Taking the permissions by value rather than defaulting them is the point:
/// there is no way to call this and get an unscoped grant by omission, so a
@ -253,9 +276,17 @@ pub fn grant(
server_id: &str,
user_nkey: &str,
permissions: &crate::policy::Permissions,
credential_expires: Option<i64>,
) -> String {
let now = now_secs();
let jwt = user_jwt(now, issuer, account, user_nkey, permissions);
let jwt = user_jwt(
now,
issuer,
account,
user_nkey,
permissions,
credential_expires,
);
response(now, issuer, server_id, user_nkey, Ok(jwt))
}
@ -337,6 +368,7 @@ mod tests {
let mine = sign(
Claims {
iat: decoded["iat"].as_i64().expect("iat"),
exp: None,
iss: account.public_key(),
jti: String::new(),
sub: user.public_key(),
@ -383,13 +415,68 @@ mod tests {
}
}
/// The `exp` of a user JWT minted at `now` for a credential expiring at
/// `credential_expires`.
fn user_exp(now: i64, credential_expires: Option<i64>) -> serde_json::Value {
let account = KeyPair::new_account();
let jwt = user_jwt(
now,
&account,
"APP",
"UCLIENT",
&perms(),
credential_expires,
);
decode_claims(&jwt)["exp"].clone()
}
#[test]
fn the_issued_user_jwt_expires_with_a_credential_that_expires_sooner() {
let now = 1_700_000_000;
assert_eq!(user_exp(now, Some(now + 60)), now + 60);
}
#[test]
fn the_issued_user_jwt_expires_after_the_max_lifetime_otherwise() {
let now = 1_700_000_000;
let latest = now + 60 * 60;
assert_eq!(user_exp(now, None), latest);
assert_eq!(user_exp(now, Some(latest + 1)), latest);
}
#[test]
fn a_granted_user_jwt_carries_an_exp_after_its_iat() {
let account = KeyPair::new_account();
let wrapper = decode_claims(&grant(
&account,
"APP",
"NSERVER",
"UCLIENT",
&perms(),
None,
));
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
let (iat, exp) = (user["iat"].as_i64(), user["exp"].as_i64());
assert!(
iat.zip(exp).is_some_and(|(iat, exp)| exp > iat),
"iat {iat:?}, exp {exp:?}"
);
}
#[test]
fn the_issued_user_jwt_carries_the_permissions_it_was_given() {
// The scoping is only real if it survives into the minted token. A
// policy that computes the right subjects and a grant that drops them
// look identical from every test that stops at the policy.
let account = KeyPair::new_account();
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
let wrapper = decode_claims(&grant(
&account,
"APP",
"NSERVER",
"UCLIENT",
&perms(),
None,
));
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
assert_eq!(user["nats"]["pub"]["allow"][0], "$KV.hive-status.alpha");
// An empty or absent allow-list is NATS' "everything": the one shape
@ -410,14 +497,21 @@ mod tests {
subscribe: Some(vec!["_INBOX.agent.atlas.>".to_owned()]),
..perms()
};
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &scoped));
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &scoped, None));
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
assert_eq!(
user["nats"]["sub"]["allow"],
serde_json::json!(["_INBOX.agent.atlas.>"])
);
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
let wrapper = decode_claims(&grant(
&account,
"APP",
"NSERVER",
"UCLIENT",
&perms(),
None,
));
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
assert!(user["nats"]["sub"].is_null(), "{}", user["nats"]);
}
@ -429,7 +523,9 @@ mod tests {
responses: Some(std::time::Duration::from_mins(31)),
..perms()
};
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &answering));
let wrapper = decode_claims(&grant(
&account, "APP", "NSERVER", "UCLIENT", &answering, None,
));
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
// The names and units nats-server's `jwt/v2` reads: `max` messages,
// `ttl` as a Go duration in nanoseconds.
@ -438,7 +534,14 @@ mod tests {
serde_json::json!({ "max": 1, "ttl": 31 * 60 * 1_000_000_000_i64 })
);
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
let wrapper = decode_claims(&grant(
&account,
"APP",
"NSERVER",
"UCLIENT",
&perms(),
None,
));
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
assert!(user["nats"]["resp"].is_null(), "{}", user["nats"]);
}
@ -446,7 +549,14 @@ mod tests {
#[test]
fn a_grant_carries_a_user_jwt_and_no_error() {
let account = KeyPair::new_account();
let claims = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
let claims = decode_claims(&grant(
&account,
"APP",
"NSERVER",
"UCLIENT",
&perms(),
None,
));
assert!(claims["nats"]["error"].is_null());
assert!(
claims["nats"]["jwt"]
@ -470,7 +580,14 @@ mod tests {
#[test]
fn the_issued_user_jwt_names_its_account_by_aud_and_sets_no_issuer_account() {
let account = KeyPair::new_account();
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT", &perms()));
let wrapper = decode_claims(&grant(
&account,
"APP",
"NSERVER",
"UCLIENT",
&perms(),
None,
));
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
// In server-config mode the account is named by `aud`...