`user add` refuses on an existing name, so the `--group` flag it takes at creation time could not be added afterwards at all: repairing an account meant hand-editing both users.json and the rendered users.yml as root. mara, on #3167: "i will not edit those files by hand, we will have the same issues elsewhere". The merge rules live in users.rs as a pure function over a UserUpdate, so they are testable without a command line, a container or a running authelia — main.rs's arm only loads, applies, publishes and prints. Removals are strict and everything else is idempotent, which is the one asymmetry here and is deliberate: a --remove-group naming a group the user does not have fails, because a revocation that reports success without revoking is the outcome nobody re-checks; while refusing an already-satisfied set would make the multi-attribute call this verb exists for break whenever one of the values was already right. A command that changes nothing at all still fails — it would otherwise rewrite both files and restart the SSO provider to no effect. Passwords are out of scope: regenerating a credential is a different intent from editing an attribute, and folded together an attribute edit can invalidate a login by accident. Extracts publish() from user_add so both verbs share the render -> store -> users.yml -> restart ordering and the comment that explains why that order, rather than the second verb copying it.
7.4 KiB
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 like the forge.
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.)
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:
# 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 writes its canonical users.json, re-renders authelia's
users.yml from it, and restarts authelia. Full reference:
../tools/swarmctl-cli.md.
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:
# 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-groupfails 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-groupcreates 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
| secret | generated by | rests in | read by |
|---|---|---|---|
jwt.key, session.key, storage-encryption.key |
authelia's first-boot unit | /var/lib/authelia-swarm/ |
authelia |
oidc-hmac.key |
same unit | same directory | authelia |
oidc-issuer.key (RSA) |
same unit | same directory | authelia signs with it; clients verify the public half at /jwks.json |
oidc-clients/<id>.digest |
same unit, via authelia crypto hash generate |
same directory, merged in through settingsFiles |
authelia |
oidc-clients/<id>.secret |
the same mint — this is its plaintext half | same directory | the relying party, in another container |
Everything above the last row is generated in-container because nothing outside that container ever reads it. That is the test worth applying to any secret added here. The last row fails it, and that is the entire reason a delivery step exists.
None of it is ever written into a nix expression. authelia's
settings are rendered into the nix store, which is world-readable and
permanent, so the client digest reaches authelia through settingsFiles
(merged at runtime) and every other secret through a *File option
carrying a path rather than a value.
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 beyond swarm.forge.sso.enable = true. A host-side
unit waits for authelia's first boot to mint the secret and copies it
into the forge container, and the forge module contributes its own client
entry — callback URL included — to authelia's client list.
The callback is built from the same source name the registration uses, so the redirect URI authelia is told to allow and the one forgejo 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 forge 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.json canonical,
users.yml a rendered artifact).
3. A hive elsewhere
No shared host, so no automatic path. The operator provides the file and names it:
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.
What this does not do
- It does not disable local login. The forge keeps its password database and gains a second door. An identity provider that can take the forge offline when it hiccups is a worse forge than one with two ways in.
- 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.