diff --git a/Cargo.lock b/Cargo.lock index b6b1966c..693b1121 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -192,38 +192,6 @@ 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" @@ -481,9 +449,6 @@ name = "bytes" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -dependencies = [ - "serde", -] [[package]] name = "bytesize" @@ -993,7 +958,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid 0.9.6", - "pem-rfc7468", "zeroize", ] @@ -1127,7 +1091,6 @@ dependencies = [ "rand_core 0.6.4", "serde", "sha2 0.10.9", - "signature", "subtle", "zeroize", ] @@ -1187,7 +1150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2055,7 +2018,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.8", + "webpki-roots", ] [[package]] @@ -2674,7 +2637,7 @@ dependencies = [ "url", "urlencoding", "vodozemac", - "webpki-roots 1.0.8", + "webpki-roots", "zeroize", ] @@ -2940,40 +2903,12 @@ 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" @@ -3185,15 +3120,6 @@ 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" @@ -3250,26 +3176,6 @@ 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" @@ -3501,7 +3407,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3742,7 +3648,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.8", + "webpki-roots", ] [[package]] @@ -3775,7 +3681,6 @@ dependencies = [ "rustls-platform-verifier", "serde", "serde_json", - "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -3788,7 +3693,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.8", + "webpki-roots", ] [[package]] @@ -4040,7 +3945,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4099,7 +4004,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4321,17 +4226,6 @@ 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" @@ -4411,25 +4305,12 @@ 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", ] @@ -4556,26 +4437,6 @@ 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" @@ -4610,17 +4471,6 @@ 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" @@ -4672,7 +4522,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4862,27 +4712,6 @@ 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" @@ -5459,15 +5288,6 @@ 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" @@ -5505,7 +5325,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 5b85aa74..184b75b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,6 @@ members = [ "hive-types", "hivectl", "swarm-controller", - "swarm-nats-auth", "swarmctl", ] @@ -113,10 +112,6 @@ 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", ] } @@ -140,22 +135,5 @@ 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" diff --git a/flake.nix b/flake.nix index 1c7a6f53..b439cdf3 100644 --- a/flake.nix +++ b/flake.nix @@ -151,9 +151,6 @@ 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; diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 519b1bbb..73c0b756 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -16,22 +16,6 @@ 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. @@ -132,55 +116,6 @@ 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 { @@ -341,51 +276,6 @@ 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//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 @@ -393,33 +283,5 @@ 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")} - ''; - }; }; } diff --git a/nix/packages/default.nix b/nix/packages/default.nix index 9fc145c4..b4cc3bca 100644 --- a/nix/packages/default.nix +++ b/nix/packages/default.nix @@ -150,12 +150,6 @@ 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 diff --git a/swarm-nats-auth/Cargo.toml b/swarm-nats-auth/Cargo.toml deleted file mode 100644 index d85f10d5..00000000 --- a/swarm-nats-auth/Cargo.toml +++ /dev/null @@ -1,41 +0,0 @@ -[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 diff --git a/swarm-nats-auth/README.md b/swarm-nats-auth/README.md deleted file mode 100644 index 9f154139..00000000 --- a/swarm-nats-auth/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# 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//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. diff --git a/swarm-nats-auth/src/introspect.rs b/swarm-nats-auth/src/introspect.rs deleted file mode 100644 index 5e5ec02c..00000000 --- a/swarm-nats-auth/src/introspect.rs +++ /dev/null @@ -1,109 +0,0 @@ -//! 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 { - 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::(r#"{"sub":"someone"}"#).is_err()); - } -} diff --git a/swarm-nats-auth/src/main.rs b/swarm-nats-auth/src/main.rs deleted file mode 100644 index fa948747..00000000 --- a/swarm-nats-auth/src/main.rs +++ /dev/null @@ -1,181 +0,0 @@ -//! 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//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 { - 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") -} diff --git a/swarm-nats-auth/src/request.rs b/swarm-nats-auth/src/request.rs deleted file mode 100644 index 0c87212a..00000000 --- a/swarm-nats-auth/src/request.rs +++ /dev/null @@ -1,134 +0,0 @@ -//! 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()); - } -} diff --git a/swarm-nats-auth/src/respond.rs b/swarm-nats-auth/src/respond.rs deleted file mode 100644 index e1473809..00000000 --- a/swarm-nats-auth/src/respond.rs +++ /dev/null @@ -1,355 +0,0 @@ -//! 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 `"."` 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 { - iat: i64, - iss: String, - jti: String, - sub: String, - name: String, - #[serde(skip_serializing_if = "Option::is_none")] - aud: Option, - 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, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - #[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(mut claims: Claims, 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 { - 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") - } -}