From 0b98f0ac8fccc308632609a0fcff7b55f605d294 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 19 Aug 2026 14:18:51 +0200 Subject: [PATCH] swarm-authelia: give each hive client an audience and JWT access tokens A swarm service that has to tell hives apart needs the token itself to say which hive presented it. Two client-registration fields were missing for that, and both are properties of the client rather than of any one consumer: - `audience` registers which `aud` values a client may request. It is a boundary, not a label: asking for one that is not registered is refused with `invalid_target`, so a hive cannot mint a token for another hive's slot. - `accessTokenSignedResponseAlg` switches the access token from authelia's default opaque handle to an RFC 9068 JWT, which is what lets a resource server verify a token against `/jwks.json` instead of asking authelia about every request. Each hive's audience is its own client id rather than a new per-hive string. The identity is the directory's, not the consumer's, and the id is already published as `hiveClientPrefix` precisely so a second consumer does not carry its own copy of the naming rule. Both fields default to authelia's own behaviour, so no existing client changes. --- nix/host-modules/swarm-authelia.nix | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index c25cf13a..6a419cf4 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -102,11 +102,26 @@ let # and a field later added to the submodule then existed on the # declared entries and not on these, which is an eval error reachable # only once hive identities are on. + # + # `audience` is the hive's own client id rather than a second per-hive + # string invented here. A swarm service that has to tell hives apart + # needs one name per hive that both sides already agree on, and the + # client id is that name — published as `hiveClientPrefix` for exactly + # this reason. Minting a parallel naming scheme would be a second thing + # to keep in step, and the one that drifts is the one nobody tests. + # + # `RS256` because a resource server that cannot call this provider back + # is a real case here: the swarm's telemetry collector verifies tokens + # offline against `/jwks.json`, and an opaque token gives it nothing to + # verify. The queue's auth-callout responder introspects instead, which + # is a different question asked of the same token. hiveClients = lib.mapAttrsToList (name: _: { id = "${cfg.hiveClientPrefix}${name}"; description = "HyperHive hive ${name}"; kind = "machine"; redirectUris = [ ]; + audience = [ "${cfg.hiveClientPrefix}${name}" ]; + accessTokenSignedResponseAlg = "RS256"; }) hyperhiveCfg.swarm.hives; # `swarm-authelia-bridge`'s own identity — distinct from @@ -188,6 +203,15 @@ let + lib.optionalString (c.tokenEndpointAuthMethod != null) '' printf -- ' token_endpoint_auth_method: %s\n' ${lib.escapeShellArg c.tokenEndpointAuthMethod} '' + # Flow-style YAML, matching `scopes` below. The values are client ids + # and hive names, which `Ident` already constrains to `[a-z0-9-]` — no + # character in that set needs quoting in a YAML flow sequence. + + lib.optionalString (c.audience != [ ]) '' + printf -- ' audience: [%s]\n' ${lib.escapeShellArg (lib.concatStringsSep ", " c.audience)} + '' + + lib.optionalString (c.accessTokenSignedResponseAlg != null) '' + printf -- ' access_token_signed_response_alg: %s\n' ${lib.escapeShellArg c.accessTokenSignedResponseAlg} + '' + ( if c.kind == "machine" then '' @@ -493,6 +517,63 @@ in the secret is at fault. ''; }; + + 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. + ''; + }; + + 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. + ''; + }; }; } );