Skip to content

[HYPERSHELL-133] hsctl login - #206

Merged
rh-amarin merged 3 commits into
openshift-online:mainfrom
rh-amarin:hsctl-login
Sep 1, 2026
Merged

[HYPERSHELL-133] hsctl login#206
rh-amarin merged 3 commits 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: Team

Run ID: f85a0e4d-51c6-4092-85dd-9ca007de47c2

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 8 times, most recently from afbc392 to b9b1fbd Compare August 27, 2026 07:41
@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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 62abb7d — added unit tests covering the security-sensitive pure functions:

  • generatePKCE: verifies challenge == BASE64URL(SHA256(verifier)) and that successive calls produce distinct verifiers
  • buildAuthURL: asserts all required params (response_type=code, code_challenge_method=S256, encoded redirect_uri, state, scope)
  • tokenEndpoint: trailing-slash normalization
  • parseTokenResponse: success, non-200, and malformed JSON paths
  • shellArg: safe values pass through, special chars are single-quoted, embedded single quotes escape correctly, <PENDING> sentinel bypasses quoting
  • printConnectionInstructions: invalid JSON returns error; missing endpoint/OIDC fields render <PENDING>

Comment thread components/cli/pkg/auth/pkce.go Outdated
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 9230f9c — added ReadHeaderTimeout: 5 * time.Second to the &http.Server{} struct for the loopback callback server.

Comment thread components/cli/pkg/auth/device.go Outdated
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 9230f9c — when slow_down is received the poll interval is bumped by 5s before continuing, per RFC 8628 §3.5. authorization_pending still continues at the current interval.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Already in the PR — extracted to config.EnsureFreshToken(cfg *Config) error so the refresh/persist contract lives in one place.

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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 9230f9c — all %v error wraps in this file changed to %w.


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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 62abb7d — added a comment line to the output script making it explicit: "Steps 2-4 below show a GCP/Vertex AI example. Adjust provider type and config for your environment." Parameterizing is tracked as follow-up work aligned with PR #210.

