Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3de7a4a1b1 | ||
|
|
b4a3eb75b0 | ||
|
|
cdb3c612b2 | ||
|
|
daa8a2eb4b | ||
|
|
4a35e1229b | ||
|
|
36c5b68cc0 |
4 changed files with 583 additions and 2 deletions
|
|
@ -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
92
docs/swarm/sso.md
Normal 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`.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue