Retire the CLI's ROPC password grant — device-code flow as the sole interactive acquire path, client-credentials for CI
LANDED (2026-07-04). Shipped essentially as proposed: the ROPC /
grant_type=passwordpath is retired and the OAuth 2.0 device grant + client-credentials are the only token paths (b457305d4; device flow4f6d6b642,740316518,bd25f69d5).shc auth loginuses the device-code flow (internal/modules/auth/commands/device.go—grant_type=urn:ietf:params:oauth:grant-type:device_code). #112.
Why / current state
Section titled “Why / current state”The CLI acquires a daemon bearer two mutually-exclusive ways today, and the
legacy one is a Resource Owner Password Credentials (ROPC / grant_type=password)
flow that OAuth 2.1 removes outright.
Legacy path — HTTP Basic → daemon-side password grant (the ROPC leg):
shc ctx login <name> --basic user:passbase64-encodes the pair and stores it verbatim as the cluster row’s flat auth header:entry["token"] = "Basic " + credential—internal/modules/ctx/commands/login.go:76-77.- The daemon-client replays that header unchanged on every single command:
activeCluster()returnsentry["token"]verbatim (internal/core/daemonclient/cluster.go:53-54), anddo()sets it asAuthorization(client.go:317). There is no client-side token; the raw credentials ride every request. - On the daemon,
CurrentUserfolds Basic into the bearer path by callingExchangeBasicForToken(ctx, ext.BasicUser, ext.BasicPass)for the request (internal/modules/auth/service.go:721-722), which delegates toExchangeBasicForTokenFull(service.go:579). That POSTsgrant_type=password,client_id=admin-cli,username,password,scope=openidto the master realm token endpoint (service.go:584-592), with aclient_credentialsfallback on 400/401 where the username/password are reinterpreted asclient_id/client_secret(service.go:609-613). The same exchange is exposed over the wire atPOST /api/auth/token(internal/modules/auth/router.go:159-176). - Because the grant only runs against
MasterRealmwith the built-inadmin-cliclient (internal/modules/auth/types.go:80-83), and the master realm is the platform trust anchor exempt from audience enforcement (types.go:57-64, “any master identity is platform root via applyMasterRealmPrivilege”), every ROPC login is effectively a platform-root credential. There is no tenant-scoped ROPC login.
The band-aid: round-tripping a password grant per command adds “200-400ms of
latency on each command and floods keycloak’s audit log”
(internal/modules/auth/bearer_cache.go:13-16; also service.go:49-54), so the
daemon carries an in-process bearerCache keyed by
sha256(api_base | user | password) (bearer_cache.go:64-85) with a 5-minute /
expires_in − 30s TTL (bearer_cache.go:41-50). It is invalidated when a cached
token later 401s (service.go:732-733). This exists only to blunt the
per-command re-auth cost the ROPC design creates — it is not a feature, it is a
mitigation for a bad acquire model, and it means the operator’s cleartext password
is present in the daemon’s request path on any cold-cache command.
The sanctioned path already exists (landed in #112,
4f6d6b642 feat(auth): shc auth login — browser SSO via OAuth2 device grant):
commands/device.gois a complete RFC 8628 device-authorization client:requestDeviceCode(device.go:102),pollForTokenwithgrant_type=urn:ietf:params:oauth:grant-type:device_codeand the fullauthorization_pending/slow_down/access_denied/expired_tokencontrol loop (device.go:132-184), and a best-effort browser opener (device.go:226).- The keycloak client it targets is already provisioned on every realm:
shc-cli, a public client with the device grant enabled as theoauth2.device.authorization.grant.enabledattribute and standard/implicit/direct/service-account flows all disabled, plus anshc-accessaudience mapper (internal/modules/auth/keycloak_admin.go:708-751; constantShcCLIClientdocumented attypes.go:66-76). - The token cache and silent refresh are wired: login persists a flat
token: "Bearer <access>"plus anoidcsub-map (issuer/client_id/realm/access_token/refresh_token/expires_at/refresh_expires_at) intoconfig.yamlat0600(internal/modules/auth/commands/store.go:8-92), anddaemonclienttransparently refreshes viagrant_type=refresh_tokenbefore expiry, rotates + re-persists the tokens, and fails closed on refresh failure (internal/core/daemonclient/oidc.go:171-268,refreshSkew= 30s atoidc.go:48).
So the daemon verifier is already flow-agnostic (realm-per-tenant, issuer-derived
ValidateBearer); the device-minted token authenticates “with ZERO daemon-side
change” (store.go:25-28, device.go:12-15). The only thing keeping ROPC alive
is that --basic is still offered and the daemon still honours the Basic→password
exchange. This proposal retires that leg.
Design
Section titled “Design”Forward-only removal of the ROPC acquire path; the device flow is promoted to the sole interactive path and a first-class client-credentials path is added for CI. No daemon verifier change.
1. CLI: remove --basic, keep device + add service-token login
Section titled “1. CLI: remove --basic, keep device + add service-token login”- Delete
--basicfromshc ctx login(login.go:46-48,70-77,116). Removing the flag deletes the only code that writes aBasic <b64>row.--token(raw bearer) and--token-exec(a command that prints a fresh token) stay — they are the CI/automation seam (see §3). shc auth login(device flow) is the documented default for humans. It already writes both the flattokenand theoidcrefresh state.- Add
shc auth login --client-credentials(or a siblingshc auth token) for headless service accounts (§3). It reusescommands/store.go’sloginSession/SaveLoginbut with a client-credentials token exchange instead of the device poll, storing theoidcsub-map withclient_id/client_secretsodaemonclientcan silently re-mint (client-credentials has no refresh token — the “refresh” is simply re-running the grant; see §3).
2. Daemon: retire the Basic→password exchange
Section titled “2. Daemon: retire the Basic→password exchange”CurrentUser: drop theext.BasicUser != ""branch (service.go:721-734). The precedence becomes UDS short-circuit → Bearer validate →X401900NOAUTH. Basic is no longer an authentication scheme for the API;SchemeBasic(types.go:441) andapi.ExtractAuth’s Basic decode stay only if other consumers need them, otherwise removed.ExchangeBasicForToken/ExchangeBasicForTokenFull(service.go:579-649) and thePOST /api/auth/tokenhandler (router.go:159-176): delete. Thepassword_grantcode (service.go:588), the client_credentials-via-Basic fallback (service.go:609-613), and theExchangeBasic*interface methods (interfaces.go:46-47) go with them.bearerCacheis deleted wholesale —bearer_cache.go, thebearerCachefield +BearerCache()accessor (service.go:49-54,179,214), and the invalidation call (service.go:732-733). With no per-command password grant there is nothing to cache: the CLI now holds a long-lived bearer client-side and refreshes it itself. The device-minted token is a normal bearer that rides the existing JWKS/userinfo verify path.
3. Non-interactive callers (CI) — client-credentials, not ROPC
Section titled “3. Non-interactive callers (CI) — client-credentials, not ROPC”A device flow needs a human, so CI needs its own path. Two supported options:
- Client-credentials (recommended). Provision a per-CI confidential client in
the target realm (a service-account client;
shc-apiis the existing confidential client,SHCAPIClientattypes.go:48-53, and service tokens are already an accepted audience).shc auth login --client-credentials --client-id … --client-secret …POSTsgrant_type=client_credentialsto the realm token endpoint and stores the resulting bearer + the client_id/secret in theoidcsub-map.daemonclient.maybeRefreshAuthHeadergains a branch: when the row has aclient_secretand norefresh_token, re-run the client-credentials grant instead ofrefresh_token(mirrorsperformRefreshatoidc.go:230-268, different form values). The daemon verifier already acceptsaud/azp == shc-api(AllowedTokenAudiences,types.go:64). - Static long-lived service token.
shc ctx login --token "$SHC_TOKEN"or--token-exec 'gen-token.sh'(both already exist,login.go:78-81,117-118).--token-execis the cleanest for CI secret managers: the token never lands inconfig.yaml.
Client-credentials is preferred over a static token because keycloak owns the rotation/expiry and the secret can be scoped to a single realm/role, whereas the old ROPC path minted a master-realm platform-root token for every caller.
Files that change
Section titled “Files that change”internal/modules/ctx/commands/login.go (drop --basic),
internal/modules/auth/service.go (drop Basic branch + exchange fns + cache
field), internal/modules/auth/bearer_cache.go (delete),
internal/modules/auth/router.go (drop /api/auth/token),
internal/modules/auth/interfaces.go (drop ExchangeBasic*),
internal/core/daemonclient/oidc.go (add client-credentials re-mint branch),
internal/modules/auth/commands/*.go (add --client-credentials). The keycloak
shc-cli client and provision script (keycloak_admin.go:708-751) are already
correct and unchanged.
Migration / rollout
Section titled “Migration / rollout”Auth-flow change, forward-only. It touches (a) an on-disk config row shape and (b) a running multi-node cluster’s request auth, so ordering matters.
- Existing
Basic <b64>rows keep working until the operator re-logs in — for one deprecation window. Ship the daemon change (§2) AFTER the CLI change (§1) has been out for a release: a daemon that no longer honours Basic will 401 any CLI still replaying aBasicrow. Recommended sequence:- Release N: CLI removes
--basic(no new Basic rows can be created), prints a deprecation notice on any existing Basic row directing the operator toshc auth login. Daemon still honours Basic (unchanged). - Release N+1: daemon drops the Basic exchange +
bearerCache. Any lingering Basic row now 401s with a cleanX401900NOAUTH, and the CLI’s error hint says “runshc auth login”.
- Release N: CLI removes
- Read-compat, not write-compat.
daemonclientalready reads both row shapes (tokenflat + optionaloidcsub-map,oidc.go:127-155); a Basic row is just atokenwith nooidc, somaybeRefreshAuthHeaderreturns it unchanged (oidc.go:179-182) — it degrades to a plain replay that the N+1 daemon rejects. No config migration script is needed: re-login overwrites the row viaSaveLogin(store.go:138-148). - Multi-node: the daemon-side change is a verifier-input change, not a stored
state change — no dqlite/raft rows move. During a rolling daemon upgrade the
cluster is briefly mixed (some nodes honour Basic, some don’t). Because the CLI
talks to a single active-cluster URL (
cluster.go:37-55) that fronts the daemon leader/local node, an operator on a still-Basic row sees a hard 401 only once every node it can reach has upgraded — matching the N+1 cutover. No quorum interaction. - CI: provision the client-credentials client (or issue the service token)
and update pipelines to
shc auth login --client-credentials/--token-execin the SAME change that removes--basicfrom their scripts, so CI never lands on the dead Basic path.
Risks & mitigations
Section titled “Risks & mitigations”- Headless boxes with no browser (the SSH case). This is exactly why the
device grant is primary, not authorization-code:
pollForTokenprints a code + URL and the operator approves on any device (device.go:132-184); the browser open is best-effort only (device.go:226-241). No localhost redirect listener is required. - CI breaks on cutover. Mitigated by the two-release window and by shipping the client-credentials path BEFORE the daemon drops Basic. A CI job that has not migrated fails with a typed 401 pointing at the new flow, never a silent mis-auth.
- Refresh-token expiry mid-job. Interactive device tokens carry
offline_access(device.go:38) so the refresh token is long-lived; the silent refresh fails closed (oidc.go:194-197). For CI, client-credentials has no refresh token — the re-mint branch (§3) re-runs the grant each time the access token nears expiry, so there is no expiry cliff. - Losing the audit-latency win the cache provided. The cache existed to hide per-command grants; with a client-side long-lived token there are zero per-command grants, so the latency win is preserved by construction and the keycloak audit-log flood disappears.
- Out of scope: the daemon-side bearer verifier (realm-per-tenant,
issuer-derived
ValidateBearer, JWKS/userinfo, audience enforcement) — this proposal changes only how the CLI acquires a token. Authorization-code+PKCE as an alternative interactive flow is noted below but NOT implemented here. RemovingSchemeBasic/api.ExtractAuthBasic decoding entirely is left to a follow-up if no other consumer remains.
Test plan
Section titled “Test plan”- Unit — CLI:
login.gono longer registers--basic(flag lookup returns nil);--token/--token-execstill round-trip. New--client-credentialsbuilds the correctgrant_type=client_credentialsform and persists anoidcrow carryingclient_id/client_secretand NOrefresh_token. - Unit — daemon: delete/replace
service_master_grant_test.go,bearer_cache_test.go,service_jwks_test.go’s cache assertions (service_jwks_test.go:127-133), and thegrant_type=password/client_credentialsassertions inauth_test.go:442-480. Add a test thatCurrentUserwith a Basic header returnsX401900NOAUTH(no exchange attempted — assert the keycloak transport is never called). - Unit — refresh: extend
oidc_internal_test.go(currently assertsgrant_type=refresh_token,oidc_internal_test.go:111) with a client-credentials-row case: near-expiry +client_secretpresent + no refresh token → re-mints viagrant_type=client_credentials, persists rotated access token, replays the fresh bearer. - Migration: a
config.yamlcarrying a legacytoken: "Basic …"row is read bydaemonclientunchanged (replayed verbatim), and against an N+1 daemon yields a clean 401; aftershc auth loginthe row is overwritten with the flat bearer +oidcsub-map and subsequent commands succeed. - Integration / app: the device-flow happy path already has
device_test.go; add an end-to-end that logs in via device grant against a provisioned tenant realm and runs a subsequent authenticated command using ONLY the cached/ refreshed token (no re-prompt), proving the “login once” UX. A CI-shaped test logs in via client-credentials and runs a command with no interactive step. - Regression: grep the tree for
grant_type=password/ExchangeBasic/bearerCachereturns zero non-test hits after the change.
Alternatives considered
Section titled “Alternatives considered”- Authorization-code + PKCE as the primary flow. Standards-clean, but it needs
a loopback redirect listener and a local browser — wrong default for the
headless/SSH CLI that is SHC’s common operator context (an operator’s laptop
talking to a remote node,
cluster.go:11-17). It stays as the documented alternative for a local-desktop operator, but the device grant is primary because it needs no listener and works over pure SSH. Theshc-clipublic client already forbidsstandardFlow(keycloak_admin.go:721), so adopting auth-code would additionally require flipping that client attribute — more keycloak surface for a worse default. - Keep ROPC behind a feature flag “for convenience.” Rejected: ROPC is removed
by OAuth 2.1, the master-only grant mints platform-root for every caller
(
types.go:57-64), and keeping it forces us to keep thebearerCacheband-aid and the cleartext-password request path alive. The device flow + client- credentials cover both the human and the CI case with no residual password grant.