Watch
0
0
Fork
You've already forked hyperhive
0

swarmctl: log in with the operator's own SSO account and reach the controller through the swarm domain

`swarmctl --swarm https://<swarm> login` runs the OAuth 2.0
authorization-code flow with PKCE against the swarm's authelia as a new
public `swarmctl` client: a pushed authorization request, the browser login
and explicit consent, and the code delivered by form_post to a loopback
port. The access and refresh tokens are cached at mode 0600 under
$XDG_STATE_HOME/swarmctl/ and refreshed under a file lock, since authelia
rotates the refresh token and revokes the login when a used one is
presented again. With --swarm (or SWARMCTL_SWARM), the agent, schedule and
forge verbs send their requests to https://<swarm>/api/ with the bearer
token instead of over the controller's unix socket, which stays the default.

authelia registers the client on every authelia host with a swarm domain:
`kind = "native"`, public, no minted secret, scopes offline_access and
authelia.bearer.authz, audience https://<swarm>/api/ (matched as a URL
prefix), redirect http://127.0.0.1/callback (any loopback port). Every
field is one authelia 4.39.20's validator requires for a public client
holding the bearer scope.

The swarm apex's /api/ answers a refused caller that sent an
Authorization header with the 401 itself instead of the login redirect,
so the CLI can tell "log in again" apart from an answer; a browser still
gets the redirect. The bearer token is not forwarded to the controller.

Refs #4409 (mara: c94764, c94782).
This commit is contained in:
atlas 2026-10-11 23:43:29 +02:00 • committed by mara
commit 2df19e4899
17 changed files with 1118 additions and 122 deletions

View file

@ -221,6 +221,18 @@ let
redirectUris = [ ];
};
# `swarmctl login`: an operator's own account, from wherever they run the
# CLI. The id is the one `swarmctl/src/remote.rs` sends. The audience is
# the controller's API on the swarm apex, which authelia matches as a URL
# prefix; the redirect matches a loopback callback on any port.
swarmctlClient = {
id = "swarmctl";
description = "swarmctl (swarm operator CLI)";
kind = "native";
redirectUris = [ "http://127.0.0.1/callback" ];
audience = [ "https://${swarmDomain}/api/" ];
};
# Derived from the client list rather than carrying its own `enable`;
# `docs/swarm/sso.md` says why that is one fact instead of two.
#
@ -290,7 +302,19 @@ let
''
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"})"
''
# A native client is public: it has no secret, so none is minted for it.
+ (
if c.kind == "native" then
''
printf -- ' public: true\n'
''
else
''
printf -- " client_secret: '%s'\n" "$(cat ${lib.escapeShellArg "${clientsDir}/${c.id}.digest"})"
''
)
+ ''
printf -- ' authorization_policy: one_factor\n'
''
# Read plainly, and that is a property of the list rather than of
@ -298,7 +322,7 @@ let
# `oidc.clients`, so the module system has applied the submodule and
# each option's default is present. A derived entry that names only
# the fields it cares about still arrives with the rest filled in.
+ lib.optionalString (c.tokenEndpointAuthMethod != null) ''
+ lib.optionalString (c.kind != "native" && 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
@ -316,6 +340,25 @@ let
printf -- ' grant_types: ["client_credentials"]\n'
printf -- ' scopes: [${lib.optionalString c.bearerAuthz "authelia.bearer.authz"}]\n'
''
# What authelia 4.39.20 requires of a public client holding
# `authelia.bearer.authz`; its validator refuses the client without
# any one of these lines.
else if c.kind == "native" then
''
printf -- ' token_endpoint_auth_method: none\n'
printf -- ' require_pkce: true\n'
printf -- ' pkce_challenge_method: S256\n'
printf -- ' require_pushed_authorization_requests: true\n'
printf -- ' consent_mode: explicit\n'
printf -- ' response_types: [code]\n'
printf -- ' response_modes: [form_post]\n'
printf -- ' grant_types: [authorization_code, refresh_token]\n'
printf -- ' scopes: [offline_access, authelia.bearer.authz]\n'
printf -- ' redirect_uris:\n'
${lib.concatMapStrings (u: ''
printf -- ' - %s\n' ${lib.escapeShellArg u}
'') c.redirectUris}
''
else
''
printf -- ' scopes: [openid, profile, email, groups]\n'
@ -382,7 +425,7 @@ let
${lib.concatMapStrings (c: ''
mint ${lib.escapeShellArg c.id}
'') cfg.oidc.clients}
'') (lib.filter (c: c.kind != "native") cfg.oidc.clients)}
# Re-rendered every boot, deliberately: the secret is minted once,
# but the metadata around it (a new redirect URI, a renamed client)
@ -507,7 +550,8 @@ in
# — see `oidcEnabled`'s comment above.
services.hyperhive.swarm.authelia.oidc.clients =
lib.optionals cfg.oidc.hiveIdentities (hiveClients ++ agentClients)
++ [ bridgeClient ];
++ [ bridgeClient ]
++ lib.optional (swarmDomain != null) swarmctlClient;
# A redirect URI on a machine client is not harmless-but-unused: it
# means whoever wrote it believes a browser is involved. Failing here
@ -582,13 +626,17 @@ in
# 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;
assertion = lib.all (
c: !(c.bearerAuthz || c.kind == "native") || 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)
map (c: "client '${c.id}'") (
lib.filter (c: (c.bearerAuthz || c.kind == "native") && c.audience == [ ]) cfg.oidc.clients
)
)
+ " sets bearerAuthz but declares no audience. The audience is "
+ " sets bearerAuthz or kind = \"native\" 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.";