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 + ''; + }; }; };