Compare commits

...
Author SHA1 Message Date
atlas
32643dae36 fix(swarm): the doc link pointed at a cfg(test) item
rustdoc builds without the test cfg, so `tests::hand_built_matches_the_reference`
resolves to nothing and broken_intra_doc_links denies it. Plain backticks
rather than making the item visible - a lint is not a reason to change an
item's visibility.

Earned, not lost: this is the first completed rustdoc run on a brand-new
crate, and the link was wrong from the first commit.
2026-08-15 09:34:33 +02:00
atlas
9b35be2a24 chore(swarm): move swarm-nats-auth's deps to the workspace
Per mara on the PR: all deps go into workspace level so versions stay
consistent. async-nats, data-encoding, nkeys and nats-jwt are new
[workspace.dependencies] entries; sha2 and futures-util now come from
there.

The crate had asked for sha2 0.10 while the workspace standard is 0.11,
and for the futures facade where the workspace carries futures-util. Both
resolved without adding a Cargo.lock entry - each was already in the tree
via something else - so the drift was invisible in the lock and would only
have surfaced as two versions of a hashing crate in one binary.
2026-08-15 09:34:33 +02:00
atlas
5be2918559 docs(swarm): add the swarm-nats-auth package readme
Every other crate has one and Cargo.toml already named it. Follows the
sibling shape: what it is, why it is a crate rather than more config, the
invariant that is easy to break (issuer_account must be absent, and
Token::new_user reintroduces it), and what the tests do NOT cover - the
introspection endpoint under test is a stub, so nothing here says anything
about the real authelia integration.
2026-08-15 09:34:33 +02:00
atlas
b22f0ecaa1 docs(swarm): trim the respond module doc under the 30-line block lint
The discovery narrative belongs in the PR body, which carries it. What
stays at the line is what the code cannot say: that issuer_account must be
absent and why reaching for Token::new_user reintroduces the bug, the
BASE32HEX-vs-BASE32 distinction, and that nats-jwt is a test oracle rather
than a runtime dependency.
2026-08-15 09:34:33 +02:00
atlas
188e27478a fix(swarm): name the account by aud, never issuer_account
The responder answered granted=true and the server still refused every
client:

  Error non operator mode account "AUTH": attempted to use issuer_account

nats_jwt::Token::new_user always sets issuer_account, which is an
operator-mode field naming the account when a signing key rather than the
account identity key signed the token. In server-config mode - what this
module renders - its mere presence is fatal, and the account is named by
the claims' aud instead.

nats-jwt can express neither aud nor the omission, so the user JWT is now
hand-built by the same signer as the response wrapper, and nats-jwt moves
to dev-dependencies as the encoder's test oracle.

Every unit test passed throughout: they assert fields that must be
present, and the defect was a field that must be absent.
2026-08-15 09:34:33 +02:00
atlas
207fc4d2a6 wip: nix unit + secret delivery for the callout responder 2026-08-15 09:34:33 +02:00
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
11 changed files with 1245 additions and 10 deletions

200
Cargo.lock generated
View file

