Compare commits

...
Author SHA1 Message Date
atlas
3de7a4a1b1 style(3149): restore the secrets script's original indentation
Review catch (argus): most of the pre-existing script body picked up ~14
leading spaces it never had, because replacing an inline `optionalString`
with a named binding changed what the formatter treated as the block's
base indent. Functionally invisible — nix strips the minimum common
indentation and bash ignores the rest — but it made the diff read as
"reindented everything, incidentally added a block" instead of "added a
block".

Net diff on this file is now 231 insertions and 2 deletions.
2026-08-11 21:56:58 +02:00
atlas
b4a3eb75b0 docs(3149): what the SSO secrets are and where each one lives
The question this answers is "what do I have to configure, and where" —
so the table of secrets is the deliverable and the prose is scaffolding
around it.

The organising idea worth keeping: a secret belongs in-container when
nothing outside that container reads it. Every one of authelia's own
secrets passes that test; the client secret's plaintext fails it, which
is what makes delivery a problem at all rather than a detail.
2026-08-11 21:56:58 +02:00
atlas
cdb3c612b2 feat(3149): deliver the client secret between the two containers
The all-local case from the delivery ruling: when one host runs both the
forge and the swarm's authelia, nothing should need an operator.

Two containers, one secret, and the awkward part is that they share this
host's network namespace but not its filesystem. They reach each other on
127.0.0.1, which makes them feel co-located — the forge still cannot open
a path inside authelia's tree. The host is the only place both are
addressable, so the copy runs there, and `hostClientSecretDir` publishes
the outside view of the inside path exactly as `hostUsersFile` already
does for the users database.

Deliberately a copy rather than 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 — binding it would make
the forge wait on a file that waits on a container that starts after it.
On a fresh hive that is a permanent stall presenting as "the forge is
broken", several layers from its cause.

The owning uid is discovered from the forge container's own state dir
rather than assumed. Whatever uid maps to forgejo inside that container
already owns the directory it was created with; writing a number here
would be a second place for it to be wrong.

The client entry is contributed to authelia's list by the forge module
itself, from the same source-name constant the registration uses, so the
redirect URI authelia allows and the one forgejo sends cannot drift.
A mismatch there is a rejected login with no error text worth reading.
2026-08-11 21:56:58 +02:00
atlas
daa8a2eb4b feat(3149): the forge registers authelia as an OIDC login source
Additive, never exclusive: forgejo keeps its local password database
and gains a second way in. An identity provider that can take the forge
offline when it hiccups is a worse forge than one with two doors.

A login source in forgejo is a database ROW, not an `app.ini` key, so
this is a unit rather than config. It is ordered AFTER forgejo — unlike
its neighbour `forgejo-gpg-init`, which runs before — because on a fresh
hive that database does not exist until forgejo has started and
migrated; running first would either fail or initialise a schema behind
the server's back.

Idempotency is by query (`admin auth list`), not by a stamp file: the
same reasoning already written down for the GPG key next to it, that a
stamp outlives a state wipe and then suppresses the repair.

Two assertions rather than defaults, both firing at eval: SSO needs a
secret path, and it needs somewhere to discover the provider. Either
one missing produces a login button that always fails — a runtime
symptom several layers from its cause, which is exactly the trade an
eval error is worth making.

The secret is read from a path and passed on argv for one exec, because
`--secret` is the only input forgejo offers — no `--secret-file`, no env
var, though its sibling `forgejo-cli actions register` has both. Inside
this container the value is already at rest in the login-source row and
the only principals are root and forgejo, so argv widens its readership
to nobody new. Accepted deliberately, not overlooked.
2026-08-11 21:56:58 +02:00
atlas
4a35e1229b feat(3149): mint each OIDC client's secret on first boot
A client secret has two holders in two containers: the relying party
authenticates with the plaintext, authelia compares a digest. Neither
side can generate it alone, and `settings` is rendered into the
world-readable nix store, so the value cannot be declared.

