From 36c5b68cc076b63bb7a10664ef2223addaf81cc5 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 11 Aug 2026 20:59:34 +0200 Subject: [PATCH 1/6] feat(3149): authelia grows an OIDC provider, derived from its clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swarm's authelia has been a session / `auth_request` provider only. SSO for the forge (and matrix behind it) needs the second role: an OIDC provider that issues tokens to relying parties. The provider is derived from `oidc.clients` rather than carrying its own `enable`, because authelia refuses to start with a provider that has no clients — a separate flag would be a second fact free to disagree with the first. The list defaults to empty, so a hive that has not opted in renders exactly what it rendered before. Its two secrets are generated in-container by the existing first-boot unit, which is the same test that unit already applies to the jwt, session and storage keys: nothing outside this container reads them. The hmac key is 64 random bytes and joins that loop; the issuer key is an RSA pair, because it *signs* id tokens and relying parties verify them against the public half at `/jwks.json` — a symmetric secret cannot serve that. No client secret appears here, and that is the point: a client secret has two holders in two containers, and `settings` is rendered into the world- readable nix store. Minting it is the next commit's problem. --- nix/host-modules/swarm-authelia.nix | 159 ++++++++++++++++++++++++---- 1 file changed, 136 insertions(+), 23 deletions(-) diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 49ad4d07..4ca3e807 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -18,6 +18,13 @@ # 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. +# # Per-service integration — putting authelia's `auth_request` in front # of the gateway's existing `auth_basic` locations — is deliberately NOT # here. Standing an SSO provider up is reversible; cutting every @@ -52,6 +59,22 @@ let # below are: the required-domain assertion in hive-network.nix should # be what an operator sees, not a coercion error from here. cookieDomain = if swarmDomain == null then "invalid" else swarmDomain; + + # authelia refuses to start with an OIDC provider that has no clients, + # so the provider is derived from the client list rather than carrying + # its own `enable`: one fact, and it cannot contradict itself. An empty + # list is the default, which makes every hive that has not opted in + # byte-identical to before. + oidcEnabled = cfg.oidc.clients != [ ]; + + # Secrets that are 64 random bytes of hex and nothing more. The OIDC + # hmac key joins them; the issuer key does not (see below — it is RSA). + randomKeys = [ + "jwt" + "session" + "storage-encryption" + ] + ++ lib.optional oidcEnabled "oidc-hmac"; in { options.services.hyperhive.swarm.authelia = { @@ -151,6 +174,59 @@ in ''; }; + oidc.clients = lib.mkOption { + type = lib.types.listOf ( + lib.types.submodule { + options = { + id = lib.mkOption { + type = lib.types.str; + example = "forgejo"; + description = '' + OAuth2 client id, as the relying party knows itself. + ''; + }; + + description = lib.mkOption { + type = lib.types.str; + example = "HyperHive forge"; + description = '' + Human-readable name, shown on authelia's consent screen. + This is the string a person reads when deciding whether + to hand an application their identity, so it should name + the application rather than the protocol. + ''; + }; + + redirectUris = lib.mkOption { + type = lib.types.listOf lib.types.str; + example = [ "https://forge.example.com/user/oauth2/authelia/callback" ]; + description = '' + Exact callback URLs the provider will redirect to. + Matched literally by authelia — a trailing-slash + difference is a rejected login, not a warning. + ''; + }; + }; + } + ); + default = [ ]; + description = '' + OIDC relying parties this provider will issue tokens to. + Declaring one turns the provider on; the default empty list + leaves this module exactly as it was — a session provider and + nothing else. + + ⚠️ **There is deliberately no secret here.** A client secret has + two holders in two containers (authelia keeps a *hash*, the + relying party the *plaintext*), and + `services.authelia.instances..settings` is rendered into the + **nix store**, which is world-readable and permanent. So this + option carries only the parts that are safe to evaluate: the + secret is minted on first boot and never passes through a nix + expression. See `docs/swarm/` for what goes where. + ''; + }; + # Derived facts, exposed for consumers that have to act on this # container **from outside it** — `swarmctl` is the first, and it # needs all three. Read-only options rather than literals repeated at @@ -242,7 +318,7 @@ in wantedBy = [ "multi-user.target" ]; before = [ "${unitName}.service" ]; requiredBy = [ "${unitName}.service" ]; - path = [ pkgs.coreutils ]; + path = [ pkgs.coreutils ] ++ lib.optional oidcEnabled pkgs.openssl; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; @@ -254,31 +330,48 @@ in SyslogIdentifier = "${unitName}-secrets"; }; script = '' - set -euo pipefail + set -euo pipefail - # Each is generated once and never rotated here: the - # session and storage keys are load-bearing for data - # already written (sessions, the encrypted store), so - # replacing one is an operator action, not a boot action. - for f in jwt session storage-encryption; do - p=${lib.escapeShellArg stateDir}/"$f".key - if [ ! -s "$p" ]; then - head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$p" - echo "generated $p" + # Each is generated once and never rotated here: the + # session and storage keys are load-bearing for data + # already written (sessions, the encrypted store), so + # replacing one is an operator action, not a boot action. + for f in ${lib.concatStringsSep " " randomKeys}; do + p=${lib.escapeShellArg stateDir}/"$f".key + if [ ! -s "$p" ]; then + head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$p" + echo "generated $p" + fi + chmod 0600 "$p" + done + ${lib.optionalString oidcEnabled '' + # The issuer key is the one secret here that is NOT + # interchangeable with a random blob: it *signs* id + # tokens, and every relying party verifies them against + # the public half served at `/jwks.json`. A symmetric + # secret cannot do that, so this one is an RSA pair. + # + # Rotating it invalidates every token already issued, + # which is why it is generated once and left alone — + # same reason as the session and storage keys above. + issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key + if [ ! -s "$issuer" ]; then + openssl genrsa -out "$issuer" 4096 + echo "generated $issuer" fi - chmod 0600 "$p" - done + chmod 0600 "$issuer" + ''} - # A users database that exists and parses, with nobody in - # it. authelia refuses to start without one, and the - # alternative to an empty file is a placeholder account — - # which is a credential nobody meant to create. - users=${lib.escapeShellArg cfg.usersFile} - if [ ! -s "$users" ]; then - echo "users: {}" > "$users" - echo "seeded empty users database at $users" - fi - chmod 0600 "$users" + # A users database that exists and parses, with nobody in + # it. authelia refuses to start without one, and the + # alternative to an empty file is a placeholder account — + # which is a credential nobody meant to create. + users=${lib.escapeShellArg cfg.usersFile} + if [ ! -s "$users" ]; then + echo "users: {}" > "$users" + echo "seeded empty users database at $users" + fi + chmod 0600 "$users" ''; }; @@ -290,6 +383,26 @@ in jwtSecretFile = "${stateDir}/jwt.key"; sessionSecretFile = "${stateDir}/session.key"; storageEncryptionKeyFile = "${stateDir}/storage-encryption.key"; + } + // lib.optionalAttrs oidcEnabled { + # Both are `LoadCredential`-delivered by upstream's module, + # so they reach authelia as `AUTHELIA_*_FILE` env and never + # as values. Same by-path discipline as the three above — + # which is what lets the provider's own secrets stay + # in-container: nothing outside this container reads them. + # + # ⚠️ The issuer key is NOT passed as the deprecated + # `issuer_private_key`: on 4.38+ the config key is the + # `jwks` *list*, and a list element cannot take the + # `_FILE` env treatment. Upstream's module bridges that + # by generating a small settings file that templates the + # PEM in (`{{ secret "" }}`) and prepending it to + # `settingsFiles`. So handing it a path here is the + # modern shape, not the legacy one — checked against the + # pin (nixpkgs `services/security/authelia.nix`), because + # the option name alone reads like the old key. + oidcHmacSecretFile = "${stateDir}/oidc-hmac.key"; + oidcIssuerPrivateKeyFile = "${stateDir}/oidc-issuer.key"; }; # Small-deployment defaults, and the scope is the From 4a35e1229bf82b427ef31a65db0694f8cda61fdb Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 11 Aug 2026 21:13:46 +0200 Subject: [PATCH 2/6] feat(3149): mint each OIDC client's secret on first boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client secret has two holders in two containers: the relying party authenticates with the plaintext, authelia compares a digest. Neither side can generate it alone, and `settings` is rendered into the world-readable nix store, so the value cannot be declared. So it is minted here, once, as two files — `.secret` and `.digest`. That split is what lets `oidc-clients.yml` be re-rendered on *every* boot from the nix-declared metadata plus the stored digest: a new redirect URI or a renamed client takes effect on rebuild without rotating a credential another container is already holding. `authelia crypto hash generate pbkdf2 --random` generates the password itself and prints it beside its digest, so no plaintext is ever handed to a second process on a command line. The clients file reaches authelia through `settingsFiles`, which upstream merges at runtime — the same mechanism it already uses for the issuer JWK. Minting fails closed: an empty secret or digest aborts the unit, and the unit is `requiredBy` authelia, so the provider refuses to start rather than serving a client that can never authenticate. That failure would otherwise surface three layers away as an opaque 401 from the token endpoint. --- nix/host-modules/swarm-authelia.nix | 133 ++++++++++++++++++++++++---- 1 file changed, 115 insertions(+), 18 deletions(-) diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 4ca3e807..fd047d03 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -75,6 +75,102 @@ let "storage-encryption" ] ++ lib.optional oidcEnabled "oidc-hmac"; + + # Per-client material lives beside the rest of authelia's state, one + # file per half: the relying party needs the PLAINTEXT, authelia keeps + # only a DIGEST. Splitting them is what lets the clients file below be + # re-rendered on every boot from a secret that was minted once. + clientsDir = "${stateDir}/oidc-clients"; + clientsFile = "${stateDir}/oidc-clients.yml"; + + # Rendered at RUNTIME, not evaluated: the digest is read from disk by + # the script, so nothing secret ever enters a nix expression (and + # therefore the store). Everything else here is public metadata that + # nix is the right place for. + renderClient = c: '' + printf -- ' - client_id: %s\n' ${lib.escapeShellArg c.id} + printf -- ' client_name: %s\n' ${lib.escapeShellArg c.description} + printf -- " client_secret: '%s'\n" "$(cat ${lib.escapeShellArg "${clientsDir}/${c.id}.digest"})" + printf -- ' authorization_policy: one_factor\n' + printf -- ' scopes: [openid, profile, email, groups]\n' + printf -- ' redirect_uris:\n' + ${lib.concatMapStrings (u: '' + printf -- ' - %s\n' ${lib.escapeShellArg u} + '') c.redirectUris} + ''; + + # The OIDC half of the first-boot generator, kept out of the script + # body so neither is read through the other's indentation. + oidcGenScript = lib.optionalString oidcEnabled '' + # The issuer key is the one secret here that is NOT interchangeable + # with a random blob: it *signs* id tokens, and every relying party + # verifies them against the public half served at `/jwks.json`. A + # symmetric secret cannot do that, so this one is an RSA pair. + # + # Rotating it invalidates every token already issued, which is why + # it is generated once and left alone — same reason as the session + # and storage keys above. + issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key + if [ ! -s "$issuer" ]; then + openssl genrsa -out "$issuer" 4096 + echo "generated $issuer" + fi + chmod 0600 "$issuer" + + # Each client's secret, minted once and kept as two files: the + # plaintext its relying party authenticates with, and the digest + # authelia compares against. The two live in different containers, + # so neither side can generate it alone — this is the only place + # that sees both. + # + # `--random` is why no plaintext ever reaches an argv: authelia + # generates the password itself and prints it beside its digest, + # so nothing has to be handed to a second process on a command + # line. + clients=${lib.escapeShellArg clientsDir} + mkdir -p "$clients" + chmod 0700 "$clients" + + mint() { + sec="$clients/$1.secret" + dig="$clients/$1.digest" + if [ -s "$sec" ] && [ -s "$dig" ]; then + chmod 0600 "$sec" "$dig" + return + fi + out=$(authelia crypto hash generate pbkdf2 --variant sha512 --random) + printf '%s' "$out" | sed -n 's/^Random Password: *//p' > "$sec" + printf '%s' "$out" | sed -n 's/^Digest: *//p' > "$dig" + chmod 0600 "$sec" "$dig" + # Fail closed. An empty secret is a client that can never + # authenticate, and it surfaces three layers away as an opaque + # 401 from the token endpoint — refusing to start is by far the + # cheaper failure to diagnose. + if [ ! -s "$sec" ] || [ ! -s "$dig" ]; then + echo "authelia crypto hash generate produced no secret/digest for $1" >&2 + exit 1 + fi + echo "minted client secret for $1" + } + + ${lib.concatMapStrings (c: '' + mint ${lib.escapeShellArg c.id} + '') cfg.oidc.clients} + + # Re-rendered every boot, deliberately: the secret is minted once, + # but the metadata around it (a new redirect URI, a renamed client) + # comes from nix and has to be able to change without disturbing + # the secret. Written through a temp file so a crash mid-write + # cannot leave authelia half a file to parse. + { + printf -- 'identity_providers:\n' + printf -- ' oidc:\n' + printf -- ' clients:\n' + ${lib.concatMapStrings renderClient cfg.oidc.clients} + } > ${lib.escapeShellArg "${clientsFile}.tmp"} + chmod 0600 ${lib.escapeShellArg "${clientsFile}.tmp"} + mv ${lib.escapeShellArg "${clientsFile}.tmp"} ${lib.escapeShellArg clientsFile} + ''; in { options.services.hyperhive.swarm.authelia = { @@ -318,7 +414,17 @@ in wantedBy = [ "multi-user.target" ]; before = [ "${unitName}.service" ]; requiredBy = [ "${unitName}.service" ]; - path = [ pkgs.coreutils ] ++ lib.optional oidcEnabled pkgs.openssl; + # `cfg.package` is here for its CLI, not its daemon: the + # client secrets are minted with `authelia crypto hash + # generate`, which is the only way to produce a digest in + # the exact form authelia will later verify. + path = [ + pkgs.coreutils + ] + ++ lib.optionals oidcEnabled [ + pkgs.openssl + cfg.package + ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; @@ -344,23 +450,7 @@ in fi chmod 0600 "$p" done - ${lib.optionalString oidcEnabled '' - # The issuer key is the one secret here that is NOT - # interchangeable with a random blob: it *signs* id - # tokens, and every relying party verifies them against - # the public half served at `/jwks.json`. A symmetric - # secret cannot do that, so this one is an RSA pair. - # - # Rotating it invalidates every token already issued, - # which is why it is generated once and left alone — - # same reason as the session and storage keys above. - issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key - if [ ! -s "$issuer" ]; then - openssl genrsa -out "$issuer" 4096 - echo "generated $issuer" - fi - chmod 0600 "$issuer" - ''} + ${oidcGenScript} # A users database that exists and parses, with nobody in # it. authelia refuses to start without one, and the @@ -379,6 +469,13 @@ in enable = true; package = cfg.package; + # Merged at RUNTIME alongside the nix-generated config, which + # is the whole reason the client digests can exist at all: + # `settings` below is rendered into the world-readable nix + # store, and a client secret's digest may not go there. + # Empty (and inert) on a hive with no clients declared. + settingsFiles = lib.optional oidcEnabled clientsFile; + secrets = { jwtSecretFile = "${stateDir}/jwt.key"; sessionSecretFile = "${stateDir}/session.key"; From daa8a2eb4bdff67d3362d4873bf8ac4b988f70fc Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 11 Aug 2026 21:26:06 +0200 Subject: [PATCH 3/6] feat(3149): the forge registers authelia as an OIDC login source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive, never exclusive: forgejo keeps its local password database and gains a second way in. An identity provider that can take the forge offline when it hiccups is a worse forge than one with two doors. A login source in forgejo is a database ROW, not an `app.ini` key, so this is a unit rather than config. It is ordered AFTER forgejo — unlike its neighbour `forgejo-gpg-init`, which runs before — because on a fresh hive that database does not exist until forgejo has started and migrated; running first would either fail or initialise a schema behind the server's back. Idempotency is by query (`admin auth list`), not by a stamp file: the same reasoning already written down for the GPG key next to it, that a stamp outlives a state wipe and then suppresses the repair. Two assertions rather than defaults, both firing at eval: SSO needs a secret path, and it needs somewhere to discover the provider. Either one missing produces a login button that always fails — a runtime symptom several layers from its cause, which is exactly the trade an eval error is worth making. The secret is read from a path and passed on argv for one exec, because `--secret` is the only input forgejo offers — no `--secret-file`, no env var, though its sibling `forgejo-cli actions register` has both. Inside this container the value is already at rest in the login-source row and the only principals are root and forgejo, so argv widens its readership to nobody new. Accepted deliberately, not overlooked. --- nix/host-modules/hive-forge/default.nix | 163 ++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 7a59a3ce..9cadbf85 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -23,6 +23,19 @@ let # that make the CA reachable are shared with hive-ci via the # `hive-ca-trust` helper; only the Go SSL_CERT_FILE concat below is # hive-forge-specific. + # Forgejo's name for the login source. A constant, not an option: it + # is the key this module's own idempotency check looks up, so making + # it configurable would buy nothing and add a way for the lookup and + # the row to disagree. + ssoSourceName = "authelia"; + + # `url` is the half of the authelia module that exists on EVERY hive — + # null when no SSO provider is configured anywhere, which the + # assertion below turns into an eval failure rather than a discovery + # request to `null/.well-known/...`. + autheliaUrl = config.services.hyperhive.swarm.authelia.url; + autheliaDiscoveryUrl = "${toString autheliaUrl}/.well-known/openid-configuration"; + caTrust = import ../lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; }; useSelfSigned = caTrust.useSelfSigned; caContainerPath = caTrust.caContainerPath; @@ -314,10 +327,86 @@ in external DNS, but that's off the CI path). ''; }; + + sso = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Register the swarm's authelia as an OpenID Connect login + source on this forge. + + **Additive, never exclusive.** Forgejo keeps its local + password database and gains an extra "sign in with" button; + this does not disable local login. Deliberate: an identity + provider that can take the forge offline when it hiccups is a + worse forge than one with two ways in. + ''; + }; + + clientId = lib.mkOption { + type = lib.types.str; + default = "forgejo"; + description = '' + OAuth2 client id this forge identifies itself with. Must match + the `id` of the corresponding entry in + `services.hyperhive.swarm.authelia.oidc.clients`. + ''; + }; + + clientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/var/lib/hyperhive/forge-oidc-secret"; + description = '' + Path **inside the forge container** holding the client + secret's plaintext. + + A path, never a value: an OIDC client secret has two holders + in two containers (authelia keeps a hash, this forge needs the + plaintext), and a literal written here would be rendered into + the world-readable nix store. + + Required when `enable` is set — deliberately no fallback. A + forge that boots with SSO half-configured presents as a login + button that always fails, which is harder to diagnose than an + eval error. + ''; + }; + }; }; config = lib.mkIf config.services.hyperhive.enable { assertions = [ + { + # Fail at EVAL, not at boot. The alternative failure is a login + # button that always 401s, three layers from the missing file. + assertion = !cfg.sso.enable || cfg.sso.clientSecretFile != null; + message = '' + services.hyperhive.swarm.forge.sso.enable requires + sso.clientSecretFile — the path (inside the forge container) + holding the OIDC client secret's plaintext. + + On a hive that also runs the swarm's authelia this is wired up + for you. Set it explicitly when authelia lives on another + host: see docs/swarm/ for which secret goes where. + ''; + } + { + # Without a provider URL there is nothing to discover against, + # and the rendered unit would ask `null/.well-known/…`. + assertion = !cfg.sso.enable || autheliaUrl != null; + message = '' + services.hyperhive.swarm.forge.sso.enable requires + services.hyperhive.swarm.authelia.url — the base URL of the + swarm's SSO provider. + + It defaults to this host's own instance only when this host + runs authelia. A hive that federates with a swarm sets it + explicitly to wherever that provider lives. + ''; + } { assertion = cfg.rootUrl == null || lib.hasSuffix "/" cfg.rootUrl; message = '' @@ -648,6 +737,80 @@ in fi ''; }; + + # Register authelia as an OIDC login source. + # + # ⚠️ Ordered AFTER forgejo, unlike forgejo-gpg-init above, and + # the difference is not stylistic: a login source is a row in + # forgejo's database, and on a fresh hive that database does + # not exist until forgejo has started and run its migrations. + # Running first would either fail or let the CLI initialise a + # schema behind the server's back. + # + # Idempotency is by QUERY, not by a stamp file — same reason + # spelled out for the GPG key above: a stamp survives a state + # wipe that took the thing it claims exists, and then suppresses + # the repair. + systemd.services.forgejo-sso-source = lib.mkIf cfg.sso.enable { + description = "register authelia as Forgejo's OIDC login source"; + after = [ "forgejo.service" ]; + requires = [ "forgejo.service" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + User = "forgejo"; + Group = "forgejo"; + SyslogIdentifier = "forgejo-sso-source"; + }; + # `FORGEJO_CUSTOM` (not `GITEA_CUSTOM` — forgejo renamed it) + # is how the CLI finds the app.ini upstream's module wrote. + environment.FORGEJO_CUSTOM = "/var/lib/forgejo/custom"; + path = [ + cfg.package + pkgs.coreutils + pkgs.gnugrep + ]; + script = '' + set -euo pipefail + + secret=$(cat ${lib.escapeShellArg cfg.sso.clientSecretFile}) + if [ -z "$secret" ]; then + echo "empty OIDC client secret at ${cfg.sso.clientSecretFile}" >&2 + exit 1 + fi + + # `--secret` is the only input forgejo offers: there is no + # --secret-file and no env var, though its sibling + # `forgejo-cli actions register` has both. So the value is + # on this argv for the length of one exec, inside this + # container, where forgejo already stores it at rest in the + # login-source row — root and forgejo are the only + # principals here, and root can read the resting copy + # anyway. Accepted deliberately (see docs/swarm/), not + # overlooked; upstream gap filed. + args="--provider openidConnect \ + --key ${lib.escapeShellArg cfg.sso.clientId} \ + --auto-discover-url ${lib.escapeShellArg autheliaDiscoveryUrl} \ + --scopes ${lib.escapeShellArg "openid profile email groups"}" + + # shellcheck disable=SC2086 + if forgejo admin auth list | grep -q "[[:space:]]${ssoSourceName}[[:space:]]"; then + id=$(forgejo admin auth list \ + | grep "[[:space:]]${ssoSourceName}[[:space:]]" \ + | cut -f1) + forgejo admin auth update-oauth --id "$id" \ + --name ${lib.escapeShellArg ssoSourceName} \ + --secret "$secret" $args + echo "updated OIDC login source ${ssoSourceName} (id $id)" + else + forgejo admin auth add-oauth \ + --name ${lib.escapeShellArg ssoSourceName} \ + --secret "$secret" $args + echo "added OIDC login source ${ssoSourceName}" + fi + ''; + }; }; }; From cdb3c612b22c68b97fb7f089fec21b2f7500b1c2 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 11 Aug 2026 21:40:05 +0200 Subject: [PATCH 4/6] feat(3149): deliver the client secret between the two containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The all-local case from the delivery ruling: when one host runs both the forge and the swarm's authelia, nothing should need an operator. Two containers, one secret, and the awkward part is that they share this host's network namespace but not its filesystem. They reach each other on 127.0.0.1, which makes them feel co-located — the forge still cannot open a path inside authelia's tree. The host is the only place both are addressable, so the copy runs there, and `hostClientSecretDir` publishes the outside view of the inside path exactly as `hostUsersFile` already does for the users database. Deliberately a copy rather than a `bindMounts` entry. nixos-container refuses to start when a bind source is missing, and this secret does not exist until authelia's first boot has minted it — binding it would make the forge wait on a file that waits on a container that starts after it. On a fresh hive that is a permanent stall presenting as "the forge is broken", several layers from its cause. The owning uid is discovered from the forge container's own state dir rather than assumed. Whatever uid maps to forgejo inside that container already owns the directory it was created with; writing a number here would be a second place for it to be wrong. The client entry is contributed to authelia's list by the forge module itself, from the same source-name constant the registration uses, so the redirect URI authelia allows and the one forgejo sends cannot drift. A mismatch there is a rejected login with no error text worth reading. --- nix/host-modules/hive-forge/default.nix | 93 ++++++++++++++++++++++++- nix/host-modules/swarm-authelia.nix | 22 ++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 9cadbf85..518a63a0 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -33,9 +33,28 @@ let # null when no SSO provider is configured anywhere, which the # assertion below turns into an eval failure rather than a discovery # request to `null/.well-known/...`. - autheliaUrl = config.services.hyperhive.swarm.authelia.url; + autheliaCfg = config.services.hyperhive.swarm.authelia; + autheliaUrl = autheliaCfg.url; autheliaDiscoveryUrl = "${toString autheliaUrl}/.well-known/openid-configuration"; + # The all-local case: this host runs BOTH the forge and the swarm's + # authelia, so the secret can be moved without an operator. The other + # two cases (swarm side via swarmctl, remote hive) leave + # `clientSecretFile` to be set explicitly — see docs/swarm/. + ssoLocal = cfg.sso.enable && autheliaCfg.enable; + + # Where the plaintext lands inside the forge container. Under + # /var/lib rather than /run: the forge may start before the delivery + # unit on a later boot, and a secret that evaporates on reboot turns a + # working login into an intermittent one. + forgeSecretPath = "/var/lib/forgejo-oidc/${cfg.sso.clientId}.secret"; + + # Forgejo's OAuth2 callback shape. Built from the SAME `ssoSourceName` + # the registration uses, so the redirect URI authelia is told to allow + # and the one forgejo will actually send cannot drift apart — a + # mismatch there is a rejected login with no error text worth reading. + ssoRedirectUri = "${effectiveRootUrl}user/oauth2/${ssoSourceName}/callback"; + caTrust = import ../lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; }; useSelfSigned = caTrust.useSelfSigned; caContainerPath = caTrust.caContainerPath; @@ -814,6 +833,78 @@ in }; }; + # One declaration, two readers. The forge knows its own callback URL; + # making the operator restate it in authelia's client list would be a + # second source of truth for a string whose mismatch is a silent + # rejected login. + services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf ssoLocal [ + { + id = cfg.sso.clientId; + description = "HyperHive forge"; + redirectUris = [ ssoRedirectUri ]; + } + ]; + + # Same case, same reasoning: this host minted the secret, so it can + # say where the forge will find it. + services.hyperhive.swarm.forge.sso.clientSecretFile = lib.mkIf ssoLocal ( + lib.mkDefault forgeSecretPath + ); + + # The delivery. It runs on the HOST because that is the only place + # both container trees are addressable: they share this host's + # network namespace, which makes them feel co-located, but their + # filesystem roots are separate — the forge cannot open a path inside + # authelia's tree however local the port looks. + # + # ⚠️ Deliberately a copy and not a `bindMounts` entry. + # nixos-container refuses to start when a bind source is missing, and + # this secret does not exist until authelia's first boot has minted + # it — so binding it would make the forge wait on a file that waits + # on a container that starts after it. On a fresh hive that is a + # permanent stall presenting as "the forge is broken", several layers + # from its cause. + systemd.services.hive-forge-oidc-secret = lib.mkIf ssoLocal { + description = "deliver the forge's OIDC client secret from authelia"; + after = [ "container@${autheliaCfg.machine}.service" ]; + requires = [ "container@${autheliaCfg.machine}.service" ]; + before = [ "container@hive-forge.service" ]; + wantedBy = [ "container@hive-forge.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + SyslogIdentifier = "hive-forge-oidc-secret"; + }; + path = [ pkgs.coreutils ]; + script = '' + set -euo pipefail + + src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.sso.clientId}.secret"} + dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-forge${forgeSecretPath}"} + + # authelia's container is up, but its first-boot generator may + # still be minting. Bounded wait, then fail: a silent skip here + # produces a forge whose SSO button 401s, which is the failure + # this whole design is trying not to ship. + for _ in $(seq 1 60); do + [ -s "$src" ] && break + sleep 2 + done + if [ ! -s "$src" ]; then + echo "authelia has not minted $src after 120s" >&2 + exit 1 + fi + + # The owning uid is DISCOVERED, not assumed: read it off the + # forge container's own state dir. Whatever uid maps to forgejo + # inside that container is by definition the one that owns the + # directory it was created with, and hardcoding a number here + # would be a second place for it to be wrong. + uid=$(stat -c %u /var/lib/nixos-containers/hive-forge/var/lib/forgejo) + install -D -m 0400 -o "$uid" -g "$uid" "$src" "$dst" + ''; + }; + networking.firewall = lib.mkIf cfg.openFirewall { allowedTCPPorts = [ cfg.httpPort diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index fd047d03..c1097d96 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -351,6 +351,28 @@ in ''; }; + hostClientSecretDir = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = "/var/lib/nixos-containers/${cfg.machine}${clientsDir}"; + description = '' + Where the minted client secrets sit **as seen from the host** — + `.secret` holds a plaintext, `.digest` the hash authelia + itself reads. + + Published for the same reason as `hostUsersFile`: the plaintext's + other reader lives in a **different container**, and containers + that share this host's network namespace still have separate + filesystem roots. The host is the only place both trees are + addressable, so the host is where a delivery step has to run. + + ⚠️ Nothing here exists until authelia's **first boot** has run. + A consumer must wait for it — it cannot be a `bindMounts` source, + because nixos-container refuses to start when a bind source is + missing, and that turns a fresh hive into a boot-order deadlock. + ''; + }; + hostUsersFile = lib.mkOption { type = lib.types.str; readOnly = true; From b4a3eb75b001d1d1000fb38125ed3489ae2b46a5 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 11 Aug 2026 21:47:05 +0200 Subject: [PATCH 5/6] 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 From 3de7a4a1b139b694874de8824d8472caae64af9f Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 11 Aug 2026 21:56:06 +0200 Subject: [PATCH 6/6] style(3149): restore the secrets script's original indentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch (argus): most of the pre-existing script body picked up ~14 leading spaces it never had, because replacing an inline `optionalString` with a named binding changed what the formatter treated as the block's base indent. Functionally invisible — nix strips the minimum common indentation and bash ignores the rest — but it made the diff read as "reindented everything, incidentally added a block" instead of "added a block". Net diff on this file is now 231 insertions and 2 deletions. --- nix/host-modules/swarm-authelia.nix | 46 ++++++++++++++--------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 81a73f15..8aca2d44 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -455,32 +455,32 @@ in SyslogIdentifier = "${unitName}-secrets"; }; script = '' - set -euo pipefail + set -euo pipefail - # Each is generated once and never rotated here: the - # session and storage keys are load-bearing for data - # already written (sessions, the encrypted store), so - # replacing one is an operator action, not a boot action. - for f in ${lib.concatStringsSep " " randomKeys}; do - p=${lib.escapeShellArg stateDir}/"$f".key - if [ ! -s "$p" ]; then - head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$p" - echo "generated $p" - fi - chmod 0600 "$p" - done + # Each is generated once and never rotated here: the + # session and storage keys are load-bearing for data + # already written (sessions, the encrypted store), so + # replacing one is an operator action, not a boot action. + for f in ${lib.concatStringsSep " " randomKeys}; do + p=${lib.escapeShellArg stateDir}/"$f".key + if [ ! -s "$p" ]; then + head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$p" + echo "generated $p" + fi + chmod 0600 "$p" + done ${oidcGenScript} - # A users database that exists and parses, with nobody in - # it. authelia refuses to start without one, and the - # alternative to an empty file is a placeholder account — - # which is a credential nobody meant to create. - users=${lib.escapeShellArg cfg.usersFile} - if [ ! -s "$users" ]; then - echo "users: {}" > "$users" - echo "seeded empty users database at $users" - fi - chmod 0600 "$users" + # A users database that exists and parses, with nobody in + # it. authelia refuses to start without one, and the + # alternative to an empty file is a placeholder account — + # which is a credential nobody meant to create. + users=${lib.escapeShellArg cfg.usersFile} + if [ ! -s "$users" ]; then + echo "users: {}" > "$users" + echo "seeded empty users database at $users" + fi + chmod 0600 "$users" ''; };