@ -192,6 +192,38 @@ dependencies = [
"tokio",
]
[[package]]
name = "async-nats"
version = "0.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83a251fa1a4c9d0fe6e816b7acd60549e473e08d14f27a1d992c2675abff05f"
dependencies = [
"base64",
"bytes",
"futures-util",
"memchr",
"nkeys",
"pin-project",
"portable-atomic",
"rand 0.10.2",
"regex",
"ring",
"rustls-native-certs",
"rustls-pki-types",
"rustls-webpki",
"serde",
"serde_json",
"serde_repr",
"thiserror 2.0.18",
"tokio",
"tokio-rustls",
"tokio-stream",
"tokio-util",
"tokio-websockets",
"tracing",
"url",
]
[[package]]
name = "async-once-cell"
version = "0.5.4"
@ -449,6 +481,9 @@ name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
dependencies = [
"serde",
]
[[package]]
name = "bytesize"
@ -958,6 +993,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
@ -1091,6 +1127,7 @@ dependencies = [
"rand_core 0.6.4",
"serde",
"sha2 0.10.9",
"signature",
"subtle",
"zeroize",
]
@ -1150,7 +1187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2018,7 +2055,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
"webpki-roots 1.0.8",
]
[[package]]
@ -2637,7 +2674,7 @@ dependencies = [
"url",
"urlencoding",
"vodozemac",
"webpki-roots",
"webpki-roots 1.0.8",
"zeroize",
]
@ -2903,12 +2940,40 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nats-jwt"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e51104ed9bd9e7d1f9117603e1186a56a3a437ceaf0761383910180f5879b801"
dependencies = [
"data-encoding",
"nkeys",
"serde",
"serde_json",
"sha2 0.10.9",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nkeys"
version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf"
dependencies = [
"data-encoding",
"ed25519",
"ed25519-dalek",
"getrandom 0.2.17",
"log",
"rand 0.8.7",
"signatory",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@ -3120,6 +3185,15 @@ dependencies = [
"digest 0.10.7",
]
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -3176,6 +3250,26 @@ dependencies = [
"siphasher",
]
[[package]]
name = "pin-project"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@ -3407,7 +3501,7 @@ dependencies = [
"once_cell",
"socket2",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3648,7 +3742,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots",
"webpki-roots 1.0.8",
]
[[package]]
@ -3681,6 +3775,7 @@ dependencies = [
"rustls-platform-verifier",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tokio-rustls",
@ -3693,7 +3788,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
"webpki-roots 1.0.8",
]
[[package]]
@ -3945,7 +4040,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4004,7 +4099,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4226,6 +4321,17 @@ dependencies = [
"serde_core",
]
[[package]]
name = "serde_repr"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.3",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
@ -4305,12 +4411,25 @@ dependencies = [
"libc",
]
[[package]]
name = "signatory"
version = "0.27.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
dependencies = [
"pkcs8",
"rand_core 0.6.4",
"signature",
"zeroize",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"digest 0.10.7",
"rand_core 0.6.4",
]
@ -4437,6 +4556,26 @@ dependencies = [
"utoipa-axum",
]
[[package]]
name = "swarm-nats-auth"
version = "0.1.0"
dependencies = [
"anyhow",
"async-nats",
"clap",
"data-encoding",
"futures-util",
"nats-jwt",
"nkeys",
"reqwest 0.13.1",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "swarmctl"
version = "0.1.0"
@ -4471,6 +4610,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
@ -4522,7 +4672,7 @@ dependencies = [
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4712,6 +4862,27 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-websockets"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d"
dependencies = [
"base64",
"bytes",
"futures-core",
"futures-sink",
"http",
"httparse",
"rand 0.8.7",
"ring",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tokio-util",
"webpki-roots 0.26.11",
]
[[package]]
name = "toml"
version = "1.1.3+spec-1.1.0"
@ -5288,6 +5459,15 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.8",
]
[[package]]
name = "webpki-roots"
version = "1.0.8"
@ -5325,7 +5505,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]

View file

@ -22,6 +22,7 @@ members = [
"hive-types",
"hivectl",
"swarm-controller",
"swarm-nats-auth",
"swarmctl",
]
@ -112,6 +113,10 @@ tokio-stream = { version = "0.1", features = ["sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
reqwest = { version = "0.13", default-features = false, features = [
# RFC 7662 introspection posts an urlencoded body; without this,
# `.form()` does not exist and the alternative is percent-encoding a
# credential by hand.
"form",
"json",
"rustls",
] }
@ -135,5 +140,22 @@ matrix-sdk = { version = "0.18", default-features = false, features = [
futures-util = "0.3"
hmac = "0.13"
sha2 = "0.11"
# The NATS protocol client, for the swarm queue's auth-callout responder.
# `default-features = false` because the default set is broad - jetstream, kv,
# object-store, websockets, service - and a callout responder speaks none of
# them. What is named is the whole requirement: the server generation we
# deploy, nkey auth, and a TLS backend.
async-nats = { version = "0.50", default-features = false, features = [
"server_2_14",
"nkeys",
"ring",
] }
data-encoding = "2"
# The nkey *format* - ed25519 + base32 + CRC16. The primitives are already in
# the tree; the format is not, and hand-rolling a key format on an auth path
# is how you get a CRC bug nobody reviews.
nkeys = "0.4"
# A TEST ORACLE, not a runtime dependency - see swarm-nats-auth's respond.rs.
nats-jwt = "0.3"
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2"

View file

@ -151,6 +151,9 @@
services.hyperhive.swarm.ui.package =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-ui;
services.hyperhive.swarm.nats.authPackage =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swarm-nats-auth;
services.hyperhive.gateway.swaggerUiTheme =
lib.mkDefault
self.packages.${pkgs.stdenv.hostPlatform.system}.swagger-ui-theme;

View file

@ -16,6 +16,22 @@ let
# authorizes.
calloutAccount = "AUTH";
clientAccount = "APP";
machine = "swarm-nats";
# Where the responder's credentials live *inside* the container, and the
# host path that resolves to. Two names for one location, because the
# host is the only place both filesystems are addressable.
secretDirInContainer = "/var/lib/swarm-nats-auth";
inContainer = name: "${secretDirInContainer}/${name}";
secretDir = "/var/lib/nixos-containers/${machine}${secretDirInContainer}";
hostPath = name: "${secretDir}/${name}";
# The responder needs all three credentials. Gating on them rather than
# on `cfg.enable` keeps a half-configured hive at "queue up, denying
# everyone" instead of "unit crash-looping on a missing file".
responderConfigured = cfg.calloutUserSeedFile != "" && cfg.calloutIssuerSeedFile != "";
clientSecretSource = "${autheliaCfg.hostClientSecretDir}/${cfg.clientId}.secret";
introspectionUrl = "${toString autheliaUrl}/api/oidc/introspection";
in
{
# The swarm's message queue: one NATS server, reached by every hive.
@ -116,6 +132,55 @@ in
rather than on purpose, so it fails at eval instead.
'';
};
authPackage = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-nats-auth";
description = ''
The auth-callout responder package.
Named `authPackage`, not `package`, on purpose: this module
deliberately has **no** `package` option for the server itself
(see the note above upstream's `services.nats` resolves
`pkgs.nats-server` on its own), so a bare `package` here would
read as "the NATS package" and mean something else entirely.
'';
};
calloutUserSeedFile = lib.mkOption {
type = lib.types.str;
default = "";
example = "/run/secrets/swarm-nats-callout-user.seed";
description = ''
Absolute host path to the **seed** whose public half is
`calloutUserPublicKey`. The responder authenticates to the queue
with it.
A `str` rather than a `path`, and the reason is not style: a
`path`-typed literal is hash-copied into the world-readable nix
store at eval time, which is the opposite of what a seed wants.
Same discipline as `otel.headersCredential`.
Until this is set the responder cannot start, and the queue
stays in its fail-closed state which is the correct behaviour,
not a gap.
'';
};
calloutIssuerSeedFile = lib.mkOption {
type = lib.types.str;
default = "";
example = "/run/secrets/swarm-nats-issuer.seed";
description = ''
Absolute host path to the **account** seed whose public half is
`calloutIssuerPublicKey`. The responder signs the user JWTs it
issues with it, so possession of this file is the authority to
admit anyone to the queue.
A `str` for the same store-leak reason as
`calloutUserSeedFile`.
'';
};
};
config = lib.mkIf cfg.enable {
@ -276,6 +341,51 @@ in
};
};
# The auth-callout responder: the half that lets the server
# above say *yes*. Without it the `auth_callout` block is a
# door nobody can open, which is the deliberate interim state.
#
# ⚠️ It is gated on the seeds being configured rather than on
# `cfg.enable`, so a half-configured hive gets a running,
# refusing queue instead of a unit that crash-loops on a
# missing file. A queue that denies everyone is a legible
# failure; a restart loop is not.
systemd.services.swarm-nats-auth = lib.mkIf responderConfigured {
description = "swarm queue auth-callout responder";
after = [ "nats.service" ];
requires = [ "nats.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = lib.concatStringsSep " " [
"${cfg.authPackage}/bin/swarm-nats-auth"
"--nats-url nats://127.0.0.1:${toString cfg.port}"
"--user-seed-file \${CREDENTIALS_DIRECTORY}/callout-user.seed"
"--issuer-seed-file \${CREDENTIALS_DIRECTORY}/issuer.seed"
"--client-secret-file \${CREDENTIALS_DIRECTORY}/oidc-client.secret"
"--client-id ${lib.escapeShellArg cfg.clientId}"
# The account admitted clients land in, by NAME: in
# server-config mode the server resolves `aud` against its
# own `accounts` block, so this and the block above have to
# be the same string — which is why both come from one let.
"--account ${lib.escapeShellArg clientAccount}"
"--introspection-url ${lib.escapeShellArg introspectionUrl}"
];
# Every credential arrives by `LoadCredential` and is named
# on the command line only as a **path** — `argv` is
# world-readable via /proc/<pid>/cmdline, so a value there
# would be readable by every process on the host netns.
LoadCredential = [
"callout-user.seed:${inContainer "callout-user.seed"}"
"issuer.seed:${inContainer "issuer.seed"}"
"oidc-client.secret:${inContainer "oidc-client.secret"}"
];
DynamicUser = true;
Restart = "on-failure";
RestartSec = "5s";
SyslogIdentifier = "swarm-nats-auth";
};
};
# The server binary, so an operator with a shell in here can
# run `nats-server -t` against the generated config. The unit
# resolves ExecStart through the store path and puts nothing
@ -283,5 +393,33 @@ in
environment.systemPackages = [ pkgs.nats-server ];
};
};
# Deliver the responder's three credentials into the container before
# it starts. Same shape as `hive-matrix-oidc-secret`, and for the same
# reason it is a copy rather than a `bindMounts` entry: nixos-container
# refuses to start when a bind source is missing, so one absent seed
# would take down the **whole container including the queue**, not
# merely the responder. A far larger blast radius than the fault.
systemd.services.swarm-nats-auth-secrets = lib.mkIf responderConfigured {
description = "deliver the swarm queue responder's credentials";
before = [ "container@swarm-nats.service" ];
wantedBy = [ "container@swarm-nats.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
SyslogIdentifier = "swarm-nats-auth-secrets";
};
path = [ pkgs.coreutils ];
script = ''
set -euo pipefail
install -d -m 0700 ${lib.escapeShellArg secretDir}
install -m 0400 ${lib.escapeShellArg cfg.calloutUserSeedFile} \
${lib.escapeShellArg (hostPath "callout-user.seed")}
install -m 0400 ${lib.escapeShellArg cfg.calloutIssuerSeedFile} \
${lib.escapeShellArg (hostPath "issuer.seed")}
install -m 0400 ${lib.escapeShellArg clientSecretSource} \
${lib.escapeShellArg (hostPath "oidc-client.secret")}
'';
};
};
}

