hyperhive/swarm-nats-auth/src/request.rs
atlas 3a75c54bcb feat(swarm): the auth-callout responder (#3112 slice 2)
Slice 1 shipped the NATS container with an auth_callout block and no
responder, which is the fail-closed state: the server answers
auth_required and admits nobody. This crate is what lets it say yes.

Connects as the callout-exempt user by nkey (never by name - the server
refuses to start if that entry carries a username), subscribes to
$SYS.REQ.USER.AUTH, validates the presented bearer token against
authelia's introspection endpoint, and replies with a signed NATS user
JWT. A denial is a signed response carrying an error, never silence: a
server that hears nothing cannot tell a refusing responder from a dead
one, so staying quiet would turn every rejection into a timeout and hide
an outage inside what looks like ordinary denials.

Everything that is not an explicit active:true denies - network error,
timeout, non-2xx, unparseable body, no token at all. Those are exactly
the conditions under which an attacker would most like this to fall
open. The introspection budget is held under the server's own 2s
auth_callout timeout by a test, since the two numbers live in different
languages in different files.

nats-jwt mints the user JWT. It cannot mint the authorization_response
wrapper - its claim enum is closed and its claims carry no aud, which
the response needs so a reply cannot be replayed at another server in
the cluster - so that half is hand-written, and a test builds a user
token both ways and requires the bytes to match. That is the only
honest basis for trusting the hand-written path on the shape the crate
does not model.

async-nats is taken with default-features off: the default set carries
jetstream, kv, object-store, websockets and service, none of which a
callout responder speaks.
2026-08-15 09:34:33 +02:00

134 lines
5.1 KiB
Rust

//! Decoding the server's authorization request.
//!
//! The payload on `$SYS.REQ.USER.AUTH` is a NATS JWT: three base64url
//! (no-pad) segments separated by `.`. The middle segment is the claim set,
//! and its `nats` object carries what the client presented.
//!
//! Decoding is deliberately hand-rolled while *minting* is not. The asymmetry
//! is the point: producing a signed artifact wrongly is silent — the server
//! rejects it and the reason is on the far side — whereas mis-parsing an
//! inbound one fails loudly on the very next field. `nats-jwt` is also
//! encode-only, so there is nothing to reuse here.
use anyhow::{Context, Result, bail};
use serde::Deserialize;
/// The subset of an `authorization_request` claim this responder acts on.
#[derive(Debug, Deserialize)]
pub struct AuthRequest {
/// Everything the connecting client sent in its `CONNECT` line.
pub connect_opts: ConnectOpts,
/// Ephemeral user nkey the server minted for this connection. The user
/// JWT we issue must be addressed to it, not to any name the client
/// chose — a client-supplied identity is a request, not a fact.
pub user_nkey: String,
/// Server this request came from; echoed back in the response so a
/// reply cannot be replayed at a different server in the cluster.
pub server_id: ServerId,
}
/// The client-controlled half of the request. Every field here is attacker
/// input: it is whatever the connecting client typed, not something the
/// server vouches for.
///
/// The wire object also carries `user` / `pass` when a client authenticates
/// that way. They are deliberately **not** modelled: the swarm authenticates
/// against the `IdP`, and a field that exists in this struct is a field
/// someone will eventually branch on.
#[derive(Debug, Deserialize)]
pub struct ConnectOpts {
/// Bearer token, when the client presented one. The only field this
/// responder acts on.
#[serde(default)]
pub auth_token: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct ServerId {
pub id: String,
}
/// Split a NATS JWT and deserialize its claim set's `nats` object.
///
/// The signature is **not** verified here — see the module docs on
/// `main.rs` for why that is a decision and not an oversight.
pub fn decode(payload: &[u8]) -> Result<AuthRequest> {
/// Only the `nats` object is modelled; `iss`/`sub`/`exp` are the server's
/// business, and claiming to understand fields we do not act on would
/// invite someone to trust them.
#[derive(Deserialize)]
struct Claims {
nats: AuthRequest,
}
let text = std::str::from_utf8(payload).context("auth request is not utf-8")?;
let mut parts = text.split('.');
// Exactly three segments: a fourth means this is not the shape we think
// it is, and a lenient split is how a parser starts accepting things the
// producer never meant to send.
let (Some(_header), Some(claims), Some(_sig), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
bail!("auth request is not a three-segment JWT")
};
let raw = base64_url_decode(claims).context("claim segment is not base64url")?;
let claims: Claims =
serde_json::from_slice(&raw).context("claim segment is not the expected JSON")?;
Ok(claims.nats)
}
/// base64url without padding, as JWT uses. Written out rather than pulled in:
/// `data-encoding` is already in the tree via `nkeys`, but its no-pad URL
/// alphabet spec is one line either way and this keeps the direct dependency
/// list honest about what this crate actually needs.
fn base64_url_decode(s: &str) -> Result<Vec<u8>> {
data_encoding::BASE64URL_NOPAD
.decode(s.as_bytes())
.context("base64url decode")
}
#[cfg(test)]
mod tests {
use super::*;
fn jwt_with(claims: &str) -> String {
let b = |s: &str| data_encoding::BASE64URL_NOPAD.encode(s.as_bytes());
format!("{}.{}.{}", b("{}"), b(claims), b("sig"))
}
#[test]
fn decodes_a_token_request() {
let req = decode(
jwt_with(
r#"{"nats":{"connect_opts":{"auth_token":"t0k"},
"user_nkey":"UABC","server_id":{"id":"NDEADBEEF"}}}"#,
)
.as_bytes(),
)
.expect("decode");
assert_eq!(req.connect_opts.auth_token.as_deref(), Some("t0k"));
assert_eq!(req.user_nkey, "UABC");
assert_eq!(req.server_id.id, "NDEADBEEF");
}
#[test]
fn a_request_without_a_token_is_still_well_formed() {
// An anonymous connect must parse, then be *rejected* by policy —
// not fail to parse. Conflating "malformed" with "unauthorized"
// loses the ability to tell a broken server from an attacker.
let req = decode(
jwt_with(r#"{"nats":{"connect_opts":{},"user_nkey":"U","server_id":{"id":"N"}}}"#)
.as_bytes(),
)
.expect("decode");
assert!(req.connect_opts.auth_token.is_none());
}
#[test]
fn rejects_a_payload_that_is_not_a_jwt() {
assert!(decode(b"not-a-jwt").is_err());
assert!(decode(b"two.parts").is_err());
assert!(decode(b"four.parts.here.too").is_err());
}
}