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.
This commit is contained in:
atlas 2026-08-14 10:50:29 +02:00
commit 59ecefe0e1

View file

@ -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/<client_id>`. 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
`<homeserver>/_matrix/client/unstable/login/sso/callback/<client_id>`,
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`