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.
53 lines
2 KiB
TOML
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
|