# The swarm's SSO provider: one authelia for the whole swarm, in a # `swarm-authelia` nixos-container. # # Two halves, and only one of them is conditional: # # - the CLIENT pointer (`url`) exists on every hive, because a hive # that doesn't run authelia still has to know where to send people. # - the CONTAINER only exists where the swarm's shared services live. # `swarm.enableRequiredServices` asserts this module's `enable` # (see ./swarm-required-services.nix); a hive is a client by default. # # Operator and agents are both subjects of the same provider, # differentiated by roles/claims rather than by mechanism — there is one # IdP and one auth path. The users store is therefore written by a # program (swarm-controller), not maintained by hand: agents are created # and destroyed continuously, so the subject set is *dynamic*. That is # also why the file backend is the right one here and not a placeholder # 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: 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 # here. Standing an SSO provider up is reversible; cutting every # operator-facing vhost over to it is not. { pkgs, lib, config, ... }: let cfg = config.services.hyperhive.swarm.authelia; hyperhiveCfg = config.services.hyperhive; gatewayCfg = hyperhiveCfg.gateway; hyperhiveDomain = hyperhiveCfg.domain; swarmDomain = hyperhiveCfg.swarm.domain; uiCfg = hyperhiveCfg.swarm.ui; # Group an account must hold to reach operator-only surfaces. Named # here because this module writes the rule that enforces it and # `swarmctl user add --group ` is what grants it — the two must # agree, and one constant is how they stay agreeing. # # ⚠️ `admins` and not a new word, because `docs/setup.md` and # `docs/swarm/sso.md` have been telling every operator to create # exactly that group since the bootstrap step existed. This is the # first rule that CONSUMES a group name; picking a different one would # have meant every account created by following the guide silently # failing the check it was supposed to pass. operatorGroup = "admins"; # Upstream's `services.authelia.instances.` derives the unit, # user, group and StateDirectory from the instance name # (`authelia` + `-`). Naming them here rather than repeating the # literal keeps the generator unit below and the module in step. instance = "swarm"; unitName = "authelia-${instance}"; stateDir = "/var/lib/${unitName}"; # The SWARM's domain, because that is where the protected apps now live # (`forge.`, `chat.`, `auth.`). It moves in the # same commit as `domain` below and cannot lag it: authelia validates # `authelia_url ⊂ cookie domain` at STARTUP, so a half-move does not # misbehave at login — it refuses to boot. # # Total on a null swarm domain for the same reason the option defaults # 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"; # 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 = { enable = lib.mkOption { type = lib.types.bool; default = false; example = true; description = '' Run the swarm's authelia in a `swarm-authelia` container on this host. `services.hyperhive.swarm.enableRequiredServices` turns this on — a swarm has one SSO provider, and that says it lives here. With it off, this hive is a *client*: `url` below still points at whoever runs it, and no container is created. ''; }; package = lib.mkOption { type = lib.types.package; default = pkgs.authelia; defaultText = lib.literalExpression "pkgs.authelia"; description = '' authelia package to run in the container. Defaults to nixpkgs's; override to pin a specific upstream. ''; }; port = lib.mkOption { type = lib.types.port; default = 9091; description = '' TCP port authelia listens on. 9091 is upstream's default and sits outside hyperhive's claimed ranges (dashboard 7000, forge 3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash). ''; }; domain = lib.mkOption { type = lib.types.str; # Under the SWARM domain, like the forge and matrix: a swarm has one # SSO provider, and the session cookie has to reach the swarm's # services. # # Total on a null swarm domain so the required-domain assertion in # hive-network.nix is the thing that fires; see the comment there. default = if swarmDomain == null then "auth.invalid" else "auth.${swarmDomain}"; defaultText = lib.literalExpression ''"auth.''${services.hyperhive.swarm.domain}"''; example = "login.example.com"; description = '' Public hostname for the SSO provider — the sub-domain shape the forge and matrix already use, under the swarm's domain because a swarm has **one** SSO provider. Must be the name browsers actually visit: it is the `authelia_url` the session cookie is validated against. ⚠️ Unlike the forge and matrix names, this one carries **no migration pin**: nothing depends on the previous `auth.''${services.hyperhive.domain}` yet, so it moves outright. ''; }; url = lib.mkOption { type = lib.types.nullOr lib.types.str; default = if cfg.enable then "https://${cfg.domain}" else null; defaultText = lib.literalExpression ''if enable then "https://''${domain}" else null''; example = "https://auth.example.com"; description = '' Base URL clients are sent to for authentication — the half of this module that exists on **every** hive, not just the one running the container. Defaults to this host's own instance **only when this module is the thing running it**; in that case the URL is not a guess, it is where this module just put the container. Otherwise `null`, and a hive that federates with a swarm sets it explicitly to wherever the swarm's authelia lives. Null means "no SSO configured" and consumers say so rather than inventing an address — an endpoint baked in as a fallback is one that resolves cleanly and points at the wrong machine. ''; }; usersFile = lib.mkOption { type = lib.types.str; default = "${stateDir}/users.yml"; defaultText = lib.literalExpression ''"/var/lib/authelia-swarm/users.yml"''; description = '' Path (inside the container) of authelia's file users database. Written by swarm-controller, not by hand: agents come and go continuously, so the subject set is dynamic and belongs to a program. This module only guarantees the file *exists* and is valid YAML at first boot, so authelia starts with no subjects rather than failing to start — a provider with nobody in it yet is the correct state before anything has provisioned users. ''; }; 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 # the call site: the machine and unit names are derived from # `instance` here, so a second copy elsewhere is a second thing to # keep in step, and the one that drifts is the one nobody tests. machine = lib.mkOption { type = lib.types.str; readOnly = true; default = "swarm-authelia"; description = '' Name of the nixos-container authelia runs in. Read-only: it is what this module declares, published so callers of `systemctl -M` and `/var/lib/nixos-containers/` do not have to hardcode it. ''; }; unit = lib.mkOption { type = lib.types.str; readOnly = true; default = "${unitName}.service"; description = '' authelia's systemd unit *inside* the container. Read-only, and derived from the instance name exactly like the unit itself. ''; }; 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; default = "/var/lib/nixos-containers/${cfg.machine}${cfg.usersFile}"; description = '' `usersFile` as seen from the **host** — the container's root prefixed onto the path authelia sees. The distinction is load-bearing: the users database is written from the host by a program that does not live in this container, while authelia only ever sees the inner path. Handing the wrong one to either side yields a file nobody reads rather than an error. ''; }; }; config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { # Authelia's own gateway surface: the vhost that fronts it and the # name the hive resolver answers for. Both live here rather than in # the gateway, and both are inside `cfg.enable` — that guard is the # load-bearing part. # # ⚠️ Every hive in a swarm knows `authelia.url`, but only the host # that RUNS the container may claim the name. A client hive # declaring this vhost would answer for a service it does not run, # and publishing the DNS record would point every agent on its # bridge at that wrong answer. services.hyperhive.gateway.localNames = [ cfg.domain ]; # `server_name = authelia.domain`, all of `/` → authelia. # # ⚠️ The server name must be exactly `cfg.domain`, not a near-miss: # authelia validates `authelia_url ⊂ session cookie domain` at # STARTUP, so a mismatch is a container that refuses to boot rather # than a login that misbehaves. # # ⚠️ And deliberately NO `dashboardAuth` here. That block is the # gateway's `auth_basic`; applying it to the SSO provider would put # the login page behind the login mechanism it exists to replace. services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // { listen = gatewayCfg.lib.listen; extraConfig = gatewayCfg.lib.securityHeaders; locations."/" = { proxyPass = "http://127.0.0.1:${toString cfg.port}/"; proxyWebsockets = true; extraConfig = '' proxy_buffering off; # authelia decides by the ORIGINAL request, not by the hop it # sees — the login redirect and the session cookie's domain # both derive from these. Without them every request looks # like it arrived at 127.0.0.1 over plain http. proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Uri $request_uri; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # A dead upstream here means "not bootstrapped" far more often # than "misconfigured proxy", and a bare 502 says the opposite. proxy_intercept_errors on; error_page 502 503 504 = /__hive_sso_unavailable; ''; }; locations."= /__hive_sso_unavailable" = { extraConfig = '' internal; alias ${gatewayCfg.lib.errorPages.ssoUnavailable}; default_type text/html; ''; }; }; containers.${cfg.machine} = { autoStart = true; ephemeral = false; # Shared host netns, like the forge and matrix containers: the # gateway reaches authelia at 127.0.0.1:. privateNetwork = false; config = { ... }: { system.stateVersion = "26.05"; # The authelia binary itself, so an operator who gets a shell # in here can run `authelia crypto hash generate` to make a # password for the users file. Without it the container runs # authelia and cannot invoke it: the unit's ExecStart resolves # through the store path, and nothing puts the CLI on PATH. environment.systemPackages = [ cfg.package ]; # This container shares the host netns, so its own # firewall.service would rewrite the HOST ruleset at every # boot. The host firewall owns all filtering. networking.firewall.enable = false; # Keep the host-copied /etc/resolv.conf intact — resolvconf's # host-tracking would regenerate it to an empty file, since # the host's copy doesn't cross the boundary after start. networking.resolvconf.enable = lib.mkForce false; # authelia's own secrets, generated in-container on first # boot. They are jwt/session/storage keys — nothing outside # this container ever reads them, which is what makes # in-container generation right rather than merely easier. # (hive-matrix generates its token host-side only because # hive-c0re has to read that one.) # # Same `User`/`Group`/`StateDirectory` as the authelia unit, # so systemd creates the directory owned by the account that # has to read the files and this unit can write nowhere else. # No chown, no mode juggling: authelia opens these paths # itself, as its own user, under `PrivateUsers=true`. systemd.services."${unitName}-secrets" = { description = "Generate authelia's secrets on first boot"; wantedBy = [ "multi-user.target" ]; before = [ "${unitName}.service" ]; requiredBy = [ "${unitName}.service" ]; # `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; User = unitName; Group = unitName; StateDirectory = unitName; StateDirectoryMode = "0700"; UMask = "0077"; SyslogIdentifier = "${unitName}-secrets"; }; script = '' 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 ${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" ''; }; services.authelia.instances.${instance} = { 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"; 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 # justification: one swarm, one authelia, no replicas. # - file users backend, written by swarm-controller # - local sqlite storage: redis buys shared session state # across replicas, and there is one instance # - filesystem notifier: SMTP is for mailing humans, and # provisioning is programmatic; a file is honest about # where those messages go instead of implying a mail path settings = { theme = "dark"; server.address = "tcp://127.0.0.1:${toString cfg.port}"; log.level = "info"; authentication_backend.file.path = cfg.usersFile; # ⚠️ `one_factor` as the DEFAULT means "any authenticated # user", which is authentication, not authorisation. The # swarm UI is operator-only, and agents are getting # authelia accounts of their own — so the day that lands, # a session alone would be enough to open it. The rule # below is what makes the distinction real; without it # the vhost's `auth_request` is a check nobody fails. # # The group is a constant rather than an option: it is the # value an operator types into `swarmctl user add --group`, # and a configurable name is one more way for the rule and # the account to disagree silently. access_control = { default_policy = "one_factor"; rules = lib.optional uiCfg.enable { domain = uiCfg.domain; subject = [ "group:${operatorGroup}" ]; policy = "one_factor"; }; }; # The cookie domain is the SWARM's domain, NOT authelia's # own host: the session cookie has to be sent to the apps # being protected (`forge.`, `chat.`), and a # cookie scoped to `auth.` reaches none of them. # authelia enforces the relationship from the other side # too — `authelia_url` must be a sub-domain of `domain`, so # setting both to the same host fails validation at startup # rather than at first login. # # ⚠️ Known and accepted consequence while a hive keeps a # domain outside the swarm's tree: this cookie is NOT sent # to that hive's own surfaces (its dashboard), so SSO # covers the swarm's services and not the hive's. It # resolves when the hive domain moves under the swarm # domain; until then it is a scope limit, not a bug to # chase. session.cookies = [ { domain = cookieDomain; authelia_url = "https://${cfg.domain}"; } ]; storage.local.path = "${stateDir}/db.sqlite3"; notifier.filesystem.filename = "${stateDir}/notification.txt"; }; }; }; }; }; }