From b4a3eb75b001d1d1000fb38125ed3489ae2b46a5 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 11 Aug 2026 21:47:05 +0200 Subject: [PATCH] docs(3149): what the SSO secrets are and where each one lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The question this answers is "what do I have to configure, and where" — so the table of secrets is the deliverable and the prose is scaffolding around it. The organising idea worth keeping: a secret belongs in-container when nothing outside that container reads it. Every one of authelia's own secrets passes that test; the client secret's plaintext fails it, which is what makes delivery a problem at all rather than a detail. --- docs/swarm/README.md | 6 ++ docs/swarm/sso.md | 92 +++++++++++++++++++++++++++++ nix/host-modules/swarm-authelia.nix | 9 +-- 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 docs/swarm/sso.md diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 77c87247..fc6f3c79 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -88,6 +88,12 @@ One authelia, one matrix, one forge per swarm — which host runs them, and what a hive that runs none of them configures instead: [`services.md`](services.md). +## Single sign-on + +Which secrets the SSO provider generates, which one has a reader in +another container, and the three ways that one gets delivered: +[`sso.md`](sso.md). + ## The swarm's hive directory ```nix diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md new file mode 100644 index 00000000..0c1c5b61 --- /dev/null +++ b/docs/swarm/sso.md @@ -0,0 +1,92 @@ +# 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. + +## 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/.digest` | same unit, via `authelia crypto hash generate` | same directory, merged in through `settingsFiles` | authelia | +| `oidc-clients/.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: + +```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. + +## 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`. diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index c1097d96..81a73f15 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -18,12 +18,9 @@ # for LDAP: what makes a directory necessary is the size of the subject # set, and this deployment's is bounded by one swarm. # -# Two roles, and only the first is unconditional: this is a **session** -# provider (`auth_request`) always, and an **OIDC** provider when -# `oidc.clients` is non-empty. The second is derived from the client list -# instead of carrying its own flag, because authelia refuses to start -# with a provider that has no clients — a separate `enable` would be a -# second fact that can disagree with the first. +# Two roles: a **session** provider always, an **OIDC** provider when +# `oidc.clients` is non-empty (derived, not flagged — authelia will not +# start with a clientless provider). Secrets map: docs/swarm/sso.md. # # Per-service integration — putting authelia's `auth_request` in front # of the gateway's existing `auth_basic` locations — is deliberately NOT