From 9b1401407782e0e7a2083b37be90e587bd65477a Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 2 Sep 2026 02:53:51 +0200 Subject: [PATCH] docs(sso): document the machine surface, and stop restating it in nix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `docs/swarm/sso.md` described a person in a browser. The swarm's other callers — the telemetry collector, the queue's auth-callout responder, each hive's agents — hold no session and follow no redirect, and nothing operator- facing said how they authenticate. Its relying-party table is forge and matrix, both browser surfaces. The new section carries what `swarm-authelia.nix` was holding in comments: one client per hive because identity belongs to the directory, the audience being that client id rather than a parallel naming scheme, and signed rather than opaque tokens because the collector verifies offline against `/jwks.json` while the queue introspects. It also states the fail-closed rule once, in the place a reader looks before touching a vhost: an error page answers 200, and `auth_request` reads any 2xx as access granted. That shape has now appeared three times — this module's `/api/` prefix and both of victorialogs' routes — which is what makes it documentation rather than a comment. The two comment blocks those replace shrink to the part that is genuinely local: the submodule-typing reason these clients are a definition rather than an append, and a loud warning against folding the machine prefix back into `/`. The security warning stays at the site; only its consequence list moves. Comments 495 -> 465 lines. Option `description` strings are untouched: they are the source `pkgs.nixosOptionsDoc` renders into the operator's options reference, so trimming one would delete published documentation rather than a duplicate. --- docs/swarm/sso.md | 63 +++++++++++++++++++++++++++++ nix/host-modules/swarm-authelia.nix | 51 +++++------------------ 2 files changed, 73 insertions(+), 41 deletions(-) diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md index b8df3bf8..b8788045 100644 --- a/docs/swarm/sso.md +++ b/docs/swarm/sso.md @@ -177,6 +177,69 @@ Two consequences worth stating plainly: directly and authenticates itself. The forward-auth vhosts protect browser surfaces; this is not one of them. +## Machine clients + +Everything above is a person in a browser. A swarm also has callers that +hold no session and follow no redirect: the telemetry collector, the +queue's auth-callout responder, and each hive's own agents. + +**One client per hive, not one per service.** A hive's identity belongs to +the directory rather than to whichever service happens to consume it, so a +hive holds a single OIDC client — `` — and mints a +different token per service from it. The alternative, letting each +consuming subsystem declare its own list, collides on the same client id +the moment a second consumer appears. + +**The audience is that client id.** A swarm service that has to tell hives +apart needs one name both sides already agree on, and the client id is +already that name. A parallel per-hive naming scheme would be a second +thing to keep in step, and it drifts silently — a mismatch presents as a +valid token refused at the target, which reads like a broken credential +rather than a broken name. + +**Tokens are signed (`RS256`), not opaque**, because a resource server +that cannot call the provider back is a real case here: the telemetry +collector verifies offline against `/jwks.json`, and an opaque token gives +it nothing to verify. The queue's responder introspects instead — a +different question asked of the same token, and the reason both +`/api/oidc/introspection` and `/jwks.json` have to stay reachable. + +### Machine callers must fail closed + +⚠️ **An error page that answers `200` is a security bug, not a cosmetic +one.** The browser surface intercepts upstream errors and serves a +friendly "SSO is unavailable" page; that page is a file, so it returns +`200`. Any machine caller routed through it receives a success carrying +HTML instead of the failure that actually happened: + +- `/api/authz/auth-request` — nginx `auth_request` treats **any 2xx as + success**, so a down provider means *access granted* +- `/api/oidc/introspection` — a token check that answers `200` +- `/api/oidc/token`, `/.well-known/openid-configuration` — a client + parsing an error page as its JSON document + +So authelia's `/api/` and `/.well-known/` prefixes are routed **without** +error interception. The split is by *audience*, not by an enumerated path +list: a human gets the page, every machine caller gets the status. +Enumerating endpoints individually would leave the next one added +silently intercepted. + +The same shape bites any machine route behind a browser-shaped gate: a +`302` to a login page is followed, the login page answers `200`, and the +caller reports success while nothing happened. Log ingest hit exactly this +and lost eleven hours of delivery in silence. + +**Checking it, if you change this routing.** Point the vhost at a dead +upstream and compare three requests, not one: + +1. through `/` — must still serve the friendly page +2. through `/api/` — must deny +3. a direct dial to authelia — must match what (2) did + +All three matter. A change that silently deleted the browser page would +pass a deny-only check, and one that quietly stopped denying would pass a +page-only check. This was verified that way when the split was introduced. + ## What this does not do - **It does not disable local login.** Each service keeps its password diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index bccca68e..ea2edf7c 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -122,13 +122,10 @@ let # be what an operator sees, not a coercion error from here. cookieDomain = if swarmDomain == null then "invalid" else swarmDomain; - # One machine client per hive in the roster. A hive's identity belongs - # to the DIRECTORY, not to whichever service happens to consume it: - # the rule is that a hive's credentials all derive from the SAME - # identity, so one hive holds ONE client and mints a different token - # per service from it. Were the queue to declare this list, the next - # consumer would collide on the same client id — and only at the - # moment it landed. + # One machine client per hive in the roster. The model — why identity is + # per hive rather than per service, why `audience` is the client id, and + # why these tokens are signed rather than opaque — is in + # `docs/swarm/sso.md`, § Machine clients. # # Fed to the option as a DEFINITION in the config block below, rather # than appended to the declared list downstream. That is what puts it @@ -137,19 +134,6 @@ let # and a field later added to the submodule then existed on the # declared entries and not on these, which is an eval error reachable # only once hive identities are on. - # - # `audience` is the hive's own client id rather than a second per-hive - # string invented here. A swarm service that has to tell hives apart - # needs one name per hive that both sides already agree on, and the - # client id is that name — published as `hiveClientPrefix` for exactly - # this reason. Minting a parallel naming scheme would be a second thing - # to keep in step, and the one that drifts is the one nobody tests. - # - # `RS256` because a resource server that cannot call this provider back - # is a real case here: the swarm's telemetry collector verifies tokens - # offline against `/jwks.json`, and an opaque token gives it nothing to - # verify. The queue's auth-callout responder introspects instead, which - # is a different question asked of the same token. hiveClients = lib.mapAttrsToList (name: _: { id = "${cfg.hiveClientPrefix}${name}"; description = "HyperHive hive ${name}"; @@ -1063,28 +1047,13 @@ in # `proxy_intercept_errors` / `error_page` are set inside that # location rather than at server level, so they do not reach here. # - # ⚠️ THIS IS A SECURITY BOUNDARY, not a tidy-up. `error_page … = - # /__hive_sso_unavailable` takes its status from the redirected - # location, which serves a FILE — so it answers **200**. Every - # machine caller then receives a success carrying an HTML body - # instead of the 502 that actually happened: + # ⚠️ THIS IS A SECURITY BOUNDARY, not a tidy-up. Do not fold it back + # into `/`: the friendly error page answers **200**, and + # `auth_request` reads any 2xx as ACCESS GRANTED. Full consequence + # list in `docs/swarm/sso.md`, § Machine callers must fail closed. # - # - `/api/authz/auth-request` — nginx `auth_request` treats any - # 2xx as success, so a down authelia would mean ACCESS GRANTED - # - `/api/oidc/introspection` — a token check answering 200 - # - `/api/oidc/token`, `/.well-known/openid-configuration` — a - # client parsing an error page as its JSON document - # - # Measured with both controls rather than reasoned: against a dead - # upstream a subrequest through `/` serves the protected content, - # and through this prefix it denies, matching a direct dial. The - # browser arm confirms `/` still serves the friendly page — a fix - # that silently deleted it would pass the deny check alone. - # - # The split is by AUDIENCE, not by path list: a human typing the - # URL gets a page explaining that SSO is down, and every `/api/` - # and `/.well-known/` caller — including the login page's own XHR — - # gets the status. Enumerating individual endpoints would leave the + # The split is by AUDIENCE, not by path list — including the login + # page's own XHR. Enumerating individual endpoints would leave the # next one added silently intercepted. locations."/api/" = { proxyPass = "http://127.0.0.1:${toString cfg.port}";