hyperhive/docs/swarm/sso.md
atlas 660629a7c6 docs(3083): getting into the SSO provider the first time
The vhost half of this change is only useful with an account behind it,
and the provider is generated with an empty user set on purpose. Document
the `swarmctl user add` step rather than automating it: bootstrapping an
IdP non-interactively means a secret arriving from a file, an env var or a
nix expression, all worse than one command typed once.

The gateway and network pages gain the rows they would otherwise be
missing — vhost map, local-dev hosts entry, and the resolver's
authoritative-name list.
2026-08-11 23:30:44 +02:00

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

Reachable is not the same as usable: the provider is generated with an empty user set, deliberately. A provider with nobody in it yet is the correct state for a fresh swarm — it is not a half-finished install, and seeding a default account would be a credential in a config file.

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.

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.