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:
parent
5c15580060
commit
2df19e4899
17 changed files with 1118 additions and 122 deletions
|
|
@ -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.";
|
||||
|
|
|
|||
Loading…
Reference in a new issue