swarm-secret-publish and its path unit now run inside authelia's container, beside the mint, and read each client secret at the path authelia writes it. The host unit and host path unit, which read the secrets through the container's root on the host, are gone, and so is deploy.authelia.hostClientSecretDir, which only they read. The publisher keeps its store identity, the secret-publisher leaf. The host hands the leaf and the store's CA into the container as nspawn system credentials, and the unit loads them; where this host mints the leaf, the container waits for swarm-bao-pki. Authelia does not wait for the publisher, which retries every 30 seconds while the store is down. The container also runs the journal forwarder the other service containers run, pushing to a receiver of its own on the swarm collector (serviceProducers swarm-authelia) as a new machine OIDC client (swarm.authelia.otel.clientId) that authelia registers for itself and the publisher carries to the store. The fetch unit reads it back under the publisher's leaf, also handed to it as a system credential, so the swarm-secret-publisher policy gains read on that one leaf; its stanza keeps create and update, which the exact path would otherwise take from the services/* prefix. Without the leaf the forwarder is off. The container drops --link-journal=host, and the host collector stops reading it. The services' "nothing at <path>" errors name the publisher in authelia's container.
324 lines
16 KiB
Markdown
324 lines
16 KiB
Markdown
# Swarm SSO
|
|
|
|
The swarm runs one authelia, and it's two things at once: the **session
|
|
provider** every protected vhost checks (`auth_request`), and — once an
|
|
operator declares any client — an **OIDC provider** issuing tokens to
|
|
relying parties: the forge and the matrix homeserver.
|
|
|
|
The second role derives rather than switches:
|
|
`services.hyperhive.swarm.authelia.oidc.clients` being non-empty turns it
|
|
on. authelia refuses to start with a provider that has no clients, so a
|
|
separate `enable` would be a second fact free to disagree with the first.
|
|
|
|
## Getting in the first time
|
|
|
|
authelia binds loopback only. The **gateway** on the host running it
|
|
publishes it as `auth.<swarm.domain>` — vhost, dnsmasq record and TLS
|
|
name all follow `deploy.authelia`, so there is nothing to turn on
|
|
separately. (Details, including why a client hive must not declare that
|
|
vhost: [`../networking/gateway.md`](../networking/gateway.md).)
|
|
|
|
**Nobody can log in until the first user exists.** authelia exits at
|
|
startup on an empty user set:
|
|
|
|
```
|
|
error reading the authentication database: could not validate the schema:
|
|
users: non zero value required
|
|
```
|
|
|
|
While the set is empty this module writes one placeholder subject,
|
|
`swarm.placeholder`, that authelia accepts and that can't log in: it's
|
|
`disabled: true`, and its password digest has an all-zero key that no
|
|
known password hashes to. authelia starts and serves its login page, and
|
|
refuses every login. The placeholder stays in `users.yml` once real
|
|
users exist, shows up in `swarmctl user list`, and `swarmctl user add`
|
|
refuses its name.
|
|
|
|
⚠️ The step below is **required to finish the install**, not an
|
|
optional first-login convenience.
|
|
|
|
Add the first subject on the host running authelia:
|
|
|
|
```console
|
|
# swarmctl user add mara --display-name Mara --email mara@example.com --group admins
|
|
added mara to /var/lib/authelia-swarm/users.yml
|
|
password: <generated>
|
|
this password is stored nowhere — record it now
|
|
```
|
|
|
|
`swarmctl` generates, hashes, and prints the password once, keeping only
|
|
the hash. It reads and writes authelia's `users.yml` directly — it's
|
|
the one user store, shared with `swarm-authelia-bridge`, which creates
|
|
agent identities in the same file. No restart: authelia watches it. Full
|
|
reference: [`../tools/swarmctl-cli.md`](../tools/swarmctl-cli.md).
|
|
|
|
You can edit `users.yml` by hand, and `swarmctl` will read what you
|
|
wrote. ⚠️ It rewrites the whole file on every change, so **comments and
|
|
formatting don't survive**; values and unrecognised keys do.
|
|
|
|
This step stays manual on purpose. Bootstrapping an identity provider
|
|
non-interactively means a secret arriving from somewhere — a file, an
|
|
env var, a nix expression — and every one of those is worse than an
|
|
operator typing one command once.
|
|
|
|
### Changing a subject afterwards
|
|
|
|
`user add` only ever adds: on a name that already exists it refuses,
|
|
rather than resurfacing as a second account or a silent overwrite.
|
|
Editing an existing subject is `user update`, and the flags compose, so
|
|
one call can change multiple things:
|
|
|
|
```console
|
|
# swarmctl user update mara --add-group admins --email mara@example.com
|
|
added to group "admins"
|
|
email: unset -> "mara@example.com"
|
|
mara is now in groups: admins
|
|
```
|
|
|
|
Two behaviours worth knowing before you rely on them:
|
|
|
|
- **`--remove-group` fails if the user isn't in that group.** Every
|
|
other flag is idempotent — setting what's already set is fine, so a
|
|
"make these four things true" call doesn't break when one of them
|
|
already was. Revocation is the exception on purpose: a typo'd group
|
|
name that reported success would leave an account holding access you
|
|
believe you took away, and that's the one outcome nobody re-checks.
|
|
- **`swarmctl` prints the resulting group list** because group names have no
|
|
registry anywhere. A misspelled `--add-group` creates a real group that
|
|
no access-control rule mentions, so the user gains nothing and no error
|
|
is possible — reading the line back is the only check there is.
|
|
|
|
Passwords are deliberately out of scope here: regenerating a credential
|
|
is a different intent from editing an attribute, and folding them means
|
|
an attribute edit can invalidate a login by accident. Resetting one is a
|
|
separate verb, `user reset-password`, which generates and hashes a new
|
|
password the same way `user add` does and refuses on an unknown user:
|
|
|
|
```console
|
|
# swarmctl user reset-password mara
|
|
reset password for mara in /var/lib/authelia-swarm/users.yml
|
|
password: <generated>
|
|
this password is stored nowhere — record it now
|
|
```
|
|
|
|
Authelia's file store has no SMTP notifier, so this is the only way a
|
|
human account gets a new password once an operator forgets the old one.
|
|
|
|
## What secrets exist, and where each one lives
|
|
|
|
[`secrets.md`](secrets.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 `swarm-secret-publish` in its container writes it to the swarm
|
|
secret store at `swarm/services/<client id>/oidc/client`. 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.
|
|
|
|
<!-- vale write-good.Passive = NO -->
|
|
|
|
⚠️ 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 publisher has written 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.
|
|
|
|
<!-- vale write-good.Passive = YES -->
|
|
|
|
### 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:
|
|
|
|
```nix
|
|
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, `core` and
|
|
`swarm-controller` all have local accounts, and `swarmctl user add`
|
|
refuses none of those names.
|
|
|
|
- **A member of `admins` becomes a matrix homeserver admin** once their
|
|
first matrix login has created the account: `swarm-controller` promotes
|
|
them within five minutes, and never demotes. See
|
|
[`../integrations/matrix.md`](../integrations/matrix.md#operators-are-homeserver-admins).
|
|
|
|
- **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 `LoadCredential` for the same reason the appservice
|
|
registration does — that keeps `DynamicUser` and `PrivateUsers` intact,
|
|
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'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.
|
|
|
|
**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` — 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
|
|
|
|
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:
|
|
|
|
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. The person who introduced the split verified it this way.
|
|
|
|
## Operator CLI login
|
|
|
|
`swarmctl login` logs an operator in as their own account, from any
|
|
machine, through the public `swarmctl` client authelia always registers: no
|
|
secret, authorization code with PKCE, a loopback redirect, and a refresh
|
|
token. Its access token carries `authelia.bearer.authz` with the audience
|
|
`https://<swarm>/api/`, so the gateway admits it at the controller's API and
|
|
nowhere else, under the same access rule as a browser session there.
|
|
Revocation is per account: once authelia stops accepting the account, the
|
|
gateway refuses its token too. See [`swarmctl/README.md`](../../swarmctl/README.md#remote-use-swarmctl-login).
|
|
|
|
## What this doesn't do
|
|
|
|
- **It doesn't disable local login — except on Grafana.** Forgejo and
|
|
tuwunel keep their password databases and gain a second door: an identity
|
|
provider that can take a service offline when it hiccups is worse than one
|
|
with two ways in, and making authelia the only path there is a separate,
|
|
reversible switch (tuwunel's `login_with_password`, forgejo's own setting).
|
|
⚠️ **Grafana is the exception, and not a reversible one.** Its module sets
|
|
`auth.disable_login_form` unconditionally: its vhost is on the public
|
|
gateway, so a password box there would be a way in whatever the
|
|
deployment. The form is all this hides. Grafana's API still takes HTTP
|
|
basic auth for the built-in `admin` account, whose password is a random
|
|
one minted into the swarm secret store, not the upstream `admin`. A
|
|
dead provider locks out the SSO accounts but not that one: an operator
|
|
reads the password from `swarm/services/<grafana client id>/admin/password`
|
|
in the store (or, as root on the Grafana host, from
|
|
`/run/swarm-grafana-secrets/admin-password`)
|
|
and calls the API with it. See [`services.md`](services.md#metrics-victoriametrics--grafana)
|
|
and [`secrets.md`](secrets.md). SSO logins are the everyday way in, which
|
|
is why `swarm.grafana.oidc.role` defaults to `Admin`.
|
|
The three secrets Grafana reads live on the host's tmpfs, so after a reboot
|
|
Grafana stays down (no login, no dashboards) until the secret store
|
|
answers; the delivery units retry and start it with no manual step.
|
|
- **It doesn't provision users.** `swarmctl` creates and destroys agents
|
|
continuously today, so the subject set belongs to a program rather
|
|
than to a config file.
|