View file

@ -150,6 +150,12 @@ in
# Uses the same per-bin extractor, just bound on its own.
swarm-controller = mkBinPackage "swarm-controller" "hyperhive swarm-level controller daemon";
# The queue's auth-callout responder. Out of `daemonBins` for the same
# reason as the two above and one more: it runs *inside* the swarm-nats
# container, not on the host, so it belongs in that container's closure
# rather than every hive's.
swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder";
# The swarm operator's CLI, out of `daemonBins` for the same reason as
# the daemon above: it is installed by the swarm-controller module on
# the one host that runs the controller, and belongs in that hive's

View file

@ -0,0 +1,41 @@
[package]
name = "swarm-nats-auth"
version.workspace = true
readme = "README.md"
edition.workspace = true
[[bin]]
name = "swarm-nats-auth"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
async-nats.workspace = true
clap.workspace = true
# base64url for decoding the inbound request JWT.
data-encoding.workspace = true
# StreamExt::next on the subscription: async-nats returns a Stream.
futures-util.workspace = true
nkeys.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
# The jti digest: base32hex(sha256(claims)) over every JWT this crate signs.
sha2.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
[dev-dependencies]
# A TEST ORACLE, not part of the production path. Neither JWT this crate emits
# is expressible through it - `Claims` has no `aud`, which the response wrapper
# needs (the server id) and the user token needs (the account name), and
# `Token::new_user` always sets `issuer_account`, which a non-operator server
# rejects outright. So both are hand-built, and this crate is what the encoder
# is checked *against*: `respond::tests::hand_built_matches_the_reference`
# builds a user token both ways and requires byte equality, on the one shape
# nats-jwt does model.
nats-jwt.workspace = true
[lints]
workspace = true

