feat(3149): authelia grows an OIDC provider, derived from its clients
The swarm's authelia has been a session / `auth_request` provider only. SSO for the forge (and matrix behind it) needs the second role: an OIDC provider that issues tokens to relying parties. The provider is derived from `oidc.clients` rather than carrying its own `enable`, because authelia refuses to start with a provider that has no clients — a separate flag would be a second fact free to disagree with the first. The list defaults to empty, so a hive that has not opted in renders exactly what it rendered before. Its two secrets are generated in-container by the existing first-boot unit, which is the same test that unit already applies to the jwt, session and storage keys: nothing outside this container reads them. The hmac key is 64 random bytes and joins that loop; the issuer key is an RSA pair, because it *signs* id tokens and relying parties verify them against the public half at `/jwks.json` — a symmetric secret cannot serve that. No client secret appears here, and that is the point: a client secret has two holders in two containers, and `settings` is rendered into the world- readable nix store. Minting it is the next commit's problem.
This commit is contained in:
parent
7fc426b4dd
commit
36c5b68cc0
1 changed files with 138 additions and 25 deletions
|
|
@ -18,6 +18,13 @@
|
|||
# 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, and only the first is unconditional: this is a **session**
|
||||
# provider (`auth_request`) always, and an **OIDC** provider when
|
||||
# `oidc.clients` is non-empty. The second is derived from the client list
|
||||
# instead of carrying its own flag, because authelia refuses to start
|
||||
# with a provider that has no clients — a separate `enable` would be a
|
||||
# second fact that can disagree with the first.
|
||||
#
|
||||
# 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 +59,22 @@ 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";
|
||||
in
|
||||
{
|
||||
options.services.hyperhive.swarm.authelia = {
|
||||
|
|
@ -151,6 +174,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
|
||||
|
|
@ -242,7 +318,7 @@ in
|
|||
wantedBy = [ "multi-user.target" ];
|
||||
before = [ "${unitName}.service" ];
|
||||
requiredBy = [ "${unitName}.service" ];
|
||||
path = [ pkgs.coreutils ];
|
||||
path = [ pkgs.coreutils ] ++ lib.optional oidcEnabled pkgs.openssl;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
|
|
@ -254,31 +330,48 @@ in
|
|||
SyslogIdentifier = "${unitName}-secrets";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
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 jwt session storage-encryption; 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"
|
||||
# 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
|
||||
${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 "$p"
|
||||
done
|
||||
chmod 0600 "$issuer"
|
||||
''}
|
||||
|
||||
# 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"
|
||||
# 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"
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -290,6 +383,26 @@ in
|
|||
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