Both services carried an `sso.enable` defaulting to false, so a swarm's own forge and homeserver shipped with their identity provider switched off unless an operator remembered two lines. Grafana never had the toggle and is the shape the other two now match. Behaves as if the setting were true: `ssoLocal` loses one conjunct, the three assertions become unconditional, and the login source and identity_provider render always. The option is removed rather than defaulted, so a config that turned SSO OFF fails where that line is instead of silently gaining a login provider on the next rebuild.
189 lines
8.5 KiB
Markdown
189 lines
8.5 KiB
Markdown
# Swarm SSO
|
|
|
|
The swarm runs one authelia, and it is two things at once: the **session
|
|
provider** every protected vhost checks (`auth_request`), and — once any
|
|
client is declared — an **OIDC provider** issuing tokens to relying
|
|
parties: the forge and the matrix homeserver.
|
|
|
|
The second role is derived rather than switched:
|
|
`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 `swarm.authelia.enable`, so there is nothing to turn on
|
|
separately. (Details, including why a client hive must not declare that
|
|
vhost: [`../gateway.md`](../gateway.md).)
|
|
|
|
**Authelia does not start until at least one user exists.** The user
|
|
store is generated empty — deliberately, since seeding a default account
|
|
would put a credential in a config file — but authelia validates it at
|
|
startup and treats "no users" as fatal:
|
|
|
|
```
|
|
error reading the authentication database: could not validate the schema:
|
|
users: non zero value required
|
|
```
|
|
|
|
It then exits 1 and systemd restarts it, so a swarm that has been
|
|
enabled but not bootstrapped shows a **crash-looping unit** and `502 Bad
|
|
Gateway` from the vhost — not a login page with nobody able to use it.
|
|
The gateway is working in that state; the upstream is not up.
|
|
|
|
⚠️ So the step below is **required to finish the install**, not an
|
|
optional first-login convenience. Run it before concluding anything is
|
|
wrong with the proxy: a 502 here means "no users yet" far more often
|
|
than it means a routing fault.
|
|
|
|
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
|
|
```
|
|
|
|
The password is generated, hashed, and printed once; only the hash is
|
|
kept. `swarmctl` reads and writes authelia's `users.yml` directly — it is
|
|
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 do not 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 several 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 is not in that group.** Every
|
|
other flag is idempotent — setting what is already set is fine, so a
|
|
"make these four things true" call does not 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 is the one outcome nobody re-checks.
|
|
- **The resulting group list is printed** 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.
|
|
|
|
## What secrets exist, and where each one lives
|
|
|
|
Every secret in the swarm, with its generator and its path, is tabulated
|
|
in one place: [`secrets.md`](secrets.md), 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 are usually 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 are genuinely different mechanisms rather than one
|
|
mechanism with flags.
|
|
|
|
### 1. All-local — one host runs both
|
|
|
|
Nothing to configure at all. Per service, a host-side unit waits for
|
|
authelia's first boot to mint that client's secret and copies it into the
|
|
service's container, and the service's own module contributes its client
|
|
entry — callback URL included — to authelia's client list.
|
|
|
|
The callback is built once and read twice, so the redirect URI authelia is
|
|
told to allow and the one the service actually sends cannot drift apart. A
|
|
mismatch there is a rejected login with no error text worth reading.
|
|
|
|
⚠️ The delivery is a copy, not a `bindMounts` entry, and deliberately so:
|
|
nixos-container refuses to start a container whose bind source is
|
|
missing, and this secret does not exist until authelia's first boot has
|
|
run. Binding it would make the service wait on a file that waits on a
|
|
container that starts after it — on a fresh hive, a permanent stall
|
|
presenting as "the forge is broken", several layers from its cause.
|
|
|
|
### 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
|
|
|
|
No shared host, so no automatic path. The operator provides the file and
|
|
names it:
|
|
|
|
```nix
|
|
services.hyperhive.swarm = {
|
|
authelia.url = "https://auth.example.com";
|
|
forge.sso = {
|
|
enable = true;
|
|
clientSecretFile = "/var/lib/hyperhive/forge-oidc-secret";
|
|
};
|
|
};
|
|
```
|
|
|
|
**Both are asserted at eval.** A hive that boots with SSO
|
|
half-configured shows a login button that always fails — a symptom
|
|
several layers from its cause, and far worse to diagnose than an
|
|
evaluation error.
|
|
|
|
## Where each relying party differs
|
|
|
|
The registration half is identical; what each service does with the
|
|
result is not.
|
|
|
|
| | 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 |
|
|
|
|
Two consequences worth stating plainly:
|
|
|
|
- **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 registration token
|
|
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 `/_matrix/` is served
|
|
directly and authenticates itself. The forward-auth vhosts protect
|
|
browser surfaces; this is not one of them.
|
|
|
|
## What this does not do
|
|
|
|
- **It does not disable local login.** Each service keeps its password
|
|
database and gains a second door. An identity provider that can take a
|
|
service offline when it hiccups is worse than one with two ways in.
|
|
Making authelia the only path is a separate, reversible switch per
|
|
service (tuwunel's `login_with_password`, forgejo's own setting).
|
|
- **It does not provision users.** Agents are created and destroyed
|
|
continuously, so the subject set belongs to a program rather than to a
|
|
config file; today that program is `swarmctl`.
|