diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 7ed2e398..2ad3785c 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -217,7 +217,7 @@ let if c.kind == "machine" then '' printf -- ' grant_types: ["client_credentials"]\n' - printf -- ' scopes: []\n' + printf -- ' scopes: [${lib.optionalString c.bearerAuthz "authelia.bearer.authz"}]\n' '' else '' @@ -575,6 +575,34 @@ in ''; }; + 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 [ @@ -820,6 +848,54 @@ in + "services.hyperhive.swarm.hives; rename the hive or the " + "colliding client."; } + # The two obligations `authelia.bearer.authz` carries. Authelia + # enforces both itself — but in its `preStart` validator, so a + # violation produces a green `nixos-rebuild switch` and an authelia + # that then refuses to come back up, taking swarm SSO with it. + # Asserting here moves the same failure to evaluation, where a + # wrong value costs a build and nothing else. + { + assertion = lib.all (c: !c.bearerAuthz || c.audience != [ ]) cfg.oidc.clients; + message = + "services.hyperhive.swarm.authelia.oidc.clients: " + + lib.concatStringsSep ", " ( + map (c: "client '${c.id}'") (lib.filter (c: c.bearerAuthz && c.audience == [ ]) cfg.oidc.clients) + ) + + " sets bearerAuthz but declares no audience. The audience is " + + "what authorises a bearer token at a given URL, so without " + + "one the scope grants access to nothing and authelia refuses " + + "the configuration outright."; + } + { + assertion = lib.all ( + c: + !c.bearerAuthz + || lib.elem c.tokenEndpointAuthMethod [ + "client_secret_basic" + "client_secret_jwt" + "private_key_jwt" + ] + ) cfg.oidc.clients; + message = + "services.hyperhive.swarm.authelia.oidc.clients: " + + lib.concatStringsSep ", " ( + map (c: "client '${c.id}' (tokenEndpointAuthMethod = ${toString c.tokenEndpointAuthMethod})") ( + lib.filter ( + c: + c.bearerAuthz + && !lib.elem c.tokenEndpointAuthMethod [ + "client_secret_basic" + "client_secret_jwt" + "private_key_jwt" + ] + ) cfg.oidc.clients + ) + ) + + ". A confidential client carrying authelia.bearer.authz must " + + "authenticate with client_secret_basic, client_secret_jwt or " + + "private_key_jwt. Notably client_secret_post is refused, and " + + "it is what an OAuth2 client library may reach for first."; + } ]; # Authelia's own gateway surface: the vhost that fronts it and the