Each request now runs in its own task, at most CONCURRENT_REQUESTS (32) at once, instead of being awaited inline by the subscription loop. A request whose exp (nats-server sets it to send time plus its authorization.timeout) has passed is skipped without a lookup or reply. Fixes #5434.
97 lines
5.3 KiB
Markdown
97 lines
5.3 KiB
Markdown
# 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.
|
|
|
|
## The sealed exchange
|
|
|
|
The server seals each auth request to the callout xkey and names its own
|
|
public xkey in the `Nats-Server-Xkey` header; the responder opens the request
|
|
with the xkey's seed and seals its reply to that header's key. The responder
|
|
logs a warning for a request with no header, or one that doesn't open, and
|
|
sends no reply: the server it serves always seals, so no server waits on one.
|
|
|
|
The seed is in the swarm secret store, and so are the seeds of the issuer key
|
|
the responder signs with and the user key it connects as. The responder reads
|
|
all three before it connects to the queue: a responder missing one doesn't
|
|
start. The `ensure-callout-keys` verb creates each pair when the store holds
|
|
none, taking an issuer or user seed from the import file it is given when that
|
|
file exists, and removes the file once the store holds a seed. It runs from
|
|
its own unit before the server, because the server needs the public halves to
|
|
start and the responder needs the server. `docs/swarm/secrets.md` has the
|
|
paths and the boot order.
|
|
|
|
## 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 secrets themselves, the callout seeds and the
|
|
responder's own OIDC client secret, are read from the secret store and held
|
|
in memory.
|
|
- **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.
|
|
It is one request's whole budget: when the `IdP` refuses the responder's own
|
|
secret, the re-read from the store and the one retry fit inside it, or the
|
|
request is denied and the re-read lands for the next one.
|
|
- **The responder answers requests concurrently, up to a fixed cap**, so one
|
|
slow lookup doesn't hold the requests behind it. A request whose `exp` has
|
|
passed gets no lookup and no reply: the server has stopped waiting for it.
|
|
- **Every issued user JWT expires**, at the presented OIDC token's own `exp`
|
|
or an hour after issue, whichever is sooner. nats-server closes the
|
|
connection at that point, and the client's reconnect comes back through
|
|
this responder. That reconnect is what makes a deleted or re-minted
|
|
credential reach an open connection.
|
|
|
|
## 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.
|