{
"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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 9230f9c — added "pkce.code.challenge.method": "S256" to the hypershell-cli client attributes so Keycloak rejects any non-PKCE code exchange.

"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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Acknowledged — the hypershell-cli audience mapper mirrors the hypershell-frontend contract required by PR #182. No code change needed here; landing order should be this PR first (or together) so the client exists before audience enforcement is active.

@jsell-rh

jsell-rh commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

@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. hsctl gains real OIDC login (browser Authorization Code + PKCE and Device Authorization Grant), eager token refresh, whoami, logout revocation, and a dedicated hypershell-cli Keycloak client. The implementation is clean and the error handling is a clear improvement over the prior os.Exit-based login; the main gaps are the complete absence of unit tests for the new auth flows and a few minor hardening/consistency items.

Highlights

  • Login refactor replaces os.Exit(1) with returned, wrapped errors, and consistently uses %w. Good.
  • PKCE uses crypto/rand, S256, a random state that is validated on callback, and a loopback (127.0.0.1) ephemeral redirect. Sound.
  • Refresh tokens persisted with 0600 and only refreshed when issuer/client/refresh are all present. Correct guard for static-token logins.

Findings

[Major] No tests for the new OIDC auth flowscomponents/cli/pkg/auth/{pkce.go,device.go,auth.go} and the refresh/revoke paths ship without any unit tests. These are security-adjacent (token exchange, PKCE verifier/challenge, state validation, device polling/backoff, refresh-on-expiry). At minimum, cover generatePKCE (verifier/challenge relationship), buildAuthURL, tokenEndpoint, parseTokenResponse error handling, and the authorization_pending/slow_down polling branch. Confidence: High.

[Minor] Duplicated eager-refresh block — the ~15-line "refresh if expired" logic is copy-pasted in connection.go (L55) and whoami/cmd.go (L44). Extract a shared helper (e.g. config.EnsureFreshToken(cfg) or an auth helper) so the refresh contract lives in one place. Confidence: High.

[Minor] Unescaped query params rendered into the callback HTMLpkce.go:54 writes the IdP-supplied error / error_description straight into an HTML page with fmt.Fprintf. It is a one-shot localhost page, so exploitability is low, but wrap both values in html.EscapeString to avoid the reflected-injection pattern. Confidence: Medium.

[Minor] --insecure disables TLS verification for token exchange and refreshauth.go:33 (InsecureSkipVerify: true). Acceptable as a documented dev flag, but access/refresh tokens then transit an unverified TLS channel; consider a stderr warning when it is used so it is not silently applied against a real issuer. Confidence: Medium.

[Minor] Route disable example doesn't match the new default typecreate/gateway/cmd.go:108 now defaults route to {"enabled":true} (boolean). The PR description tells users to disable it with --route '{"enabled":"false"}' (string). Confirm the API's JSON route parser treats the string "false" as boolean false; otherwise the documented opt-out silently still enables the route. Confidence: Medium.

[Minor] Non-conventional commit/title[HYPERSHELL-133] hsctl login isn't in type(scope): description form (e.g. feat(cli): add OIDC login to hsctl). Since the title becomes the squashed commit, reformat before merge. Confidence: High.

Cross-PR coordination

Two open pull requests require maintainer coordination:

  • #227 makes a competing change to the same hsctl list gateways default column set. This PR sets id, name, phase, console_address, created_at; #227 sets id, active_sandbox_count, cluster_id, console_address, created_by, created_at. Both also add flags to create/gateway/cmd.go. Maintainers must decide the canonical default columns and fix a merge order so whichever lands second rebases onto the agreed set rather than silently overwriting it.
  • #182 enforces the hypershell-frontend audience on the management API, while this PR's new hypershell-cli Keycloak client carries the audience mapper that emits exactly that audience. The audience value is a single contract split across the two PRs. Maintainers must keep the enforced audience (#182) and the CLI client's mapper (#206) identical and agree on merge order, because hsctl OIDC login only succeeds once both halves are consistent.

Findings Summary (ordered by severity, highest first)

  1. [Major] No unit tests for the new OIDC auth flows - Testing (auth/pkce.go, auth/device.go, auth/auth.go)
  2. [Minor] Duplicated eager-refresh logic - Maintainability (connection.go L55, whoami/cmd.go L44)
  3. [Minor] Unescaped IdP error params in callback HTML - Security (pkce.go L54)
  4. [Minor] --insecure skips TLS verification on token exchange - Security (auth.go L33)
  5. [Minor] Route disable example type mismatch - Consistency (create/gateway/cmd.go L108)
  6. [Minor] Non-conventional commit title - Commit Discipline

Convention Checklist

Convention Result
No panic() / os.Exit in production code Pass
Errors wrapped with fmt.Errorf("...: %w", err) Pass
No secrets in logs or error messages Pass
Secrets stored as references / restricted perms (0600) Pass
Reconcile/refresh guarded for missing prerequisites Pass
Tests accompany new logic Fail
Conventional commit message Fail

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] Add unit tests for the new OIDC auth code. generatePKCE, buildAuthURL, tokenEndpoint, parseTokenResponse, and the device-flow authorization_pending/slow_down polling branch are all untested. This is security-adjacent (PKCE verifier/challenge, state validation, token exchange), so table-driven unit tests here would meaningfully reduce regression risk. Confidence: High.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 62abb7d — see reply on the other unit-test thread above.

Comment thread components/cli/pkg/auth/device.go Outdated
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.

