Skip to content
SHC Docs

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=password path is retired and the OAuth 2.0 device grant + client-credentials are the only token paths (b457305d4; device flow 4f6d6b642, 740316518, bd25f69d5). shc auth login uses the device-code flow (internal/modules/auth/commands/device.gogrant_type=urn:ietf:params:oauth:grant-type:device_code). #112.

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:pass base64-encodes the pair and stores it verbatim as the cluster row’s flat auth header: entry["token"] = "Basic " + credentialinternal/modules/ctx/commands/login.go:76-77.
  • The daemon-client replays that header unchanged on every single command: activeCluster() returns entry["token"] verbatim (internal/core/daemonclient/cluster.go:53-54), and do() sets it as Authorization (client.go:317). There is no client-side token; the raw credentials ride every request.
  • On the daemon, CurrentUser folds Basic into the bearer path by calling ExchangeBasicForToken(ctx, ext.BasicUser, ext.BasicPass) for the request (internal/modules/auth/service.go:721-722), which delegates to ExchangeBasicForTokenFull (service.go:579). That POSTs grant_type=password, client_id=admin-cli, username, password, scope=openid to the master realm token endpoint (service.go:584-592), with a client_credentials fallback on 400/401 where the username/password are reinterpreted as client_id/client_secret (service.go:609-613). The same exchange is exposed over the wire at POST /api/auth/token (internal/modules/auth/router.go:159-176).
  • Because the grant only runs against MasterRealm with the built-in admin-cli client (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.go is a complete RFC 8628 device-authorization client: requestDeviceCode (device.go:102), pollForToken with grant_type=urn:ietf:params:oauth:grant-type:device_code and the full authorization_pending/slow_down/access_denied/expired_token control 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 the oauth2.device.authorization.grant.enabled attribute and standard/implicit/direct/service-account flows all disabled, plus an shc-access audience mapper (internal/modules/auth/keycloak_admin.go:708-751; constant ShcCLIClient documented at types.go:66-76).
  • The token cache and silent refresh are wired: login persists a flat token: "Bearer <access>" plus an oidc sub-map (issuer/client_id/realm/access_token/refresh_token/expires_at/refresh_expires_at) into config.yaml at 0600 (internal/modules/auth/commands/store.go:8-92), and daemonclient transparently refreshes via grant_type=refresh_token before expiry, rotates + re-persists the tokens, and fails closed on refresh failure (internal/core/daemonclient/oidc.go:171-268, refreshSkew = 30s at oidc.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.

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 --basic from shc ctx login (login.go:46-48,70-77,116). Removing the flag deletes the only code that writes a Basic <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 flat token and the oidc refresh state.
  • Add shc auth login --client-credentials (or a sibling shc auth token) for headless service accounts (§3). It reuses commands/store.go’s loginSession/SaveLogin but with a client-credentials token exchange instead of the device poll, storing the oidc sub-map with client_id/client_secret so daemonclient can 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 the ext.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) and api.ExtractAuth’s Basic decode stay only if other consumers need them, otherwise removed.
  • ExchangeBasicForToken / ExchangeBasicForTokenFull (service.go:579-649) and the POST /api/auth/token handler (router.go:159-176): delete. The password_grant code (service.go:588), the client_credentials-via-Basic fallback (service.go:609-613), and the ExchangeBasic* interface methods (interfaces.go:46-47) go with them.
  • bearerCache is deleted wholesalebearer_cache.go, the bearerCache field + 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-api is the existing confidential client, SHCAPIClient at types.go:48-53, and service tokens are already an accepted audience). shc auth login --client-credentials --client-id … --client-secret … POSTs grant_type=client_credentials to the realm token endpoint and stores the resulting bearer + the client_id/secret in the oidc sub-map. daemonclient.maybeRefreshAuthHeader gains a branch: when the row has a client_secret and no refresh_token, re-run the client-credentials grant instead of refresh_token (mirrors performRefresh at oidc.go:230-268, different form values). The daemon verifier already accepts aud/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-exec is the cleanest for CI secret managers: the token never lands in config.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.

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.

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 a Basic row. Recommended sequence:
    1. Release N: CLI removes --basic (no new Basic rows can be created), prints a deprecation notice on any existing Basic row directing the operator to shc auth login. Daemon still honours Basic (unchanged).
    2. Release N+1: daemon drops the Basic exchange + bearerCache. Any lingering Basic row now 401s with a clean X401900NOAUTH, and the CLI’s error hint says “run shc auth login”.
  • Read-compat, not write-compat. daemonclient already reads both row shapes (token flat + optional oidc sub-map, oidc.go:127-155); a Basic row is just a token with no oidc, so maybeRefreshAuthHeader returns 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 via SaveLogin (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-exec in the SAME change that removes --basic from their scripts, so CI never lands on the dead Basic path.
  • Headless boxes with no browser (the SSH case). This is exactly why the device grant is primary, not authorization-code: pollForToken prints 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. Removing SchemeBasic/api.ExtractAuth Basic decoding entirely is left to a follow-up if no other consumer remains.
  • Unit — CLI: login.go no longer registers --basic (flag lookup returns nil); --token / --token-exec still round-trip. New --client-credentials builds the correct grant_type=client_credentials form and persists an oidc row carrying client_id/client_secret and NO refresh_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 the grant_type=password/ client_credentials assertions in auth_test.go:442-480. Add a test that CurrentUser with a Basic header returns X401900NOAUTH (no exchange attempted — assert the keycloak transport is never called).
  • Unit — refresh: extend oidc_internal_test.go (currently asserts grant_type=refresh_token, oidc_internal_test.go:111) with a client-credentials-row case: near-expiry + client_secret present + no refresh token → re-mints via grant_type=client_credentials, persists rotated access token, replays the fresh bearer.
  • Migration: a config.yaml carrying a legacy token: "Basic …" row is read by daemonclient unchanged (replayed verbatim), and against an N+1 daemon yields a clean 401; after shc auth login the row is overwritten with the flat bearer + oidc sub-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 / bearerCache returns zero non-test hits after the change.
  • 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. The shc-cli public client already forbids standardFlow (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 the bearerCache band-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.