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
|
|
@ -171,6 +171,7 @@ in
|
|||
type = lib.types.enum [
|
||||
"interactive"
|
||||
"machine"
|
||||
"native"
|
||||
];
|
||||
default = "interactive";
|
||||
example = "machine";
|
||||
|
|
@ -185,6 +186,12 @@ in
|
|||
client's own, so it receives an access token and no
|
||||
id-token.
|
||||
|
||||
`native` is a public client a person runs on their own
|
||||
machine (RFC 8252): no secret, authorization code with
|
||||
PKCE through a loopback `redirectUris` entry, and a refresh
|
||||
token. Its access token carries `authelia.bearer.authz`
|
||||
for its `audience`, so the gateway admits it as that person.
|
||||
|
||||
This is declared rather than inferred from an empty
|
||||
`redirectUris`, because authelia permits only the grants a
|
||||
client names — omitting `grant_types` yields
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -38,18 +38,23 @@ let
|
|||
inherit (stylixTheme) stylixThemeColors themedColorsCss;
|
||||
|
||||
# Repeated verbatim by every location that should be operator-gated
|
||||
# (`/`, `/api/`, `/api/docs/`) rather than set once on the server:
|
||||
# (`/`, `/api/docs/`; `/api/` takes `authRequest` with its own 401
|
||||
# handler) rather than set once on the server:
|
||||
# nginx's `auth_request` does NOT inherit across sibling locations, so
|
||||
# setting it only on the page would leave this UI's own API and API
|
||||
# docs reachable without a session.
|
||||
swarmAuthRequest = ''
|
||||
authRequest = ''
|
||||
auth_request /__hive_authelia;
|
||||
# Captured BEFORE the error_page jump: inside the 401 handler
|
||||
# `$request_uri` is the internal one, so building the return
|
||||
# link there sends the operator back to the auth subrequest
|
||||
# instead of the page they asked for.
|
||||
auth_request_set $target_url $scheme://$http_host$request_uri;
|
||||
error_page 401 =302 https://${autheliaCfg.domain}/?rd=$target_url;
|
||||
'';
|
||||
loginRedirect = "https://${autheliaCfg.domain}/?rd=$target_url";
|
||||
swarmAuthRequest = ''
|
||||
${authRequest}
|
||||
error_page 401 =302 ${loginRedirect};
|
||||
'';
|
||||
swarmCfg = config.services.hyperhive.swarm;
|
||||
hiveDomain = config.services.hyperhive.domain;
|
||||
|
|
@ -218,13 +223,24 @@ in
|
|||
# `Remote-User` from authelia's answer, set here so it replaces
|
||||
# any the client sent: swarm-controller takes it as the sender of
|
||||
# the text the operator types to an agent (`agent_input.rs`).
|
||||
# A refused caller that sent an `Authorization` header (`swarmctl
|
||||
# --swarm`) gets the 401 itself; a browser still gets the login.
|
||||
# The bearer token stops here and never reaches the controller.
|
||||
extraConfig = ''
|
||||
${swarmAuthRequest}
|
||||
${authRequest}
|
||||
error_page 401 = @swarm_api_unauthorized;
|
||||
auth_request_set $authelia_user $upstream_http_remote_user;
|
||||
proxy_set_header Remote-User $authelia_user;
|
||||
proxy_set_header Authorization "";
|
||||
'';
|
||||
};
|
||||
# ⚠️ THE ONE LOCATION ON THIS VHOST WITH NO `swarmAuthRequest`,
|
||||
"@swarm_api_unauthorized".extraConfig = ''
|
||||
if ($http_authorization) {
|
||||
return 401;
|
||||
}
|
||||
return 302 ${loginRedirect};
|
||||
'';
|
||||
# ⚠️ THE ONE PROXIED LOCATION ON THIS VHOST WITH NO `auth_request`,
|
||||
# and that is deliberate rather than an omission.
|
||||
#
|
||||
# A forge webhook is a machine POST carrying an HMAC signature
|
||||
|
|
|
|||
|
|
@ -234,6 +234,52 @@ let
|
|||
in
|
||||
v.forceSSL && !(v.addSSL or false);
|
||||
}
|
||||
{
|
||||
# `swarmctl --swarm` reads a 401 as "log in again"; a 302 would hand it
|
||||
# the login page instead. The `/` clause is the control: the browser
|
||||
# route still redirects, so a pass means the two routes differ.
|
||||
name = "the controller API answers a refused bearer caller 401 and a browser the login";
|
||||
ok =
|
||||
let
|
||||
l = allLocal.services.nginx.virtualHosts."t.local".locations;
|
||||
api = l."/api/".extraConfig;
|
||||
refused = l."@swarm_api_unauthorized".extraConfig;
|
||||
in
|
||||
lib.hasInfix "error_page 401 = @swarm_api_unauthorized;" api
|
||||
&& !(lib.hasInfix "=302" api)
|
||||
&& lib.hasInfix ''proxy_set_header Authorization "";'' api
|
||||
&& lib.hasInfix "if ($http_authorization) {\n return 401;\n}" refused
|
||||
&& lib.hasInfix "return 302 https://auth.t.local/?rd=$target_url;" refused
|
||||
&& lib.hasInfix "error_page 401 =302 https://auth.t.local/" l."/".extraConfig;
|
||||
}
|
||||
{
|
||||
# authelia 4.39.20 refuses a bearer-scoped public client missing any of
|
||||
# these at startup, after a green deploy; and a public client must not
|
||||
# be handed a secret.
|
||||
name = "the swarmctl login client is public, bearer-scoped to the controller API, and gets no secret";
|
||||
ok =
|
||||
let
|
||||
s = allLocal.containers.swarm-authelia.config.systemd.services.authelia-swarm-secrets.script;
|
||||
entry = lib.last (lib.splitString "client_id: %s\\n' swarmctl" s);
|
||||
client = lib.head (lib.splitString "client_id: %s" entry);
|
||||
in
|
||||
lib.all (line: lib.hasInfix line client) [
|
||||
"public: true"
|
||||
"token_endpoint_auth_method: none"
|
||||
"require_pkce: true"
|
||||
"pkce_challenge_method: S256"
|
||||
"require_pushed_authorization_requests: true"
|
||||
"consent_mode: explicit"
|
||||
"response_modes: [form_post]"
|
||||
"grant_types: [authorization_code, refresh_token]"
|
||||
"scopes: [offline_access, authelia.bearer.authz]"
|
||||
"audience: [%s]\\n' https://t.local/api/"
|
||||
"' - %s\\n' http://127.0.0.1/callback"
|
||||
]
|
||||
&& !(lib.hasInfix "client_secret" client)
|
||||
&& !(lib.hasInfix "mint swarmctl" s)
|
||||
&& lib.hasInfix "mint swarm-authelia-bridge" s;
|
||||
}
|
||||
{
|
||||
name = "a hive with matrix off serves no matrix discovery endpoint";
|
||||
ok =
|
||||
|
|
|
|||
Loading…
Reference in a new issue