66
swarm-nats-auth/README.md Normal file
View file

@ -0,0 +1,66 @@
# swarm-nats-auth
The auth-callout responder for the swarm's NATS queue — the half that lets
the server say *yes*.
`nix/host-modules/swarm-nats.nix` configures `nats-server` with an
`auth_callout` block. **That block with no responder is the fail-closed
state**, and it is the measured one rather than the obvious one: on the pinned
nats-server, both `authorization { }` and `authorization { users: [] }` answer
PONG to an anonymous client, while an `auth_callout` block sets
`auth_required` and refuses every client no responder has approved. So the
module ships the final config from the start and this crate only adds the
ability to approve. No interim hole is ever opened.
## Why a crate and not more config
The reply is a **signed NATS user JWT**. Signing needs the account nkey seed
and the JWT framing, which is program work — which is why the container and
its config landed first and this arrived separately, rather than the pair
being one change.
## The invariant that is easy to break
**`issuer_account` must be absent from the issued user token.** It is an
operator-mode field. The module renders *server-config* mode (`accounts
{ AUTH, APP }`, no operator), where its mere presence makes the server refuse
the client — `Error non operator mode account "AUTH": attempted to use
issuer_account` — while the responder cheerfully reports `granted=true`. The
account is named by the claims' `aud` instead.
`nats_jwt::Token::new_user` always sets it, so reaching for that constructor
reintroduces the bug. `nats-jwt` is a **dev-dependency**: it cannot express
`aud` on either token, and its role here is as the encoder's *test oracle*,
not part of the path that runs.
## Rules the code follows
- **A denial is a signed response carrying an error, never silence.** The
server cannot tell an absent responder from a refusing one, so staying quiet
turns every rejection into a timeout and hides an outage inside what looks
like ordinary denials.
- **Anything that is not an explicit `{"active": true}` denies** — including
an introspection call that could not be *made*. The failure modes of an HTTP
call are exactly the conditions under which an attacker would most like this
to fall open.
- **Every credential is a path, never a value.** A value in nix config lands
in the world-readable store; a value in `argv` is readable via
`/proc/<pid>/cmdline`, which is `0444`. Paths are not secrets, so passing
them as flags is fine.
- **The introspection timeout is pinned below the server's `authorization.
timeout`**, with a test asserting the relation — a responder that answers
after the server gave up is indistinguishable from one that never answered.
## What the tests do and do not cover
Unit tests cover the JWT framing, including byte-equality against `nats-jwt`
on the one shape that crate models. They are **not** sufficient on their own:
this crate's shape is decided by a server that parses what it emits, so the
change was also driven against a real `nats-server` — every refusal repeated
*with the responder live*, because a responder that says yes to everyone
passes "a client can connect" perfectly. That harness lives outside this repo;
the PR that added this crate links it.
⚠️ **Its introspection endpoint is a stub.** Those runs prove this crate's own
behaviour and nothing about the real authelia integration, which needs a
deployment.

View file

@ -0,0 +1,109 @@
//! Validating a presented bearer token against the `IdP`.
//!
//! RFC 7662 token introspection: POST the token to authelia, which answers
//! `{"active": true|false, ...}`. `active` is the whole verdict — this
//! responder does not second-guess it, and does not inspect scopes, because a
//! second place that decides who may connect is a second place to get it
//! wrong.
//!
//! # Everything that is not an explicit `active: true` is a denial
//!
//! Network error, timeout, non-2xx, unparseable body, `active: false` — all
//! deny. That is not defensive coding, it is the only shape that is safe: the
//! failure modes of an HTTP call are exactly the conditions under which an
//! attacker would most like this to fall open.
//!
//! # The timeout is not a tuning knob
//!
//! `swarm-nats.nix` sets `authorization.timeout = "2s"`, so the server stops
//! waiting after two seconds and denies the connection anyway. An
//! introspection call allowed to run longer than that cannot produce a useful
//! answer — it can only hold a task open past the point where the result
//! matters. The budget below is deliberately under the server's.
use std::time::Duration;
use anyhow::{Context, Result};
use serde::Deserialize;
/// Kept under `swarm-nats.nix`'s `authorization.timeout = "2s"` — see the
/// module docs. A slower answer is not a late success, it is a denial that
/// already happened.
pub const INTROSPECTION_TIMEOUT: Duration = Duration::from_millis(1500);
/// The one field this responder acts on. Authelia returns more (`sub`,
/// `scope`, `exp`); modelling them would imply we check them.
#[derive(Debug, Deserialize)]
struct IntrospectionResponse {
active: bool,
}
/// Ask the `IdP` whether `token` is currently valid.
///
/// Returns `Ok(true)` **only** on a 2xx whose body says `active: true`.
/// Every other outcome is `Ok(false)` with the reason logged, or `Err` when
/// the call could not be made at all — callers must treat both as a denial.
///
/// The token is never logged, not even truncated: a prefix is enough to
/// correlate across logs, and a credential that is 80% redacted is still a
/// credential in a journal.
pub async fn is_active(
http: &reqwest::Client,
url: &str,
client_id: &str,
client_secret: &str,
token: &str,
) -> Result<bool> {
let resp = http
.post(url)
.basic_auth(client_id, Some(client_secret))
.form(&[("token", token)])
.timeout(INTROSPECTION_TIMEOUT)
.send()
.await
.context("introspection request")?;
let status = resp.status();
if !status.is_success() {
tracing::warn!(%status, "introspection returned non-2xx; denying");
return Ok(false);
}
let body: IntrospectionResponse = match resp.json().await {
Ok(b) => b,
Err(e) => {
tracing::warn!(error = ?e, "introspection body did not parse; denying");
return Ok(false);
}
};
Ok(body.active)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_timeout_stays_under_the_servers() {
// `swarm-nats.nix` sets `authorization.timeout = "2s"`. If someone
// raises this constant past that, the extra time buys nothing: the
// server has already denied the connection. This test is the only
// thing tying the two numbers together, since they live in different
// languages in different files.
assert!(
INTROSPECTION_TIMEOUT < Duration::from_secs(2),
"introspection budget must stay under the server's auth_callout timeout"
);
}
#[test]
fn only_active_true_deserializes_to_a_grant() {
let yes: IntrospectionResponse = serde_json::from_str(r#"{"active":true}"#).unwrap();
assert!(yes.active);
let no: IntrospectionResponse = serde_json::from_str(r#"{"active":false}"#).unwrap();
assert!(!no.active);
// A body that omits `active` is not a grant with a default - it is a
// response we do not understand, and it must fail to parse rather
// than deserialize to `false` quietly under some future `#[serde(default)]`.
assert!(serde_json::from_str::<IntrospectionResponse>(r#"{"sub":"someone"}"#).is_err());
}
}

