From 36c5b68cc076b63bb7a10664ef2223addaf81cc5 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 11 Aug 2026 20:59:34 +0200 Subject: [PATCH] feat(3149): authelia grows an OIDC provider, derived from its clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- nix/host-modules/swarm-authelia.nix | 159 ++++++++++++++++++++++++---- 1 file changed, 136 insertions(+), 23 deletions(-) diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 49ad4d07..4ca3e807 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -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..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 "" }}`) 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