So it is minted here, once, as two files — `<id>.secret` and
`<id>.digest`. That split is what lets `oidc-clients.yml` be re-rendered
on *every* boot from the nix-declared metadata plus the stored digest: a
new redirect URI or a renamed client takes effect on rebuild without
rotating a credential another container is already holding.

`authelia crypto hash generate pbkdf2 --random` generates the password
itself and prints it beside its digest, so no plaintext is ever handed to
a second process on a command line. The clients file reaches authelia
through `settingsFiles`, which upstream merges at runtime — the same
mechanism it already uses for the issuer JWK.

Minting fails closed: an empty secret or digest aborts the unit, and the
unit is `requiredBy` authelia, so the provider refuses to start rather
than serving a client that can never authenticate. That failure would
otherwise surface three layers away as an opaque 401 from the token
endpoint.
2026-08-11 21:56:58 +02:00
atlas
36c5b68cc0 feat(3149): authelia grows an OIDC provider, derived from its clients
The swarm's authelia has been a session / `auth_request` provider only.
SSO for the forge (and matrix behind it) needs the second role: an OIDC
provider that issues tokens to relying parties.

The provider is derived from `oidc.clients` rather than carrying its own
`enable`, because authelia refuses to start with a provider that has no
clients — a separate flag would be a second fact free to disagree with
the first. The list defaults to empty, so a hive that has not opted in
renders exactly what it rendered before.

Its two secrets are generated in-container by the existing first-boot
unit, which is the same test that unit already applies to the jwt,
session and storage keys: nothing outside this container reads them. The
hmac key is 64 random bytes and joins that loop; the issuer key is an RSA
pair, because it *signs* id tokens and relying parties verify them
against the public half at `/jwks.json` — a symmetric secret cannot serve
that.

No client secret appears here, and that is the point: a client secret has
two holders in two containers, and `settings` is rendered into the world-
readable nix store. Minting it is the next commit's problem.
2026-08-11 21:56:58 +02:00
4 changed files with 583 additions and 2 deletions

View file