181
swarm-nats-auth/src/main.rs Normal file
View file

@ -0,0 +1,181 @@
//! Auth-callout responder for the swarm's NATS queue.
//!
//! `nix/host-modules/swarm-nats.nix` configures `nats-server` with an
//! `auth_callout` block and no responder, which is the fail-closed state: the
//! server answers `"auth_required":true` and admits nobody. This binary is what
//! makes it able to say *yes*.
//!
//! It connects as the one 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 answers with a NATS user JWT signed
//! by the account key. A rejection is answered explicitly: silence is
//! indistinguishable from the responder being down, and the queue is the
//! swarm's control path.
//!
//! # Secrets
//!
//! Every credential is taken as a **path**, never a value. Two reasons, both
//! previously learned the hard way here: a value in nix config is rendered into
//! the world-readable store, and a value in `argv` is readable by anyone via
//! `/proc/<pid>/cmdline`, which is `0444`. Paths are not secrets, so passing
//! them as flags is fine.
use std::path::PathBuf;
use anyhow::Context;
use clap::Parser;
use futures_util::StreamExt;
mod introspect;
mod request;
mod respond;
/// Subject the NATS server publishes authorization requests on.
const AUTH_SUBJECT: &str = "$SYS.REQ.USER.AUTH";
#[derive(Debug, Parser)]
#[command(
name = "swarm-nats-auth",
about = "Auth-callout responder for the swarm NATS queue"
)]
struct Args {
/// NATS server to connect to.
#[arg(long, default_value = "nats://127.0.0.1:4222")]
nats_url: String,
/// Path to the seed of the callout-exempt user this responder connects as.
/// Its public half is `services.hyperhive.swarm.nats.calloutUserPublicKey`.
#[arg(long)]
user_seed_file: PathBuf,
/// Path to the account signing seed used to sign issued user JWTs. Its
/// public half is `services.hyperhive.swarm.nats.calloutIssuerPublicKey`.
#[arg(long)]
issuer_seed_file: PathBuf,
/// Authelia's OIDC introspection endpoint.
#[arg(long)]
introspection_url: String,
/// `OAuth2` client id this responder introspects as. Must match
/// `services.hyperhive.swarm.nats.clientId`, whose default this mirrors.
#[arg(long, default_value = "swarm-nats")]
client_id: String,
/// Account an admitted client is placed in. Must name an entry in the
/// server's own `accounts` block — in server-config mode the account is
/// resolved by *name*, so a value the server does not know is a grant it
/// refuses. The module passes its `clientAccount`; the default mirrors it.
#[arg(long, default_value = "APP")]
account: String,
/// Path to this responder's own OIDC client secret.
#[arg(long)]
client_secret_file: PathBuf,
}
/// Read a secret file and strip surrounding whitespace.
///
/// The trim matters: an `echo`-created seed file ends in a newline, and an
/// nkey seed with a trailing byte is not a seed — it fails at parse with a
/// message about encoding rather than about the file, which sends you looking
/// in the wrong place. The value is never logged, and the error deliberately
/// names only the path.
fn read_secret(path: &std::path::Path) -> anyhow::Result<String> {
let raw = std::fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let trimmed = raw.trim();
if trimmed.is_empty() {
anyhow::bail!("{} is empty", path.display());
}
Ok(trimmed.to_owned())
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
)
.init();
let args = Args::parse();
let client_secret = read_secret(&args.client_secret_file)?;
let http = reqwest::Client::new();
let issuer = nkeys::KeyPair::from_seed(&read_secret(&args.issuer_seed_file)?)
.context("parse the account signing seed")?;
let user_seed = read_secret(&args.user_seed_file)?;
let client = async_nats::ConnectOptions::with_nkey(user_seed)
.name("swarm-nats-auth")
.connect(&args.nats_url)
.await
.with_context(|| format!("connect to {}", args.nats_url))?;
let mut requests = client
.subscribe(AUTH_SUBJECT)
.await
.with_context(|| format!("subscribe to {AUTH_SUBJECT}"))?;
tracing::info!(
nats_url = %args.nats_url,
subject = AUTH_SUBJECT,
"swarm-nats-auth: connected, awaiting authorization requests"
);
while let Some(msg) = requests.next().await {
// Decode failures are logged and dropped, never propagated: this loop
// is the swarm's login path, and exiting on one malformed payload
// would let any client take authentication down for everyone.
let req = match request::decode(&msg.payload) {
Ok(req) => req,
Err(e) => {
tracing::warn!(error = ?e, "undecodable auth request, ignoring");
continue;
}
};
// No token is a denial, not an error: an anonymous connect is a
// normal thing for a client to attempt and an abnormal thing to
// grant. Introspection is only reached once something was presented.
let granted = match &req.connect_opts.auth_token {
Some(token) => introspect::is_active(
&http,
&args.introspection_url,
&args.client_id,
&client_secret,
token,
)
.await
// An introspection that could not be *made* is a denial too. The
// failure modes of an HTTP call are exactly the conditions under
// which an attacker would most like this to fall open.
.unwrap_or_else(|e| {
tracing::warn!(error = ?e, "introspection failed; denying");
false
}),
None => false,
};
tracing::info!(
user_nkey = %req.user_nkey,
server_id = %req.server_id.id,
granted,
"auth request"
);
// Always reply, including on a denial. A server that hears nothing
// cannot tell a refusing responder from a dead one, so silence turns
// every rejection into a 2s timeout and hides an outage inside what
// looks like ordinary denials.
let Some(reply_to) = msg.reply.clone() else {
tracing::warn!("auth request had no reply subject; dropping");
continue;
};
let token = if granted {
respond::grant(&issuer, &args.account, &req.server_id.id, &req.user_nkey)
} else {
respond::deny(&issuer, &req.server_id.id, &req.user_nkey)
};
if let Err(e) = client.publish(reply_to, token.into()).await {
tracing::warn!(error = ?e, "failed to publish auth response");
}
}
anyhow::bail!("subscription to {AUTH_SUBJECT} ended")
}

