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.
This commit is contained in:
atlas 2026-08-11 21:26:06 +02:00 committed by mara
commit daa8a2eb4b

View file

@ -23,6 +23,19 @@ 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/...`.
autheliaUrl = config.services.hyperhive.swarm.authelia.url;
autheliaDiscoveryUrl = "${toString autheliaUrl}/.well-known/openid-configuration";
caTrust = import ../lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
useSelfSigned = caTrust.useSelfSigned;
caContainerPath = caTrust.caContainerPath;
@ -314,10 +327,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,6 +737,80 @@ 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
'';
};
};
};