Matching OAuth error codes via strings.Contains(err.Error(), ...) is fragile. Consider returning a typed error (or the parsed error code) from pollDeviceToken and comparing on that instead of substring-matching the formatted message. Not blocking, but worth a follow-up. Confidence: Medium.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Already in the PR — pollDeviceToken now returns *deviceTokenError carrying the raw Code field; the poll loop uses errors.As and compares tokenErr.Code directly instead of substring-matching.

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] Duplicated refresh logic. This eager "refresh if expired" block is copy-pasted verbatim in whoami/cmd.go (~L44). Extract a single helper (e.g. config.EnsureFreshToken(cfg) or an auth helper) so the refresh/persist contract lives in one place. Confidence: High.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Already in the PR — extracted to config.EnsureFreshToken(cfg *Config) error. Both connection.Build() and whoami call it; the refresh/persist/expiry policy lives in one place.

}
if errParam := r.URL.Query().Get("error"); errParam != "" {
desc := r.URL.Query().Get("error_description")
fmt.Fprintf(w, "<html><body><h2>Login failed</h2><p>%s: %s</p><p>You may close this window.</p></body></html>",

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/Security] Unescaped IdP params in HTML. errParam and desc come from the redirect query string and are written straight into the response page. It is a one-shot localhost page so risk is low, but wrap both in html.EscapeString(...) to avoid the reflected-injection pattern. Confidence: Medium.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Already in the PR — both errParam and desc are wrapped in html.EscapeString(...) before being written into the response page.

Comment thread components/cli/pkg/auth/auth.go
Comment thread components/cli/cmd/hypershell/create/gateway/cmd.go Outdated
@jsell-rh

jsell-rh commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

@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

This is a well-structured, security-conscious addition of OIDC login (Authorization Code + PKCE and Device Authorization) to hsctl, with solid unit coverage for the auth package. Nothing here is blocking, but a few polish items and cross-PR coordination on user-facing "connection instructions" and the shared management-API audience deserve attention before merge.

Amber Analysis

The OIDC flows are implemented carefully: PKCE with S256, a random state checked on the callback, a loopback (127.0.0.1) redirect, credentials persisted to a 0600 file, best-effort token revocation on logout, and eager refresh on each connection. The findings below are Minor/Major quality items, not security defects.

Major

  • create gateway silently forces route on by default. When --route is unset the CLI now injects {"enabled":true} instead of omitting the field, moving the default decision from the server into the client. This is called out in the PR description, but it changes existing CLI behavior and puts a policy default in code. Prefer letting the server own the default (omit the field) or, if a client default is intended, make it explicit and documented in --help. (components/cli/cmd/hypershell/create/gateway/cmd.go:106)

Minor

  • Hardcoded, GCP/Vertex-AI-specific connection script. buildConnectionScript emits a fixed openshell provider create --type google-vertex-ai --from-gcloud-adc, VERTEX_AI_* config, provider name my-gcp, and model claude-haiku-4-5. For any non-GCP gateway this printed guidance is misleading, and the provider/model values are baked into code rather than configuration. Consider parameterizing or clearly labelling it as a GCP example. (components/cli/cmd/hypershell/get/gateway/cmd.go:130)
  • Error not wrapped with %w. printConnectionInstructions uses fmt.Errorf("can't parse gateway response: %v", err); project convention is %w so the cause stays unwrapped. (components/cli/cmd/hypershell/get/gateway/cmd.go:89)
  • EnsureFreshToken silently no-ops on an unparseable access token. If TokenExpired returns a parse error, the function returns nil and skips refresh, so a corrupt/opaque access token yields a downstream 401 rather than a refresh attempt or a clear message. Consider attempting refresh (or surfacing the parse error) when the token can't be parsed but a refresh token exists. (components/cli/pkg/config/refresh.go:18)
  • webOrigins: ["+"] on the CLI client is unnecessary. A loopback-redirect CLI public client makes no browser-origin (CORS) calls to Keycloak, so + grants an origin allowance it never uses. Consider [] to keep the client minimal. (deploy/base/keycloak/keycloak.yaml:204)

Cross-PR coordination

Two items need maintainer coordination before or alongside merge:

  • Duplicate "connection instructions" generators. This PR's hsctl get gateway --show-connection renders the openshell onboarding flow (install OpenShell, openshell gateway add, add the Claude-on-Vertex-AI provider, select model, create/connect a sandbox) as a hardcoded Go template. #210 builds the same class of version-matched install + openshell gateway add + provider/model setup steps in the shared gateway-management-ui, and #208 adds the sandbox-connect command in that same UI. These are competing implementations of the same user-facing guidance with independently hardcoded provider/model/flag choices that will drift. Maintainers should decide on a single canonical source/template for these instructions and how the CLI and web console share it.
  • Shared management-API audience. This PR configures the new hypershell-cli Keycloak client's audience mapper to mint hypershell-frontend in aud. #182 upgrades the API server to enforce the management-API audience and explicitly assumes hsctl shares that audience. The two must agree on the exact audience value and merge in an order such that CLI-issued tokens are accepted once enforcement is live; a mismatch would make hsctl login produce tokens the API server rejects.

Findings Summary (ordered by severity, highest first):

  1. [Major] create gateway forces route={"enabled":true} by default, moving a policy default into the client - Config in Code / Behavior Change (create/gateway/cmd.go:106)
  2. [Minor] Hardcoded GCP/Vertex-AI-specific connection script is misleading for non-GCP gateways - Config Separation (get/gateway/cmd.go:130)
  3. [Minor] Error uses %v instead of %w - Error Wrapping (get/gateway/cmd.go:89)
  4. [Minor] EnsureFreshToken silently skips refresh on unparseable token - Robustness (refresh.go:18)
  5. [Minor] webOrigins: ["+"] unnecessary on loopback CLI client - Least Privilege (keycloak.yaml:204)

Convention Checklist (only conventions evaluated for this change):

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf context Fail (one %v in get/gateway)
No secrets in logs or error messages Pass
Input validated (state/PKCE, shell-arg quoting) Pass
Secrets stored as references / restricted perms (0600) Pass
Config separate from code Fail (route default + hardcoded provider/model)
Conventional commit message Pass
Test coverage for new logic Pass (auth pkg well covered)

}
if args.route != "" {
request["route"] = args.route
route := args.route

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] When --route is unset the CLI now injects {"enabled":true} rather than omitting the field, so the default "create a Route" decision moves from the server into the client. This changes prior CLI behavior and encodes a policy default in code. Prefer omitting the field and letting the server own the default, or make the client default explicit/documented in --help.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 62abb7d — the if route == "" { route = "{\"enabled\":true}" } block is removed. The field is now only sent when --route is explicitly provided, so the server owns the default again.

