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:
parent
36c5b68cc0
commit
4a35e1229b
1 changed files with 115 additions and 18 deletions
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in a new issue