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.
This commit is contained in:
atlas 2026-08-19 14:18:51 +02:00 committed by mara
commit 0b98f0ac8f

View file

@ -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.
'';
};
};
}
);