From 176d95c0f0773094e7fee289300d3befa2c9f062 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 15 Aug 2026 13:14:05 +0200 Subject: [PATCH] feat(swarm-authelia): let an oidc client say it is a machine client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `renderClient` could only emit the authorization-code shape, so a daemon client was expressed as an interactive one with an empty redirect list. Authelia permits only the grants a client names, and an omitted `grant_types` means authorization-code alone — so that shape cannot obtain a token at all. Measured against authelia 4.39.20, rendering exactly what this module produced for `swarm-nats`: client_secret_basic → unauthorized_client: The OAuth 2.0 Client is not allowed to use authorization grant 'client_credentials' introspection → {"active":false} (works) Introspection is all the queue's responder needs today, which is why nothing was visibly broken while the comment in `swarm-nats.nix` described a grant that was never configured. Adds `kind = "interactive" | "machine"` rather than inferring from an empty `redirectUris`, because the two differ in what authelia permits and not merely in what is populated. `openid` is dropped from a machine client's scopes because authelia refuses that combination outright — a daemon receives an access token and never an id-token. An assertion rejects redirect URIs on a machine client: they are not harmlessly unused, they mean the author believed a browser was involved. Refs #3274. --- nix/host-modules/swarm-authelia.nix | 92 +++++++++++++++++++++++++---- nix/host-modules/swarm-nats.nix | 12 +++- 2 files changed, 92 insertions(+), 12 deletions(-) diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 37e6a511..3d8a3af5 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -99,17 +99,43 @@ let # 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} - ''; + # 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' + '' + + ( + 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. @@ -305,13 +331,44 @@ in ''; }; + 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. ''; }; }; @@ -403,6 +460,19 @@ in }; config = lib.mkIf (hyperhiveCfg.enable && cfg.enable) { + # 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; + # 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 diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index bdadea39..ada04d1f 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -240,11 +240,21 @@ in # queue's clients are daemons with nobody to redirect, so a # non-interactive grant is what makes a hive able to authenticate at # all. No redirect URI exists or is wanted. + # + # ⚠️ `kind` says that, and an empty `redirectUris` does NOT. This + # declaration carried the comment above while rendering as an + # interactive client, because authelia permits only the grants a + # client names and an omitted `grant_types` means authorization-code + # alone. Measured against 4.39.20: the token endpoint answered + # `unauthorized_client: The OAuth 2.0 Client is not allowed to use + # authorization grant 'client_credentials'`. Introspection — which + # is all the responder needs today — worked throughout, which is why + # nothing was visibly broken while the comment was untrue. services.hyperhive.swarm.authelia.oidc.clients = lib.mkIf autheliaCfg.enable [ { id = cfg.clientId; description = "HyperHive swarm queue"; - redirectUris = [ ]; + kind = "machine"; } ];