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