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/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 7a59a3ce..518a63a0 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -23,6 +23,38 @@ 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/...`. + 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; @@ -314,10 +346,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,9 +756,155 @@ 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 + ''; + }; }; }; + # 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 49ad4d07..8aca2d44 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -18,6 +18,10 @@ # 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 @@ -52,6 +56,118 @@ 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"; + + # 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 = { @@ -151,6 +267,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 @@ -179,6 +348,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; @@ -242,7 +433,17 @@ in wantedBy = [ "multi-user.target" ]; before = [ "${unitName}.service" ]; requiredBy = [ "${unitName}.service" ]; - path = [ pkgs.coreutils ]; + # `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; @@ -260,7 +461,7 @@ in # 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 + 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" @@ -268,6 +469,7 @@ in 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 @@ -286,10 +488,37 @@ 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"; 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