docs(sso): document the machine surface, and stop restating it in nix
`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.
This commit is contained in:
parent
f625151556
commit
9b14014077
2 changed files with 73 additions and 41 deletions
|
|
@ -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 — `<hiveClientPrefix><hive>` — 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
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
|
|
|
|||
Loading…
Reference in a new issue