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";