feat(3149): mint each OIDC client's secret on first boot

A client secret has two holders in two containers: the relying party
authenticates with the plaintext, authelia compares a digest. Neither
side can generate it alone, and `settings` is rendered into the
world-readable nix store, so the value cannot be declared.

So it is minted here, once, as two files — `<id>.secret` and
`<id>.digest`. That split is what lets `oidc-clients.yml` be re-rendered
on *every* boot from the nix-declared metadata plus the stored digest: a
new redirect URI or a renamed client takes effect on rebuild without
rotating a credential another container is already holding.

`authelia crypto hash generate pbkdf2 --random` generates the password
itself and prints it beside its digest, so no plaintext is ever handed to
a second process on a command line. The clients file reaches authelia
through `settingsFiles`, which upstream merges at runtime — the same
mechanism it already uses for the issuer JWK.

Minting fails closed: an empty secret or digest aborts the unit, and the
unit is `requiredBy` authelia, so the provider refuses to start rather
than serving a client that can never authenticate. That failure would
otherwise surface three layers away as an opaque 401 from the token
endpoint.
This commit is contained in:
atlas 2026-08-11 21:13:46 +02:00 committed by mara
commit 4a35e1229b

View file

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