//! 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, } #[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 { /// 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> { 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()); } }