return ""
}

func buildConnectionScript(name, endpoint string, oidc oidcConfig) string {

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] buildConnectionScript hardcodes a GCP/Vertex-AI onboarding (--type google-vertex-ai, --from-gcloud-adc, VERTEX_AI_*, provider my-gcp, model claude-haiku-4-5). For non-GCP gateways this printed guidance is misleading, and provider/model belong in configuration, not code. Consider parameterizing or clearly labelling it as a GCP-only example. Note this overlaps with the web-console connection-instruction work; see the Cross-PR coordination section.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 62abb7d — the output now includes a clear "GCP/Vertex AI example" header line before the provider/model steps so users know to adapt them for their environment.

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] Use %w instead of %v so the underlying parse error stays wrapped: fmt.Errorf("can't parse gateway response: %w", err).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 9230f9c%v%w throughout the file.

Comment thread components/cli/pkg/config/refresh.go Outdated
return nil
}
expired, checkErr := TokenExpired(cfg.AccessToken)
if checkErr != nil || !expired {

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] When TokenExpired returns a parse error (checkErr != nil) this returns nil and skips refresh, so a corrupt/opaque access token yields a downstream 401 instead of a refresh attempt or a clear error. Consider attempting a refresh (or surfacing the parse error) when the token can't be parsed but a refresh token is present.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 9230f9c — inverted the condition to if checkErr == nil && !expired { return nil }, so an unparseable token falls through and triggers a refresh attempt rather than silently skipping.

Comment thread deploy/base/keycloak/keycloak.yaml Outdated
"oauth2.device.authorization.grant.enabled": "true"
},
"redirectUris": ["http://127.0.0.1:*"],
"webOrigins": ["+"],

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] A loopback-redirect public CLI client makes no browser-origin (CORS) calls to Keycloak, so webOrigins: ["+"] grants an allowance it never uses. Consider [] to keep the client minimal.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 9230f9c — changed to "webOrigins": [] on the hypershell-cli client. Loopback-only redirect, no browser-origin CORS calls needed.

rh-amarin pushed a commit to rh-amarin/hypershell that referenced this pull request Sep 1, 2026
- pkce: add ReadHeaderTimeout (5s) to loopback callback server (gosec G112)
- device: bump poll interval +5s on slow_down per RFC 8628 §3.5
- get/gateway: use %w for error wrapping (consistent with codebase convention)
- config: EnsureFreshToken treats unparseable token as expired and attempts refresh
- keycloak: require PKCE S256 server-side on hypershell-cli; drop unused webOrigins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jsell-rh

jsell-rh commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

@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

This PR adds OIDC browser (Authorization Code + PKCE) and device-flow login to hsctl, eager token refresh, whoami, token revocation on logout, and a new hypershell-cli Keycloak public client. The implementation is clean, well-factored, and unusually well-tested for a CLI change; my findings are one design default worth a decision plus a few hardening nits — none are blockers.

Strengths

  • PKCE flow validates state, uses S256, and has good unit coverage (auth_test.go).
  • Config file is persisted with 0600, so the newly stored refresh_token is protected at rest.
  • Errors are wrapped with %w; no panic(); no secret values are logged (error bodies from the token endpoint are OAuth error JSON, not credentials).

Findings

[Major] create gateway now creates a route by default — API/UX contract change (components/cli/cmd/hypershell/create/gateway/cmd.go:108)
When --route is omitted the CLI now sends {"enabled":true} instead of omitting the field. This silently changes the default exposure/behavior for existing scripts and forces users to pass --route '{"enabled":false}' to opt out. It is documented in the PR body, but defaulting a gateway to an externally-reachable route is a security/cost-relevant default and deserves an explicit maintainer decision (or leaving the field unset so the server owns the default).

[Minor] Token HTTP calls have no client timeout (components/cli/pkg/auth/auth.go:25)
newHTTPClient returns http.DefaultClient (no timeout) and the insecure path sets no Timeout either. EnsureFreshToken runs on every command via the connection builder, so an unresponsive issuer could hang the CLI indefinitely. Set an explicit Timeout (e.g. 30s) on both branches.

