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.
139 lines
5.4 KiB
Rust
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());
|
|
}
|
|
}
|