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.)

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 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.

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:

# 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:

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, 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 beyond swarm.forge.sso.enable = true or swarm.matrix.sso.enable = true. 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:

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:

What this does not do