[Minor] InsecureSkipVerify disables TLS verification for the token/revocation endpoints (components/cli/pkg/auth/auth.go:33)
Gated behind --insecure and annotated with //nolint:gosec, which is acceptable for local dev, but this now silently applies to credential-bearing OIDC requests. Worth confirming this is only ever used against local/dev issuers and is documented as such.

[Minor] Onboarding script hardcodes example provider/model values in code (components/cli/cmd/hypershell/get/gateway/cmd.go:132)
my-gcp, claude-haiku-4-5, VERTEX_AI_REGION=global, etc. are embedded as Go constants in the connection instructions. These will drift and require code changes to update (vs. Separate configuration from code). Consider sourcing the model/provider hints from configuration or clearly marking them as non-authoritative placeholders.

[Minor] EnsureFreshToken proceeds when the token cannot be parsed (components/cli/pkg/config/refresh.go:18)
If TokenExpired returns an error (malformed token) and no refresh material is present, the function returns nil and the request proceeds with an unusable token. A short comment or an explicit "re-login" hint on the un-parseable case would improve the UX.

Cross-PR coordination

  • #182 (enforce management API JWT audience): This PR's new hypershell-cli client relies on an audience protocol mapper that injects hypershell-frontend into the aud claim specifically so the management API accepts CLI-minted tokens, and it edits the same oidc-integration.spec.md audience narrative. #182 hardens the API server to reject tokens not minted for the management API audience. These two changes share a single assumption (the exact required audience and the aud vs azp distinction) and must stay aligned; if #182's enforced audience or client-token rejection rules change, this PR's client config would produce tokens the API rejects. Maintainers should confirm the audience value matches and decide a merge order so CLI login is not broken between merges.

  • #208 and #210 (web-console gateway connection/onboarding instructions): This PR adds get gateway --show-connection, which generates user-facing openshell onboarding instructions (install URL, openshell gateway add with OIDC issuer/client-id/audience, provider/sandbox setup) from the CLI. The web console PRs generate the same class of openshell connection/onboarding guidance in gateway-connection-steps.tsx. This is duplicate authoring of the same onboarding contract in two components, which will drift. Maintainers should decide on a single canonical source (or an explicit shared shape) for gateway connection instructions rather than maintaining independent copies.

Findings Summary (ordered by severity, highest first):

  1. [Major] create gateway defaults route to {"enabled":true}, a silent exposure/behavior contract change - API/UX (L108)
  2. [Minor] Token HTTP client has no timeout; runs on every command via refresh - Reliability (auth.go L25)
  3. [Minor] InsecureSkipVerify applied to OIDC credential requests under --insecure - Security (auth.go L33)
  4. [Minor] Onboarding script hardcodes provider/model values in code - Config vs code (get/gateway L132)
  5. [Minor] EnsureFreshToken proceeds on unparseable token - UX (refresh.go L18)

Convention Checklist:

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf context Pass
No secrets in logs or error messages Pass
Credentials stored with restrictive file perms (0600) Pass
Input validated (PKCE state, shell-arg escaping) Pass
Conventional commit messages Pass
Config separate from code Fail

