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"
|
"storage-encryption"
|
||||||
]
|
]
|
||||||
++ lib.optional oidcEnabled "oidc-hmac";
|
++ 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
|
in
|
||||||
{
|
{
|
||||||
options.services.hyperhive.swarm.authelia = {
|
options.services.hyperhive.swarm.authelia = {
|
||||||
|
|
@ -318,7 +414,17 @@ in
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
before = [ "${unitName}.service" ];
|
before = [ "${unitName}.service" ];
|
||||||
requiredBy = [ "${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 = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
|
|
@ -344,23 +450,7 @@ in
|
||||||
fi
|
fi
|
||||||
chmod 0600 "$p"
|
chmod 0600 "$p"
|
||||||
done
|
done
|
||||||
${lib.optionalString oidcEnabled ''
|
${oidcGenScript}
|
||||||
# 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"
|
|
||||||
''}
|
|
||||||
|
|
||||||
# A users database that exists and parses, with nobody in
|
# A users database that exists and parses, with nobody in
|
||||||
# it. authelia refuses to start without one, and the
|
# it. authelia refuses to start without one, and the
|
||||||
|
|
@ -379,6 +469,13 @@ in
|
||||||
enable = true;
|
enable = true;
|
||||||
package = cfg.package;
|
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 = {
|
secrets = {
|
||||||
jwtSecretFile = "${stateDir}/jwt.key";
|
jwtSecretFile = "${stateDir}/jwt.key";
|
||||||
sessionSecretFile = "${stateDir}/session.key";
|
sessionSecretFile = "${stateDir}/session.key";
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue