feat(swarm-authelia): let an oidc client say it is a machine client

`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.
This commit is contained in:
atlas 2026-08-15 13:14:05 +02:00 committed by mara
commit 176d95c0f0
2 changed files with 92 additions and 12 deletions

View file

@ -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

View file

@ -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";
}
];