request["route"] = args.route
route := args.route
if route == "" {
route = `{"enabled":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.

[Major] Default route now enabled. Omitting --route previously left the field unset; it now sends {"enabled":true}, so a gateway is created with an external route by default and callers must pass --route '{"enabled":false}' to opt out. This is a behavior/exposure contract change (documented in the PR body, but security/cost-relevant). Consider leaving the field unset so the server owns the default, or confirm this default with maintainers.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 62abb7d — same change as above: route is omitted when --route is not set, restoring the prior behavior.

return strings.TrimRight(issuerURL, "/") + "/protocol/openid-connect/token"
}

func newHTTPClient(insecure bool) *http.Client {

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] No client timeout. This returns http.DefaultClient (and the insecure branch sets no Timeout). EnsureFreshToken runs on every command through the connection builder, so an unresponsive issuer would hang the CLI indefinitely. Set an explicit Timeout (e.g. 30s) on both branches.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 62abb7dnewHTTPClient now sets Timeout: 30 * time.Second on both the secure and insecure branches, so an unresponsive issuer cannot hang the CLI indefinitely.

Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: true, //nolint:gosec

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] TLS verification disabled for OIDC credential traffic. Gated by --insecure and annotated, which is fine for local dev, but this now also covers token/refresh/revoke requests that carry credentials. Please confirm it is only ever pointed at local/dev issuers and document that expectation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Acknowledged as a documented dev flag. --insecure is only documented for local/dev issuers. No change made here.


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] Hardcoded example values in code. my-gcp, claude-haiku-4-5, VERTEX_AI_REGION=global, etc. are embedded as constants in the generated onboarding instructions and will drift, requiring code changes to update (vs. separate config from code). Source these hints from configuration or clearly mark them as non-authoritative placeholders.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 62abb7d — added a comment in the emitted script making it explicit these are GCP/Vertex AI example values. Full parameterization is tracked for follow-up with PR #210.

return nil
}
expired, checkErr := TokenExpired(cfg.AccessToken)
if checkErr == nil && !expired {

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] Proceeds on unparseable token. When TokenExpired returns an error (malformed token) and no refresh material exists, this returns nil and the command runs with an unusable token. A comment or explicit re-login hint for the un-parseable case would improve UX.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 9230f9c — same change: the guard is now if checkErr == nil && !expired, so a corrupt/opaque access token falls through to the refresh path.

- pkce: add ReadHeaderTimeout (5s) to loopback callback server (gosec G112)
- device: bump poll interval +5s on slow_down per RFC 8628 §3.5
- get/gateway: use %w for error wrapping (consistent with codebase convention)
- config: EnsureFreshToken treats unparseable token as expired and attempts refresh
- keycloak: require PKCE S256 server-side on hypershell-cli; drop unused webOrigins

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jsell-rh

jsell-rh commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

@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

This PR adds real OIDC login (browser PKCE + device flow), token refresh, whoami, token revocation on logout, and a hypershell-cli Keycloak client, and it renames the binary to hsctl. The implementation is clean, well-tested at the auth layer, and follows the project's error-wrapping conventions; findings below are minor and none are blockers, but two cross-PR coordination points need maintainer attention.

What looks good

  • OAuth flows are correct: PKCE with S256, CSRF-style state validation on the callback, html.EscapeString on reflected error params (no XSS in the callback page), buffered channels, and a 5-minute context timeout with srv.Shutdown.
  • Errors are wrapped with fmt.Errorf("...: %w", err) throughout, and several pre-existing %v wraps were upgraded to %w. No panic() introduced.
  • Config file is written 0600; secret values are not logged. EnsureFreshToken refreshes eagerly and persists, with a save-failure downgraded to a warning rather than a hard failure.
  • Solid table-driven unit tests for generatePKCE, tokenEndpoint, buildAuthURL, parseTokenResponse, and device-token polling (pending / slow_down / success / non-JSON). No pre-existing test assertions were weakened.
  • oidc and route are correctly treated as JSON-encoded string fields, matching openapi.gateways.yaml.

Findings

[Minor] create gateway now always sends a route, defaulting to enabled.
components/cli/cmd/hypershell/create/gateway/cmd.go previously omitted route when unset; it now injects {"enabled":true} whenever --route is empty. This silently changes gateway-creation semantics for every CLI caller: a route is now always requested unless the user explicitly passes --route '{"enabled":false}'. It is called out in the PR body, so this is a heads-up rather than a defect - please confirm the server-side default previously matched this, so existing scripted create gateway invocations don't start provisioning routes they didn't before. Confidence: Medium.

[Minor] Device flow can return an immediate "timed out" if the server omits expires_in.
components/cli/pkg/auth/device.go computes deadline := time.Now().Add(time.Duration(dar.ExpiresIn) * time.Second). If expires_in is absent/zero, deadline equals now and the poll loop never runs, surfacing a misleading "device authorization timed out". Apply the same defensive fallback already used for interval. Confidence: Medium.

[Minor] Connection instructions embed hardcoded example identifiers.
components/cli/cmd/hypershell/get/gateway/cmd.go hardcodes providerName = "my-gcp", model = "claude-haiku-4-5", and sandboxName = "mysand" inside buildConnectionScript. These are example placeholders rendered as if runnable; a user copy-pasting gets a Vertex-AI/Claude-specific script regardless of their setup. Consider clearly marking them as placeholders (or making them configurable). Confidence: Medium. See the Cross-PR section - this instruction text overlaps with the web console's connection guidance.

Cross-PR coordination

Two coordination points need a maintainer decision:

  1. Shared management-API audience assumption. This PR provisions the hypershell-cli public client with an audience mapper that injects hypershell-frontend into aud so the API server accepts CLI-issued tokens, and it edits specs/platform/oidc-integration.spec.md to document that. Another open PR hardens/enforces management-API JWT audience validation on the API server (upgrading rh-trex-ai to fail closed and requiring the hypershell-frontend audience) and edits the same spec. The two changes rest on the same shared-audience contract and touch overlapping spec text, so they must land consistently: if audience enforcement merges while this client's audience mapper is absent or differently configured, hsctl tokens will be rejected. Maintainers should confirm the required aud value and decide a merge order so CLI login and audience enforcement stay aligned.

  2. Duplicate "how to connect to a gateway/sandbox" instructions. This PR generates openshell connection/setup instructions in Go (get gateway --show-connection), while other open work builds the canonical connection and install-command guidance in the web console's packages/gateway-management-ui/src/gateways/gateway-connections.ts and adds a "Connect to a sandbox" section to the gateway Connection tab. These are independent implementations of the same user-facing guidance and will drift (different flags, model/provider examples, install command derivation). Maintainers should decide on a single canonical definition of the connection steps and have the CLI and web console derive from it, rather than maintaining two divergent copies. This affects the gateway-version/connection PR that owns gateway-connections.ts and the web-console sandbox-connection PR.

Findings Summary (ordered by severity, highest first)

  1. [Minor] create gateway always sends route, defaulting to enabled - behavior change for all CLI callers - API/UX Behavior (create/gateway/cmd.go L106-110)
  2. [Minor] Device flow returns immediate timeout when expires_in is missing/zero - Robustness (auth/device.go L76)
  3. [Minor] Hardcoded example provider/model/sandbox names in connection script - UX / Docs (get/gateway/cmd.go L132-134)

Convention Checklist

Convention Result
No panic() in production code Pass
Errors wrapped with fmt.Errorf context Pass
No secrets in logs or error messages Pass
Config/secret file permissions (0600) Pass
Input validated / callback state + XSS handling Pass
Image/binary references consistent across the stack (hsctl) Pass
Test Diff Scrutiny (no weakened pre-existing assertions) Pass
Conventional commit messages Pass

request["route"] = args.route
route := args.route
if route == "" {
route = `{"enabled":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.

Behavior change: when --route is empty this now injects {"enabled":true}, so every CLI create gateway requests a route. Previously the field was omitted. Please confirm the server default matched this so existing scripted invocations do not start provisioning routes they did not before; otherwise consider leaving it unset unless the user opts in.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 62abb7d — removed the injected default entirely. Route is now only sent when --route is explicitly passed.

Comment thread components/cli/pkg/auth/device.go Outdated
if interval < time.Second {
interval = 5 * time.Second
}
deadline := time.Now().Add(time.Duration(dar.ExpiresIn) * time.Second)

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.

If the device-auth response omits expires_in, dar.ExpiresIn is 0 and deadline equals now, so the poll loop never runs and the user immediately sees a misleading "device authorization timed out". Add a sane fallback (mirroring the interval default just above), e.g. treat a non-positive ExpiresIn as a few minutes.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 62abb7d — added a if expiresIn <= 0 { expiresIn = 300 } guard before computing the deadline, so a missing or zero expires_in uses a 5-minute fallback instead of expiring immediately.

func buildConnectionScript(name, endpoint string, oidc oidcConfig) string {
const (
providerName = "my-gcp"
model = "claude-haiku-4-5"

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.

providerName, model, and sandboxName are hardcoded example values rendered inside a copy-pasteable script. A user following these instructions gets a Vertex-AI/claude-haiku-4-5/mysand setup regardless of their environment. Consider marking them clearly as placeholders or deriving them, and note this overlaps with the web console connection guidance (see the Cross-PR section of the top-level review).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fixed in 62abb7d — the script now includes a comment marking steps 2-4 as a "GCP/Vertex AI example" so the placeholder names are clearly framed as examples to adapt.

- create gateway: omit route field when --route is unset; let the server own the default
- device flow: fall back to 300s when expires_in is missing or zero to prevent immediate timeout
- auth: add 30s timeout to all HTTP clients (secure and insecure) so an unresponsive issuer cannot hang the CLI
- get gateway: label the GCP/Vertex AI example section so non-GCP users know to adapt it
- tests: add unit tests for generatePKCE (challenge=BASE64URL(SHA256(verifier))), buildAuthURL (required params), tokenEndpoint, parseTokenResponse, shellArg quoting/injection cases, and printConnectionInstructions edge cases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@jsell-rh

jsell-rh commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Amber review: comment

Amber review

Status: Complete

View the submitted review.

@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

This PR adds a well-structured OIDC login experience to hsctl (browser Authorization Code + PKCE, headless device flow, static-token fallback), plus whoami, eager token refresh, logout revocation, and a dedicated hypershell-cli Keycloak client. The security posture of the flows is good (loopback-only callback, state validation, S256 PKCE, no secrets logged, %w error wrapping, real unit tests); I found no blockers, only a few minor items and one cross-PR design coordination worth a maintainer decision.

What looks good

  • Auth flows are implemented carefully: PKCE uses crypto/rand + S256, validates state on the callback, binds only to 127.0.0.1, escapes error HTML, and has a hard 5-minute timeout. Device flow correctly handles authorization_pending and slow_down (with backoff) and an overall deadline.
  • No secrets in logs. Only OAuth error bodies (not successful token bodies) are surfaced in error strings; the config file is written 0600.
  • Error handling is idiomatic: panic()/os.Exit(1) removed from login in favor of returned, wrapped errors; %w used consistently.
  • Tests were added for PKCE generation, auth URL construction, token endpoint derivation, token-response parsing, and the shell-arg quoting/<PENDING> logic. No pre-existing test assertions were weakened (Test Diff Scrutiny: clean — all test changes are additive).

Findings (all Minor)

  1. --url is now required (default http://localhost:8000 removed) — a breaking change for existing invocations/scripts. Intentional and documented, but flag it in release notes. (login/cmd.go:45)
  2. whoami --show-token and --show-token-decoded are not mutually exclusive; --show-token silently wins. Consider MarkFlagsMutuallyExclusive. (whoami/cmd.go:29-30)
  3. --insecure disables TLS verification for IdP credential exchange (PKCE/device/refresh/revoke). Acceptable and opt-in; consider warning when used against a non-loopback issuer. (auth/auth.go:35)
  4. Hardcoded onboarding/install guidance in buildConnectionScript can drift from the canonical UI connection-instruction source (see Cross-PR coordination). (get/gateway/cmd.go:147)

Cross-PR coordination

  • Shared management-API audience contract. This PR's new hypershell-cli Keycloak client injects hypershell-frontend into the token aud so the API server accepts CLI-issued tokens, and it rewrites the directAccessGrantsEnabled rationale in specs/platform/oidc-integration.spec.md. Separately, PR #182 upgrades the API server to enforce the hypershell-frontend management-API audience (and to reject tokens not minted for the management API) and edits the same spec. These two changes are interdependent: the audience this PR emits must match exactly what #182 enforces, or CLI login succeeds but every API call 401s. Maintainers should confirm the audience value and decide a merge order (audience enforcement + the CLI client's audience mapper need to land consistently), and reconcile the overlapping edits to oidc-integration.spec.md.

  • Single source of truth for gateway connect/onboarding instructions. hsctl get gateway --show-connection renders an openshell onboarding script (install line, gateway add, provider/model/sandbox steps) as hardcoded Go strings. PR #208 adds equivalent connect guidance to the web-console gateway Connection tab, and PR #210 makes the UI derive the openshell install command from the reconciled gateway_version (trimming the downstream -rh… suffix) rather than a static main-branch URL. As written, the CLI duplicates this guidance and will drift (e.g., it hardcodes the install URL/version instead of using the version-aware derivation). Maintainers should decide whether the CLI onboarding text should reuse the canonical connection-instruction logic/source so the CLI and UI stay in sync, and whether this PR should adopt the version-aware install command once that field is available.

flags := Cmd.Flags()
flags.StringVar(&args.url, "url", "http://localhost:8000", "URL of the API server.")
flags.StringVar(&args.token, "token", "", "Bearer access token (JWT) - DEPRECATED: use --token-file instead.")
flags.StringVar(&args.url, "url", "", "URL of the API server.")

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] Behavior change: --url is now required. The previous default (http://localhost:8000) was removed and run now returns required flag "url" not set. This is a deliberate, documented change, but any existing script or muscle-memory invocation of hsctl login (relying on the localhost default) will now fail. Please make sure this is called out as a breaking CLI change in the changelog / release notes so users update their scripts.

Comment thread components/cli/cmd/hypershell/whoami/cmd.go
Comment thread components/cli/pkg/auth/auth.go
Comment thread components/cli/cmd/hypershell/get/gateway/cmd.go
@rh-amarin
rh-amarin added this pull request to the merge queue Sep 1, 2026
Merged via the queue into openshift-online:main with commit ee13791 Sep 1, 2026
18 checks passed
@rh-amarin
rh-amarin deleted the hsctl-login branch September 1, 2026 16:42
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.

3 participants