diff --git a/docs/swarm/sso.md b/docs/swarm/sso.md index c49a2029..4f49ca84 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: the forge and the matrix homeserver. +parties like the forge. 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` 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 +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 entry — callback URL included — to authelia's client list. -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 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 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 service wait on a file that waits on a +run. Binding it would make the forge 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,38 +159,12 @@ 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.** 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 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 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 93b6973d..4a7e9d5c 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -22,43 +22,6 @@ 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 @@ -431,70 +394,6 @@ 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 { @@ -595,140 +494,8 @@ 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; @@ -840,34 +607,6 @@ 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 @@ -875,11 +614,7 @@ 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`