feat(3149): authelia grows an OIDC provider, derived from its clients

The swarm's authelia has been a session / `auth_request` provider only.
SSO for the forge (and matrix behind it) needs the second role: an OIDC
provider that issues tokens to relying parties.

The provider is derived from `oidc.clients` rather than carrying its own
`enable`, because authelia refuses to start with a provider that has no
clients — a separate flag would be a second fact free to disagree with
the first. The list defaults to empty, so a hive that has not opted in
renders exactly what it rendered before.

Its two secrets are generated in-container by the existing first-boot
unit, which is the same test that unit already applies to the jwt,
session and storage keys: nothing outside this container reads them. The
hmac key is 64 random bytes and joins that loop; the issuer key is an RSA
pair, because it *signs* id tokens and relying parties verify them
against the public half at `/jwks.json` — a symmetric secret cannot serve
that.

No client secret appears here, and that is the point: a client secret has
two holders in two containers, and `settings` is rendered into the world-
readable nix store. Minting it is the next commit's problem.
This commit is contained in:
atlas 2026-08-11 20:59:34 +02:00 committed by mara
commit 36c5b68cc0

View file

@ -18,6 +18,13 @@
# 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, and only the first is unconditional: this is a **session**
# provider (`auth_request`) always, and an **OIDC** provider when
# `oidc.clients` is non-empty. The second is derived from the client list
# instead of carrying its own flag, because authelia refuses to start
# with a provider that has no clients — a separate `enable` would be a
# second fact that can disagree with the first.
#
# 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
@ -52,6 +59,22 @@ 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";
in
{
options.services.hyperhive.swarm.authelia = {
@ -151,6 +174,59 @@ 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.<n>.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
@ -242,7 +318,7 @@ in
wantedBy = [ "multi-user.target" ];
before = [ "${unitName}.service" ];
requiredBy = [ "${unitName}.service" ];
path = [ pkgs.coreutils ];
path = [ pkgs.coreutils ] ++ lib.optional oidcEnabled pkgs.openssl;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
@ -254,31 +330,48 @@ in
SyslogIdentifier = "${unitName}-secrets";
};
script = ''
set -euo pipefail
set -euo pipefail
# Each is generated once and never rotated here: the
# 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 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"
echo "generated $p"
# Each is generated once and never rotated here: the
# 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
p=${lib.escapeShellArg stateDir}/"$f".key
if [ ! -s "$p" ]; then
head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$p"
echo "generated $p"
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 "$p"
done
chmod 0600 "$issuer"
''}
# A users database that exists and parses, with nobody in
# it. authelia refuses to start without one, and the
# alternative to an empty file is a placeholder account —
# which is a credential nobody meant to create.
users=${lib.escapeShellArg cfg.usersFile}
if [ ! -s "$users" ]; then
echo "users: {}" > "$users"
echo "seeded empty users database at $users"
fi
chmod 0600 "$users"
# A users database that exists and parses, with nobody in
# it. authelia refuses to start without one, and the
# alternative to an empty file is a placeholder account —
# which is a credential nobody meant to create.
users=${lib.escapeShellArg cfg.usersFile}
if [ ! -s "$users" ]; then
echo "users: {}" > "$users"
echo "seeded empty users database at $users"
fi
chmod 0600 "$users"
'';
};
@ -290,6 +383,26 @@ in
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 "<path>" }}`) 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