credentials.md drops the target-shape framing and keeps the rules, where a secret lives, the swarm- and hive-level secrets and how to add one. The queue responder's keys, per-principal identities, how a reader reaches the store, the store UI's login and where the root lives move to 4-implementation/swarm/credentials.md. The per-secret reference moves to 4-implementation/swarm/credentials-reference.md. Links and comments follow.
10 KiB
SSO: relying-party secrets and machine callers
How authelia's client secrets reach the forge and the homeserver, how the two differ, and why machine callers must fail closed. The operator's view is the SSO page.
What secrets exist, and where each one lives
credentials-reference.md tabulates every secret in the swarm, with its
generator and its path, in one place — including authelia's own keys
(session, JWT, storage-encryption, OIDC HMAC, OIDC issuer) and the two
halves of each client secret. That page's two rules — a secret is always
a path, never a value, and the generator and the reader typically live in
different containers — are why the client secret's plaintext half needs
the delivery step below and the rest of authelia's keys don't.
Getting the plaintext to the relying party
Three cases, and they're genuinely different mechanisms rather than one mechanism with flags.
1. All-local — one host runs both
Nothing to configure at all. authelia mints each client's secret at its
first boot, and the mint in its container creates it in the swarm
secret store at swarm/services/<client id>/oidc/client, keeping only the digest on disk. A host-side unit
then reads it from the store and writes it into the service's container:
swarm-bao-forge-oidc for the forge, under the forge publisher's store
leaf, into /run/hive-forge-oidc/<client id>.secret on the host, which
forgejo-sso-source loads as a credential; swarm-bao-matrix-oidc for matrix, under
matrix-ctl's. A unit that can't read the secret yet fails and retries
every 30 seconds for a day, so there is no start order to get right. The
service's own module contributes its client entry — callback URL
included — to authelia's client list.
Each relying service's own glue module builds its callback once, and both authelia's client list and the service's own OIDC client config read that same value, so the redirect URI authelia accepts and the one the service actually sends can't drift apart. A mismatch there is a rejected login with no error text worth reading.
⚠️ nixos-container refuses to start a container whose bind source is
missing, and this secret doesn't exist until authelia's first boot has run
and the mint has stored it. The forge therefore binds the directory
/run/hive-forge-oidc, which a tmpfiles rule creates at boot, never the
file. The directory is tmpfs, so the secret is gone after a reboot until
the fetch runs again; forgejo keeps it in its login-source row, so logins
still work meanwhile. Matrix's secret is written into the container's state
directory instead.
2. Swarm-managed services
The controller side owns provisioning: swarmctl writes both halves, the
same way it already owns authelia's user store (users.yml, read and
written in place).
3. A hive elsewhere
The forge's host needn't be the store's or authelia's host, and the
operator places no secret file: swarm-bao-forge-oidc runs on the
forge's host as in the all-local case and fetches the secret over the
store's address. What that host needs is the forge publisher's store
leaf, issued from the store's CA and copied over from the store's host:
services.hyperhive = {
swarm.authelia.url = "https://auth.example.com";
deploy.bao = {
forgePublisherClientCertFile = "/var/lib/swarm-bao-pki/forge-publisher.pem";
forgePublisherClientKeyFile = "/var/lib/swarm-bao-pki/forge-publisher-key.pem";
};
};
A forge host without that leaf still evaluates. swarm-bao-forge-oidc
renders and fails at start, naming both options, and the forge has no
login source until an operator places the leaf. Where the store runs on
the same host, glue-bao-tls.nix defaults both options to the leaf it
mints.
Where each relying party differs
The registration half is identical; what each service does with the result isn't.
| forge | matrix | |
|---|---|---|
| how it learns the config | a oneshot calls forgejo admin auth, writing a login-source row into its database |
tuwunel reads a [[global.identity_provider]] entry from its config file |
| how it reads the secret | a path inside its container | the same path, handed on by LoadCredential |
| callback URL | <root>/user/oauth2/<source>/callback |
<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>, a shape tuwunel fixes rather than accepts |
| cost of a malformed entry | the login source is missing | the homeserver can refuse to start |
Four consequences worth stating plainly:
-
A person's first forge login creates their forge account, named after authelia's
preferred_username([oauth2_client]in the forge module), provided the subject has an email. It starts as an ordinary user;swarmctl forge make-admin <name>makes it a site admin. A name that already has a local forge account doesn't get it handed over: forgejo asks for that account's own password first (ACCOUNT_LINKING = login). Agents,coreandswarm-controllerall have local accounts, andswarmctl user addrefuses none of those names. -
A member of
adminsbecomes a matrix homeserver admin once their first matrix login has created the account:swarm-controllerpromotes them within five minutes, and never demotes. See2-handbook/swarm/matrix.md. -
tuwunel re-reads its secret file on every OAuth exchange, not only at startup, and its own sandboxing hides most paths from it. It gets the file through
LoadCredentialfor the same reason the appservice registration does — that keepsDynamicUserandPrivateUsersintact, with no host-side ownership arrangement to maintain. -
Matrix SSO lives inside the homeserver. The client-server API is spoken by non-browser clients holding matrix access tokens — every agent's own daemon — as well as by federation, so the homeserver serves
/_matrix/directly and authenticates it itself. The forward-auth vhosts protect browser surfaces; this isn't 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.
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.
Authelia signs tokens (RS256), not opaque ones, because a resource server
that can't 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— nginxauth_requesttreats any 2xx as success, so a down provider means access granted/api/oidc/introspection— a token check that answers200/api/oidc/token,/.well-known/openid-configuration— a client parsing an error page as its JSON document
The gateway routes authelia's /api/ and /.well-known/ prefixes, and
the bridge's /bridge/, 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: the
caller follows a 302 to a login page, 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:
- through
/— must still serve the friendly page - through
/api/— must deny - 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. The person who introduced the split verified it this way.