Skip to content

[HYPERSHELL-133] hsctl login - #206

Open
rh-amarin wants to merge 1 commit into
openshift-online:mainfrom
rh-amarin:hsctl-login
Open

[HYPERSHELL-133] hsctl login#206
rh-amarin wants to merge 1 commit into
openshift-online:mainfrom
rh-amarin:hsctl-login

Conversation

@rh-amarin

@rh-amarin rh-amarin commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Add OIDC login to hsctl CLI with two flows:
    • Browser (default): Authorization Code + PKCE -- starts a local callback server, opens the browser, exchanges the auth code for tokens
    • Headless (--no-browser): Device Authorization Grant -- prints a verification URL + user code, polls until the user authenticates
  • Store refresh_token, issuer_url, and client_id in the config file; eagerly refresh expired access tokens on each connection
  • Add hsctl whoami command showing username, email, issuer, API URL, and token expiry
    • --show-token / -t: prints only the raw token (pipe-friendly)
    • --show-token-decoded: prints only the decoded JWT claims as pretty-printed JSON
  • Update hsctl logout to revoke the refresh token at Keycloak before clearing config
  • Add hypershell-cli Keycloak client (public, device flow enabled, http://127.0.0.1:* redirect URIs)
  • Update OIDC integration, local development, data model, and OpenShell gateway OIDC specs
  • Use hsctl in all --help usage output (was incorrectly showing hypershell)
  • Improve hsctl list gateways default columns: show name, phase, and console_address instead of internal IDs and rarely-populated external_dns
  • get gateway {id} --show-connection will display help commands on how to connect with openshell CLI
  • Fixes sending with route enabled by default if not provided, so to not create a route from the CLI you should specify `--route '{"enabled":"false"}'

Test plan

  • hsctl login --url "$API_URL" --issuer-url "$OIDC_ISSUER" -- browser opens, tokens stored
  • hsctl login --no-browser --url "$API_URL" --issuer-url "$OIDC_ISSUER" -- verification URL + code printed, tokens stored after device auth
  • hsctl list fleets -- succeeds with stored token
  • hsctl list gateways -- shows name, phase, console_address columns
  • hsctl whoami -- shows correct username, email, expiry
  • hsctl whoami --show-token -- prints only the raw token, no other output
  • hsctl whoami --show-token-decoded -- prints decoded JWT claims as JSON
  • hsctl logout -- clears config; subsequent commands require re-login
  • Expired token -- re-running any command silently refreshes using stored refresh token
  • hsctl login --token-file "$FILE" -- static token path still works

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository YAML (base), Central YAML (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: 768a9da2-f4e1-4a1d-9d3a-a42533a6f9c6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@rh-amarin
rh-amarin force-pushed the hsctl-login branch 7 times, most recently from 7123984 to afbc392 Compare August 27, 2026 06:35
@jsell-rh

jsell-rh commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

Amber review

Status: Complete

Verdict

COMMENT — no blockers, but merge coordination is required. The hsctl OIDC login feature (PKCE browser flow, device flow, eager refresh, whoami, logout revocation) is well-structured and the Keycloak hypershell-cli client is correctly modeled, but the security-sensitive auth package ships without unit tests and there are a few convention/hardening gaps. Most importantly, this PR shares an audience contract with #182 and duplicates gateway-connection guidance already being added by #208/#210 — both need maintainer coordination before merge.

Hi, Amber here. I reviewed the OIDC login work against the HyperShell conventions (CLAUDE.md), the security standard, and the control-plane conventions. Overall this is a clean, thoughtful implementation: PKCE uses S256 with a random state that is validated in the callback, the loopback listener binds to 127.0.0.1:0, tokens are persisted with 0600 perms, --insecure is opt-in, and error paths generally use %w. My findings are quality/hardening and coordination items, not correctness blockers.

Key observations

Testing (Major). components/cli/pkg/auth/{pkce,device,auth}.go, whoami/cmd.go, and the new printConnectionInstructions/shellArg helpers in get/gateway/cmd.go have no unit tests. These are security-sensitive, pure-ish functions that are easy to cover: generatePKCE (verifier/challenge S256 relationship), buildAuthURL (params), shellArg (quoting/injection), resolveEndpoint, and the callback state mismatch path. Please add tests before merge.

Duplication (Minor). The eager-refresh block is copy-pasted between pkg/connection/connection.go and cmd/hypershell/whoami/cmd.go. Extract a single helper (e.g. config.EnsureFreshToken(cfg) or an auth helper) so the refresh/persist/expiry policy lives in one place.

Config-vs-code (Minor). buildConnectionScript hardcodes a specific provider (google-vertex-ai/my-gcp), model (claude-haiku-4-5), and sandbox name. That is opinionated static content baked into the CLI binary and will drift from the web-console guidance (see Cross-PR below).

Hardening (Minor). The PKCE callback http.Server has no ReadHeaderTimeout; the hypershell-cli Keycloak client does not enforce PKCE server-side (pkce.code.challenge.method: S256); and the device-flow poller does not honor slow_down by widening the interval (RFC 8628).

Route default behavior change (informational). create gateway now always sends route={"enabled":true} when --route is omitted. The field is a *string on GatewayCreateRequest, so serialization is correct, and the PR body calls this out. Just flagging that this is a behavioral default change consistent with commit #213.

@jsell-rh jsell-rh left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

COMMENT — no blockers, but merge coordination is required. The hsctl OIDC login feature (PKCE browser flow, device flow, eager refresh, whoami, logout revocation) is well-structured and the Keycloak hypershell-cli client is correctly modeled, but the security-sensitive auth package ships without unit tests and there are a few convention/hardening gaps. Most importantly, this PR shares an audience contract with #182 and duplicates gateway-connection guidance already being added by #208/#210 — both need maintainer coordination before merge.

Hi, Amber here. I reviewed the OIDC login work against the HyperShell conventions (CLAUDE.md), the security standard, and the control-plane conventions. Overall this is a clean, thoughtful implementation: PKCE uses S256 with a random state that is validated in the callback, the loopback listener binds to 127.0.0.1:0, tokens are persisted with 0600 perms, --insecure is opt-in, and error paths generally use %w. My findings are quality/hardening and coordination items, not correctness blockers.

Key observations

Testing (Major). components/cli/pkg/auth/{pkce,device,auth}.go, whoami/cmd.go, and the new printConnectionInstructions/shellArg helpers in get/gateway/cmd.go have no unit tests. These are security-sensitive, pure-ish functions that are easy to cover: generatePKCE (verifier/challenge S256 relationship), buildAuthURL (params), shellArg (quoting/injection), resolveEndpoint, and the callback state mismatch path. Please add tests before merge.

Duplication (Minor). The eager-refresh block is copy-pasted between pkg/connection/connection.go and cmd/hypershell/whoami/cmd.go. Extract a single helper (e.g. config.EnsureFreshToken(cfg) or an auth helper) so the refresh/persist/expiry policy lives in one place.

Config-vs-code (Minor). buildConnectionScript hardcodes a specific provider (google-vertex-ai/my-gcp), model (claude-haiku-4-5), and sandbox name. That is opinionated static content baked into the CLI binary and will drift from the web-console guidance (see Cross-PR below).

Hardening (Minor). The PKCE callback http.Server has no ReadHeaderTimeout; the hypershell-cli Keycloak client does not enforce PKCE server-side (pkce.code.challenge.method: S256); and the device-flow poller does not honor slow_down by widening the interval (RFC 8628).

Route default behavior change (informational). create gateway now always sends route={"enabled":true} when --route is omitted. The field is a *string on GatewayCreateRequest, so serialization is correct, and the PR body calls this out. Just flagging that this is a behavioral default change consistent with commit #213.

Cross-PR coordination

I compared this PR against the 23 other open PRs (listed below). Two material coordination items and one duplicate-solution item stand out; the rest are unrelated.

1. Shared management-API audience contract with #182 (fix(auth): enforce management API JWT audience) — coordination + ordering required.

  • #182 hardens the API server to require aud contains hypershell-frontend and to reject tokens not minted for the management API.
  • This PR is what actually makes hsctl tokens satisfy that requirement: the new hypershell-cli Keycloak client carries an oidc-audience-mapper with included.client.audience: hypershell-frontend.
  • They are complementary but interdependent: if #182 merges first, hsctl OIDC login is unusable until this PR adds the CLI client (there is no hypershell-cli client at all today). If this PR merges first, the audience mapper exists but isn't enforced.
  • Both PRs edit the same rationale table in specs/platform/oidc-integration.spec.md; #182 adds a "Shared management API resource audience" row that names hsctl explicitly. Maintainers should confirm the audience value stays hypershell-frontend on both sides and decide the merge order (ideally land the CLI client with, or before, enforcement).

2. Duplicate gateway-connection guidance with #210 and #208 — single source of truth needed.

  • This PR's get gateway --show-connection emits an openshell gateway add … registration command plus provider-create and sandbox-create steps.
  • #210 (feat(web-console): add gateway-matched CLI installation) adds gateway registration + provider setup commands to the console Connection tab.
  • #208 (web-console: instructions for sandbox connecting) adds an openshell sandbox connect … section to the same tab.
  • The same "how to connect openshell to a gateway" flow is now authored independently in Go (this PR) and TypeScript (#208/#210), with different opinionated defaults. This is a duplicate-solution / drift risk, not a merge conflict. Maintainers should decide on a canonical command sequence (and where provider/model defaults live) so the CLI and console stay consistent.

3. #216 (fix(console): support OpenShift Route ingress) — related, not conflicting. This PR consumes the gateway route_address/console_address fields (verified present on the model); #216 changes how console_address is published. No design conflict — just a producer/consumer relationship worth being aware of.

No other open PR modifies deploy/base/keycloak/keycloak.yaml or components/cli/, so there is no competing ownership of the CLI or the Keycloak client definition.

Other open PRs reviewed for conflicts

#216 console Route ingress, #214 UI adjustments, #212 e2e perf harness, #211 kind metrics/connectivity, #210 gateway-matched CLI install, #209 Dashboard UI, #208 sandbox connect UI, #207 reconcile trace correlation, #201 Red Hat openshell images, #200 control-plane reconciliation contract, #194 OpenShell Helm chart, #189/#188/#135 dep bumps, #185 world sync spec, #182 JWT audience, #179 keycloak client reconcile, #151 gateway re-provision gate, #150 local images worktree, #148 openshell branch build, #109 security tools, #75/#73 dep bumps.

Findings Summary (ordered by severity, highest first)

  1. [Major] New auth package + whoami + connection-script helpers have no unit tests despite being security-sensitive — Missing Tests (pkg/auth/pkce.go, pkg/auth/device.go, cmd/hypershell/whoami/cmd.go, cmd/hypershell/get/gateway/cmd.go)
  2. [Minor] Eager-refresh logic duplicated between connection.go and whoami/cmd.goMaintainability (pkg/connection/connection.go:55, whoami/cmd.go:47)
  3. [Minor] Connection script hardcodes provider/model/sandbox defaults (config-vs-code; drifts from console PRs) — Convention (get/gateway/cmd.go:132)
  4. [Minor] PKCE callback http.Server lacks ReadHeaderTimeoutHardening (pkg/auth/pkce.go:69)
  5. [Minor] hypershell-cli Keycloak client does not enforce PKCE server-side (pkce.code.challenge.method: S256) — Hardening (deploy/base/keycloak/keycloak.yaml:197)
  6. [Minor] Device-flow poller ignores slow_down (does not widen interval per RFC 8628) — Correctness (pkg/auth/device.go:75)
  7. [Minor] %v used instead of %w when wrapping the gateway-parse error — Error Wrapping (get/gateway/cmd.go:89)

Convention Checklist

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf("...: %w", err) Fail (get/gateway:89)
No secrets in logs or error messages Pass (token only printed via explicit --show-token)
Secrets stored as references / config perms Pass (config written 0600)
Input validated / injection prevented Pass (shellArg quoting; OAuth state validated)
Config separate from code Fail (hardcoded provider/model in connection script)
Conventional commit message Pass
Tests accompany new logic Fail (auth package untested)
OpenAPI client not manually edited Pass (N/A — generated client untouched)

return parseTokenResponse(resp)
}

func generatePKCE() (verifier, challenge string, err error) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] Missing tests. This new auth package (PKCE + device flow), whoami, and the printConnectionInstructions/shellArg helpers are security-sensitive but ship without unit tests. These are cheap to cover as pure functions:

  • generatePKCE() — assert challenge == base64url(sha256(verifier)).
  • buildAuthURL() — assert response_type, code_challenge_method=S256, encoded redirect_uri.
  • The /callback handler state-mismatch path returns an error and does not leak a code.
  • shellArg() — quoting/injection cases.

Please add tests before merge.

codeCh <- code
})

srv := &http.Server{Handler: mux}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Hardening. &http.Server{Handler: mux} has no ReadHeaderTimeout (gosec G112). Even though this is a short-lived loopback callback server, set a small ReadHeaderTimeout (e.g. 5 * time.Second) so a stuck client connection can't hold the goroutine open until the 5-minute context deadline.

return tr, nil
}

if strings.Contains(err.Error(), "authorization_pending") || strings.Contains(err.Error(), "slow_down") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Correctness. Per RFC 8628 §3.5, on a slow_down response the client MUST increase the polling interval by 5s. Here slow_down is treated identically to authorization_pending and keeps polling at the same rate, which Keycloak may reject. Consider bumping interval when slow_down is seen.

return
}

// Refresh the access token eagerly if it is expired and a refresh token is available.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Duplication. This eager-refresh block (check expiry → auth.Refresh → persist → warn/return) is duplicated almost verbatim in cmd/hypershell/whoami/cmd.go. Extract a single helper (e.g. config.EnsureFreshToken(cfg) or an auth helper) so the refresh/persist/expiry policy lives in one place and can't drift.

func printConnectionInstructions(w io.Writer, body []byte) error {
var gw gatewayResponse
if err := json.Unmarshal(body, &gw); err != nil {
return fmt.Errorf("can't parse gateway response: %v", err)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Error wrapping. Use %w instead of %v to preserve the wrapped error (CLAUDE.md: fmt.Errorf("context: %w", err)). login.go in this same PR was converted %v%w; keep the new code consistent.


func buildConnectionScript(name, endpoint string, oidc oidcConfig) string {
const (
providerName = "my-gcp"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Config-vs-code + cross-PR drift. The provider (my-gcp/google-vertex-ai), model (claude-haiku-4-5), and sandbox name are hardcoded into the binary. This is opinionated guidance baked into code, and it overlaps with the console connection instructions being added in #210 (gateway registration + provider setup) and #208 (openshell sandbox connect). Please align on a single canonical command sequence / defaults source so the CLI and web console don't drift.

{
"clientId": "hypershell-cli",
"enabled": true,
"publicClient": true,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Minor] Hardening. This is a public client using the authorization-code flow. The CLI always sends PKCE, but the client doesn't require it server-side. Consider adding "attributes": { "pkce.code.challenge.method": "S256" } so Keycloak rejects any non-PKCE code exchange (prevents a downgrade).

"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "hypershell-frontend",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-PR coordination (#182). This audience mapper (included.client.audience: hypershell-frontend) is exactly the contract #182 (fix(auth): enforce management API JWT audience) enforces — it requires aud to contain hypershell-frontend and rejects tokens not minted for the management API. These PRs are interdependent: without this client hsctl OIDC login can't produce an accepted token, and #182 makes the audience mandatory. Please coordinate merge order (land this client with/before enforcement) and keep the audience value identical on both sides. Both PRs also edit the same rationale table in specs/platform/oidc-integration.spec.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants