From 9fa75a7f2f4d79076ab204bcc7921ee14f96b096 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 14 Aug 2026 10:19:50 +0200 Subject: [PATCH 1/4] =?UTF-8?q?feat(3150):=20matrix=20SSO=20options=20?= =?UTF-8?q?=E2=80=94=20opt-in=20OIDC=20relying=20party?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the staged rollout mara laid out on the issue: the options and nothing that changes a running hive. `enable` defaults false, so this commit is inert until someone opts in. Three rules taken from hive-forge/default.nix rather than re-derived, since it is the same pattern already merged and reviewed: - `enable` ADDS a login path and leaves password login alone. An identity provider that can take the homeserver offline when it hiccups is worse than two ways in. Making authelia the only path is tuwunel's `login_with_password`, a separate and reversible switch, deliberately not folded in here. - the client secret is a PATH, never a value: it has two holders in two containers (authelia keeps a hash, the homeserver needs plaintext), and a literal would be rendered into the world-readable store. - required when enabled, no fallback. Worse here than for the forge: tuwunel reads OIDC from its config file rather than a database row, so a malformed block can stop the server outright instead of merely hiding a button. Also records the constraint that shapes the whole issue — matrix SSO lives inside the homeserver, never behind a forward-auth proxy, because the client-server API is spoken by non-browser clients holding matrix access tokens plus federation. --- nix/host-modules/hive-matrix.nix | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 4a7e9d5c..2ac05277 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -394,6 +394,62 @@ in ''; }; }; + + sso = { + enable = lib.mkOption { + type = lib.types.bool; + default = false; + description = '' + Let this homeserver delegate login to the swarm's authelia, + as an OIDC relying party ("matrix next-gen auth"). + + This **adds** a way in. Password login keeps working: an + identity provider that can take the homeserver offline when + it hiccups is a worse homeserver than one with two ways in. + Making authelia the only path is a separate, reversible + switch (tuwunel's `login_with_password`), deliberately not + folded in here. + + ⚠️ Matrix SSO lives **inside** the homeserver, never behind a + forward-auth proxy: the client-server API is spoken by + non-browser clients holding matrix access tokens — every + agent's own `hive-matrix-daemon` — plus federation, and a + proxy in front of `/_matrix/` breaks all of it. + ''; + }; + + clientId = lib.mkOption { + type = lib.types.str; + default = "tuwunel"; + description = '' + OAuth2 client id this homeserver identifies itself with. Must + match the `id` of the corresponding entry in + `services.hyperhive.swarm.authelia.oidc.clients`. + ''; + }; + + clientSecretFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/var/lib/tuwunel-oidc/tuwunel.secret"; + description = '' + Path **inside the matrix container** holding the client + secret's plaintext. + + A path, never a value: an OIDC client secret has two holders + in two containers (authelia keeps a hash, this homeserver + needs the plaintext), and a literal written here would be + rendered into the world-readable nix store. + + Required when `enable` is set — deliberately no fallback. A + homeserver that boots with SSO half-configured is worse than + one that fails to evaluate: tuwunel reads OIDC from its + config file rather than a database row, so a malformed block + can stop the server outright instead of merely hiding a + button. + ''; + }; + }; }; config = lib.mkIf cfg.enable { From 59ecefe0e17fd9ed403a440ce8998d18dec8990f Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 14 Aug 2026 10:50:29 +0200 Subject: [PATCH 2/4] feat(3150): wire tuwunel's identity_provider + register the authelia client Stage 1's actual login path. The provider entry is rendered only when sso.enable is set, via optionalAttrs rather than an empty list, so a hive that has not opted in renders byte-identical tuwunel settings. The secret reaches tuwunel through LoadCredential rather than as a direct path: upstream requires the file to survive systemd sandboxing and suggests /etc/tuwunel/, which this container has no writable etc for. The registration token two units below already solves it the same way, and for the same reason -- DynamicUser and PrivateUsers stay intact. Three assertions fail at eval instead of at boot. tuwunel reads identity providers from its config file, so a half-configured one does not hide a login button, it can stop the homeserver from starting. --- nix/host-modules/hive-matrix.nix | 153 ++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 2 deletions(-) diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 2ac05277..23db1bde 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -22,6 +22,43 @@ let # it is rebuilt from the current CA every boot rather than going stale. matrixCaBundle = "/run/hive-matrix-ca/ca-bundle.crt"; swarmDomain = config.services.hyperhive.swarm.domain; + + # `url` is the half of the authelia module that exists on EVERY hive — + # null when no SSO provider is configured anywhere, which the assertion + # below turns into an eval failure rather than a discovery request to + # `null/.well-known/…`. + autheliaCfg = config.services.hyperhive.swarm.authelia; + autheliaUrl = autheliaCfg.url; + + # The all-local case: this host runs BOTH the homeserver and the swarm's + # authelia, so the secret can be moved without an operator. The other + # two cases (swarm side, remote hive) leave `clientSecretFile` to be set + # explicitly — same split the forge module documents. + ssoLocal = cfg.sso.enable && autheliaCfg.enable; + + # Where the plaintext lands inside the matrix container. Under /var/lib + # rather than /run: the homeserver may start before the delivery unit on + # a later boot, and a secret that evaporates on reboot turns a working + # login into an intermittent one. + matrixSecretPath = "/var/lib/tuwunel-oidc/${cfg.sso.clientId}.secret"; + + # ⚠️ tuwunel does NOT read the path above directly, and this indirection + # is not ceremony. Upstream's own words: "under systemd the path must be + # visible to the service after sandboxing (ReadWritePaths / ProtectHome), + # typically by placing the file under /etc/tuwunel/" — which this + # container has no writable etc for. `LoadCredential` is the answer + # already in use two units below for the registration token, and for the + # same reason: it keeps `DynamicUser=true` + `PrivateUsers=true` intact + # with no host-side chown or GID pinning. + matrixSecretCredential = "/run/credentials/tuwunel.service/oidc_client_secret"; + + # Format-locked by tuwunel, not chosen here: the callback host must point + # directly at the matrix server and the path is fixed at + # `/_matrix/client/unstable/login/sso/callback/`. Built once + # and read twice — by the homeserver's own config and by the client entry + # handed to authelia — because a redirect-URI mismatch is a rejected + # login with no error text worth reading. + ssoCallbackUrl = "https://${toString cfg.gatewayHost}/_matrix/client/unstable/login/sso/callback/${cfg.sso.clientId}"; # Falls back to the SWARM domain: a swarm runs one homeserver, so its # identifier belongs to the swarm rather than to whichever hive happens # to host it — otherwise moving the container between hives would look @@ -401,7 +438,15 @@ in default = false; description = '' Let this homeserver delegate login to the swarm's authelia, - as an OIDC relying party ("matrix next-gen auth"). + as an OIDC relying party — matrix SSO (`m.login.sso`), an + extra flow offered alongside password login. + + ⚠️ Not to be confused with tuwunel's `oidc_*` settings, which + point the other way: those make this homeserver an + *authorization server* for matrix clients. This option makes + it a *client* of an external identity provider. The two + families share the protocol's name and answer opposite + questions. This **adds** a way in. Password login keeps working: an identity provider that can take the homeserver offline when @@ -550,8 +595,80 @@ in "matrix.example.com" or "homeserver.internal". ''; } + { + # Fail at EVAL, not at boot. tuwunel reads its identity providers + # from the config file, so a half-configured one does not hide a + # login button — it can stop the homeserver from starting at all. + assertion = !cfg.sso.enable || cfg.sso.clientSecretFile != null; + message = '' + services.hyperhive.swarm.matrix.sso.enable requires + sso.clientSecretFile — the path (inside the matrix container) + holding the OIDC client secret's plaintext. + + On a hive that also runs the swarm's authelia this is wired up + for you. Set it explicitly when authelia lives on another + host: see docs/swarm/ for which secret goes where. + ''; + } + { + # Without a provider URL there is nothing to discover against, and + # the rendered config would name `null` as its issuer. + assertion = !cfg.sso.enable || autheliaUrl != null; + message = '' + services.hyperhive.swarm.matrix.sso.enable requires + services.hyperhive.swarm.authelia.url — the base URL of the + swarm's SSO provider. + + It defaults to this host's own instance only when this host + runs authelia. A hive that federates with a swarm sets it + explicitly to wherever that provider lives. + ''; + } + { + # The callback URL must name the homeserver itself, and with no + # gateway vhost there is no public name for it to be built from. + assertion = !cfg.sso.enable || cfg.gatewayHost != null; + message = '' + services.hyperhive.swarm.matrix.sso.enable requires + services.hyperhive.swarm.matrix.gatewayHost. + + tuwunel's SSO callback URL is format-locked to + `/_matrix/client/unstable/login/sso/callback/`, + and the identity provider redirects a browser to it — so it + has to be a name the browser can reach, which is exactly what + `gatewayHost` is. With it null the homeserver is direct on + httpPort and has no such name. + ''; + } ]; + # One declaration, two readers. The homeserver knows its own callback + # URL; making the operator restate it in authelia's client list would + # be a second source of truth for a string whose mismatch is a silent + # rejected login. + services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf ssoLocal [ + { + id = cfg.sso.clientId; + description = "HyperHive matrix"; + redirectUris = [ ssoCallbackUrl ]; + } + ]; + + # Same case, same reasoning: this host minted the secret, so it can say + # where the homeserver will find it. + services.hyperhive.swarm.matrix.sso.clientSecretFile = lib.mkIf ssoLocal ( + lib.mkDefault matrixSecretPath + ); + + # ⚠️ Deliberately NO `networking.hosts` entry for authelia's name, and + # the difference from hive-forge (which needs one) is worth stating: + # that container resolves through the host's resolvers, where the swarm + # domain has no records. This one resolves through the hive's dnsmasq + # at `bridgeIp` (see the static resolv.conf below), and every + # `gateway.localNames` entry — authelia's domain among them — is + # already mapped there. Adding a loopback override would only create a + # second answer that can disagree with the first. + # Activation-time token generation — without this the bind-mount # would hand tuwunel an empty file on first boot and break every # registration until restart. Idempotent; @@ -663,6 +780,34 @@ in # Tuwunel's default suffix is " 💕" — suppress it so agent # display names are clean (just the agent name, no emoji). new_user_displayname_suffix = ""; + } + # `optionalAttrs`, not a key set to `[]`: with SSO off the + # rendered settings must be *exactly* what they were before + # this option existed, and an empty list is still a key. + // lib.optionalAttrs cfg.sso.enable { + # tuwunel's OIDC server and this list are the two ends of one + # pipe: `oidc_native_auth` stays false (its default), which + # upstream defines as "the OIDC server runs only to broker + # for a configured identity_provider". So the client-facing + # half needs no configuration — only the upstream half does. + identity_provider = [ + { + # A free, case-insensitive string, not an enum: a + # recognised brand gets defaults and provider-specific + # workarounds, an unrecognised one simply gets neither. + # Which is why `issuer_url` below is not optional for us + # — the pre-supplied issuers cover public providers only. + brand = "authelia"; + client_id = cfg.sso.clientId; + client_secret_file = matrixSecretCredential; + issuer_url = toString autheliaUrl; + callback_url = ssoCallbackUrl; + # Explicit, though a lone provider is auto-defaulted: + # relying on that logs a warning every startup, and a + # recurring warning that is expected is one nobody reads. + default = true; + } + ]; }; }; # Keeps DynamicUser=true + PrivateUsers=true intact — no @@ -670,7 +815,11 @@ in # See `man systemd.exec` → LoadCredential. systemd.services.tuwunel.serviceConfig.LoadCredential = [ "registration_token:${toString cfg.registrationTokenFile}" - ]; + ] + # Same mechanism, second secret. tuwunel re-reads this file on + # every OAuth exchange, not just at startup, so it has to outlive + # the unit's start — a credentials path does. + ++ lib.optional cfg.sso.enable "oidc_client_secret:${toString cfg.sso.clientSecretFile}"; # Federation TLS against a peer whose cert chains to the swarm # root: tuwunel's outbound client is reqwest with the `rustls` From f022e978133a3a91eaa29683eda6c19267fdb8f3 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 14 Aug 2026 10:56:34 +0200 Subject: [PATCH 3/4] feat(3150): deliver the OIDC client secret from authelia to the homeserver Runs on the host: the two containers share a network namespace but not a filesystem root, so this is the only place both trees are addressable. A copy rather than a bindMounts entry. nixos-container refuses to start when a bind source is missing, and the secret does not exist until authelia's first boot has minted it. The registration token dodges that with an activation script that pre-creates the file; that is unavailable here, because tuwunel requires the secret to exist and be non-empty, so a placeholder would satisfy the mount and then stop the homeserver. Bounded wait then fail, never a silent skip: authelia's container can be up while its generator is still minting. --- nix/host-modules/hive-matrix.nix | 60 ++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 23db1bde..93b6973d 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -660,6 +660,66 @@ in lib.mkDefault matrixSecretPath ); + # The delivery. It runs on the HOST because that is the only place both + # container trees are addressable: they share this host's network + # namespace, which makes them feel co-located, but their filesystem + # roots are separate — the homeserver cannot open a path inside + # authelia's tree however local the port looks. + # + # ⚠️ Deliberately a copy and not a `bindMounts` entry. + # nixos-container refuses to start when a bind source is missing, and + # this secret does not exist until authelia's first boot has minted it + # — so binding it would make the homeserver wait on a file that waits + # on a container that starts after it. On a fresh hive that is a + # permanent stall presenting as "matrix is broken", several layers from + # its cause. + # + # The registration token above dodges that with an activation script + # that pre-creates the file. ⚠️ That dodge is NOT available here: + # tuwunel requires the secret file to exist *and be non-empty*, so a + # zero-byte placeholder would satisfy the bind mount and then stop the + # homeserver from starting. + systemd.services.hive-matrix-oidc-secret = lib.mkIf ssoLocal { + description = "deliver the homeserver's OIDC client secret from authelia"; + after = [ "container@${autheliaCfg.machine}.service" ]; + requires = [ "container@${autheliaCfg.machine}.service" ]; + before = [ "container@hive-matrix.service" ]; + wantedBy = [ "container@hive-matrix.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + SyslogIdentifier = "hive-matrix-oidc-secret"; + }; + path = [ pkgs.coreutils ]; + script = '' + set -euo pipefail + + src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.sso.clientId}.secret"} + dst=${lib.escapeShellArg "/var/lib/nixos-containers/hive-matrix${toString cfg.sso.clientSecretFile}"} + + # authelia's container is up, but its first-boot generator may + # still be minting. Bounded wait, then fail: a silent skip here + # produces a homeserver whose SSO login dead-ends, which is the + # failure this whole design is trying not to ship. + for _ in $(seq 1 60); do + [ -s "$src" ] && break + sleep 2 + done + if [ ! -s "$src" ]; then + echo "authelia has not minted $src after 120s" >&2 + exit 1 + fi + + # root-owned 0400, and deliberately NOT the forge's `stat -c %u` + # uid discovery: that reads the service's state dir to learn which + # uid to hand the file to, and tuwunel runs under `DynamicUser`, so + # there is no stable uid to discover. It never reads this path + # directly anyway — `LoadCredential` does, as root, before the + # sandbox and the dynamic user exist. + install -D -m 0400 -o root -g root "$src" "$dst" + ''; + }; + # ⚠️ Deliberately NO `networking.hosts` entry for authelia's name, and # the difference from hive-forge (which needs one) is worth stating: # that container resolves through the host's resolvers, where the swarm From 8d1471430d6e443818309cc3c44c265c50a8cd66 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 14 Aug 2026 10:59:59 +0200 Subject: [PATCH 4/4] docs(3150): swarm SSO covers both relying parties The registration half is shared; what each service does with the result is not, so the differences get a table rather than a second page. States why matrix reads its secret through LoadCredential and why /_matrix/ is not a forward-auth surface -- both are conclusions a reader would otherwise have to re-derive from the module. --- docs/swarm/sso.md | 52 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md index 4f49ca84..c49a2029 100644 --- a/docs/swarm/sso.md +++ b/docs/swarm/sso.md @@ -3,7 +3,7 @@ The swarm runs one authelia, and it is two things at once: the **session provider** every protected vhost checks (`auth_request`), and — once any client is declared — an **OIDC provider** issuing tokens to relying -parties like the forge. +parties: the forge and the matrix homeserver. The second role is derived rather than switched: `services.hyperhive.swarm.authelia.oidc.clients` being non-empty turns it @@ -116,20 +116,20 @@ mechanism with flags. ### 1. All-local — one host runs both -Nothing to configure beyond `swarm.forge.sso.enable = true`. A host-side -unit waits for authelia's first boot to mint the secret and copies it -into the forge container, and the forge module contributes its own client +Nothing to configure beyond `swarm.forge.sso.enable = true` or +`swarm.matrix.sso.enable = true`. Per service, a host-side unit waits for +authelia's first boot to mint that client's secret and copies it into the +service's container, and the service's own module contributes its client entry — callback URL included — to authelia's client list. -The callback is built from the same source name the registration uses, so -the redirect URI authelia is told to allow and the one forgejo actually -sends cannot drift apart. A mismatch there is a rejected login with no -error text worth reading. +The callback is built once and read twice, so the redirect URI authelia is +told to allow and the one the service actually sends cannot drift apart. A +mismatch there is a rejected login with no error text worth reading. ⚠️ The delivery is a copy, not a `bindMounts` entry, and deliberately so: nixos-container refuses to start a container whose bind source is missing, and this secret does not exist until authelia's first boot has -run. Binding it would make the forge wait on a file that waits on a +run. Binding it would make the service wait on a file that waits on a container that starts after it — on a fresh hive, a permanent stall presenting as "the forge is broken", several layers from its cause. @@ -159,12 +159,38 @@ half-configured shows a login button that always fails — a symptom several layers from its cause, and far worse to diagnose than an evaluation error. +## Where each relying party differs + +The registration half is identical; what each service does with the +result is not. + +| | forge | matrix | +|---|---|---| +| how it learns the config | a oneshot calls `forgejo admin auth`, writing a login-source row into its database | tuwunel reads a `[[global.identity_provider]]` entry from its config file | +| how it reads the secret | a path inside its container | the same path, handed on by `LoadCredential` | +| callback URL | `/user/oauth2//callback` | `/_matrix/client/unstable/login/sso/callback/`, a shape tuwunel fixes rather than accepts | +| cost of a malformed entry | the login source is missing | the homeserver can refuse to start | + +Two consequences worth stating plainly: + +- **tuwunel re-reads its secret file on every OAuth exchange**, not only + at startup, and its own sandboxing hides most paths from it. It gets the + file through `LoadCredential` for the same reason the registration token + does — that keeps `DynamicUser` and `PrivateUsers` intact, with no + host-side ownership arrangement to maintain. +- **Matrix SSO lives inside the homeserver.** The client-server API is + spoken by non-browser clients holding matrix access tokens — every + agent's own daemon — as well as by federation, so `/_matrix/` is served + directly and authenticates itself. The forward-auth vhosts protect + browser surfaces; this is not one of them. + ## What this does not do -- **It does not disable local login.** The forge keeps its password - database and gains a second door. An identity provider that can take - the forge offline when it hiccups is a worse forge than one with two - ways in. +- **It does not disable local login.** Each service keeps its password + database and gains a second door. An identity provider that can take a + service offline when it hiccups is worse than one with two ways in. + Making authelia the only path is a separate, reversible switch per + service (tuwunel's `login_with_password`, forgejo's own setting). - **It does not provision users.** Agents are created and destroyed continuously, so the subject set belongs to a program rather than to a config file; today that program is `swarmctl`.