Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-nats-auth/src/request.rs
atlas 6971b4e4ed swarm-nats-auth: answer callout requests concurrently, skip expired ones
Each request now runs in its own task, at most CONCURRENT_REQUESTS (32)
at once, instead of being awaited inline by the subscription loop. A
request whose exp (nats-server sets it to send time plus its
authorization.timeout) has passed is skipped without a lookup or reply.

Fixes #5434.
2026-10-17 23:36:01 +02:00

139 lines
5.4 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,
}
/// The claims of a request that this responder acts on. `iss`/`sub` are the
/// server's business, and claiming to understand fields we do not act on
/// would invite someone to trust them.
#[derive(Debug, Deserialize)]
pub struct Claims {
/// When the server stops waiting for the answer, in whole Unix seconds:
/// nats-server sets it to the time it sent the request plus its
/// `authorization.timeout`, rounded down.
#[serde(default)]
pub exp: Option<i64>,
pub nats: AuthRequest,
}
/// Split a NATS JWT and deserialize its claim set.
///
/// 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<Claims> {
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")?;
serde_json::from_slice(&raw).context("claim segment is not the expected JSON")
}
/// 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#"{"exp":1760000002,"nats":{"connect_opts":{"auth_token":"t0k"},
"user_nkey":"UABC","server_id":{"id":"NDEADBEEF"}}}"#,
)
.as_bytes(),
)
.expect("decode");
assert_eq!(req.nats.connect_opts.auth_token.as_deref(), Some("t0k"));
assert_eq!(req.nats.user_nkey, "UABC");
assert_eq!(req.nats.server_id.id, "NDEADBEEF");
assert_eq!(req.exp, Some(1_760_000_002));
}
#[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.nats.connect_opts.auth_token.is_none());
assert_eq!(req.exp, 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());
}
}