diff --git a/docs/swarm/README.md b/docs/swarm/README.md index fc6f3c79..77c87247 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -88,12 +88,6 @@ 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 deleted file mode 100644 index 0c1c5b61..00000000 --- a/docs/swarm/sso.md +++ /dev/null @@ -1,92 +0,0 @@ -# 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 518a63a0..7a59a3ce 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -23,38 +23,6 @@ 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; @@ -346,86 +314,10 @@ 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 = '' @@ -756,155 +648,9 @@ 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 8aca2d44..49ad4d07 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -18,10 +18,6 @@ # 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 @@ -56,118 +52,6 @@ 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 = { @@ -267,59 +151,6 @@ 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 @@ -348,28 +179,6 @@ 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; @@ -433,17 +242,7 @@ in 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 - ]; + path = [ pkgs.coreutils ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; @@ -461,7 +260,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 ${lib.concatStringsSep " " randomKeys}; do + 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" @@ -469,7 +268,6 @@ 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 @@ -488,37 +286,10 @@ 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