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
|
|
@ -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`...
|
||||
|
|
|
|||
Loading…
Reference in a new issue