Compare commits

..
4 changed files with 2 additions and 583 deletions

View file

@ -88,12 +88,6 @@ 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

View file

@ -1,92 +0,0 @@
# 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`.

View file

@ -23,38 +23,6 @@ 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;
@ -346,86 +314,10 @@ 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 = ''
@ -756,155 +648,9 @@ 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

View file

@ -18,10 +18,6 @@
# 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
@ -56,118 +52,6 @@ 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 = {
@ -267,59 +151,6 @@ 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
@ -348,28 +179,6 @@ 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;
@ -433,17 +242,7 @@ in
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
];
path = [ pkgs.coreutils ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
@ -461,7 +260,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 ${lib.concatStringsSep " " randomKeys}; do
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"
@ -469,7 +268,6 @@ 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
@ -488,37 +286,10 @@ 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