@ -88,6 +88,12 @@ One authelia, one matrix, one forge per swarm — which host runs them,
and what a hive that runs none of them configures instead:
[`services.md`](services.md).
## Single sign-on
Which secrets the SSO provider generates, which one has a reader in
another container, and the three ways that one gets delivered:
[`sso.md`](sso.md).
## The swarm's hive directory
```nix

92
docs/swarm/sso.md Normal file
View file

@ -0,0 +1,92 @@
# Swarm SSO
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.
The second role is derived rather than switched:
`services.hyperhive.swarm.authelia.oidc.clients` being non-empty turns it
on. authelia refuses to start with a provider that has no clients, so a
separate `enable` would be a second fact free to disagree with the first.
## What secrets exist, and where each one lives
| secret | generated by | rests in | read by |
|---|---|---|---|
| `jwt.key`, `session.key`, `storage-encryption.key` | authelia's first-boot unit | `/var/lib/authelia-swarm/` | authelia |
| `oidc-hmac.key` | same unit | same directory | authelia |
| `oidc-issuer.key` (RSA) | same unit | same directory | authelia signs with it; clients verify the **public** half at `/jwks.json` |
| `oidc-clients/<id>.digest` | same unit, via `authelia crypto hash generate` | same directory, merged in through `settingsFiles` | authelia |
| `oidc-clients/<id>.secret` | the same mint — this is its plaintext half | same directory | **the relying party, in another container** |
Everything above the last row is generated in-container because nothing
outside that container ever reads it. That is the test worth applying to
any secret added here. The last row fails it, and that is the entire
reason a delivery step exists.
**None of it is ever written into a nix expression.** authelia's
`settings` are rendered into the nix store, which is world-readable and
permanent, so the client digest reaches authelia through `settingsFiles`
(merged at runtime) and every other secret through a `*File` option
carrying a path rather than a value.
## Getting the plaintext to the relying party
Three cases, and they are genuinely different mechanisms rather than one
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
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 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
container that starts after it — on a fresh hive, a permanent stall
presenting as "the forge is broken", several layers from its cause.
### 2. Swarm-managed services
The controller side owns provisioning: `swarmctl` writes both halves, the
same way it already owns authelia's user store (`users.json` canonical,
`users.yml` a rendered artifact).
### 3. A hive elsewhere
No shared host, so no automatic path. The operator provides the file and
names it:
```nix
services.hyperhive.swarm = {
authelia.url = "https://auth.example.com";
forge.sso = {
enable = true;
clientSecretFile = "/var/lib/hyperhive/forge-oidc-secret";
};
};
```
**Both are asserted at eval.** A hive that boots with SSO
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.
## 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 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

@ -23,6 +23,38 @@ let
# that make the CA reachable are shared with hive-ci via the
# `hive-ca-trust` helper; only the Go SSL_CERT_FILE concat below is
# hive-forge-specific.
# Forgejo's name for the login source. A constant, not an option: it
# is the key this module's own idempotency check looks up, so making
# it configurable would buy nothing and add a way for the lookup and
# the row to disagree.
ssoSourceName = "authelia";
# `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;
autheliaDiscoveryUrl = "${toString autheliaUrl}/.well-known/openid-configuration";
# The all-local case: this host runs BOTH the forge and the swarm's
# authelia, so the secret can be moved without an operator. The other
# two cases (swarm side via swarmctl, remote hive) leave
# `clientSecretFile` to be set explicitly — see docs/swarm/.
ssoLocal = cfg.sso.enable && autheliaCfg.enable;
# Where the plaintext lands inside the forge container. Under
# /var/lib rather than /run: the forge 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.
forgeSecretPath = "/var/lib/forgejo-oidc/${cfg.sso.clientId}.secret";
# Forgejo's OAuth2 callback shape. Built from the SAME `ssoSourceName`
# the registration uses, so the redirect URI authelia is told to allow
# and the one forgejo will actually send cannot drift apart — a
# mismatch there is a rejected login with no error text worth reading.
ssoRedirectUri = "${effectiveRootUrl}user/oauth2/${ssoSourceName}/callback";
caTrust = import ../lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
useSelfSigned = caTrust.useSelfSigned;
caContainerPath = caTrust.caContainerPath;
@ -314,10 +346,86 @@ in
external DNS, but that's off the CI path).
'';
};
sso = {
enable = lib.mkOption {
type = lib.types.bool;
default = false;
example = true;
description = ''
Register the swarm's authelia as an OpenID Connect login
source on this forge.
**Additive, never exclusive.** Forgejo keeps its local
password database and gains an extra "sign in with" button;
this does not disable local login. Deliberate: an identity
provider that can take the forge offline when it hiccups is a
worse forge than one with two ways in.
'';
};
clientId = lib.mkOption {
type = lib.types.str;
default = "forgejo";
description = ''
OAuth2 client id this forge 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/hyperhive/forge-oidc-secret";
description = ''
Path **inside the forge 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 forge 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
forge that boots with SSO half-configured presents as a login
button that always fails, which is harder to diagnose than an
eval error.
'';
};
};
};
config = lib.mkIf config.services.hyperhive.enable {
assertions = [
{
# Fail at EVAL, not at boot. The alternative failure is a login
# button that always 401s, three layers from the missing file.
assertion = !cfg.sso.enable || cfg.sso.clientSecretFile != null;
message = ''
services.hyperhive.swarm.forge.sso.enable requires
sso.clientSecretFile the path (inside the forge 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 unit would ask `null/.well-known/…`.
assertion = !cfg.sso.enable || autheliaUrl != null;
message = ''
services.hyperhive.swarm.forge.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.
'';
}
{
assertion = cfg.rootUrl == null || lib.hasSuffix "/" cfg.rootUrl;
message = ''
@ -648,9 +756,155 @@ in
fi
'';
};
# Register authelia as an OIDC login source.
#
# ⚠️ Ordered AFTER forgejo, unlike forgejo-gpg-init above, and
# the difference is not stylistic: a login source is a row in
# forgejo's database, and on a fresh hive that database does
# not exist until forgejo has started and run its migrations.
# Running first would either fail or let the CLI initialise a
# schema behind the server's back.
#
# Idempotency is by QUERY, not by a stamp file — same reason
# spelled out for the GPG key above: a stamp survives a state
# wipe that took the thing it claims exists, and then suppresses
# the repair.
systemd.services.forgejo-sso-source = lib.mkIf cfg.sso.enable {
description = "register authelia as Forgejo's OIDC login source";
after = [ "forgejo.service" ];
requires = [ "forgejo.service" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
User = "forgejo";
Group = "forgejo";
SyslogIdentifier = "forgejo-sso-source";
};
# `FORGEJO_CUSTOM` (not `GITEA_CUSTOM` — forgejo renamed it)
# is how the CLI finds the app.ini upstream's module wrote.
environment.FORGEJO_CUSTOM = "/var/lib/forgejo/custom";
path = [
cfg.package
pkgs.coreutils
pkgs.gnugrep
];
script = ''
set -euo pipefail
secret=$(cat ${lib.escapeShellArg cfg.sso.clientSecretFile})
if [ -z "$secret" ]; then
echo "empty OIDC client secret at ${cfg.sso.clientSecretFile}" >&2
exit 1
fi
# `--secret` is the only input forgejo offers: there is no
# --secret-file and no env var, though its sibling
# `forgejo-cli actions register` has both. So the value is
# on this argv for the length of one exec, inside this
# container, where forgejo already stores it at rest in the
# login-source row — root and forgejo are the only
# principals here, and root can read the resting copy
# anyway. Accepted deliberately (see docs/swarm/), not
# overlooked; upstream gap filed.
args="--provider openidConnect \
--key ${lib.escapeShellArg cfg.sso.clientId} \
--auto-discover-url ${lib.escapeShellArg autheliaDiscoveryUrl} \
--scopes ${lib.escapeShellArg "openid profile email groups"}"
# shellcheck disable=SC2086
if forgejo admin auth list | grep -q "[[:space:]]${ssoSourceName}[[:space:]]"; then
id=$(forgejo admin auth list \
| grep "[[:space:]]${ssoSourceName}[[:space:]]" \
| cut -f1)
forgejo admin auth update-oauth --id "$id" \
--name ${lib.escapeShellArg ssoSourceName} \
--secret "$secret" $args
echo "updated OIDC login source ${ssoSourceName} (id $id)"
else
forgejo admin auth add-oauth \
--name ${lib.escapeShellArg ssoSourceName} \
--secret "$secret" $args
echo "added OIDC login source ${ssoSourceName}"
fi
'';
};
};
};
# One declaration, two readers. The forge 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 forge";
redirectUris = [ ssoRedirectUri ];
}
];
# Same case, same reasoning: this host minted the secret, so it can
# say where the forge will find it.
services.hyperhive.swarm.forge.sso.clientSecretFile = lib.mkIf ssoLocal (
lib.mkDefault forgeSecretPath
);
# 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 forge 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 forge wait on a file that waits
# on a container that starts after it. On a fresh hive that is a
# permanent stall presenting as "the forge is broken", several layers
# from its cause.
systemd.services.hive-forge-oidc-secret = lib.mkIf ssoLocal {
description = "deliver the forge's OIDC client secret from authelia";
after = [ "container@${autheliaCfg.machine}.service" ];
requires = [ "container@${autheliaCfg.machine}.service" ];
before = [ "container@hive-forge.service" ];
wantedBy = [ "container@hive-forge.service" ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
SyslogIdentifier = "hive-forge-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-forge${forgeSecretPath}"}
# authelia's container is up, but its first-boot generator may
# still be minting. Bounded wait, then fail: a silent skip here
# produces a forge whose SSO button 401s, 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
# The owning uid is DISCOVERED, not assumed: read it off the
# forge container's own state dir. Whatever uid maps to forgejo
# inside that container is by definition the one that owns the
# directory it was created with, and hardcoding a number here
# would be a second place for it to be wrong.
uid=$(stat -c %u /var/lib/nixos-containers/hive-forge/var/lib/forgejo)
install -D -m 0400 -o "$uid" -g "$uid" "$src" "$dst"
'';
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [
cfg.httpPort

View file

@ -18,6 +18,10 @@
# for LDAP: what makes a directory necessary is the size of the subject
# set, and this deployment's is bounded by one swarm.
#
# Two roles: a **session** provider always, an **OIDC** provider when
# `oidc.clients` is non-empty (derived, not flagged — authelia will not
# start with a clientless provider). Secrets map: docs/swarm/sso.md.
#
# Per-service integration — putting authelia's `auth_request` in front
# of the gateway's existing `auth_basic` locations — is deliberately NOT
# here. Standing an SSO provider up is reversible; cutting every
@ -52,6 +56,118 @@ let
# below are: the required-domain assertion in hive-network.nix should
# be what an operator sees, not a coercion error from here.
cookieDomain = if swarmDomain == null then "invalid" else swarmDomain;
# authelia refuses to start with an OIDC provider that has no clients,
# so the provider is derived from the client list rather than carrying
# its own `enable`: one fact, and it cannot contradict itself. An empty
# list is the default, which makes every hive that has not opted in
# byte-identical to before.
oidcEnabled = cfg.oidc.clients != [ ];
# Secrets that are 64 random bytes of hex and nothing more. The OIDC
# hmac key joins them; the issuer key does not (see below — it is RSA).
randomKeys = [
"jwt"
"session"
"storage-encryption"
]
++ lib.optional oidcEnabled "oidc-hmac";
# Per-client material lives beside the rest of authelia's state, one
# file per half: the relying party needs the PLAINTEXT, authelia keeps
# only a DIGEST. Splitting them is what lets the clients file below be
# re-rendered on every boot from a secret that was minted once.
clientsDir = "${stateDir}/oidc-clients";
clientsFile = "${stateDir}/oidc-clients.yml";
# Rendered at RUNTIME, not evaluated: the digest is read from disk by
# the script, so nothing secret ever enters a nix expression (and
# therefore the store). Everything else here is public metadata that
# nix is the right place for.
renderClient = c: ''
printf -- ' - client_id: %s\n' ${lib.escapeShellArg c.id}
printf -- ' client_name: %s\n' ${lib.escapeShellArg c.description}
printf -- " client_secret: '%s'\n" "$(cat ${lib.escapeShellArg "${clientsDir}/${c.id}.digest"})"
printf -- ' authorization_policy: one_factor\n'
printf -- ' scopes: [openid, profile, email, groups]\n'
printf -- ' redirect_uris:\n'
${lib.concatMapStrings (u: ''
printf -- ' - %s\n' ${lib.escapeShellArg u}
'') c.redirectUris}
'';
# The OIDC half of the first-boot generator, kept out of the script
# body so neither is read through the other's indentation.
oidcGenScript = lib.optionalString oidcEnabled ''
# The issuer key is the one secret here that is NOT interchangeable
# with a random blob: it *signs* id tokens, and every relying party
# verifies them against the public half served at `/jwks.json`. A
# symmetric secret cannot do that, so this one is an RSA pair.
#
# Rotating it invalidates every token already issued, which is why
# it is generated once and left alone — same reason as the session
# and storage keys above.
issuer=${lib.escapeShellArg stateDir}/oidc-issuer.key
if [ ! -s "$issuer" ]; then
openssl genrsa -out "$issuer" 4096
echo "generated $issuer"
fi
chmod 0600 "$issuer"
# Each client's secret, minted once and kept as two files: the
# plaintext its relying party authenticates with, and the digest
# authelia compares against. The two live in different containers,
# so neither side can generate it alone — this is the only place
# that sees both.
#
# `--random` is why no plaintext ever reaches an argv: authelia
# generates the password itself and prints it beside its digest,
# so nothing has to be handed to a second process on a command
# line.
clients=${lib.escapeShellArg clientsDir}
mkdir -p "$clients"
chmod 0700 "$clients"
mint() {
sec="$clients/$1.secret"
dig="$clients/$1.digest"
if [ -s "$sec" ] && [ -s "$dig" ]; then
chmod 0600 "$sec" "$dig"
return
fi
out=$(authelia crypto hash generate pbkdf2 --variant sha512 --random)
printf '%s' "$out" | sed -n 's/^Random Password: *//p' > "$sec"
printf '%s' "$out" | sed -n 's/^Digest: *//p' > "$dig"
chmod 0600 "$sec" "$dig"
# Fail closed. An empty secret is a client that can never
# authenticate, and it surfaces three layers away as an opaque
# 401 from the token endpoint — refusing to start is by far the
# cheaper failure to diagnose.
if [ ! -s "$sec" ] || [ ! -s "$dig" ]; then
echo "authelia crypto hash generate produced no secret/digest for $1" >&2
exit 1
fi
echo "minted client secret for $1"
}
${lib.concatMapStrings (c: ''
mint ${lib.escapeShellArg c.id}
'') cfg.oidc.clients}
# Re-rendered every boot, deliberately: the secret is minted once,
# but the metadata around it (a new redirect URI, a renamed client)
# comes from nix and has to be able to change without disturbing
# the secret. Written through a temp file so a crash mid-write
# cannot leave authelia half a file to parse.
{
printf -- 'identity_providers:\n'
printf -- ' oidc:\n'
printf -- ' clients:\n'
${lib.concatMapStrings renderClient cfg.oidc.clients}
} > ${lib.escapeShellArg "${clientsFile}.tmp"}
chmod 0600 ${lib.escapeShellArg "${clientsFile}.tmp"}
mv ${lib.escapeShellArg "${clientsFile}.tmp"} ${lib.escapeShellArg clientsFile}
'';
in
{
options.services.hyperhive.swarm.authelia = {
@ -151,6 +267,59 @@ in
'';
};
oidc.clients = lib.mkOption {
type = lib.types.listOf (
lib.types.submodule {
options = {
id = lib.mkOption {
type = lib.types.str;
example = "forgejo";
description = ''
OAuth2 client id, as the relying party knows itself.
'';
};
description = lib.mkOption {
type = lib.types.str;
example = "HyperHive forge";
description = ''
Human-readable name, shown on authelia's consent screen.
This is the string a person reads when deciding whether
to hand an application their identity, so it should name
the application rather than the protocol.
'';
};
redirectUris = lib.mkOption {
type = lib.types.listOf lib.types.str;
example = [ "https://forge.example.com/user/oauth2/authelia/callback" ];
description = ''
Exact callback URLs the provider will redirect to.
Matched literally by authelia a trailing-slash
difference is a rejected login, not a warning.
'';
};
};
}
);
default = [ ];
description = ''
OIDC relying parties this provider will issue tokens to.
Declaring one turns the provider on; the default empty list
leaves this module exactly as it was a session provider and
nothing else.
**There is deliberately no secret here.** A client secret has
two holders in two containers (authelia keeps a *hash*, the
relying party the *plaintext*), and
`services.authelia.instances.<n>.settings` is rendered into the
**nix store**, which is world-readable and permanent. So this
option carries only the parts that are safe to evaluate: the
secret is minted on first boot and never passes through a nix
expression. See `docs/swarm/` for what goes where.
'';
};
# Derived facts, exposed for consumers that have to act on this
# container **from outside it** — `swarmctl` is the first, and it
# needs all three. Read-only options rather than literals repeated at
@ -179,6 +348,28 @@ in
'';
};
hostClientSecretDir = lib.mkOption {
type = lib.types.str;
readOnly = true;
default = "/var/lib/nixos-containers/${cfg.machine}${clientsDir}";
description = ''
Where the minted client secrets sit **as seen from the host**
`<id>.secret` holds a plaintext, `<id>.digest` the hash authelia
itself reads.
Published for the same reason as `hostUsersFile`: the plaintext's
other reader lives in a **different container**, and containers
that share this host's network namespace still have separate
filesystem roots. The host is the only place both trees are
addressable, so the host is where a delivery step has to run.
Nothing here exists until authelia's **first boot** has run.
A consumer must wait for it it cannot be a `bindMounts` source,
because nixos-container refuses to start when a bind source is
missing, and that turns a fresh hive into a boot-order deadlock.
'';
};
hostUsersFile = lib.mkOption {
type = lib.types.str;
readOnly = true;
@ -242,7 +433,17 @@ in
wantedBy = [ "multi-user.target" ];
before = [ "${unitName}.service" ];
requiredBy = [ "${unitName}.service" ];
path = [ pkgs.coreutils ];
# `cfg.package` is here for its CLI, not its daemon: the
# client secrets are minted with `authelia crypto hash
# generate`, which is the only way to produce a digest in
# the exact form authelia will later verify.
path = [
pkgs.coreutils
]
++ lib.optionals oidcEnabled [
pkgs.openssl
cfg.package
];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
@ -260,7 +461,7 @@ in
# session and storage keys are load-bearing for data
# already written (sessions, the encrypted store), so
# replacing one is an operator action, not a boot action.
for f in jwt session storage-encryption; do
for f in ${lib.concatStringsSep " " randomKeys}; do
p=${lib.escapeShellArg stateDir}/"$f".key
if [ ! -s "$p" ]; then
head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$p"
@ -268,6 +469,7 @@ in
fi
chmod 0600 "$p"
done
${oidcGenScript}
# A users database that exists and parses, with nobody in
# it. authelia refuses to start without one, and the
@ -286,10 +488,37 @@ in
enable = true;
package = cfg.package;
# Merged at RUNTIME alongside the nix-generated config, which
# is the whole reason the client digests can exist at all:
# `settings` below is rendered into the world-readable nix
# store, and a client secret's digest may not go there.
# Empty (and inert) on a hive with no clients declared.
settingsFiles = lib.optional oidcEnabled clientsFile;
secrets = {
jwtSecretFile = "${stateDir}/jwt.key";
sessionSecretFile = "${stateDir}/session.key";
storageEncryptionKeyFile = "${stateDir}/storage-encryption.key";
}
// lib.optionalAttrs oidcEnabled {
# Both are `LoadCredential`-delivered by upstream's module,
# so they reach authelia as `AUTHELIA_*_FILE` env and never
# as values. Same by-path discipline as the three above —
# which is what lets the provider's own secrets stay
# in-container: nothing outside this container reads them.
#
# ⚠️ The issuer key is NOT passed as the deprecated
# `issuer_private_key`: on 4.38+ the config key is the
# `jwks` *list*, and a list element cannot take the
# `_FILE` env treatment. Upstream's module bridges that
# by generating a small settings file that templates the
# PEM in (`{{ secret "<path>" }}`) and prepending it to
# `settingsFiles`. So handing it a path here is the
# modern shape, not the legacy one — checked against the
# pin (nixpkgs `services/security/authelia.nix`), because
# the option name alone reads like the old key.
oidcHmacSecretFile = "${stateDir}/oidc-hmac.key";
oidcIssuerPrivateKeyFile = "${stateDir}/oidc-issuer.key";
};
# Small-deployment defaults, and the scope is the