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`. diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index 4a7e9d5c..93b6973d 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 @@ -394,6 +431,70 @@ 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 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 + 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 { @@ -494,8 +595,140 @@ 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 + ); + + # 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 + # 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; @@ -607,6 +840,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 @@ -614,7 +875,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`