hyperhive/swarm-nats-auth/Cargo.toml
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

53 lines
2 KiB
TOML

[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
clap.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
# The NATS protocol client. `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 here is the whole requirement:
# the server generation we actually deploy, nkey auth, and a TLS backend.
# (Checked what dropping the defaults costs, the way `internal-logs` was once
# lost that way: nothing in the unused set is a diagnostic.)
async-nats = { version = "0.50", default-features = false, features = [
"server_2_14",
"nkeys",
"ring",
] }
# base64url for decoding the inbound request JWT. Already in the tree via
# nkeys; named directly because this crate uses it directly.
data-encoding = "2"
# StreamExt::next on the subscription. async-nats returns a Stream, not an
# iterator, and futures is already in the tree.
futures = "0.3"
# nkey seed handling + signing. The primitives (ed25519-dalek, data-encoding)
# are already in the tree, but the nkey *format* - ed25519 + base32 + CRC16 -
# is not, and hand-rolling a key format on an auth path is how you get a
# CRC bug nobody reviews.
nkeys = "0.4"
# NATS JWT claim types + signing. The reply this responder sends is a *signed
# user JWT*, whose `jti` is base32(sha256(claims)) and whose header must say
# `ed25519-nkey` - format details with no feedback loop until the server
# rejects the token. Its own deps (data-encoding, nkeys, serde, serde_json,
# sha2) are already in the tree, so this costs no new transitive weight.
nats-jwt = "0.3"
# The jti digest. Already in the tree via nats-jwt; named directly because
# this crate computes one itself for the response wrapper.
sha2 = "0.10"
[lints]
workspace = true