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

@ -278,7 +278,9 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
// normal thing for a client to attempt and an abnormal thing to
// grant. Neither the store nor introspection is reached until
// something was presented.
let (caller, permissions) = match req
// `expires` is the presented credential's own expiry, when it has one:
// an agent's queue secret has none.
let (caller, permissions, expires) = match req
.connect_opts
.auth_token
.as_deref()
@ -289,15 +291,16 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
Some(agent_token::Presented::Agent(token)) => (
Some(format!("agent:{}", token.agent)),
agent_token::authorize(&policy, &store, &token).await,
None,
),
Some(agent_token::Presented::Malformed(e)) => {
tracing::warn!(error = %e, "malformed agent token; denying");
(None, None)
(None, None, None)
}
Some(agent_token::Presented::Bearer(token)) => {
introspected(&policy, &http, args, &client_secret, token).await
}
None => (None, None),
None => (None, None, None),
};
// The caller is an identifier, not a credential: a client id, or
// `agent:<name>` for an agent token. One line per auth request, a connect
@ -326,6 +329,7 @@ async fn serve(args: &Args) -> anyhow::Result<()> {
&req.server_id.id,
&req.user_nkey,
permissions,
expires,
),
None => respond::deny(&issuer, &req.server_id.id, &req.user_nkey),
};
@ -439,8 +443,9 @@ async fn ensure_callout_keys(
Ok(())
}
/// The OIDC path: who the `IdP` says presented `token`, and what the policy
/// grants that client. `None` permissions is a denial.
/// The OIDC path: who the `IdP` says presented `token`, what the policy
/// grants that client, and when the token expires. `None` permissions is a
/// denial.
///
/// The caller is an identity or nothing — see `introspect`'s module docs.
/// There is no "admitted, identity unknown" branch to write here because
@ -451,7 +456,7 @@ async fn introspected(
args: &Args,
client_secret: &oidc_secret::OidcSecret,
token: &str,
) -> (Option<String>, Option<policy::Permissions>) {
) -> (Option<String>, Option<policy::Permissions>, Option<i64>) {
let deadline = tokio::time::Instant::now() + introspect::INTROSPECTION_TIMEOUT;
let caller = client_secret
.introspect(deadline, |secret| async move {
@ -473,6 +478,7 @@ async fn introspected(
tracing::warn!(error = ?e, "introspection failed; denying");
None
});
let (caller, expires) = caller.map_or((None, None), |c| (Some(c.client_id), c.expires));
// Admission said who; the policy says what. A caller the `IdP`
// vouches for but no rule matches is denied — see `policy`'s module
// docs for why that is deny and not "connect with nothing".
@ -486,7 +492,7 @@ async fn introspected(
"authenticated client matches no policy rule; denying"
);
}
(caller, permissions)
(caller, permissions, expires)
}
#[cfg(test)]