View file

@ -0,0 +1,134 @@
//! 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());
}
}

View file

@ -0,0 +1,355 @@
//! Minting the reply the server expects on `msg.reply`.
//!
//! Both JWTs are hand-built because neither is expressible through
//! `nats-jwt`: its `Claims` has no `aud`, which the `authorization_response`
//! wrapper needs (the asking server's id, so a captured reply cannot be
//! replayed elsewhere in the cluster) and the user token needs (the account
//! name). Its `IntoNatsClaims` also returns a closed
//! `NatsClaims { User, Account }`.
//!
//! ⛔ **`issuer_account` must be ABSENT from the user token.** It is an
//! operator-mode field, and `nix/host-modules/swarm-nats.nix` renders
//! *server-config* mode, where its mere presence makes the server refuse the
//! client — `Error non operator mode account "AUTH": attempted to use
//! issuer_account`, with the responder having answered `granted=true`.
//! `nats_jwt::Token::new_user` always sets it, which is why reaching for that
//! constructor again would reintroduce the bug.
//!
//! Signing follows `nats-jwt`'s own `sign()`: serialise the claims with `jti`
//! **empty**, sha256 that, `BASE32HEX_NOPAD` the digest into `jti`,
//! re-serialise, sign `"<b64url header>.<b64url claims>"` with the account
//! key. ⚠️ `BASE32HEX`, not `BASE32` — one word, and the only symptom is a
//! token the server rejects without saying why.
//!
//! `nats-jwt` is therefore a **dev-dependency, not a runtime one**: it is this
//! module's test oracle: `tests::hand_built_matches_the_reference` builds a
//! user token both ways and requires byte equality, so the encoder is checked
//! against the reference on the one shape that crate does model.
use data_encoding::{BASE32HEX_NOPAD, BASE64URL_NOPAD};
use nkeys::KeyPair;
use serde::Serialize;
use sha2::{Digest, Sha256};
/// Header every NATS JWT carries. Byte-identical to `nats-jwt`'s, and the
/// equality test would catch it drifting.
const JWT_HEADER: &str = r#"{"typ":"JWT","alg":"ed25519-nkey"}"#;
/// A NATS JWT claim set, with `aud` — which the callout response needs and
/// `nats-jwt`'s own `Claims` lacks.
#[derive(Serialize)]
struct Claims<T: Serialize> {
iat: i64,
iss: String,
jti: String,
sub: String,
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
aud: Option<String>,
nats: T,
}
/// The `nats` object of an `authorization_response`.
///
/// Exactly one of `jwt` / `error` is set. They are `Option`s rather than an
/// enum so the serialised shape matches the wire format directly, and the
/// constructors below are the only way to build one — a caller cannot
/// accidentally produce a response that carries both, which a server would be
/// free to read either way.
#[derive(Serialize)]
struct AuthResponse {
#[serde(skip_serializing_if = "Option::is_none")]
jwt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(rename = "type")]
kind: &'static str,
version: i64,
}
/// Sign a claim set the way `nats-jwt` does. `now` is injected so the test
/// can pin it; production passes the wall clock.
fn sign<T: Serialize>(mut claims: Claims<T>, key: &KeyPair) -> String {
claims.jti = String::new();
let unhashed = serde_json::to_string(&claims).expect("claims serialisation cannot fail");
let digest = Sha256::digest(unhashed.as_bytes());
claims.jti = BASE32HEX_NOPAD.encode(&digest);
let body = serde_json::to_string(&claims).expect("claims serialisation cannot fail");
let half = format!(
"{}.{}",
BASE64URL_NOPAD.encode(JWT_HEADER.as_bytes()),
BASE64URL_NOPAD.encode(body.as_bytes())
);
let sig = key
.sign(half.as_bytes())
.expect("ed25519 signing cannot fail");
format!("{half}.{}", BASE64URL_NOPAD.encode(&sig))
}
/// Wrap a decision in an `authorization_response` addressed to the server
/// that asked.
///
/// A denial is a *signed response carrying an error*, never silence: the
/// server cannot tell an absent responder from a refusing one, so staying
/// quiet turns every rejection into a timeout and hides an outage behind
/// what looks like normal denials.
fn response(
now: i64,
issuer: &KeyPair,
server_id: &str,
user_nkey: &str,
verdict: Result<String, String>,
) -> String {
let (jwt, error) = match verdict {
Ok(user_jwt) => (Some(user_jwt), None),
Err(reason) => (None, Some(reason)),
};
sign(
Claims {
iat: now,
iss: issuer.public_key(),
jti: String::new(),
sub: user_nkey.to_owned(),
name: user_nkey.to_owned(),
aud: Some(server_id.to_owned()),
nats: AuthResponse {
jwt,
error,
kind: "authorization_response",
version: 2,
},
},
issuer,
)
}
/// Current unix seconds, as the JWT `iat`.
fn now_secs() -> i64 {
i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time is after the unix epoch")
.as_secs(),
)
.expect("seconds since the epoch fit in an i64")
}
/// The `nats` object of a **user** JWT, server-config mode.
///
/// Field-for-field what `nats-jwt` emits **minus `issuer_account`**, whose
/// presence a non-operator server rejects outright (see the module docs). The
/// account is named by the enclosing claims' `aud` instead.
///
/// The `-1`s are NATS' "unlimited": no cap on subscriptions, payload size or
/// total data. Limits belong on the account, which the module owns; putting
/// them here would mean every issued token silently disagreed with the
/// deployed policy the moment that policy changed.
#[derive(Serialize)]
struct UserNats {
#[serde(rename = "type")]
kind: &'static str,
subs: i64,
data: i64,
payload: i64,
bearer_token: bool,
version: i64,
}
/// Mint the user JWT an admitted client presents.
///
/// `account` is the account **name** from the server's `accounts` block (the
/// module's `clientAccount`), not a public key — in config mode the server
/// resolves `aud` against its own config rather than against a key.
fn user_jwt(now: i64, issuer: &KeyPair, account: &str, user_nkey: &str) -> String {
sign(
Claims {
iat: now,
iss: issuer.public_key(),
jti: String::new(),
sub: user_nkey.to_owned(),
name: user_nkey.to_owned(),
aud: Some(account.to_owned()),
nats: UserNats {
kind: "user",
subs: -1,
data: -1,
payload: -1,
bearer_token: false,
version: 2,
},
},
issuer,
)
}
/// Grant: mint a user JWT placing the client in `account` and wrap it.
pub fn grant(issuer: &KeyPair, account: &str, server_id: &str, user_nkey: &str) -> String {
let now = now_secs();
let jwt = user_jwt(now, issuer, account, user_nkey);
response(now, issuer, server_id, user_nkey, Ok(jwt))
}
/// Deny, with a reason the server can log.
///
/// The reason is deliberately coarse (`"unauthorized"`), not a description of
/// *why*: this string reaches an unauthenticated peer, and a precise one turns
/// the auth path into an oracle for which tokens exist.
pub fn deny(issuer: &KeyPair, server_id: &str, user_nkey: &str) -> String {
response(
now_secs(),
issuer,
server_id,
user_nkey,
Err("unauthorized".to_owned()),
)
}
#[cfg(test)]
mod tests {
use super::*;
/// Mirror of what `nats-jwt` puts in a user token, **in its field order**.
///
/// Deliberately *not* [`super::UserNats`]: this one carries
/// `issuer_account`, because the reference does. Production must not, and
/// keeping the two structs separate is what lets one test pin the encoder
/// against `nats-jwt` while another asserts production diverges from it in
/// exactly one field.
///
/// 🩸 The first version of the equality test round-tripped `nats` through
/// `serde_json::Value` and failed — not because the encoder was wrong, but
/// because `Value` is a `BTreeMap`, so re-serialising sorted the keys and
/// changed the bytes. The gate's *method* was the defect. Worth keeping the
/// scar: `jti` is a hash over serialised claims, so anything that reorders
/// fields between hashing and signing is a live hazard here, not merely a
/// test artefact.
#[derive(serde::Serialize)]
struct ReferenceUserNats {
#[serde(rename = "type")]
kind: &'static str,
issuer_account: String,
subs: i64,
data: i64,
payload: i64,
bearer_token: bool,
version: i64,
}
/// The gate that makes the hand-built encoder trustworthy: build a *user*
/// token with `nats-jwt` and with this module's `sign`, and require the
/// bytes to be identical.
///
/// It works because `nats-jwt`'s `sign()` derives `iat` from the wall
/// clock and everything else from its inputs, so signing the same claims
/// in the same second must produce the same string. If this module's
/// header, field order, `jti` alphabet or signing input ever drifts from
/// the reference, this fails — including the `BASE32HEX`-vs-`BASE32`
/// distinction, which is otherwise invisible until a server refuses a
/// token.
#[test]
fn hand_built_matches_the_reference() {
let account = KeyPair::new_account();
let user = KeyPair::new_user();
let reference =
nats_jwt::Token::new_user(account.public_key(), user.public_key()).sign(&account);
// Mirror what `nats-jwt` puts in a user token **as a struct**, in its
// field order.
//
// 🩸 The first version of this test round-tripped `nats` through
// `serde_json::Value` and failed — not because the encoder was wrong,
// but because `Value` is a `BTreeMap`, so re-serialising sorted the
// keys and changed the bytes. The gate's *method* was the defect, and
// it is worth keeping the scar: `jti` is a hash over serialised
// claims, so anything that reorders fields between hashing and
// signing is a live hazard here, not just a test artefact.
let decoded = decode_claims(&reference);
let mine = sign(
Claims {
iat: decoded["iat"].as_i64().expect("iat"),
iss: account.public_key(),
jti: String::new(),
sub: user.public_key(),
name: user.public_key(),
aud: None,
nats: ReferenceUserNats {
kind: "user",
issuer_account: account.public_key(),
subs: -1,
data: -1,
payload: -1,
bearer_token: false,
version: 2,
},
},
&account,
);
assert_eq!(mine, reference, "hand-built encoder drifted from nats-jwt");
}
#[test]
fn a_denial_is_signed_and_carries_no_jwt() {
let account = KeyPair::new_account();
let token = deny(&account, "NSERVER", "UCLIENT");
let claims = decode_claims(&token);
assert_eq!(claims["nats"]["type"], "authorization_response");
assert_eq!(claims["nats"]["error"], "unauthorized");
assert!(
claims["nats"]["jwt"].is_null(),
"a denial must not carry a user jwt"
);
// `aud` binds the reply to the asking server; without it a captured
// response is replayable at any other server in the cluster.
assert_eq!(claims["aud"], "NSERVER");
}
#[test]
fn a_grant_carries_a_user_jwt_and_no_error() {
let account = KeyPair::new_account();
let claims = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT"));
assert!(claims["nats"]["error"].is_null());
assert!(
claims["nats"]["jwt"]
.as_str()
.is_some_and(|j| j.contains('.'))
);
}
/// 🩸 THE REGRESSION TEST FOR THE BUG THE UNIT TESTS COULD NOT SEE.
///
/// The first version of this module minted the user JWT with
/// `nats_jwt::Token::new_user`, which always sets `issuer_account`. Every
/// test passed, the responder logged `granted=true`, and the server still
/// refused every client: `Error non operator mode account "AUTH":
/// attempted to use issuer_account`.
///
/// The lesson is in what this asserts — an **absence**. The suite was full
/// of assertions about fields that had to be *present*, and a field that
/// must not exist is invisible to all of them. It took running a real
/// server to find, so the cheap check goes here to keep it found.
#[test]
fn the_issued_user_jwt_names_its_account_by_aud_and_sets_no_issuer_account() {
let account = KeyPair::new_account();
let wrapper = decode_claims(&grant(&account, "APP", "NSERVER", "UCLIENT"));
let user = decode_claims(wrapper["nats"]["jwt"].as_str().expect("a user jwt"));
// In server-config mode the account is named by `aud`...
assert_eq!(user["aud"], "APP", "the user jwt must name its account");
// ...and `issuer_account` is an operator-mode field whose mere
// presence makes the server reject the client.
assert!(
user["nats"]["issuer_account"].is_null(),
"issuer_account is fatal in non-operator mode"
);
assert_eq!(user["nats"]["type"], "user");
assert_eq!(user["sub"], "UCLIENT");
assert_eq!(user["iss"], account.public_key());
}
fn decode_claims(jwt: &str) -> serde_json::Value {
let body = jwt.split('.').nth(1).expect("claims segment");
serde_json::from_slice(&BASE64URL_NOPAD.decode(body.as_bytes()).expect("base64url"))
.expect("claims json")
}
}