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
|
# for LDAP: what makes a directory necessary is the size of the subject
|
||||||
# set, and this deployment's is bounded by one swarm.
|
# 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
|
# Per-service integration — putting authelia's `auth_request` in front
|
||||||
# of the gateway's existing `auth_basic` locations — is deliberately NOT
|
# of the gateway's existing `auth_basic` locations — is deliberately NOT
|
||||||
# here. Standing an SSO provider up is reversible; cutting every
|
# 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
|
# below are: the required-domain assertion in hive-network.nix should
|
||||||
# be what an operator sees, not a coercion error from here.
|
# be what an operator sees, not a coercion error from here.
|
||||||
cookieDomain = if swarmDomain == null then "invalid" else swarmDomain;
|
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
|
in
|
||||||
{
|
{
|
||||||
options.services.hyperhive.swarm.authelia = {
|
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
|
# Derived facts, exposed for consumers that have to act on this
|
||||||
# container **from outside it** — `swarmctl` is the first, and it
|
# container **from outside it** — `swarmctl` is the first, and it
|
||||||
# needs all three. Read-only options rather than literals repeated at
|
# needs all three. Read-only options rather than literals repeated at
|
||||||
|
|
@ -242,7 +318,7 @@ in
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
before = [ "${unitName}.service" ];
|
before = [ "${unitName}.service" ];
|
||||||
requiredBy = [ "${unitName}.service" ];
|
requiredBy = [ "${unitName}.service" ];
|
||||||
path = [ pkgs.coreutils ];
|
path = [ pkgs.coreutils ] ++ lib.optional oidcEnabled pkgs.openssl;
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
Type = "oneshot";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
|
|
@ -254,31 +330,48 @@ in
|
||||||
SyslogIdentifier = "${unitName}-secrets";
|
SyslogIdentifier = "${unitName}-secrets";
|
||||||
};
|
};
|
||||||
script = ''
|
script = ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Each is generated once and never rotated here: the
|
# Each is generated once and never rotated here: the
|
||||||
# session and storage keys are load-bearing for data
|
# session and storage keys are load-bearing for data
|
||||||
# already written (sessions, the encrypted store), so
|
# already written (sessions, the encrypted store), so
|
||||||
# replacing one is an operator action, not a boot action.
|
# 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
|
p=${lib.escapeShellArg stateDir}/"$f".key
|
||||||
if [ ! -s "$p" ]; then
|
if [ ! -s "$p" ]; then
|
||||||
head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$p"
|
head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n' > "$p"
|
||||||
echo "generated $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
|
fi
|
||||||
chmod 0600 "$p"
|
chmod 0600 "$issuer"
|
||||||
done
|
''}
|
||||||
|
|
||||||
# A users database that exists and parses, with nobody in
|
# A users database that exists and parses, with nobody in
|
||||||
# it. authelia refuses to start without one, and the
|
# it. authelia refuses to start without one, and the
|
||||||
# alternative to an empty file is a placeholder account —
|
# alternative to an empty file is a placeholder account —
|
||||||
# which is a credential nobody meant to create.
|
# which is a credential nobody meant to create.
|
||||||
users=${lib.escapeShellArg cfg.usersFile}
|
users=${lib.escapeShellArg cfg.usersFile}
|
||||||
if [ ! -s "$users" ]; then
|
if [ ! -s "$users" ]; then
|
||||||
echo "users: {}" > "$users"
|
echo "users: {}" > "$users"
|
||||||
echo "seeded empty users database at $users"
|
echo "seeded empty users database at $users"
|
||||||
fi
|
fi
|
||||||
chmod 0600 "$users"
|
chmod 0600 "$users"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -290,6 +383,26 @@ in
|
||||||
jwtSecretFile = "${stateDir}/jwt.key";
|
jwtSecretFile = "${stateDir}/jwt.key";
|
||||||
sessionSecretFile = "${stateDir}/session.key";
|
sessionSecretFile = "${stateDir}/session.key";
|
||||||
storageEncryptionKeyFile = "${stateDir}/storage-encryption.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
|
# Small-deployment defaults, and the scope is the
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue