Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/swarm-nats-auth/README.md
atlas 6646329da5 swarm-nats-auth: read its own OIDC client secret from the store
The responder reads its OIDC client secret at
`swarm/services/<client-id>/oidc/client` with the store login it already
opens for the callout keys, and holds it in memory. `--client-secret-file`
is removed.

When introspection answers non-2xx, the responder re-reads the secret from
the store once and, if it changed, retries the introspection once. Both fit
inside the request's 1.5s introspection budget, under the server's 2s
callout timeout: the budget is now one deadline for the whole request. A
re-read that does not finish by then keeps running in the background, the
request is denied, and the next request introspects with what it read. One
re-read runs at a time; a refusal while one runs waits on it.

In swarm-nats.nix the `swarm-bao-nats-auth-oidc` fetch unit, the flag and
the `oidc-client.secret` LoadCredential entry are gone, along with the
unit's policy ordering in glue-bao-readers-policy-order.nix. A tmpfiles `r`
rule removes the copy a container still holds at
/var/lib/nixos-containers/swarm-nats/var/lib/swarm-nats-auth/oidc-client.secret;
nothing reads it.

No policy change: the `swarm-nats-auth` role already reads this path
(`natsAuthReaders` in swarm-bao.nix), which is the role the removed fetch
unit logged in as.

Deploy order: none, and no operator step. The store's grant already exists,
so the queue host can be deployed before, after or without the store's host
and authelia's. Rolling back re-creates the fetch unit and the file; the
store leaf is untouched.
2026-10-06 14:54:19 +02:00

89 lines
4.7 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.
## 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.