From ba56bfe32eaa32f34dfacb9fde0ec872d1a8d3d6 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 1 Oct 2026 10:09:03 +0200 Subject: [PATCH] nix: split swarm-authelia into service and deploy-mode files `swarm.authelia` (what the SSO provider is to every hive: ports, domain, `url`, the OIDC client register, the published names and the bridge's address) moves to nix/host-modules/swarm-authelia-service.nix. Everything else -- the `deploy.authelia` options, the whole `config` block including `containers.swarm-authelia`, and the helpers only they read -- stays in nix/host-modules/swarm-authelia.nix, which default.nix now imports alongside the new file. Both halves read four `let` bindings. `cfg`, `swarmDomain` and `deployCfg` are option reads, so each file binds them from `config`; the service file has no `hyperhiveCfg`, so it spells the paths out, as swarm-nats-service.nix does. `instance` and `unitName` are literals, not options, so the service file carries its own copy of the two (`unit`'s default reads `unitName`). `deployCfg` is in the service file only for `bridgeUrl`'s default, which is moved as it is. `hyperhiveDomain` had no reader and is dropped rather than carried into either file. A pure move: option paths, option definitions and config are unchanged apart from three comments that pointed "above"/"below" across the new file boundary and now name the file. Authelia's container toplevel, the host toplevel (with `c0re.hyperhiveFlake` pinned, since the flake source path lands in /etc/hyperhive/serve.json), the `swarm.authelia` and `deploy.authelia` values and option set, and the eleven module-eval checks that enable authelia evaluate to the same derivations before and after. Refs #3742 --- nix/host-modules/default.nix | 1 + nix/host-modules/swarm-authelia-service.nix | 464 ++++++++++++++++++++ nix/host-modules/swarm-authelia.nix | 464 +------------------- 3 files changed, 476 insertions(+), 453 deletions(-) create mode 100644 nix/host-modules/swarm-authelia-service.nix diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 5057dd0c..8fbb0c05 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -40,6 +40,7 @@ ./glue-services-issuer-bao-identity.nix ./glue-swarm-bao-otel-oidc-client.nix ./glue-swarm-otel-oidc-client.nix + ./swarm-authelia-service.nix ./swarm-authelia.nix ./swarm-bao-service.nix ./swarm-bao.nix diff --git a/nix/host-modules/swarm-authelia-service.nix b/nix/host-modules/swarm-authelia-service.nix new file mode 100644 index 00000000..1545dda4 --- /dev/null +++ b/nix/host-modules/swarm-authelia-service.nix @@ -0,0 +1,464 @@ +# The swarm's SSO provider as every hive sees it: where it answers, the OIDC +# client register every service checks itself against, and the names published +# for consumers acting on it from outside, identical on every host. What the +# host running it decides, and the container itself, are in ./swarm-authelia.nix. +{ + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.swarm.authelia; + swarmDomain = config.services.hyperhive.swarm.domain; + deployCfg = config.services.hyperhive.deploy; + + # The unit name upstream's `services.authelia.instances.` derives, + # which `unit` publishes. ./swarm-authelia.nix binds the same two names for + # the instance it declares, and the two must agree. + instance = "swarm"; + unitName = "authelia-${instance}"; +in +{ + # `enable` and both packages moved to `services.hyperhive.deploy.authelia` + # — see ./deploy.nix. Whether this host runs the swarm's SSO provider, and + # which build it runs, are deployment decisions, in ./swarm-authelia.nix. + # Here is what authelia IS, including `url` and the OIDC client registry + # every hive needs as a *client* whether or not it runs the container. + options.services.hyperhive.swarm.authelia = { + 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). + ''; + }; + + metricsPort = lib.mkOption { + type = lib.types.port; + default = 9959; + description = '' + TCP port authelia serves its Prometheus metrics on, bound to + loopback. Upstream's default, kept so an operator reading + authelia's documentation finds what they expect. + + A separate port from {option}`port` because it is a separate + listener with a different audience: the main one is proxied by + the gateway and reachable from the swarm, this one is scraped by + the collector on this host and by nothing else. + + ⚠️ Every swarm container shares the host network namespace, so + two services defaulting to the same port do not conflict at build + time — one simply loses at runtime, with nothing in any log. Check + a new value against the others before changing this. + + ::: {.note} + Loopback means this endpoint is only reachable by a collector on + the *same host*, so the scrape target is declared only when one is + enabled here. Run the swarm's collector elsewhere and authelia's + metrics are simply not collected — no error, and nothing in a log + to say so. Making them reachable across hosts is a different piece + of work: the endpoint would have to be published under a name, + with a certificate and an audience. + ::: + ''; + }; + + 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.str; + default = "https://${cfg.domain}"; + defaultText = lib.literalExpression ''"https://''${domain}"''; + 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. + + Names {option}`domain`, and does **not** ask whether this host + runs the container: a swarm has one SSO provider, so every hive + addresses the same name and resolution decides where it is — + dnsmasq locally on the host serving the vhost, the real network + anywhere else. There is no loopback-vs-remote branch to get + wrong, the same way {option}`services.hyperhive.swarm.otel.domain` + has none. + + Not having SSO is not a supported deployment: every swarm has an + IdP, so this is never `null`. + ''; + }; + + oidc.hiveIdentities = lib.mkOption { + type = lib.types.bool; + default = true; + description = '' + Mint machine clients per hive in + {option}`services.hyperhive.swarm.hives`, so each hive can + authenticate to swarm services as itself: `hive-` for the + hive's own daemons, and `hive--agent` for the agent containers + running on it. + + Two clients rather than one because they are not the same + principal — a hive's daemons run on the host and an agent runs + in a container the host hands a credential to, so a swarm + service has to be able to grant them different things. It is + one client per *hive* on the agent side, not per agent: agents + are created at runtime, and a per-agent client would make + creating one a config change plus an authelia reload. The cost + is that agents on a hive are indistinguishable from each other, + tracked as a follow-up rather than papered over. + + On by default: a swarm's hives have identities, and that is a + fact about the swarm rather than about any one host. It used to + default to whether the queue ran on *this* machine, which made + the answer differ between two hosts of one swarm — set it false + for a swarm whose hives authenticate to nothing. + + 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. + + ⚠️ `null` is NOT accepted on a client with `bearerAuthz`. + Measured against authelia 4.39.20: under that scope the + method must be *stated*: omitting it is refused with + `must be configured as 'client_secret_basic', … but it's + configured as` an empty string. The sentence above is + true of an ordinary client and false of that one, which + is exactly how a reviewer reads this option and concludes + the assertion below is wrong. + ''; + }; + + 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. + ''; + }; + + bearerAuthz = lib.mkOption { + type = lib.types.bool; + default = false; + example = true; + description = '' + Grant this client the `authelia.bearer.authz` scope, so it + may present its access token to authelia's authz endpoint + and be authorised by an `access_control` rule — how a + scraper reaches a service published behind the gateway. + + A named capability rather than a free-form `scopes` list, + for the same reason `kind` derives the rest: authelia + refuses some scope/grant combinations outright (`openid` + with `client_credentials` among them), and a list would + make those combinations expressible again. This admits the + one value that is legal here and nothing else. + + ::: {.note} + Setting this obliges two other options, and the assertions + below enforce it. Authelia checks the same thing, but only + in its `preStart` validator — which means a violation + builds and deploys cleanly and then fails to restart, + taking swarm SSO down. The assertions move that to + evaluation, where a wrong value costs nothing. + ::: + ''; + }; + + 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..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. + ''; + }; + + agentClientSuffix = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = "-agent"; + description = '' + Suffix appended to a hive's own client id to name the client its + *agent containers* present — agents on hive `alpha` all present + `${config.services.hyperhive.swarm.authelia.hiveClientPrefix}alpha${config.services.hyperhive.swarm.authelia.agentClientSuffix}`. + Read-only for the same reason as `hiveClientPrefix`, and read by the + same consumer with the same failure mode: a split spelling denies + every agent as a timeout. + + A suffix on the hive's id rather than a prefix of its own, because + `agent-alpha` reads as *the agent named alpha* — which is precisely + what this identity does not say. + + One id per hive rather than per agent, because agents are created at + runtime and a per-agent client would make creating one a config + change plus a reload. The consequence is that this identity says + *which hive* an agent belongs to and never *which agent* — the + broker cannot tell two agents on one hive apart. + ''; + }; + + 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/` 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. + ''; + }; + + 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 deployCfg.authelia.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. + ''; + }; + }; +} diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index b755b898..de375ebf 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -32,7 +32,6 @@ let networkCfg = config.services.hyperhive.network; hyperhiveCfg = config.services.hyperhive; gatewayCfg = hyperhiveCfg.gateway; - hyperhiveDomain = hyperhiveCfg.domain; swarmDomain = hyperhiveCfg.swarm.domain; deployCfg = hyperhiveCfg.deploy; @@ -114,13 +113,14 @@ let # The SWARM's domain, because that is where the protected apps now live # (`forge.`, `chat.`, `auth.`). 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. + # same commit as `domain` (./swarm-authelia-service.nix) 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. + # Total on a null swarm domain for the same reason the option defaults in + # ./swarm-authelia-service.nix 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. The model — why identity is @@ -390,452 +390,10 @@ let privateNetwork = false; in { - # `enable` and both packages moved to `services.hyperhive.deploy.authelia` - # — see ./deploy.nix. Whether this host runs the swarm's SSO provider, and - # which build it runs, are deployment decisions; what stays here is what - # authelia IS, including `url` and the OIDC client registry every hive needs - # as a *client* whether or not it runs the container. - options.services.hyperhive.swarm.authelia = { - 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). - ''; - }; - - metricsPort = lib.mkOption { - type = lib.types.port; - default = 9959; - description = '' - TCP port authelia serves its Prometheus metrics on, bound to - loopback. Upstream's default, kept so an operator reading - authelia's documentation finds what they expect. - - A separate port from {option}`port` because it is a separate - listener with a different audience: the main one is proxied by - the gateway and reachable from the swarm, this one is scraped by - the collector on this host and by nothing else. - - ⚠️ Every swarm container shares the host network namespace, so - two services defaulting to the same port do not conflict at build - time — one simply loses at runtime, with nothing in any log. Check - a new value against the others before changing this. - - ::: {.note} - Loopback means this endpoint is only reachable by a collector on - the *same host*, so the scrape target is declared only when one is - enabled here. Run the swarm's collector elsewhere and authelia's - metrics are simply not collected — no error, and nothing in a log - to say so. Making them reachable across hosts is a different piece - of work: the endpoint would have to be published under a name, - with a certificate and an audience. - ::: - ''; - }; - - 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.str; - default = "https://${cfg.domain}"; - defaultText = lib.literalExpression ''"https://''${domain}"''; - 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. - - Names {option}`domain`, and does **not** ask whether this host - runs the container: a swarm has one SSO provider, so every hive - addresses the same name and resolution decides where it is — - dnsmasq locally on the host serving the vhost, the real network - anywhere else. There is no loopback-vs-remote branch to get - wrong, the same way {option}`services.hyperhive.swarm.otel.domain` - has none. - - Not having SSO is not a supported deployment: every swarm has an - IdP, so this is never `null`. - ''; - }; - - oidc.hiveIdentities = lib.mkOption { - type = lib.types.bool; - default = true; - description = '' - Mint machine clients per hive in - {option}`services.hyperhive.swarm.hives`, so each hive can - authenticate to swarm services as itself: `hive-` for the - hive's own daemons, and `hive--agent` for the agent containers - running on it. - - Two clients rather than one because they are not the same - principal — a hive's daemons run on the host and an agent runs - in a container the host hands a credential to, so a swarm - service has to be able to grant them different things. It is - one client per *hive* on the agent side, not per agent: agents - are created at runtime, and a per-agent client would make - creating one a config change plus an authelia reload. The cost - is that agents on a hive are indistinguishable from each other, - tracked as a follow-up rather than papered over. - - On by default: a swarm's hives have identities, and that is a - fact about the swarm rather than about any one host. It used to - default to whether the queue ran on *this* machine, which made - the answer differ between two hosts of one swarm — set it false - for a swarm whose hives authenticate to nothing. - - 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. - - ⚠️ `null` is NOT accepted on a client with `bearerAuthz`. - Measured against authelia 4.39.20: under that scope the - method must be *stated*: omitting it is refused with - `must be configured as 'client_secret_basic', … but it's - configured as` an empty string. The sentence above is - true of an ordinary client and false of that one, which - is exactly how a reviewer reads this option and concludes - the assertion below is wrong. - ''; - }; - - 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. - ''; - }; - - bearerAuthz = lib.mkOption { - type = lib.types.bool; - default = false; - example = true; - description = '' - Grant this client the `authelia.bearer.authz` scope, so it - may present its access token to authelia's authz endpoint - and be authorised by an `access_control` rule — how a - scraper reaches a service published behind the gateway. - - A named capability rather than a free-form `scopes` list, - for the same reason `kind` derives the rest: authelia - refuses some scope/grant combinations outright (`openid` - with `client_credentials` among them), and a list would - make those combinations expressible again. This admits the - one value that is legal here and nothing else. - - ::: {.note} - Setting this obliges two other options, and the assertions - below enforce it. Authelia checks the same thing, but only - in its `preStart` validator — which means a violation - builds and deploys cleanly and then fails to restart, - taking swarm SSO down. The assertions move that to - evaluation, where a wrong value costs nothing. - ::: - ''; - }; - - 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..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. - ''; - }; - - agentClientSuffix = lib.mkOption { - type = lib.types.str; - readOnly = true; - default = "-agent"; - description = '' - Suffix appended to a hive's own client id to name the client its - *agent containers* present — agents on hive `alpha` all present - `${config.services.hyperhive.swarm.authelia.hiveClientPrefix}alpha${config.services.hyperhive.swarm.authelia.agentClientSuffix}`. - Read-only for the same reason as `hiveClientPrefix`, and read by the - same consumer with the same failure mode: a split spelling denies - every agent as a timeout. - - A suffix on the hive's id rather than a prefix of its own, because - `agent-alpha` reads as *the agent named alpha* — which is precisely - what this identity does not say. - - One id per hive rather than per agent, because agents are created at - runtime and a per-agent client would make creating one a config - change plus a reload. The consequence is that this identity says - *which hive* an agent belongs to and never *which agent* — the - broker cannot tell two agents on one hive apart. - ''; - }; - - 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/` 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. - ''; - }; - - 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 deployCfg.authelia.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. - ''; - }; - }; - - # What stays above is what authelia IS to every hive: where it answers - # (`url`), the OIDC register every service checks itself against, its port. - # What the host running the container decides is here — which two builds it + # What authelia IS to every hive, where it answers (`url`), the OIDC + # register every service checks itself against and its port, is + # `swarm.authelia` in ./swarm-authelia-service.nix. What the host running + # the container decides is here — which two builds it # runs, and three filesystem paths that only exist on the machine running # `swarm-authelia`. A hive that does not run it has nothing at any of those # paths. `enable` already lives in ./deploy.nix, which also carries the