Compare commits

...
Author SHA1 Message Date
atlas
8d1471430d 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.
2026-08-14 10:59:59 +02:00
atlas
f022e97813 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.
2026-08-14 10:56:34 +02:00
atlas
59ecefe0e1 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.
2026-08-14 10:50:29 +02:00
atlas
9fa75a7f2f feat(3150): matrix SSO options — opt-in OIDC relying party
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.
2026-08-14 10:19:50 +02:00
2 changed files with 305 additions and 14 deletions

View file

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

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
@ -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
`<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
);
# 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`