Two review findings, folded together. mara: a swarm integrated auto deployed forge always has metrics, so the toggle is gone. The endpoint follows behindGateway instead, which is the swarm-integrated shape and the condition the protected location lives under. Serving it without that location would put it on a listener openFirewall can expose with nothing in front. argus: /metrics matched no access_control rule, so default_policy one_factor governed it. That is any authenticated subject, which today means any operator and tomorrow any agent. My audience argument covered the Bearer path only; the same endpoint also accepts CookieSession, and a cookie carries no audience at all, so the audience was never what stood between a browser session and this data. The rule is deny rather than a client-scoped allow because the collector's client does not exist yet. Authelia refuses a subject naming an unregistered client, and does so in a preStart validator rather than at build time, so naming one early yields a green nixos-rebuild and dead swarm SSO on the next restart. Denying until the client is registered makes publishing the endpoint safe on its own; registering it is a one-line change from deny to that allow. Rule order is load-bearing: authelia takes the first match.
1205 lines
55 KiB
Nix
1205 lines
55 KiB
Nix
# The swarm's SSO provider: one authelia for the whole swarm, in a
|
||
# `swarm-authelia` nixos-container.
|
||
#
|
||
# Two halves, and only one of them is conditional:
|
||
#
|
||
# - the CLIENT pointer (`url`) exists on every hive, because a hive
|
||
# that doesn't run authelia still has to know where to send people.
|
||
# - the CONTAINER only exists where the swarm's shared services live.
|
||
# `swarm.enableRequiredServices` asserts this module's `enable`
|
||
# (see ./swarm-required-services.nix); a hive is a client by default.
|
||
#
|
||
# Operator and agents are both subjects of the same provider,
|
||
# differentiated by roles/claims rather than by mechanism — there is one
|
||
# IdP and one auth path. The users store is written by a program
|
||
# (`swarm-authelia-bridge`, see that option's doc comment), not
|
||
# maintained by hand: agents are created and destroyed continuously, so
|
||
# the subject set is *dynamic*. That is also why the file backend is
|
||
# right here, not a placeholder for LDAP: what makes a directory
|
||
# necessary is the size of the subject set, bounded by one swarm.
|
||
#
|
||
# Two roles: a **session** provider always, an **OIDC** provider when
|
||
# `oidc.clients` is non-empty (derived, not flagged). In practice OIDC
|
||
# is always on now: the bridge needs its own machine-client identity for
|
||
# its introspection calls, contributed unconditionally, not behind
|
||
# `oidc.hiveIdentities`. 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
|
||
# operator-facing vhost over to it is not.
|
||
{
|
||
pkgs,
|
||
lib,
|
||
config,
|
||
...
|
||
}:
|
||
let
|
||
cfg = config.services.hyperhive.swarm.authelia;
|
||
networkCfg = config.services.hyperhive.network;
|
||
hyperhiveCfg = config.services.hyperhive;
|
||
gatewayCfg = hyperhiveCfg.gateway;
|
||
hyperhiveDomain = hyperhiveCfg.domain;
|
||
swarmDomain = hyperhiveCfg.swarm.domain;
|
||
uiCfg = hyperhiveCfg.swarm.ui;
|
||
forgeCfg = hyperhiveCfg.swarm.forge;
|
||
|
||
# Group an account must hold to reach operator-only surfaces. Named
|
||
# here because this module writes the rule that enforces it and
|
||
# `swarmctl user add --group <this>` is what grants it — the two must
|
||
# agree, and one constant is how they stay agreeing.
|
||
#
|
||
# ⚠️ `admins` and not a new word, because `docs/setup.md` and
|
||
# `docs/swarm/sso.md` have been telling every operator to create
|
||
# exactly that group since the bootstrap step existed. This is the
|
||
# first rule that CONSUMES a group name; picking a different one would
|
||
# have meant every account created by following the guide silently
|
||
# failing the check it was supposed to pass.
|
||
operatorGroup = "admins";
|
||
|
||
# Upstream's `services.authelia.instances.<name>` derives the unit,
|
||
# user, group and StateDirectory from the instance name
|
||
# (`authelia` + `-<name>`). Naming them here rather than repeating the
|
||
# literal keeps the generator unit below and the module in step.
|
||
instance = "swarm";
|
||
unitName = "authelia-${instance}";
|
||
stateDir = "/var/lib/${unitName}";
|
||
|
||
tlsCfg = hyperhiveCfg.tls;
|
||
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
|
||
# `swarm-authelia-bridge` verifies the gateway when it introspects by name.
|
||
# Nothing in this container trusted the swarm CA, which is a runtime file no
|
||
# build-time option can name — so an https call out of here could only ever
|
||
# fail `UnknownIssuer`. Same defect the queue's responder hit.
|
||
caBundleModule = caTrust.trustBundle {
|
||
inherit pkgs;
|
||
name = cfg.machine;
|
||
consumers = [ "swarm-authelia-bridge" ];
|
||
};
|
||
|
||
# The SWARM's domain, because that is where the protected apps now live
|
||
# (`forge.<swarm>`, `chat.<swarm>`, `auth.<swarm>`). It moves in the
|
||
# same commit as `domain` below and cannot lag it: authelia validates
|
||
# `authelia_url ⊂ cookie domain` at STARTUP, so a half-move does not
|
||
# misbehave at login — it refuses to boot.
|
||
#
|
||
# Total on a null swarm domain for the same reason the option defaults
|
||
# 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;
|
||
|
||
# One machine client per hive in the roster. A hive's identity belongs
|
||
# to the DIRECTORY, not to whichever service happens to consume it:
|
||
# the rule is that a hive's credentials all derive from the SAME
|
||
# identity, so one hive holds ONE client and mints a different token
|
||
# per service from it. Were the queue to declare this list, the next
|
||
# consumer would collide on the same client id — and only at the
|
||
# moment it landed.
|
||
#
|
||
# Fed to the option as a DEFINITION in the config block below, rather
|
||
# than appended to the declared list downstream. That is what puts it
|
||
# through the submodule: one list, one type, every option's default
|
||
# present. Appending a raw attrset instead left the list half-typed —
|
||
# and a field later added to the submodule then existed on the
|
||
# declared entries and not on these, which is an eval error reachable
|
||
# only once hive identities are on.
|
||
#
|
||
# `audience` is the hive's own client id rather than a second per-hive
|
||
# string invented here. A swarm service that has to tell hives apart
|
||
# needs one name per hive that both sides already agree on, and the
|
||
# client id is that name — published as `hiveClientPrefix` for exactly
|
||
# this reason. Minting a parallel naming scheme would be a second thing
|
||
# to keep in step, and the one that drifts is the one nobody tests.
|
||
#
|
||
# `RS256` because a resource server that cannot call this provider back
|
||
# is a real case here: the swarm's telemetry collector verifies tokens
|
||
# offline against `/jwks.json`, and an opaque token gives it nothing to
|
||
# verify. The queue's auth-callout responder introspects instead, which
|
||
# is a different question asked of the same token.
|
||
hiveClients = lib.mapAttrsToList (name: _: {
|
||
id = "${cfg.hiveClientPrefix}${name}";
|
||
description = "HyperHive hive ${name}";
|
||
kind = "machine";
|
||
redirectUris = [ ];
|
||
audience = [ "${cfg.hiveClientPrefix}${name}" ];
|
||
accessTokenSignedResponseAlg = "RS256";
|
||
}) hyperhiveCfg.swarm.hives;
|
||
|
||
# `swarm-authelia-bridge`'s own identity — distinct from
|
||
# `swarm-controller`'s (`swarm-controller.nix`'s `queueClientId`). A
|
||
# resource server introspecting a token proves its OWN identity to the
|
||
# IdP (RFC 7662), separately from whichever principal's token it is
|
||
# checking, so the bridge needs a client even though it never presents
|
||
# a token itself. Contributed unconditionally below (not gated behind
|
||
# `oidc.hiveIdentities`/an operator-declared `oidc.clients` entry): the
|
||
# bridge is a core, always-present part of this module, not an opt-in
|
||
# consumer — see `usersFile`'s doc comment.
|
||
bridgeClientId = "swarm-authelia-bridge";
|
||
bridgeClient = {
|
||
id = bridgeClientId;
|
||
description = "HyperHive swarm-authelia-bridge (users-database writer)";
|
||
kind = "machine";
|
||
redirectUris = [ ];
|
||
};
|
||
|
||
# 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.
|
||
#
|
||
# ⚠️ In practice this is now unconditionally `true` whenever the module
|
||
# is enabled: `bridgeClient` above is an unconditional definition of
|
||
# `oidc.clients` (see the `config` block), so the list is never empty.
|
||
# Kept as a derived boolean rather than simplified to a literal `true`
|
||
# so the OIDC-gated code below stays self-documenting about WHY it is
|
||
# conditional, not just that it happens to always be on today.
|
||
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.
|
||
# A machine client is not an interactive one with the redirect list left
|
||
# empty: authelia derives the permitted grant from what is declared, and an
|
||
# omitted `grant_types` means authorization-code ONLY. Measured against
|
||
# authelia 4.39.20 — a client rendered without it answers a
|
||
# `client_credentials` request with
|
||
# unauthorized_client: The OAuth 2.0 Client is not allowed to use
|
||
# authorization grant 'client_credentials'
|
||
# so the two shapes have to be told apart here rather than inferred from an
|
||
# empty list.
|
||
#
|
||
# `openid` is deliberately absent from a machine client's scopes: authelia
|
||
# REFUSES the combination outright ("the values 'openid' are not allowed"
|
||
# with `client_credentials`), because a daemon receives an access token and
|
||
# never an id-token. There is no user to identify.
|
||
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'
|
||
''
|
||
# Read plainly, and that is a property of the list rather than of
|
||
# this line: every entry reaching here is a definition of
|
||
# `oidc.clients`, so the module system has applied the submodule and
|
||
# each option's default is present. A derived entry that names only
|
||
# the fields it cares about still arrives with the rest filled in.
|
||
+ lib.optionalString (c.tokenEndpointAuthMethod != null) ''
|
||
printf -- ' token_endpoint_auth_method: %s\n' ${lib.escapeShellArg c.tokenEndpointAuthMethod}
|
||
''
|
||
# Flow-style YAML, matching `scopes` below. The values are client ids
|
||
# and hive names, which `Ident` already constrains to `[a-z0-9-]` — no
|
||
# character in that set needs quoting in a YAML flow sequence.
|
||
+ lib.optionalString (c.audience != [ ]) ''
|
||
printf -- ' audience: [%s]\n' ${lib.escapeShellArg (lib.concatStringsSep ", " c.audience)}
|
||
''
|
||
+ lib.optionalString (c.accessTokenSignedResponseAlg != null) ''
|
||
printf -- ' access_token_signed_response_alg: %s\n' ${lib.escapeShellArg c.accessTokenSignedResponseAlg}
|
||
''
|
||
+ (
|
||
if c.kind == "machine" then
|
||
''
|
||
printf -- ' grant_types: ["client_credentials"]\n'
|
||
printf -- ' scopes: []\n'
|
||
''
|
||
else
|
||
''
|
||
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 = {
|
||
enable = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = false;
|
||
example = true;
|
||
description = ''
|
||
Run the swarm's authelia in a `swarm-authelia` container on this
|
||
host. `services.hyperhive.swarm.enableRequiredServices` turns
|
||
this on — a swarm has one SSO provider, and that says it lives
|
||
here.
|
||
|
||
With it off, this hive is a *client*: `url` below still points
|
||
at whoever runs it, and no container is created.
|
||
'';
|
||
};
|
||
|
||
package = lib.mkOption {
|
||
type = lib.types.package;
|
||
default = pkgs.authelia;
|
||
defaultText = lib.literalExpression "pkgs.authelia";
|
||
description = ''
|
||
authelia package to run in the container. Defaults to
|
||
nixpkgs's; override to pin a specific upstream.
|
||
'';
|
||
};
|
||
|
||
port = lib.mkOption {
|
||
type = lib.types.port;
|
||
default = 9091;
|
||
description = ''
|
||
TCP port authelia listens on. 9091 is upstream's default and
|
||
sits outside hyperhive's claimed ranges (dashboard 7000, forge
|
||
3000, matrix 8008, every agent in 8100..8999 via FNV-1a hash).
|
||
'';
|
||
};
|
||
|
||
domain = lib.mkOption {
|
||
type = lib.types.str;
|
||
# Under the SWARM domain, like the forge and matrix: a swarm has one
|
||
# SSO provider, and the session cookie has to reach the swarm's
|
||
# services.
|
||
#
|
||
# Total on a null swarm domain so the required-domain assertion in
|
||
# hive-network.nix is the thing that fires; see the comment there.
|
||
default = if swarmDomain == null then "auth.invalid" else "auth.${swarmDomain}";
|
||
defaultText = lib.literalExpression ''"auth.''${services.hyperhive.swarm.domain}"'';
|
||
example = "login.example.com";
|
||
description = ''
|
||
Public hostname for the SSO provider — the sub-domain shape the
|
||
forge and matrix already use, under the swarm's domain because a
|
||
swarm has **one** SSO provider. Must be the name browsers
|
||
actually visit: it is the `authelia_url` the session cookie is
|
||
validated against.
|
||
|
||
⚠️ Unlike the forge and matrix names, this one carries **no
|
||
migration pin**: nothing depends on the previous
|
||
`auth.''${services.hyperhive.domain}` yet, so it moves outright.
|
||
'';
|
||
};
|
||
|
||
url = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
default = if cfg.enable then "https://${cfg.domain}" else null;
|
||
defaultText = lib.literalExpression ''if enable then "https://''${domain}" else null'';
|
||
example = "https://auth.example.com";
|
||
description = ''
|
||
Base URL clients are sent to for authentication — the half of
|
||
this module that exists on **every** hive, not just the one
|
||
running the container.
|
||
|
||
Defaults to this host's own instance **only when this module is
|
||
the thing running it**; in that case the URL is not a guess, it
|
||
is where this module just put the container. Otherwise `null`,
|
||
and a hive that federates with a swarm sets it explicitly to
|
||
wherever the swarm's authelia lives. Null means "no SSO
|
||
configured" and consumers say so rather than inventing an
|
||
address — an endpoint baked in as a fallback is one that
|
||
resolves cleanly and points at the wrong machine.
|
||
'';
|
||
};
|
||
|
||
usersFile = lib.mkOption {
|
||
type = lib.types.str;
|
||
default = "${stateDir}/users.yml";
|
||
defaultText = lib.literalExpression ''"/var/lib/authelia-swarm/users.yml"'';
|
||
description = ''
|
||
Path (inside the container) of authelia's file users database.
|
||
|
||
Written by `swarm-authelia-bridge`, not by hand: agents come and
|
||
go continuously, so the subject set is dynamic and belongs to a
|
||
program. `swarm-controller` cannot write this file itself — a
|
||
different uid owns it — so the bridge is the only writer,
|
||
running inside this same container as this file's actual owner.
|
||
This module only guarantees the file *exists* and is valid YAML
|
||
at first boot, so authelia starts with no subjects rather than
|
||
failing to start — a provider with nobody in it yet is the
|
||
correct state before anything has provisioned users.
|
||
'';
|
||
};
|
||
|
||
oidc.hiveIdentities = lib.mkOption {
|
||
type = lib.types.bool;
|
||
default = hyperhiveCfg.swarm.nats.enable;
|
||
defaultText = lib.literalExpression "services.hyperhive.swarm.nats.enable";
|
||
description = ''
|
||
Mint one machine client per hive in
|
||
{option}`services.hyperhive.swarm.hives`, so each hive can
|
||
authenticate to swarm services as itself.
|
||
|
||
Defaults to whether the swarm message queue is enabled, because
|
||
that is the first service that needs a hive to prove who it is.
|
||
It is an option rather than a hard-coded condition so a second
|
||
consumer — the swarm telemetry collector — can turn it on
|
||
without the queue, and so a swarm that wants the identities
|
||
provisioned ahead of either can say so.
|
||
|
||
The clients are inert until something authenticates with them:
|
||
each is a client id and a secret sitting on this host. What
|
||
delivers a secret to a hive that is not this host is a separate
|
||
problem and deliberately not solved here.
|
||
'';
|
||
};
|
||
|
||
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.
|
||
'';
|
||
};
|
||
|
||
kind = lib.mkOption {
|
||
type = lib.types.enum [
|
||
"interactive"
|
||
"machine"
|
||
];
|
||
default = "interactive";
|
||
example = "machine";
|
||
description = ''
|
||
Whether a human logs in through this client, or a daemon
|
||
authenticates as itself.
|
||
|
||
`interactive` is the authorization-code flow: a browser is
|
||
redirected, a person authenticates, the client receives an
|
||
id-token. `machine` is `client_credentials`: there is
|
||
nobody to redirect and no identity to assert but the
|
||
client's own, so it receives an access token and no
|
||
id-token.
|
||
|
||
This is declared rather than inferred from an empty
|
||
`redirectUris`, because authelia permits only the grants a
|
||
client names — omitting `grant_types` yields
|
||
authorization-code alone, and a daemon then fails at the
|
||
token endpoint with `unauthorized_client` rather than at
|
||
evaluation.
|
||
'';
|
||
};
|
||
|
||
redirectUris = lib.mkOption {
|
||
type = lib.types.listOf lib.types.str;
|
||
default = [ ];
|
||
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.
|
||
|
||
Meaningless for `kind = "machine"`, which is asserted
|
||
rather than silently ignored.
|
||
'';
|
||
};
|
||
|
||
tokenEndpointAuthMethod = lib.mkOption {
|
||
type = lib.types.nullOr (
|
||
lib.types.enum [
|
||
"client_secret_basic"
|
||
"client_secret_post"
|
||
"client_secret_jwt"
|
||
"private_key_jwt"
|
||
"none"
|
||
]
|
||
);
|
||
default = null;
|
||
example = "client_secret_post";
|
||
description = ''
|
||
How this client proves its identity at the token
|
||
endpoint. `null` leaves authelia on its own default
|
||
(`client_secret_basic`), which is what every client that
|
||
does not say otherwise gets.
|
||
|
||
Set it when the relying party's implementation differs,
|
||
because authelia enforces the registered method rather
|
||
than accepting whatever arrives. tuwunel sends
|
||
`client_secret_post`, and against a client registered for
|
||
basic the result is a 401 from `/api/oidc/token` **after
|
||
a successful consent** — the login looks like it worked
|
||
right up to the last hop, and neither the redirect nor
|
||
the secret is at fault.
|
||
'';
|
||
};
|
||
|
||
audience = lib.mkOption {
|
||
type = lib.types.listOf lib.types.str;
|
||
default = [ ];
|
||
example = [ "hive-alpha" ];
|
||
description = ''
|
||
Audiences (`aud`) this client is permitted to request a
|
||
token for. Empty means it asks for none, which is the
|
||
right answer for a client whose resource server does not
|
||
distinguish callers.
|
||
|
||
⚠️ Registering an audience only *permits* it — the value
|
||
lands in a token when the client **asks** for it at the
|
||
token endpoint, and a client that does not send
|
||
`audience=` receives a token with `aud: []` however
|
||
complete this list looks. Measured against authelia
|
||
4.39.20: the config reads exactly right and the resource
|
||
server rejects every token, because a config that grants
|
||
and a request that claims are two separate acts.
|
||
|
||
Requesting an audience that is *not* listed here is
|
||
refused with `invalid_target`, which is what makes this
|
||
usable as a boundary rather than a label: a client cannot
|
||
mint a token for a resource slot that is not its own.
|
||
'';
|
||
};
|
||
|
||
accessTokenSignedResponseAlg = lib.mkOption {
|
||
type = lib.types.nullOr (
|
||
lib.types.enum [
|
||
"none"
|
||
"RS256"
|
||
]
|
||
);
|
||
default = null;
|
||
example = "RS256";
|
||
description = ''
|
||
Signing algorithm for this client's **access** tokens.
|
||
`null` leaves authelia on its default, which issues an
|
||
opaque token (`authelia_at_…`) — a database handle that
|
||
carries no claims and means nothing to anyone but this
|
||
provider.
|
||
|
||
Set `RS256` when the resource server verifies the token
|
||
*itself* rather than asking this provider about it: that
|
||
yields an RFC 9068 JWT (`at+jwt`) carrying `aud`, `iss`
|
||
and `client_id`, verifiable against `/jwks.json` with no
|
||
round trip.
|
||
|
||
⚠️ This is what makes a token readable by an
|
||
OIDC-verifying consumer at all. A resource server given
|
||
an opaque token is not *misconfigured* — it is
|
||
structurally unable to verify it, and says so in terms
|
||
that point at the verifier rather than at the token's
|
||
format.
|
||
'';
|
||
};
|
||
};
|
||
}
|
||
);
|
||
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
|
||
# the call site: the machine and unit names are derived from
|
||
# `instance` here, so a second copy elsewhere is a second thing to
|
||
# keep in step, and the one that drifts is the one nobody tests.
|
||
hiveClientPrefix = lib.mkOption {
|
||
type = lib.types.str;
|
||
readOnly = true;
|
||
default = "hive-";
|
||
description = ''
|
||
Prefix of the OAuth2 client id minted for each hive in
|
||
`services.hyperhive.swarm.hives` — the client for hive `alpha` is
|
||
`${config.services.hyperhive.swarm.authelia.hiveClientPrefix}alpha`.
|
||
Read-only for the same reason as `machine` and `unit`: it is what
|
||
this module produces, published so a consumer does not carry a
|
||
second copy.
|
||
|
||
The consumer that matters is the queue's auth-callout responder,
|
||
which decides *which hive* a connection is by stripping this
|
||
prefix off the introspected client id. Split the two spellings and
|
||
every hive is denied — as a timeout, indistinguishable from a hive
|
||
that simply has not reported.
|
||
'';
|
||
};
|
||
|
||
machine = lib.mkOption {
|
||
type = lib.types.str;
|
||
readOnly = true;
|
||
default = "swarm-authelia";
|
||
description = ''
|
||
Name of the nixos-container authelia runs in. Read-only: it is
|
||
what this module declares, published so callers of
|
||
`systemctl -M` and `/var/lib/nixos-containers/<name>` do not
|
||
have to hardcode it.
|
||
'';
|
||
};
|
||
|
||
unit = lib.mkOption {
|
||
type = lib.types.str;
|
||
readOnly = true;
|
||
default = "${unitName}.service";
|
||
description = ''
|
||
authelia's systemd unit *inside* the container. Read-only, and
|
||
derived from the instance name exactly like the unit itself.
|
||
'';
|
||
};
|
||
|
||
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;
|
||
default = "/var/lib/nixos-containers/${cfg.machine}${cfg.usersFile}";
|
||
description = ''
|
||
`usersFile` as seen from the **host** — the container's root
|
||
prefixed onto the path authelia sees.
|
||
|
||
Published for callers that only ever need to *read* the file
|
||
(e.g. an operator diagnosing a bad entry). `swarm-authelia-bridge`
|
||
itself never uses this path — it runs inside the container, as
|
||
the file's own owner, and writes the in-container path directly.
|
||
'';
|
||
};
|
||
|
||
bridgePackage = lib.mkOption {
|
||
type = lib.types.package;
|
||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-authelia-bridge";
|
||
description = ''
|
||
`swarm-authelia-bridge` package — the only process allowed to
|
||
write `usersFile`. Wired by default from this flake's own
|
||
package set (see `flake.nix`); override to run a different
|
||
build.
|
||
'';
|
||
};
|
||
|
||
bridgePort = lib.mkOption {
|
||
type = lib.types.port;
|
||
default = 9092;
|
||
description = ''
|
||
TCP port `swarm-authelia-bridge` listens on, loopback-bound
|
||
(`127.0.0.1:''${bridgePort}`) — one above authelia's own default
|
||
`port` (9091), outside hyperhive's other claimed ranges.
|
||
|
||
Reachable directly from this host's other processes (this
|
||
container shares the host netns, same as authelia's own `port`)
|
||
without going through the gateway — this is an internal
|
||
service-to-service endpoint, not something meant to be exposed
|
||
publicly.
|
||
'';
|
||
};
|
||
|
||
bridgeUrl = lib.mkOption {
|
||
type = lib.types.nullOr lib.types.str;
|
||
readOnly = true;
|
||
default = if cfg.enable then "http://127.0.0.1:${toString cfg.bridgePort}" else null;
|
||
defaultText = lib.literalExpression ''if enable then "http://127.0.0.1:''${bridgePort}" else null'';
|
||
description = ''
|
||
Where `swarm-authelia-bridge` answers, **as seen from this
|
||
host** — correct only when a caller (`swarm-controller`) also
|
||
runs on this host, the same co-location assumption
|
||
`swarm.nix`'s `clientSecretFile` documents for its own
|
||
cross-host case. `null` when this host doesn't run
|
||
`swarm-authelia` at all.
|
||
|
||
A split-host swarm has no automated delivery for this address:
|
||
the operator points `swarm-controller`'s own option at wherever
|
||
this host has made the bridge reachable (a firewall rule, a
|
||
different bind address), the same manual-copy shape used
|
||
throughout this codebase's other cross-host cases.
|
||
'';
|
||
};
|
||
};
|
||
|
||
config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) {
|
||
# The derived half of the client list, declared the same way an
|
||
# operator declares one. Everything downstream then reads a single
|
||
# uniformly-typed `cfg.oidc.clients` and cannot tell the parts apart —
|
||
# including the assertions below, which is why a hive named `x`
|
||
# colliding with a declared `hive-x` is caught rather than rendered
|
||
# twice. `bridgeClient` is unconditional (plain list concatenation,
|
||
# not `mkIf`-gated like `hiveClients`): the bridge is always present
|
||
# wherever this module is, so `oidc.clients` is never actually empty
|
||
# — see `oidcEnabled`'s comment above.
|
||
services.hyperhive.swarm.authelia.oidc.clients =
|
||
lib.optionals cfg.oidc.hiveIdentities hiveClients
|
||
++ [ bridgeClient ];
|
||
|
||
# A redirect URI on a machine client is not harmless-but-unused: it
|
||
# means whoever wrote it believes a browser is involved. Failing here
|
||
# is how that belief gets corrected at the point it was expressed,
|
||
# rather than at a token endpoint months later.
|
||
assertions =
|
||
map (c: {
|
||
assertion = c.kind != "machine" || c.redirectUris == [ ];
|
||
message =
|
||
"services.hyperhive.swarm.authelia.oidc.clients: client '${c.id}' is "
|
||
+ "kind = \"machine\" but declares redirectUris. A client_credentials "
|
||
+ "client has nobody to redirect; drop the URIs or make it "
|
||
+ "kind = \"interactive\".";
|
||
}) cfg.oidc.clients
|
||
# Two clients sharing an id renders two YAML entries under one name.
|
||
# Newly reachable now that part of the list is DERIVED: a hive called
|
||
# `x` and a service client called `hive-x` never met before. Authelia
|
||
# would reject it, but three layers away and at boot — naming both
|
||
# sources here is the cheaper failure.
|
||
++ [
|
||
{
|
||
# The bridge introspects by name, so a null URL becomes a nix
|
||
# coercion error several files from its cause. Only reachable by
|
||
# enabling authelia and clearing `url` by hand — an assertion
|
||
# rather than a fallback, because a guessed URL that evaluates
|
||
# cleanly is worse than a refused build.
|
||
assertion = cfg.url != null;
|
||
message =
|
||
"services.hyperhive.swarm.authelia.url must not be null when authelia "
|
||
+ "is enabled: swarm-authelia-bridge introspects at "
|
||
+ "`\${url}/api/oidc/introspection` from inside its container.";
|
||
}
|
||
{
|
||
assertion = lib.length (lib.unique (map (c: c.id) cfg.oidc.clients)) == lib.length cfg.oidc.clients;
|
||
message =
|
||
"services.hyperhive.swarm.authelia: duplicate OIDC client id(s): "
|
||
+ lib.concatStringsSep ", " (
|
||
lib.unique (
|
||
lib.filter (id: lib.count (x: x == id) (map (c: c.id) cfg.oidc.clients) > 1) (
|
||
map (c: c.id) cfg.oidc.clients
|
||
)
|
||
)
|
||
)
|
||
+ ". Hive identities are named `hive-<name>` from "
|
||
+ "services.hyperhive.swarm.hives; rename the hive or the "
|
||
+ "colliding client.";
|
||
}
|
||
];
|
||
|
||
# Authelia's own gateway surface: the vhost that fronts it and the
|
||
# name the hive resolver answers for. Both live here rather than in
|
||
# the gateway, and both are inside `cfg.enable` — that guard is the
|
||
# load-bearing part.
|
||
#
|
||
# ⚠️ Every hive in a swarm knows `authelia.url`, but only the host
|
||
# that RUNS the container may claim the name. A client hive
|
||
# declaring this vhost would answer for a service it does not run,
|
||
# and publishing the DNS record would point every agent on its
|
||
# bridge at that wrong answer.
|
||
services.hyperhive.gateway.localNames = [ cfg.domain ];
|
||
|
||
# This swarm-ui quick-links entry, same guard as the vhost/DNS name
|
||
# above (only the host actually running the container claims it —
|
||
# see `services.hyperhive.swarm.controller.links`'s description for
|
||
# the contribute-your-own-entry idiom).
|
||
services.hyperhive.swarm.controller.links = [
|
||
{
|
||
label = "Authelia";
|
||
icon = "🔑";
|
||
url = "https://${cfg.domain}/";
|
||
}
|
||
];
|
||
|
||
# `server_name = authelia.domain`, all of `/` → authelia.
|
||
#
|
||
# ⚠️ The server name must be exactly `cfg.domain`, not a near-miss:
|
||
# authelia validates `authelia_url ⊂ session cookie domain` at
|
||
# STARTUP, so a mismatch is a container that refuses to boot rather
|
||
# than a login that misbehaves.
|
||
#
|
||
# ⚠️ And deliberately NO `dashboardAuth` here. That block is the
|
||
# gateway's `auth_basic`; applying it to the SSO provider would put
|
||
# the login page behind the login mechanism it exists to replace.
|
||
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
|
||
listen = gatewayCfg.lib.listen;
|
||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||
locations."/" = {
|
||
proxyPass = "http://127.0.0.1:${toString cfg.port}/";
|
||
proxyWebsockets = true;
|
||
extraConfig = ''
|
||
proxy_buffering off;
|
||
# authelia decides by the ORIGINAL request, not by the hop it
|
||
# sees — the login redirect and the session cookie's domain
|
||
# both derive from these. Without them every request looks
|
||
# like it arrived at 127.0.0.1 over plain http.
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
proxy_set_header X-Forwarded-Host $host;
|
||
proxy_set_header X-Forwarded-Uri $request_uri;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
# A dead upstream here means "not bootstrapped" far more often
|
||
# than "misconfigured proxy", and a bare 502 says the opposite.
|
||
proxy_intercept_errors on;
|
||
error_page 502 503 504 = /__hive_sso_unavailable;
|
||
'';
|
||
};
|
||
locations."= /__hive_sso_unavailable" = {
|
||
extraConfig = ''
|
||
internal;
|
||
alias ${gatewayCfg.lib.errorPages.ssoUnavailable};
|
||
default_type text/html;
|
||
'';
|
||
};
|
||
};
|
||
|
||
# Order the container after the host CA generator, so the bind source
|
||
# exists before nspawn sets the mount up.
|
||
systemd.services."container@${cfg.machine}" = caTrust.containerOrdering;
|
||
|
||
containers.${cfg.machine} = {
|
||
autoStart = true;
|
||
ephemeral = false;
|
||
# Shared host netns, like the forge and matrix containers: the
|
||
# gateway reaches authelia at 127.0.0.1:<port>.
|
||
privateNetwork = false;
|
||
# Public trust bundle only, read-only. Empty when the gateway is not
|
||
# self-signed, so the whole trust path drops out cleanly.
|
||
bindMounts = caTrust.bindMount;
|
||
|
||
config =
|
||
{ ... }:
|
||
{
|
||
imports = [
|
||
(import ./swarm-container-resolver.nix {
|
||
inherit (networkCfg) bridgeIp;
|
||
dnsConsumers = [ "authelia-${instance}.service" ];
|
||
})
|
||
caBundleModule
|
||
];
|
||
|
||
system.stateVersion = "26.05";
|
||
|
||
# The authelia binary itself, so an operator who gets a shell
|
||
# in here can run `authelia crypto hash generate` to make a
|
||
# password for the users file. Without it the container runs
|
||
# authelia and cannot invoke it: the unit's ExecStart resolves
|
||
# through the store path, and nothing puts the CLI on PATH.
|
||
environment.systemPackages = [ cfg.package ];
|
||
|
||
# This container shares the host netns, so its own
|
||
# firewall.service would rewrite the HOST ruleset at every
|
||
# boot. The host firewall owns all filtering.
|
||
networking.firewall.enable = false;
|
||
# resolvconf stays off because the resolver unit imported above
|
||
# owns /etc/resolv.conf. Leaving it on would let host-tracking
|
||
# regenerate the file empty, since the host's copy doesn't
|
||
# cross the boundary after start.
|
||
networking.resolvconf.enable = lib.mkForce false;
|
||
|
||
# authelia's own secrets, generated in-container on first
|
||
# boot. They are jwt/session/storage keys — nothing outside
|
||
# this container ever reads them, which is what makes
|
||
# in-container generation right rather than merely easier.
|
||
# (hive-matrix generates its token host-side only because
|
||
# hive-c0re has to read that one.)
|
||
#
|
||
# Same `User`/`Group`/`StateDirectory` as the authelia unit,
|
||
# so systemd creates the directory owned by the account that
|
||
# has to read the files and this unit can write nowhere else.
|
||
# No chown, no mode juggling: authelia opens these paths
|
||
# itself, as its own user, under `PrivateUsers=true`.
|
||
systemd.services."${unitName}-secrets" = {
|
||
description = "Generate authelia's secrets on first boot";
|
||
wantedBy = [ "multi-user.target" ];
|
||
before = [ "${unitName}.service" ];
|
||
requiredBy = [ "${unitName}.service" ];
|
||
# `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;
|
||
User = unitName;
|
||
Group = unitName;
|
||
StateDirectory = unitName;
|
||
StateDirectoryMode = "0700";
|
||
UMask = "0077";
|
||
SyslogIdentifier = "${unitName}-secrets";
|
||
};
|
||
script = ''
|
||
set -euo pipefail
|
||
|
||
# Each is generated once and never rotated here: the
|
||
# 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 ${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"
|
||
echo "generated $p"
|
||
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
|
||
# alternative to an empty file is a placeholder account —
|
||
# which is a credential nobody meant to create.
|
||
users=${lib.escapeShellArg cfg.usersFile}
|
||
if [ ! -s "$users" ]; then
|
||
echo "users: {}" > "$users"
|
||
echo "seeded empty users database at $users"
|
||
fi
|
||
chmod 0600 "$users"
|
||
'';
|
||
};
|
||
|
||
# The only process allowed to write `cfg.usersFile` — see that
|
||
# option's doc comment, and the crate's own README for the full
|
||
# "why does an unprivileged swarm-controller need a bridge at
|
||
# all" reasoning. Runs as `unitName` (`authelia-swarm`) — THE
|
||
# point of this whole unit: it is that same account, so it
|
||
# owns the file it writes and needs no elevated privilege.
|
||
# Ordered after authelia's own secrets generator (needs its
|
||
# own client secret, minted by that unit's `mint` loop) and
|
||
# after authelia itself (introspects against its local
|
||
# `/api/oidc/introspection`, so needs it answering — not
|
||
# load-bearing at start, since nothing calls the bridge yet at
|
||
# boot, but a clean dependency order beats a would-be-transient
|
||
# failure on the first real request).
|
||
systemd.services.swarm-authelia-bridge = {
|
||
description = "swarm-authelia-bridge: the only writer of authelia's users database";
|
||
wantedBy = [ "multi-user.target" ];
|
||
after = [
|
||
"${unitName}-secrets.service"
|
||
"${unitName}.service"
|
||
];
|
||
wants = [
|
||
"${unitName}-secrets.service"
|
||
"${unitName}.service"
|
||
];
|
||
serviceConfig = {
|
||
ExecStart = "${cfg.bridgePackage}/bin/swarm-authelia-bridge";
|
||
User = unitName;
|
||
Group = unitName;
|
||
Restart = "on-failure";
|
||
RestartSec = "5s";
|
||
};
|
||
environment = {
|
||
SWARM_AUTHELIA_BRIDGE_BIND = "127.0.0.1:${toString cfg.bridgePort}";
|
||
# ONE file, and this is it. The bridge used to carry a
|
||
# second, private `…-users.json` it treated as canonical
|
||
# while writing `users.yml` as a rendering of it — two
|
||
# canonical stores for one physical file, which is what
|
||
# made `swarm agent create` refuse to start on a hive whose
|
||
# `users.yml` already held users.
|
||
SWARM_AUTHELIA_BRIDGE_USERS_FILE = cfg.usersFile;
|
||
# The CONFIGURED authelia, not whatever is on `PATH`: the
|
||
# argon2 parameters baked into a hash have to match the
|
||
# verifier's — same reasoning as `swarmctl`'s own
|
||
# `SWARMCTL_AUTHELIA_BIN`.
|
||
SWARM_AUTHELIA_BRIDGE_AUTHELIA_BIN = "${cfg.package}/bin/authelia";
|
||
# By name through the gateway, not loopback. A loopback
|
||
# literal encodes "authelia is in my netns" at the call site,
|
||
# and authelia's OIDC endpoints are https-only in effect —
|
||
# reached directly they answer 400, because the forwarded
|
||
# headers nginx injects for every other consumer are what let
|
||
# it determine its own issuer. Same URL `swarm-nats-auth`
|
||
# uses, so there is one idiom rather than two.
|
||
SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL = "${cfg.url}/api/oidc/introspection";
|
||
SWARM_AUTHELIA_BRIDGE_CLIENT_ID = bridgeClientId;
|
||
# Minted by `${unitName}-secrets`'s `mint` loop (it iterates
|
||
# every entry in `cfg.oidc.clients`, which now always
|
||
# includes `bridgeClient`) — same file this container's own
|
||
# `renderClient` reads the digest half of.
|
||
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret";
|
||
};
|
||
};
|
||
|
||
services.authelia.instances.${instance} = {
|
||
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
|
||
# justification: one swarm, one authelia, no replicas.
|
||
# - file users backend, written by swarm-controller
|
||
# - local sqlite storage: redis buys shared session state
|
||
# across replicas, and there is one instance
|
||
# - filesystem notifier: SMTP is for mailing humans, and
|
||
# provisioning is programmatic; a file is honest about
|
||
# where those messages go instead of implying a mail path
|
||
settings = {
|
||
theme = "dark";
|
||
server.address = "tcp://127.0.0.1:${toString cfg.port}";
|
||
|
||
# Let a machine present an OAuth2 access token to the same
|
||
# `auth_request` endpoint browsers use, so a scraper can be
|
||
# authenticated by the gateway instead of every service
|
||
# growing its own static bearer.
|
||
#
|
||
# ⚠️ `authn_strategies` REPLACES the defaults rather than
|
||
# adding to them, so `CookieSession` is listed explicitly.
|
||
# Dropping it does not fail to evaluate and does not fail to
|
||
# start — it silently ends every operator session on the
|
||
# swarm UI, which rides this same endpoint.
|
||
#
|
||
# Unconditional, and not keyed to whichever service is
|
||
# currently scraped: this only makes a *scheme* available.
|
||
# Authorisation is the audience — authelia refuses a token
|
||
# that carries no audience for the requested URL, and a
|
||
# client may only be issued audiences it is registered for.
|
||
# So enabling the scheme grants nobody anything until a
|
||
# client is registered for a specific URL.
|
||
server.endpoints.authz.auth-request = {
|
||
implementation = "AuthRequest";
|
||
authn_strategies = [
|
||
{
|
||
name = "HeaderAuthorization";
|
||
schemes = [ "Bearer" ];
|
||
}
|
||
{ name = "CookieSession"; }
|
||
];
|
||
};
|
||
log.level = "info";
|
||
|
||
# `watch` is load-bearing, not a convenience: authelia reads
|
||
# this file once at startup, and `swarm-authelia-bridge` writes
|
||
# it to create agent identities while being unable to restart
|
||
# authelia — running unprivileged is the whole reason it may
|
||
# write the file at all. Without this, an identity it creates is
|
||
# real on disk and invisible until something unrelated restarts.
|
||
authentication_backend.file = {
|
||
path = cfg.usersFile;
|
||
watch = true;
|
||
};
|
||
|
||
# ⚠️ `one_factor` as the DEFAULT means "any authenticated
|
||
# user", which is authentication, not authorisation. The
|
||
# swarm UI is operator-only, and agents are getting
|
||
# authelia accounts of their own — so the day that lands,
|
||
# a session alone would be enough to open it. The rule
|
||
# below is what makes the distinction real; without it
|
||
# the vhost's `auth_request` is a check nobody fails.
|
||
#
|
||
# The group is a constant rather than an option: it is the
|
||
# value an operator types into `swarmctl user add --group`,
|
||
# and a configurable name is one more way for the rule and
|
||
# the account to disagree silently.
|
||
access_control = {
|
||
default_policy = "one_factor";
|
||
# ⚠️ ORDER MATTERS — authelia takes the FIRST matching rule.
|
||
# The metrics rule is listed first so it cannot be shadowed
|
||
# by a broader domain rule added later.
|
||
rules =
|
||
# The forge's metrics endpoint. `deny` is deliberate and
|
||
# is the whole protection right now: the endpoint is
|
||
# always served (a swarm-integrated forge always has
|
||
# metrics), and `default_policy` is `one_factor`, which
|
||
# means *any* authenticated subject — every operator
|
||
# today, every agent once they hold authelia accounts.
|
||
#
|
||
# Being reachable by a Bearer token is not sufficient on
|
||
# its own: `authn_strategies` on this endpoint also
|
||
# accepts `CookieSession`, and a cookie carries no
|
||
# audience, so the audience is not what stands between a
|
||
# browser session and this data.
|
||
#
|
||
# The collector gets in by REPLACING this with a
|
||
# client-scoped allow (`subject = ["oauth2:client:<id>"]`)
|
||
# once such a client is registered. Denying until then is
|
||
# what makes publishing the endpoint safe on its own —
|
||
# authelia refuses a subject naming a client that is not
|
||
# registered, and it does so in a `preStart` validator,
|
||
# so naming one early takes the whole SSO service down on
|
||
# the next restart rather than failing the build.
|
||
lib.optional forgeCfg.behindGateway {
|
||
domain = forgeCfg.domain;
|
||
resources = [ "^/metrics$" ];
|
||
policy = "deny";
|
||
}
|
||
++ lib.optional uiCfg.enable {
|
||
domain = uiCfg.domain;
|
||
subject = [ "group:${operatorGroup}" ];
|
||
policy = "one_factor";
|
||
};
|
||
};
|
||
|
||
# The cookie domain is the SWARM's domain, NOT authelia's
|
||
# own host: the session cookie has to be sent to the apps
|
||
# being protected (`forge.<swarm>`, `chat.<swarm>`), and a
|
||
# cookie scoped to `auth.<swarm>` reaches none of them.
|
||
# authelia enforces the relationship from the other side
|
||
# too — `authelia_url` must be a sub-domain of `domain`, so
|
||
# setting both to the same host fails validation at startup
|
||
# rather than at first login.
|
||
#
|
||
# ⚠️ Known and accepted consequence while a hive keeps a
|
||
# domain outside the swarm's tree: this cookie is NOT sent
|
||
# to that hive's own surfaces (its dashboard), so SSO
|
||
# covers the swarm's services and not the hive's. It
|
||
# resolves when the hive domain moves under the swarm
|
||
# domain; until then it is a scope limit, not a bug to
|
||
# chase.
|
||
session.cookies = [
|
||
{
|
||
domain = cookieDomain;
|
||
authelia_url = "https://${cfg.domain}";
|
||
}
|
||
];
|
||
|
||
storage.local.path = "${stateDir}/db.sqlite3";
|
||
notifier.filesystem.filename = "${stateDir}/notification.txt";
|
||
};
|
||
};
|
||
};
|
||
};
|
||
};
|
||
}
|