diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index c3bd0b29d..6e9596bd8 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -132,6 +132,7 @@ production. |---------|-------| | Realm | `hypershell` | | Frontend client | `hypershell-frontend` (public, standard flow + direct access grants) | +| CLI client | `hypershell-cli` (public, standard flow + device authorization grant, used by `hsctl login`) | | Provisioner client | `hypershell-provisioner` (confidential, service account) | | Control plane client | `hypershell-control-plane` (confidential, service account, client_credentials) | | Admin user | `admin` / `admin` (role: `hypershell-admins`) | @@ -203,6 +204,23 @@ session management during `make kind-up`. 4. Sign in with `admin`/`admin` or `developer`/`developer` 5. Keycloak redirects back to the web console with a valid session +### hsctl login (management API) + +Build the CLI with `make build-cli`, then authenticate against the Kind cluster: + +```bash +./components/cli/hsctl login \ + --url https://api.hypershell.localhost \ + --issuer-url https://keycloak.hypershell.localhost/realms/hypershell \ + --insecure +``` + +For headless environments, add `--no-browser` to use the device authorization flow. +The CLI stores tokens in `~/.config/hypershell/config.json` (or `~/.hypershell.json` +if that legacy path already exists) and uses the `hypershell-cli` Keycloak client. + +Check identity with `hsctl whoami` and log out with `hsctl logout`. + ### Hot reload and OIDC Web console hot reload (`make kind-web-console-up`) runs the Vite dev server diff --git a/Makefile b/Makefile index 0f9c4d164..f42252547 100644 --- a/Makefile +++ b/Makefile @@ -436,7 +436,7 @@ generate-cli: cd scripts/cli-generator && go run . \ --spec ../../components/api-server/openapi/openapi.yaml \ --out ../../components/cli \ - --binary hypershell \ + --binary hsctl \ --project hypershell \ --api-prefix /api/hypershell/v1 \ --module github.com/openshift-online/hypershell/components/cli diff --git a/components/api-server/Makefile b/components/api-server/Makefile index 9e828d873..f736542ea 100644 --- a/components/api-server/Makefile +++ b/components/api-server/Makefile @@ -82,7 +82,7 @@ generate-cli: cd "$(CLI_GENERATOR_DIR)" && GOWORK=off go run . \ --spec "$(OPENAPI_SPEC)" \ --out "$(CLI_DIR)" \ - --binary hypershell \ + --binary hsctl \ --project hypershell \ --api-prefix "$(SDK_API_PREFIX)" \ --module "$(CLI_MODULE)" diff --git a/components/cli/Dockerfile b/components/cli/Dockerfile index 343607b34..6bcf5e998 100644 --- a/components/cli/Dockerfile +++ b/components/cli/Dockerfile @@ -9,13 +9,13 @@ RUN go mod download COPY cmd/ cmd/ COPY pkg/ pkg/ -RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o hypershell ./cmd/hypershell +RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o hsctl ./cmd/hypershell FROM registry.access.redhat.com/hi/static:1787099997@sha256:f4d5109b57cf7eab0a7adc566f2d78f80fa0c5ec9ccab698c9fb8eb448db6071 -COPY --from=builder /workspace/hypershell /usr/local/bin/ +COPY --from=builder /workspace/hsctl /usr/local/bin/ -ENTRYPOINT ["/usr/local/bin/hypershell"] +ENTRYPOINT ["/usr/local/bin/hsctl"] LABEL org.opencontainers.image.title="HyperShell CLI" \ org.opencontainers.image.description="Command line tool for the HyperShell API server" \ diff --git a/components/cli/cmd/hypershell/apply/cmd.go b/components/cli/cmd/hypershell/apply/cmd.go index d19a7234e..8632b8edc 100644 --- a/components/cli/cmd/hypershell/apply/cmd.go +++ b/components/cli/cmd/hypershell/apply/cmd.go @@ -29,10 +29,10 @@ var Cmd = &cobra.Command{ Short: "Apply resources from file or directory", Long: "Apply resources from YAML files or kustomize directories.\n\n" + "Examples:\n" + - " hypershell apply -f resource.yaml\n" + - " hypershell apply -f ./resources/\n" + - " hypershell apply -k ./overlays/prod/\n" + - " hypershell apply -f - < resource.yaml", + " hsctl apply -f resource.yaml\n" + + " hsctl apply -f ./resources/\n" + + " hsctl apply -k ./overlays/prod/\n" + + " hsctl apply -f - < resource.yaml", Args: cobra.NoArgs, RunE: run, } diff --git a/components/cli/cmd/hypershell/completion/cmd.go b/components/cli/cmd/hypershell/completion/cmd.go index e1d2a3095..4af46b041 100644 --- a/components/cli/cmd/hypershell/completion/cmd.go +++ b/components/cli/cmd/hypershell/completion/cmd.go @@ -11,9 +11,9 @@ var Cmd = &cobra.Command{ Short: "Generate shell completion scripts", Long: "Generate shell completion scripts for the CLI.\n\n" + "Examples:\n" + - " hypershell completion bash > /etc/bash_completion.d/hypershell\n" + - " hypershell completion zsh > \"${fpath[1]}/_hypershell\"\n" + - " hypershell completion fish > ~/.config/fish/completions/hypershell.fish", + " hsctl completion bash > /etc/bash_completion.d/hsctl\n" + + " hsctl completion zsh > \"${fpath[1]}/_hsctl\"\n" + + " hsctl completion fish > ~/.config/fish/completions/hsctl.fish", Args: cobra.ExactArgs(1), ValidArgs: []string{"bash", "zsh", "fish"}, RunE: run, diff --git a/components/cli/cmd/hypershell/create/gateway/cmd.go b/components/cli/cmd/hypershell/create/gateway/cmd.go index 4593380fc..6a42f2132 100644 --- a/components/cli/cmd/hypershell/create/gateway/cmd.go +++ b/components/cli/cmd/hypershell/create/gateway/cmd.go @@ -37,8 +37,8 @@ var Cmd = &cobra.Command{ Short: "Create a gateway", Long: "Create a new gateway.\n\n" + "Examples:\n" + - " hypershell create gateway --cluster-id --database-id --external-dns --image --name --phase --release-id --route --server-dns-names --service-type --status --supervisor-image --tls-mode \n" + - " hypershell create gateway --body request.json", + " hsctl create gateway --cluster-id --database-id --external-dns --image --name --phase --release-id --route --server-dns-names --service-type --status --supervisor-image --tls-mode \n" + + " hsctl create gateway --body request.json", Args: cobra.NoArgs, RunE: run, } diff --git a/components/cli/cmd/hypershell/create/gatewayNetwork/cmd.go b/components/cli/cmd/hypershell/create/gatewayNetwork/cmd.go index 3e2c1d6f7..99ed0b02b 100644 --- a/components/cli/cmd/hypershell/create/gatewayNetwork/cmd.go +++ b/components/cli/cmd/hypershell/create/gatewayNetwork/cmd.go @@ -29,8 +29,8 @@ var Cmd = &cobra.Command{ Short: "Create a gatewayNetwork", Long: "Create a new gatewayNetwork.\n\n" + "Examples:\n" + - " hypershell create gatewayNetwork --hub-gateway-id --name --status --topology --tunnel-mode \n" + - " hypershell create gatewayNetwork --body request.json", + " hsctl create gatewayNetwork --hub-gateway-id --name --status --topology --tunnel-mode \n" + + " hsctl create gatewayNetwork --body request.json", Args: cobra.NoArgs, RunE: run, } diff --git a/components/cli/cmd/hypershell/create/gatewayRelease/cmd.go b/components/cli/cmd/hypershell/create/gatewayRelease/cmd.go index 9e6d6ac3c..e7d6ac664 100644 --- a/components/cli/cmd/hypershell/create/gatewayRelease/cmd.go +++ b/components/cli/cmd/hypershell/create/gatewayRelease/cmd.go @@ -30,8 +30,8 @@ var Cmd = &cobra.Command{ Short: "Create a gatewayRelease", Long: "Create a new gatewayRelease.\n\n" + "Examples:\n" + - " hypershell create gatewayRelease --canary-duration --canary-percent --image --name --rollout-strategy --status \n" + - " hypershell create gatewayRelease --body request.json", + " hsctl create gatewayRelease --canary-duration --canary-percent --image --name --rollout-strategy --status \n" + + " hsctl create gatewayRelease --body request.json", Args: cobra.NoArgs, RunE: run, } diff --git a/components/cli/cmd/hypershell/create/managedCluster/cmd.go b/components/cli/cmd/hypershell/create/managedCluster/cmd.go index 28041a11e..e431a2b52 100644 --- a/components/cli/cmd/hypershell/create/managedCluster/cmd.go +++ b/components/cli/cmd/hypershell/create/managedCluster/cmd.go @@ -30,8 +30,8 @@ var Cmd = &cobra.Command{ Short: "Create a managedCluster", Long: "Create a new managedCluster.\n\n" + "Examples:\n" + - " hypershell create managedCluster --api-server-url --kubeconfig-secret --name --provider --region --status \n" + - " hypershell create managedCluster --body request.json", + " hsctl create managedCluster --api-server-url --kubeconfig-secret --name --provider --region --status \n" + + " hsctl create managedCluster --body request.json", Args: cobra.NoArgs, RunE: run, } diff --git a/components/cli/cmd/hypershell/create/managedDatabase/cmd.go b/components/cli/cmd/hypershell/create/managedDatabase/cmd.go index 619a4b5df..ab527ce76 100644 --- a/components/cli/cmd/hypershell/create/managedDatabase/cmd.go +++ b/components/cli/cmd/hypershell/create/managedDatabase/cmd.go @@ -32,8 +32,8 @@ var Cmd = &cobra.Command{ Short: "Create a managedDatabase", Long: "Create a new managedDatabase.\n\n" + "Examples:\n" + - " hypershell create managedDatabase --connection-secret --engine --engine-version --instance-class --name --provider --region --status \n" + - " hypershell create managedDatabase --body request.json", + " hsctl create managedDatabase --connection-secret --engine --engine-version --instance-class --name --provider --region --status \n" + + " hsctl create managedDatabase --body request.json", Args: cobra.NoArgs, RunE: run, } diff --git a/components/cli/cmd/hypershell/create/role/cmd.go b/components/cli/cmd/hypershell/create/role/cmd.go index 51538688f..f7bb5929f 100644 --- a/components/cli/cmd/hypershell/create/role/cmd.go +++ b/components/cli/cmd/hypershell/create/role/cmd.go @@ -29,8 +29,8 @@ var Cmd = &cobra.Command{ Short: "Create a role", Long: "Create a new role.\n\n" + "Examples:\n" + - " hypershell create role --built-in --description --display-name --name --permissions \n" + - " hypershell create role --body request.json", + " hsctl create role --built-in --description --display-name --name --permissions \n" + + " hsctl create role --body request.json", Args: cobra.NoArgs, RunE: run, } diff --git a/components/cli/cmd/hypershell/create/roleBinding/cmd.go b/components/cli/cmd/hypershell/create/roleBinding/cmd.go index 67f75ccd7..4663511b3 100644 --- a/components/cli/cmd/hypershell/create/roleBinding/cmd.go +++ b/components/cli/cmd/hypershell/create/roleBinding/cmd.go @@ -28,8 +28,8 @@ var Cmd = &cobra.Command{ Short: "Create a roleBinding", Long: "Create a new roleBinding.\n\n" + "Examples:\n" + - " hypershell create roleBinding --gateway-id --role-id --scope --user-id \n" + - " hypershell create roleBinding --body request.json", + " hsctl create roleBinding --gateway-id --role-id --scope --user-id \n" + + " hsctl create roleBinding --body request.json", Args: cobra.NoArgs, RunE: run, } diff --git a/components/cli/cmd/hypershell/delete/gateway/cmd.go b/components/cli/cmd/hypershell/delete/gateway/cmd.go index 1ec4a6882..66cf80366 100644 --- a/components/cli/cmd/hypershell/delete/gateway/cmd.go +++ b/components/cli/cmd/hypershell/delete/gateway/cmd.go @@ -20,8 +20,8 @@ var Cmd = &cobra.Command{ Short: "Delete a gateway", Long: "Delete a gateway by ID.\n\n" + "Examples:\n" + - " hypershell delete gateway 2abc123\n" + - " hypershell delete gateway 2abc123 --yes", + " hsctl delete gateway 2abc123\n" + + " hsctl delete gateway 2abc123 --yes", Args: cobra.ExactArgs(1), RunE: run, } diff --git a/components/cli/cmd/hypershell/delete/gatewayNetwork/cmd.go b/components/cli/cmd/hypershell/delete/gatewayNetwork/cmd.go index f9daee031..c8003bc5a 100644 --- a/components/cli/cmd/hypershell/delete/gatewayNetwork/cmd.go +++ b/components/cli/cmd/hypershell/delete/gatewayNetwork/cmd.go @@ -20,8 +20,8 @@ var Cmd = &cobra.Command{ Short: "Delete a gatewayNetwork", Long: "Delete a gatewayNetwork by ID.\n\n" + "Examples:\n" + - " hypershell delete gatewayNetwork 2abc123\n" + - " hypershell delete gatewayNetwork 2abc123 --yes", + " hsctl delete gatewayNetwork 2abc123\n" + + " hsctl delete gatewayNetwork 2abc123 --yes", Args: cobra.ExactArgs(1), RunE: run, } diff --git a/components/cli/cmd/hypershell/delete/gatewayRelease/cmd.go b/components/cli/cmd/hypershell/delete/gatewayRelease/cmd.go index e99c4eb82..3cfa79d1b 100644 --- a/components/cli/cmd/hypershell/delete/gatewayRelease/cmd.go +++ b/components/cli/cmd/hypershell/delete/gatewayRelease/cmd.go @@ -20,8 +20,8 @@ var Cmd = &cobra.Command{ Short: "Delete a gatewayRelease", Long: "Delete a gatewayRelease by ID.\n\n" + "Examples:\n" + - " hypershell delete gatewayRelease 2abc123\n" + - " hypershell delete gatewayRelease 2abc123 --yes", + " hsctl delete gatewayRelease 2abc123\n" + + " hsctl delete gatewayRelease 2abc123 --yes", Args: cobra.ExactArgs(1), RunE: run, } diff --git a/components/cli/cmd/hypershell/delete/managedCluster/cmd.go b/components/cli/cmd/hypershell/delete/managedCluster/cmd.go index 94607be54..9c90e5e31 100644 --- a/components/cli/cmd/hypershell/delete/managedCluster/cmd.go +++ b/components/cli/cmd/hypershell/delete/managedCluster/cmd.go @@ -20,8 +20,8 @@ var Cmd = &cobra.Command{ Short: "Delete a managedCluster", Long: "Delete a managedCluster by ID.\n\n" + "Examples:\n" + - " hypershell delete managedCluster 2abc123\n" + - " hypershell delete managedCluster 2abc123 --yes", + " hsctl delete managedCluster 2abc123\n" + + " hsctl delete managedCluster 2abc123 --yes", Args: cobra.ExactArgs(1), RunE: run, } diff --git a/components/cli/cmd/hypershell/delete/managedDatabase/cmd.go b/components/cli/cmd/hypershell/delete/managedDatabase/cmd.go index c012663cd..bb6fa8d2d 100644 --- a/components/cli/cmd/hypershell/delete/managedDatabase/cmd.go +++ b/components/cli/cmd/hypershell/delete/managedDatabase/cmd.go @@ -20,8 +20,8 @@ var Cmd = &cobra.Command{ Short: "Delete a managedDatabase", Long: "Delete a managedDatabase by ID.\n\n" + "Examples:\n" + - " hypershell delete managedDatabase 2abc123\n" + - " hypershell delete managedDatabase 2abc123 --yes", + " hsctl delete managedDatabase 2abc123\n" + + " hsctl delete managedDatabase 2abc123 --yes", Args: cobra.ExactArgs(1), RunE: run, } diff --git a/components/cli/cmd/hypershell/delete/role/cmd.go b/components/cli/cmd/hypershell/delete/role/cmd.go index 5bbcb92f0..6fd0600d0 100644 --- a/components/cli/cmd/hypershell/delete/role/cmd.go +++ b/components/cli/cmd/hypershell/delete/role/cmd.go @@ -20,8 +20,8 @@ var Cmd = &cobra.Command{ Short: "Delete a role", Long: "Delete a role by ID.\n\n" + "Examples:\n" + - " hypershell delete role 2abc123\n" + - " hypershell delete role 2abc123 --yes", + " hsctl delete role 2abc123\n" + + " hsctl delete role 2abc123 --yes", Args: cobra.ExactArgs(1), RunE: run, } diff --git a/components/cli/cmd/hypershell/delete/roleBinding/cmd.go b/components/cli/cmd/hypershell/delete/roleBinding/cmd.go index 8bed6dd2d..402355055 100644 --- a/components/cli/cmd/hypershell/delete/roleBinding/cmd.go +++ b/components/cli/cmd/hypershell/delete/roleBinding/cmd.go @@ -20,8 +20,8 @@ var Cmd = &cobra.Command{ Short: "Delete a roleBinding", Long: "Delete a roleBinding by ID.\n\n" + "Examples:\n" + - " hypershell delete roleBinding 2abc123\n" + - " hypershell delete roleBinding 2abc123 --yes", + " hsctl delete roleBinding 2abc123\n" + + " hsctl delete roleBinding 2abc123 --yes", Args: cobra.ExactArgs(1), RunE: run, } diff --git a/components/cli/cmd/hypershell/get/gateway/cmd.go b/components/cli/cmd/hypershell/get/gateway/cmd.go index 955c05ac9..9a522fd11 100644 --- a/components/cli/cmd/hypershell/get/gateway/cmd.go +++ b/components/cli/cmd/hypershell/get/gateway/cmd.go @@ -1,9 +1,12 @@ package gateway import ( + "encoding/json" "fmt" "io" "os" + "regexp" + "strings" "github.com/spf13/cobra" @@ -13,6 +16,8 @@ import ( "github.com/openshift-online/hypershell/components/cli/pkg/urls" ) +var showConnection bool + var Cmd = &cobra.Command{ Use: "gateway ID", Aliases: []string{"gateways"}, @@ -22,6 +27,10 @@ var Cmd = &cobra.Command{ RunE: run, } +func init() { + Cmd.Flags().BoolVar(&showConnection, "show-connection", false, "Print openshell connection instructions for the gateway") +} + func run(cmd *cobra.Command, argv []string) error { id := argv[0] @@ -38,18 +47,138 @@ func run(cmd *cobra.Command, argv []string) error { resp, err := conn.Get(urls.GatewayPath(id), nil) if err != nil { - return fmt.Errorf("can't retrieve gateway: %v", err) + return fmt.Errorf("can't retrieve gateway: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("can't read response: %v", err) + return fmt.Errorf("can't read response: %w", err) } if resp.StatusCode != 200 { return fmt.Errorf("API returned %d: %s", resp.StatusCode, string(body)) } + if showConnection { + return printConnectionInstructions(os.Stdout, body) + } + return dump.Pretty(os.Stdout, body) } + +type gatewayResponse struct { + Name string `json:"name"` + Phase string `json:"phase"` + ExternalDNS string `json:"external_dns"` + RouteAddress string `json:"route_address"` + Oidc *string `json:"oidc"` +} + +type oidcConfig struct { + Issuer string `json:"issuer"` + ClientID string `json:"client_id"` + Audience string `json:"audience"` +} + +const pending = "" + +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: %w", err) + } + + var oidc oidcConfig + if gw.Oidc != nil { + _ = json.Unmarshal([]byte(*gw.Oidc), &oidc) + } + + endpoint := resolveEndpoint(gw) + if endpoint == "" { + endpoint = pending + } + if oidc.Issuer == "" { + oidc.Issuer = pending + } + if oidc.ClientID == "" { + oidc.ClientID = pending + } + if oidc.Audience == "" { + oidc.Audience = pending + } + + fmt.Fprintln(w, buildConnectionScript(gw.Name, endpoint, oidc)) + return nil +} + +func resolveEndpoint(gw gatewayResponse) string { + if dns := strings.TrimSpace(gw.ExternalDNS); dns != "" { + if strings.HasPrefix(dns, "https://") || strings.HasPrefix(dns, "http://") { + return dns + } + return "https://" + dns + } + if addr := strings.TrimSpace(gw.RouteAddress); addr != "" { + addr = strings.TrimPrefix(addr, "grpcs://") + addr = strings.TrimPrefix(addr, "grpc://") + return addr + } + return "" +} + +func buildConnectionScript(name, endpoint string, oidc oidcConfig) string { + const ( + providerName = "my-gcp" + model = "claude-haiku-4-5" + sandboxName = "mysand" + ) + + addParts := []string{ + "openshell gateway add", + " --name " + shellArg(name), + " --oidc-issuer " + shellArg(oidc.Issuer), + " --oidc-client-id " + shellArg(oidc.ClientID), + " --oidc-audience " + shellArg(oidc.Audience), + " " + shellArg(endpoint), + } + + lines := []string{ + "# Install openshell: https://docs.nvidia.com/openshell/about/installation", + "", + "# 1. Log in to the gateway", + strings.Join(addParts, " \\\n"), + "", + "# Steps 2-4 below show a GCP/Vertex AI example. Adjust provider type and config for your environment.", + "", + "# 2. Add the Claude on Vertex AI provider", + "openshell provider create \\", + " --name " + providerName + " \\", + " --type google-vertex-ai \\", + " --from-gcloud-adc \\", + ` --config VERTEX_AI_PROJECT_ID="$ANTHROPIC_VERTEX_PROJECT_ID" \`, + " --config VERTEX_AI_REGION=global", + "", + "# 3. Select the model", + "openshell inference set --provider " + providerName + " --model " + model, + "", + "# 4. Create a sandbox", + "openshell sandbox create \\", + " --name " + sandboxName + " \\", + " --env=ANTHROPIC_BASE_URL=https://inference.local \\", + " --env=ANTHROPIC_API_KEY=unused \\", + " --no-auto-providers \\", + " -- claude --bare --model " + model, + } + + return strings.Join(lines, "\n") +} + +var safeShellArg = regexp.MustCompile(`^[A-Za-z0-9_./:@%+=,-]+$`) + +func shellArg(value string) string { + if value == pending || safeShellArg.MatchString(value) { + return value + } + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} diff --git a/components/cli/cmd/hypershell/get/gateway/cmd_test.go b/components/cli/cmd/hypershell/get/gateway/cmd_test.go new file mode 100644 index 000000000..c85b87686 --- /dev/null +++ b/components/cli/cmd/hypershell/get/gateway/cmd_test.go @@ -0,0 +1,49 @@ +package gateway + +import ( + "bytes" + "strings" + "testing" +) + +func TestShellArg(t *testing.T) { + cases := []struct { + input string + want string + }{ + {"simple", "simple"}, + {"with space", "'with space'"}, + {"it's", `'it'"'"'s'`}, + {"https://example.com/path", "https://example.com/path"}, + {"", ""}, + {"foo bar'baz", `'foo bar'"'"'baz'`}, + {`semi;colon`, `'semi;colon'`}, + } + for _, tc := range cases { + if got := shellArg(tc.input); got != tc.want { + t.Errorf("shellArg(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestPrintConnectionInstructions_InvalidJSON(t *testing.T) { + err := printConnectionInstructions(&bytes.Buffer{}, []byte("not-json")) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +func TestPrintConnectionInstructions_PendingWhenEmpty(t *testing.T) { + body := []byte(`{"name":"mygw","phase":"ready"}`) + var buf bytes.Buffer + if err := printConnectionInstructions(&buf, body); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "") { + t.Errorf("expected for missing endpoint/oidc, got:\n%s", out) + } + if !strings.Contains(out, "mygw") { + t.Errorf("expected gateway name in output, got:\n%s", out) + } +} diff --git a/components/cli/cmd/hypershell/list/gateways/cmd.go b/components/cli/cmd/hypershell/list/gateways/cmd.go index d30d61561..bed8e5bf4 100644 --- a/components/cli/cmd/hypershell/list/gateways/cmd.go +++ b/components/cli/cmd/hypershell/list/gateways/cmd.go @@ -38,7 +38,7 @@ func init() { fs := Cmd.Flags() arguments.AddParameterFlag(fs, &args.parameter) arguments.AddNoHeadersFlag(fs, &args.noHeaders) - arguments.AddColumnsFlag(fs, &args.columns, "id, cluster_id, database_id, external_dns, created_at") + arguments.AddColumnsFlag(fs, &args.columns, "id, name, phase, console_address, created_at") arguments.AddOutputFlag(fs, &args.outputFmt) fs.StringVar(&args.search, "search", "", "Search filter expression.") fs.StringVar(&args.orderBy, "order-by", "", "Order by expression.") diff --git a/components/cli/cmd/hypershell/login/cmd.go b/components/cli/cmd/hypershell/login/cmd.go index 0999aef0c..97f7ba29c 100644 --- a/components/cli/cmd/hypershell/login/cmd.go +++ b/components/cli/cmd/hypershell/login/cmd.go @@ -8,39 +8,104 @@ import ( "github.com/spf13/cobra" + "github.com/openshift-online/hypershell/components/cli/pkg/auth" "github.com/openshift-online/hypershell/components/cli/pkg/config" ) +const defaultClientID = "hypershell-cli" + var args struct { url string + issuerURL string + clientID string token string tokenFile string + noBrowser bool insecure bool } var Cmd = &cobra.Command{ Use: "login", Short: "Log in to the API server", - Long: "Log in, saving the credentials to the configuration file.\n\n" + + Long: "Log in using OIDC (browser or device flow), saving credentials to the config file.\n\n" + "Examples:\n" + - " hypershell login --token-file ~/.config/token --url http://localhost:8000\n" + - " echo \"$JWT_TOKEN\" | hypershell login --token-file /dev/stdin --url local", + " hsctl login --url https://api.hypershell.localhost \\\n" + + " --issuer-url https://keycloak.hypershell.localhost/realms/hypershell --insecure\n\n" + + " # Device flow for headless/SSH environments\n" + + " hsctl login --no-browser --url https://api.hypershell.localhost \\\n" + + " --issuer-url https://keycloak.hypershell.localhost/realms/hypershell --insecure\n\n" + + " # Static token (service accounts / automation)\n" + + " hsctl login --token-file ~/.config/token --url https://api.hypershell.localhost --insecure", Args: cobra.NoArgs, RunE: run, } func init() { 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.") + flags.StringVar(&args.issuerURL, "issuer-url", "", "OIDC issuer URL (Keycloak realm).") + flags.StringVar(&args.clientID, "client-id", defaultClientID, "OIDC client ID.") + flags.BoolVar(&args.noBrowser, "no-browser", false, "Use device authorization flow instead of opening a browser.") + flags.StringVar(&args.token, "token", "", "Bearer access token (JWT) -- use --token-file instead.") flags.StringVar(&args.tokenFile, "token-file", "", "File containing bearer access token (use /dev/stdin to read from stdin).") - flags.BoolVar(&args.insecure, "insecure", false, "Enables insecure communication with the server.") + flags.BoolVar(&args.insecure, "insecure", false, "Disable TLS verification.") } func run(cmd *cobra.Command, argv []string) error { + if args.url == "" { + _ = cmd.Usage() + return fmt.Errorf("required flag \"url\" not set") + } + + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("can't load config: %w", err) + } + if cfg == nil { + cfg = new(config.Config) + } + + cfg.URL = args.url + cfg.Insecure = args.insecure + + // Static token path (service accounts / scripts) + if args.tokenFile != "" || args.token != "" { + return staticTokenLogin(cfg) + } + + if args.issuerURL == "" { + _ = cmd.Usage() + return fmt.Errorf("required flag \"issuer-url\" not set") + } + + // OIDC path + cfg.IssuerURL = args.issuerURL + cfg.ClientID = args.clientID + + var tr auth.TokenResponse + if args.noBrowser { + tr, err = auth.DeviceFlow(args.issuerURL, args.clientID, args.insecure) + } else { + tr, err = auth.BrowserPKCE(args.issuerURL, args.clientID, args.insecure) + } + if err != nil { + return err + } + + cfg.AccessToken = tr.AccessToken + cfg.RefreshToken = tr.RefreshToken + + if err := config.Save(cfg); err != nil { + return fmt.Errorf("can't save config: %w", err) + } + + fmt.Fprintf(os.Stderr, "Login successful.\n") + return nil +} + +func staticTokenLogin(cfg *config.Config) error { var token string - // Handle token input (prefer --token-file over --token for security) if args.tokenFile != "" { var reader io.Reader if args.tokenFile == "/dev/stdin" { @@ -48,47 +113,34 @@ func run(cmd *cobra.Command, argv []string) error { } else { file, err := os.Open(args.tokenFile) if err != nil { - return fmt.Errorf("can't open token file '%s': %v", args.tokenFile, err) + return fmt.Errorf("can't open token file '%s': %w", args.tokenFile, err) } defer file.Close() reader = file } - tokenBytes, err := io.ReadAll(reader) if err != nil { - return fmt.Errorf("can't read token: %v", err) + return fmt.Errorf("can't read token: %w", err) } token = strings.TrimSpace(string(tokenBytes)) - } else if args.token != "" { - fmt.Fprintf(os.Stderr, "Warning: Using --token flag exposes token in shell history. Use --token-file instead.\n") - token = args.token } else { - fmt.Fprintf(os.Stderr, "A token is required. Use '--token-file ' or '--token-file /dev/stdin'.\n") - os.Exit(1) + fmt.Fprintf(os.Stderr, "Warning: --token exposes the token in shell history. Use --token-file instead.\n") + token = args.token } if token == "" { - fmt.Fprintf(os.Stderr, "Token cannot be empty.\n") - os.Exit(1) - } - - cfg, err := config.Load() - if err != nil { - return fmt.Errorf("can't load config file: %v", err) - } - if cfg == nil { - cfg = new(config.Config) + return fmt.Errorf("token is empty") } cfg.AccessToken = token - cfg.URL = args.url - cfg.Insecure = args.insecure + cfg.RefreshToken = "" + cfg.IssuerURL = "" + cfg.ClientID = "" - err = config.Save(cfg) - if err != nil { - return fmt.Errorf("can't save config file: %v", err) + if err := config.Save(cfg); err != nil { + return fmt.Errorf("can't save config: %w", err) } - fmt.Fprintf(os.Stderr, "Login successful. Configuration saved.\n") + fmt.Fprintf(os.Stderr, "Login successful.\n") return nil } diff --git a/components/cli/cmd/hypershell/logout/cmd.go b/components/cli/cmd/hypershell/logout/cmd.go index be5250d66..26d98233b 100644 --- a/components/cli/cmd/hypershell/logout/cmd.go +++ b/components/cli/cmd/hypershell/logout/cmd.go @@ -2,16 +2,18 @@ package logout import ( "fmt" + "os" "github.com/spf13/cobra" + "github.com/openshift-online/hypershell/components/cli/pkg/auth" "github.com/openshift-online/hypershell/components/cli/pkg/config" ) var Cmd = &cobra.Command{ Use: "logout", Short: "Log out", - Long: "Log out, removing credentials from the config file.", + Long: "Log out, revoking the token at the identity provider and removing credentials from the config file.", Args: cobra.NoArgs, RunE: run, } @@ -22,10 +24,16 @@ func run(cmd *cobra.Command, argv []string) error { return fmt.Errorf("can't load configuration file: %w", err) } + // Revoke the refresh token at Keycloak (best-effort; continue even if it fails) + if cfg.RefreshToken != "" && cfg.IssuerURL != "" && cfg.ClientID != "" { + if revokeErr := auth.Revoke(cfg.IssuerURL, cfg.ClientID, cfg.RefreshToken, cfg.Insecure); revokeErr != nil { + fmt.Fprintf(os.Stderr, "Warning: could not revoke token: %v\n", revokeErr) + } + } + cfg.Disarm() - err = config.Save(cfg) - if err != nil { + if err := config.Save(cfg); err != nil { return fmt.Errorf("can't save configuration file: %w", err) } diff --git a/components/cli/cmd/hypershell/main.go b/components/cli/cmd/hypershell/main.go index a7d947d44..14fdeb952 100644 --- a/components/cli/cmd/hypershell/main.go +++ b/components/cli/cmd/hypershell/main.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" + "github.com/openshift-online/hypershell/components/cli/cmd/hypershell/apply" "github.com/openshift-online/hypershell/components/cli/cmd/hypershell/completion" "github.com/openshift-online/hypershell/components/cli/cmd/hypershell/config" "github.com/openshift-online/hypershell/components/cli/cmd/hypershell/create" @@ -16,17 +17,19 @@ import ( "github.com/openshift-online/hypershell/components/cli/cmd/hypershell/logout" "github.com/openshift-online/hypershell/components/cli/cmd/hypershell/revoke" "github.com/openshift-online/hypershell/components/cli/cmd/hypershell/version" + "github.com/openshift-online/hypershell/components/cli/cmd/hypershell/whoami" ) var root = &cobra.Command{ - Use: "hypershell", - Short: "hypershell CLI", - Long: "Command line tool for the hypershell API server.", + Use: "hsctl", + Short: "hsctl CLI", + Long: "Command line tool for the HyperShell API server.", SilenceUsage: true, SilenceErrors: true, } func init() { + root.AddCommand(apply.Cmd) root.AddCommand(completion.Cmd) root.AddCommand(config.Cmd) root.AddCommand(create.Cmd) @@ -37,6 +40,7 @@ func init() { root.AddCommand(logout.Cmd) root.AddCommand(revoke.Cmd) root.AddCommand(version.Cmd) + root.AddCommand(whoami.Cmd) } func main() { diff --git a/components/cli/cmd/hypershell/whoami/cmd.go b/components/cli/cmd/hypershell/whoami/cmd.go new file mode 100644 index 000000000..79d4b1940 --- /dev/null +++ b/components/cli/cmd/hypershell/whoami/cmd.go @@ -0,0 +1,99 @@ +package whoami + +import ( + "encoding/json" + "fmt" + "os" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/spf13/cobra" + + "github.com/openshift-online/hypershell/components/cli/pkg/config" +) + +var args struct { + showToken bool + showTokenDecoded bool +} + +var Cmd = &cobra.Command{ + Use: "whoami", + Short: "Show current login information", + Long: "Display the user identity, token expiry, and API server from the saved configuration.", + Args: cobra.NoArgs, + RunE: run, +} + +func init() { + Cmd.Flags().BoolVarP(&args.showToken, "show-token", "t", false, "Print only the raw access token.") + Cmd.Flags().BoolVar(&args.showTokenDecoded, "show-token-decoded", false, "Print only the decoded token claims as JSON.") +} + +func run(cmd *cobra.Command, argv []string) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("can't load config: %w", err) + } + + armed, reason := cfg.Armed() + if !armed { + return fmt.Errorf("not logged in: %s", reason) + } + + if err := config.EnsureFreshToken(cfg); err != nil { + return err + } + + if args.showToken { + fmt.Fprintln(os.Stdout, cfg.AccessToken) + return nil + } + + token, err := config.ParseToken(cfg.AccessToken) + if err != nil { + return fmt.Errorf("can't parse token: %w", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return fmt.Errorf("unexpected token claims type") + } + + if args.showTokenDecoded { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(map[string]any(claims)) + } + + username := stringClaim(claims, "preferred_username") + if username == "" { + username = stringClaim(claims, "sub") + } + email := stringClaim(claims, "email") + issuer := stringClaim(claims, "iss") + + fmt.Fprintf(os.Stdout, "User: %s\n", username) + if email != "" { + fmt.Fprintf(os.Stdout, "Email: %s\n", email) + } + fmt.Fprintf(os.Stdout, "Issuer: %s\n", issuer) + fmt.Fprintf(os.Stdout, "API URL: %s\n", cfg.URL) + + if exp, ok := claims["exp"].(float64); ok && exp > 0 { + expTime := time.Unix(int64(exp), 0) + remaining := time.Until(expTime) + if remaining > 0 { + fmt.Fprintf(os.Stdout, "Expires: %s (in %s)\n", expTime.Local().Format(time.RFC3339), remaining.Truncate(time.Second)) + } else { + fmt.Fprintf(os.Stdout, "Expires: %s (expired)\n", expTime.Local().Format(time.RFC3339)) + } + } + + return nil +} + +func stringClaim(claims jwt.MapClaims, key string) string { + v, _ := claims[key].(string) + return v +} diff --git a/components/cli/pkg/auth/auth.go b/components/cli/pkg/auth/auth.go new file mode 100644 index 000000000..5771d8e76 --- /dev/null +++ b/components/cli/pkg/auth/auth.go @@ -0,0 +1,84 @@ +package auth + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type TokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + RefreshExpiresIn int `json:"refresh_expires_in"` + TokenType string `json:"token_type"` +} + +func tokenEndpoint(issuerURL string) string { + return strings.TrimRight(issuerURL, "/") + "/protocol/openid-connect/token" +} + +func newHTTPClient(insecure bool) *http.Client { + if !insecure { + return &http.Client{Timeout: 30 * time.Second} + } + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: true, //nolint:gosec + }, + }, + } +} + +func Refresh(issuerURL, clientID, refreshToken string, insecure bool) (TokenResponse, error) { + resp, err := newHTTPClient(insecure).PostForm(tokenEndpoint(issuerURL), url.Values{ + "grant_type": {"refresh_token"}, + "client_id": {clientID}, + "refresh_token": {refreshToken}, + }) + if err != nil { + return TokenResponse{}, fmt.Errorf("token refresh: %w", err) + } + defer resp.Body.Close() + return parseTokenResponse(resp) +} + +func Revoke(issuerURL, clientID, token string, insecure bool) error { + revokeURL := strings.TrimRight(issuerURL, "/") + "/protocol/openid-connect/revoke" + resp, err := newHTTPClient(insecure).PostForm(revokeURL, url.Values{ + "client_id": {clientID}, + "token": {token}, + }) + if err != nil { + return fmt.Errorf("token revocation request: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("revocation failed (%d): %s", resp.StatusCode, body) + } + return nil +} + +func parseTokenResponse(resp *http.Response) (TokenResponse, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return TokenResponse{}, fmt.Errorf("reading token response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return TokenResponse{}, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, body) + } + var tr TokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return TokenResponse{}, fmt.Errorf("parsing token response: %w", err) + } + return tr, nil +} diff --git a/components/cli/pkg/auth/auth_test.go b/components/cli/pkg/auth/auth_test.go new file mode 100644 index 000000000..e592a7cd7 --- /dev/null +++ b/components/cli/pkg/auth/auth_test.go @@ -0,0 +1,113 @@ +package auth + +import ( + "crypto/sha256" + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestGeneratePKCE(t *testing.T) { + verifier, challenge, err := generatePKCE() + if err != nil { + t.Fatalf("generatePKCE: %v", err) + } + if verifier == "" || challenge == "" { + t.Fatal("expected non-empty verifier and challenge") + } + + // RFC 7636: challenge = BASE64URL(SHA256(ASCII(verifier))) + sum := sha256.Sum256([]byte(verifier)) + want := base64.RawURLEncoding.EncodeToString(sum[:]) + if challenge != want { + t.Errorf("challenge mismatch: got %q want %q", challenge, want) + } + + // Each call produces a distinct verifier + v2, _, err := generatePKCE() + if err != nil { + t.Fatalf("generatePKCE second call: %v", err) + } + if verifier == v2 { + t.Error("expected different verifiers on successive calls") + } +} + +func TestBuildAuthURL(t *testing.T) { + u := buildAuthURL("https://sso.example.com", "my-client", "http://127.0.0.1:9999/callback", "state123", "challenge456") + + parsed, err := url.Parse(u) + if err != nil { + t.Fatalf("invalid URL: %v", err) + } + q := parsed.Query() + + checks := map[string]string{ + "response_type": "code", + "client_id": "my-client", + "redirect_uri": "http://127.0.0.1:9999/callback", + "state": "state123", + "code_challenge": "challenge456", + "code_challenge_method": "S256", + "scope": "openid email profile", + } + for k, want := range checks { + if got := q.Get(k); got != want { + t.Errorf("param %q: got %q want %q", k, got, want) + } + } + + if !strings.HasPrefix(u, "https://sso.example.com/") { + t.Errorf("URL should be rooted at issuer, got %s", u) + } +} + +func TestTokenEndpoint(t *testing.T) { + cases := []struct{ issuer, want string }{ + {"https://sso.example.com", "https://sso.example.com/protocol/openid-connect/token"}, + {"https://sso.example.com/", "https://sso.example.com/protocol/openid-connect/token"}, + } + for _, tc := range cases { + if got := tokenEndpoint(tc.issuer); got != tc.want { + t.Errorf("tokenEndpoint(%q) = %q, want %q", tc.issuer, got, tc.want) + } + } +} + +func TestParseTokenResponse(t *testing.T) { + t.Run("success", func(t *testing.T) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + rec.WriteString(`{"access_token":"acc","refresh_token":"ref","expires_in":300}`) + tr, err := parseTokenResponse(rec.Result()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tr.AccessToken != "acc" || tr.RefreshToken != "ref" || tr.ExpiresIn != 300 { + t.Errorf("unexpected token response: %+v", tr) + } + }) + + t.Run("non-200", func(t *testing.T) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusUnauthorized) + rec.WriteString(`{"error":"invalid_client"}`) + _, err := parseTokenResponse(rec.Result()) + if err == nil { + t.Fatal("expected error for non-200 status") + } + }) + + t.Run("malformed json", func(t *testing.T) { + rec := httptest.NewRecorder() + rec.WriteHeader(http.StatusOK) + rec.WriteString(`not-json`) + _, err := parseTokenResponse(rec.Result()) + if err == nil { + t.Fatal("expected error for malformed JSON") + } + }) +} diff --git a/components/cli/pkg/auth/device.go b/components/cli/pkg/auth/device.go new file mode 100644 index 000000000..e30810875 --- /dev/null +++ b/components/cli/pkg/auth/device.go @@ -0,0 +1,139 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +type deviceTokenError struct { + Code string + Description string +} + +func (e *deviceTokenError) Error() string { + if e.Description != "" { + return e.Code + ": " + e.Description + } + return e.Code +} + +type deviceAuthResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// DeviceFlow performs an OAuth2 device authorization grant. +// It prints a verification URL and user code, then polls until the user +// completes authentication or the code expires. +func DeviceFlow(issuerURL, clientID string, insecure bool) (TokenResponse, error) { + deviceURL := strings.TrimRight(issuerURL, "/") + "/protocol/openid-connect/auth/device" + client := newHTTPClient(insecure) + + resp, err := client.PostForm(deviceURL, url.Values{ + "client_id": {clientID}, + "scope": {"openid email profile"}, + }) + if err != nil { + return TokenResponse{}, fmt.Errorf("device authorization request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return TokenResponse{}, fmt.Errorf("reading device auth response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return TokenResponse{}, fmt.Errorf("device authorization failed (%d): %s", resp.StatusCode, body) + } + + var dar deviceAuthResponse + if err := json.Unmarshal(body, &dar); err != nil { + return TokenResponse{}, fmt.Errorf("parsing device auth response: %w", err) + } + + if dar.VerificationURIComplete != "" { + fmt.Fprintf(os.Stderr, "\nTo complete login, open:\n %s\n\n", dar.VerificationURIComplete) + } else { + fmt.Fprintf(os.Stderr, "\nGo to %s and enter code: %s\n\n", dar.VerificationURI, dar.UserCode) + } + fmt.Fprintf(os.Stderr, "Waiting for authentication") + + interval := time.Duration(dar.Interval) * time.Second + if interval < time.Second { + interval = 5 * time.Second + } + expiresIn := dar.ExpiresIn + if expiresIn <= 0 { + expiresIn = 300 + } + deadline := time.Now().Add(time.Duration(expiresIn) * time.Second) + + for time.Now().Before(deadline) { + time.Sleep(interval) + fmt.Fprintf(os.Stderr, ".") + + tr, err := pollDeviceToken(client, issuerURL, clientID, dar.DeviceCode) + if err == nil { + fmt.Fprintf(os.Stderr, "\n") + return tr, nil + } + + var tokenErr *deviceTokenError + if errors.As(err, &tokenErr) { + if tokenErr.Code == "authorization_pending" { + continue + } + if tokenErr.Code == "slow_down" { + interval += 5 * time.Second + continue + } + } + + fmt.Fprintf(os.Stderr, "\n") + return TokenResponse{}, err + } + + return TokenResponse{}, fmt.Errorf("device authorization timed out") +} + +func pollDeviceToken(client *http.Client, issuerURL, clientID, deviceCode string) (TokenResponse, error) { + resp, err := client.PostForm(tokenEndpoint(issuerURL), url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "client_id": {clientID}, + "device_code": {deviceCode}, + }) + if err != nil { + return TokenResponse{}, fmt.Errorf("device token poll: %w", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + var errResp struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + } + if jsonErr := json.Unmarshal(body, &errResp); jsonErr == nil && errResp.Error != "" { + return TokenResponse{}, &deviceTokenError{Code: errResp.Error, Description: errResp.ErrorDescription} + } + return TokenResponse{}, fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, body) + } + + var tr TokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return TokenResponse{}, fmt.Errorf("parsing token response: %w", err) + } + return tr, nil +} diff --git a/components/cli/pkg/auth/pkce.go b/components/cli/pkg/auth/pkce.go new file mode 100644 index 000000000..48504d820 --- /dev/null +++ b/components/cli/pkg/auth/pkce.go @@ -0,0 +1,144 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "fmt" + "html" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +// BrowserPKCE performs an OAuth2 authorization code flow with PKCE. +// It starts a local callback server, opens a browser to the authorization URL, +// waits for the redirect, and exchanges the code for tokens. +func BrowserPKCE(issuerURL, clientID string, insecure bool) (TokenResponse, error) { + verifier, challenge, err := generatePKCE() + if err != nil { + return TokenResponse{}, err + } + + stateBytes := make([]byte, 16) + if _, err := rand.Read(stateBytes); err != nil { + return TokenResponse{}, fmt.Errorf("generating state: %w", err) + } + state := base64.RawURLEncoding.EncodeToString(stateBytes) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return TokenResponse{}, fmt.Errorf("starting callback listener: %w", err) + } + + port := ln.Addr().(*net.TCPAddr).Port + callbackURL := fmt.Sprintf("http://127.0.0.1:%d/callback", port) + + codeCh := make(chan string, 1) + errCh := make(chan error, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("state") != state { + http.Error(w, "state mismatch", http.StatusBadRequest) + errCh <- fmt.Errorf("state mismatch in callback") + return + } + if errParam := r.URL.Query().Get("error"); errParam != "" { + desc := r.URL.Query().Get("error_description") + fmt.Fprintf(w, "

Login failed

%s: %s

You may close this window.

", + html.EscapeString(errParam), html.EscapeString(desc)) + errCh <- fmt.Errorf("authorization error: %s - %s", errParam, desc) + return + } + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "missing code", http.StatusBadRequest) + errCh <- fmt.Errorf("missing code in callback") + return + } + fmt.Fprintf(w, "

Login successful

You may close this window and return to the terminal.

") + codeCh <- code + }) + + srv := &http.Server{ + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + go srv.Serve(ln) //nolint:errcheck + + authURL := buildAuthURL(issuerURL, clientID, callbackURL, state, challenge) + fmt.Fprintf(os.Stderr, "Opening browser for authentication...\n") + fmt.Fprintf(os.Stderr, "If the browser does not open, visit:\n %s\n\n", authURL) + openBrowser(authURL) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + defer srv.Shutdown(ctx) //nolint:errcheck + + var code string + select { + case code = <-codeCh: + case err := <-errCh: + return TokenResponse{}, err + case <-ctx.Done(): + return TokenResponse{}, fmt.Errorf("login timed out after 5 minutes") + } + + resp, err := newHTTPClient(insecure).PostForm(tokenEndpoint(issuerURL), url.Values{ + "grant_type": {"authorization_code"}, + "client_id": {clientID}, + "code": {code}, + "redirect_uri": {callbackURL}, + "code_verifier": {verifier}, + }) + if err != nil { + return TokenResponse{}, fmt.Errorf("token exchange: %w", err) + } + defer resp.Body.Close() + return parseTokenResponse(resp) +} + +func generatePKCE() (verifier, challenge string, err error) { + buf := make([]byte, 32) + if _, err = rand.Read(buf); err != nil { + return "", "", fmt.Errorf("generating code verifier: %w", err) + } + verifier = base64.RawURLEncoding.EncodeToString(buf) + sum := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(sum[:]) + return verifier, challenge, nil +} + +func buildAuthURL(issuerURL, clientID, redirectURI, state, codeChallenge string) string { + base := strings.TrimRight(issuerURL, "/") + "/protocol/openid-connect/auth" + params := url.Values{ + "response_type": {"code"}, + "client_id": {clientID}, + "redirect_uri": {redirectURI}, + "state": {state}, + "scope": {"openid email profile"}, + "code_challenge": {codeChallenge}, + "code_challenge_method": {"S256"}, + } + return base + "?" + params.Encode() +} + +func openBrowser(u string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", u) + case "windows": + cmd = exec.Command("cmd", "/c", "start", u) + default: + cmd = exec.Command("xdg-open", u) + } + _ = cmd.Start() +} diff --git a/components/cli/pkg/config/config.go b/components/cli/pkg/config/config.go index 0047a893c..d121a79e9 100644 --- a/components/cli/pkg/config/config.go +++ b/components/cli/pkg/config/config.go @@ -8,10 +8,13 @@ import ( ) type Config struct { - AccessToken string `json:"access_token,omitempty"` - URL string `json:"url,omitempty"` - Insecure bool `json:"insecure,omitempty"` - Pager string `json:"pager,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + IssuerURL string `json:"issuer_url,omitempty"` + ClientID string `json:"client_id,omitempty"` + URL string `json:"url,omitempty"` + Insecure bool `json:"insecure,omitempty"` + Pager string `json:"pager,omitempty"` } func Load() (cfg *Config, err error) { @@ -106,6 +109,9 @@ func (c *Config) Armed() (armed bool, reason string) { func (c *Config) Disarm() { c.AccessToken = "" + c.RefreshToken = "" + c.IssuerURL = "" + c.ClientID = "" c.URL = "" c.Insecure = false } diff --git a/components/cli/pkg/config/refresh.go b/components/cli/pkg/config/refresh.go new file mode 100644 index 000000000..8e91626ff --- /dev/null +++ b/components/cli/pkg/config/refresh.go @@ -0,0 +1,33 @@ +package config + +import ( + "fmt" + "os" + + "github.com/openshift-online/hypershell/components/cli/pkg/auth" +) + +// EnsureFreshToken refreshes the access token if it is expired and a refresh +// token is available. The Config is updated in place and persisted. Save +// failures are logged to stderr but not returned as errors. +func EnsureFreshToken(cfg *Config) error { + if cfg.RefreshToken == "" || cfg.IssuerURL == "" || cfg.ClientID == "" { + return nil + } + expired, checkErr := TokenExpired(cfg.AccessToken) + if checkErr == nil && !expired { + return nil + } + tr, refreshErr := auth.Refresh(cfg.IssuerURL, cfg.ClientID, cfg.RefreshToken, cfg.Insecure) + if refreshErr != nil { + return fmt.Errorf("session expired and token refresh failed: %w - run 'hsctl login' to authenticate", refreshErr) + } + cfg.AccessToken = tr.AccessToken + if tr.RefreshToken != "" { + cfg.RefreshToken = tr.RefreshToken + } + if saveErr := Save(cfg); saveErr != nil { + fmt.Fprintf(os.Stderr, "Warning: could not persist refreshed token: %v\n", saveErr) + } + return nil +} diff --git a/components/cli/pkg/connection/connection.go b/components/cli/pkg/connection/connection.go index e4df95ab1..fdd40d7d4 100644 --- a/components/cli/pkg/connection/connection.go +++ b/components/cli/pkg/connection/connection.go @@ -50,6 +50,10 @@ func (b *ConnectionBuilder) Build() (result *Connection, err error) { return } + if err = config.EnsureFreshToken(b.cfg); err != nil { + return + } + transport := &http.Transport{} if b.cfg.Insecure { transport.TLSClientConfig = &tls.Config{ diff --git a/components/pr-test/e2e-openshell-roks.sh b/components/pr-test/e2e-openshell-roks.sh index 651f4dce6..83b9eef8c 100755 --- a/components/pr-test/e2e-openshell-roks.sh +++ b/components/pr-test/e2e-openshell-roks.sh @@ -30,7 +30,7 @@ CLI="${OC:-oc}" # The system /bin/openshell on some hosts is 0.0.55 and lacks the `workspace` # subcommand, so default to the user-local install that has it. OPENSHELL="${OPENSHELL_BIN:-$HOME/.local/bin/openshell}" -HSCTL="${HSCTL_BIN:-/home/mturansk/projects/bin/hsctl}" +HSCTL="${HSCTL_BIN:-hsctl}" HS_NAMESPACE="${HYPERSHELL_NAMESPACE:-hypershell}" GW_NAMESPACE="" GW_NAME="${GATEWAY_NAME:-e2e-oidc-gw}" @@ -102,9 +102,8 @@ fail_test() { } # delete_gateway -# Deletes a gateway by id via the REST API. hsctl exposes no `delete` subcommand -# (only create/get/list/login), so both cleanup and stale-gateway -# re-provisioning must call DELETE /api/hypershell/v1/gateways/{id} directly. +# Deletes a gateway by id via the REST API. Used when hsctl is unavailable or +# unauthenticated; hsctl delete gateway --yes is preferred after login. # The control plane then tears down the tenant namespace. Returns 0 on 2xx. delete_gateway() { local id="$1" code @@ -309,9 +308,13 @@ LOGIN_TOKEN=$(curl -sk -X POST "${TOKEN_ENDPOINT}" \ -d "username=${OIDC_USERNAME}" -d "password=${OIDC_PASSWORD}" 2>/dev/null \ | python3 -c "import json,sys; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true) if [[ -n "$LOGIN_TOKEN" ]]; then - echo "$LOGIN_TOKEN" | "${HSCTL}" login --url "https://${API_HOST}" --token-file /dev/stdin --insecure &>/dev/null || true + echo "$LOGIN_TOKEN" | "${HSCTL}" login --url "https://${API_HOST}" --token-file /dev/stdin --insecure || { + red "ERROR: hsctl login failed" + exit 1 + } else - "${HSCTL}" login --url "https://${API_HOST}" --insecure &>/dev/null || true + red "ERROR: could not acquire management API token from Keycloak" + exit 1 fi echo "" diff --git a/components/pr-test/e2e-openshell.sh b/components/pr-test/e2e-openshell.sh index f3d6c1e91..8d5d108b0 100755 --- a/components/pr-test/e2e-openshell.sh +++ b/components/pr-test/e2e-openshell.sh @@ -95,15 +95,29 @@ if [[ -z "$API_HOST" ]]; then exit 1 fi -# Login to hypershell CLI (no auth mode for stage/dev) -dim "Logging in to hypershell CLI..." -"${HSCTL}" login "https://${API_HOST}" --insecure-skip-tls-verify &>/dev/null || true +# Log hsctl in via a management-plane token (password grant on hypershell-frontend). +# Interactive OIDC (hypershell-cli) needs a browser/device flow and is not suitable here. KC_HOST=$($CLI get route keycloak -n "$KC_NAMESPACE" -o jsonpath='{.spec.host}' 2>/dev/null || true) if [[ -z "$KC_HOST" ]]; then red "ERROR: Keycloak route not found in namespace ${KC_NAMESPACE}" exit 1 fi OIDC_ISSUER="https://${KC_HOST}/realms/hypershell" +TOKEN_ENDPOINT="${OIDC_ISSUER}/protocol/openid-connect/token" + +dim "Logging in to hypershell CLI..." +LOGIN_TOKEN=$(curl -sk -X POST "${TOKEN_ENDPOINT}" \ + -d "grant_type=password" -d "client_id=${OIDC_CLIENT_ID}" \ + -d "username=${OIDC_USERNAME}" -d "password=${OIDC_PASSWORD}" 2>/dev/null \ + | python3 -c "import json,sys; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true) +if [[ -z "$LOGIN_TOKEN" ]]; then + red "ERROR: could not acquire management API token from Keycloak" + exit 1 +fi +echo "$LOGIN_TOKEN" | "${HSCTL}" login --url "https://${API_HOST}" --token-file /dev/stdin --insecure || { + red "ERROR: hsctl login failed" + exit 1 +} echo "" bold "HyperShell OpenShell Gateway End-to-End Test" diff --git a/deploy/base/keycloak/keycloak.yaml b/deploy/base/keycloak/keycloak.yaml index c4d494303..174b30759 100644 --- a/deploy/base/keycloak/keycloak.yaml +++ b/deploy/base/keycloak/keycloak.yaml @@ -191,6 +191,74 @@ data: } ] }, + { + "clientId": "hypershell-cli", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "fullScopeAllowed": true, + "attributes": { + "oauth2.device.authorization.grant.enabled": "true", + "pkce.code.challenge.method": "S256" + }, + "redirectUris": ["http://127.0.0.1:*"], + "webOrigins": [], + "defaultClientScopes": ["openid", "email", "profile"], + "protocolMappers": [ + { + "name": "audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "hypershell-frontend", + "id.token.claim": "false", + "access.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "sub", + "protocol": "openid-connect", + "protocolMapper": "oidc-sub-mapper", + "consentRequired": false, + "config": { + "access.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "realm-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "claim.name": "groups", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true", + "introspection.token.claim": "true" + } + }, + { + "name": "realm-access-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": false, + "config": { + "multivalued": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "id.token.claim": "false", + "access.token.claim": "true", + "userinfo.token.claim": "false", + "introspection.token.claim": "true" + } + } + ] + }, { "clientId": "hypershell-provisioner", "enabled": true, diff --git a/specs/platform/data-model.spec.md b/specs/platform/data-model.spec.md index 81c7f6d6c..24dd8287b 100644 --- a/specs/platform/data-model.spec.md +++ b/specs/platform/data-model.spec.md @@ -309,11 +309,13 @@ The `hsctl` CLI mirrors the REST API 1-for-1. Every REST operation has a corresp #### Auth & Context -| Operation | `hypershell` Command | Status | +| Operation | `hsctl` Command | Status | |---|---|---| -| Authenticate | `hsctl login [SERVER_URL] --token ` | ✅ implemented | +| Authenticate (browser PKCE) | `hsctl login --url --issuer-url ` | ✅ implemented | +| Authenticate (device flow) | `hsctl login --no-browser --url --issuer-url ` | ✅ implemented | +| Authenticate (static token) | `hsctl login --token-file --url ` | ✅ implemented | | Log out | `hsctl logout` | ✅ implemented | -| Identity | `hsctl whoami` | 🔲 planned | +| Identity | `hsctl whoami` | ✅ implemented | | Config get | `hsctl config get ` | ✅ implemented | | Config set | `hsctl config set ` | ✅ implemented | @@ -429,6 +431,22 @@ cat gateway.yaml | hsctl apply -f - | `-o wide` | Wide table output | | `--limit ` | Max items to return (default: 100) | +### Authentication Context + +The CLI stores credentials and context in `~/.config/hypershell/config.json` (or `HYPERSHELL_CONFIG` env var override). The config holds the API server URL, OIDC issuer URL, client ID, access token, and refresh token. + +```sh +# Interactive login (opens browser via PKCE) +hsctl login --url https://api.example.com --issuer-url https://keycloak.example.com/realms/hypershell + +# Headless login (device flow -- prints a URL and code, polls until complete) +hsctl login --no-browser --url https://api.example.com --issuer-url https://keycloak.example.com/realms/hypershell + +hsctl list gateways +hsctl create gateway --name api-gateway --cluster-id eks-1 --release-id v1.0 --database-id db-1 +``` + + ## Design Decisions | Decision | Rationale | diff --git a/specs/platform/local-development.spec.md b/specs/platform/local-development.spec.md index 08b3b871c..61e8ef29a 100644 --- a/specs/platform/local-development.spec.md +++ b/specs/platform/local-development.spec.md @@ -105,7 +105,8 @@ The Kind cluster Keycloak instance serves as the local equivalent of the downstr | Setting | Value | |---------|-------| | Realm | `hypershell` | -| Client | `hypershell-frontend` (public, standard flow + direct access grants) | +| Client | `hypershell-frontend` (public, standard flow + direct access grants, used by web console BFF) | +| CLI client | `hypershell-cli` (public, standard flow + device authorization grant, used by `hsctl login`) | | Provisioner client | `hypershell-provisioner` (confidential, service account with `manage-clients` and `manage-users` roles) | | Admin role | `hypershell-admins` | | User role | `hypershell-users` | diff --git a/specs/platform/oidc-integration.spec.md b/specs/platform/oidc-integration.spec.md index 04708f6fb..a6bb4d130 100644 --- a/specs/platform/oidc-integration.spec.md +++ b/specs/platform/oidc-integration.spec.md @@ -223,6 +223,28 @@ Protocol mappers (retained as-is): The BFF validates the `aud` claim matches `OIDC_CLIENT_ID` (i.e., `hypershell-frontend`). +### `hypershell-cli` Client + +The `hypershell-cli` client is used by the `hsctl` CLI for interactive user authentication. It is a public client (no client secret). It supports both browser-based Authorization Code + PKCE and Device Authorization Grant (for headless/SSH environments). + +| Setting | Value | Rationale | +|---------|-------|-----------| +| `publicClient` | `true` | No client secret; PKCE secures the browser flow | +| `standardFlowEnabled` | `true` | Authorization Code + PKCE for browser login | +| `directAccessGrantsEnabled` | `false` | CLI does not use password grant | +| `oauth2.device.authorization.grant.enabled` | `true` | Device flow for headless environments | +| `pkce.code.challenge.method` | `S256` | Reject non-PKCE authorization code exchanges | +| `redirectUris` | `http://127.0.0.1:*` | Ephemeral localhost callback port for PKCE | +| `webOrigins` | `[]` | Loopback-only redirect; no browser-origin CORS calls to Keycloak | +| `defaultClientScopes` | `openid`, `email`, `profile` | Standard OIDC scopes | + +Protocol mappers (same as `hypershell-frontend`): +- **Audience mapper** -- includes `hypershell-frontend` in the `aud` claim so the API server's JWT validator accepts CLI-issued tokens +- **Sub mapper** -- includes `sub` in the access token +- **Realm roles mapper** -- maps realm roles to the `groups` claim + +The CLI stores the access token, refresh token, issuer URL, and client ID in `~/.config/hypershell/config.json` (or `~/.hypershell.json` if the legacy path exists). On each command the CLI refreshes the access token eagerly if it is expired and a refresh token is available. + ### `hypershell-provisioner` Client The `hypershell-provisioner` client is a confidential service account used for automated Keycloak administration (e.g., per-gateway client provisioning). It is not used by the BFF or browser. See `openshell-gateway-credentials.spec.md` for its role in gateway OIDC provisioning. @@ -555,7 +577,8 @@ The `hypershell-frontend` client SHALL be configured with deployment-appropriate | OIDC always-on in Kind | OIDC is the only supported authentication method. Running without it masks integration issues and diverges from production. | | `hypershell-frontend` client reused for BFF | The client already exists with the correct audience mapper and role claims. Creating a separate BFF client would duplicate configuration and require additional Keycloak provisioning. PKCE secures the public client adequately for a BFF. | | Restrict `redirectUris` from wildcard | Wildcard redirect URIs are an OAuth security anti-pattern (open redirect). Restricting to the deployment's console origin prevents authorization code interception. | -| `directAccessGrantsEnabled` retained | Password grant is used by CLI tooling and curl-based testing in local dev. Disabling it would break the documented CLI authentication flow in `openshell-gateway-oidc.spec.md`. | +| `directAccessGrantsEnabled` retained on `hypershell-frontend` | Password grant is retained on the web console client for `curl`-based testing and E2E test token acquisition. The `hypershell-cli` client disables it -- the CLI now uses Authorization Code + PKCE or Device Authorization Grant. | +| Separate `hypershell-cli` client instead of reusing `hypershell-frontend` | The CLI's redirect URI (`http://127.0.0.1:*`) and the BFF's redirect URIs (HTTPS console origin) are incompatible on a single client. A separate public client also lets device flow be enabled for CLI only, without exposing it on the BFF client. | | Session secret as deployment configuration | Each deployment generates or provisions its own encryption key. In Kind, `openssl rand -hex 32` during `kind-up` stored in a Kubernetes Secret. In production, provisioned via the deployment's secret management system. | | Chunked session cookies (`session` + `session_tok`) | Storing the access token, refresh token, and ID token together would push a single encrypted cookie past the ~4KB browser limit (a limit this project has hit before). Splitting a hot-path identity cookie from a refresh cookie keeps each well under the limit and keeps the per-request read small. Both use the same key and are set atomically via `@fastify/secure-session` named sessions. | | In-process single-flight refresh instead of a shared lock | The BFF is stateless (encrypted cookies, no shared store), so a distributed lock would add infrastructure the design deliberately avoids. Coalescing concurrent refreshes within an instance handles the common case (an SPA firing parallel requests), and a failed refresh falls back to re-authentication, which is safe. Session affinity MAY be enabled if cross-replica refresh races become material. | diff --git a/specs/platform/openshell-gateway-oidc.spec.md b/specs/platform/openshell-gateway-oidc.spec.md index b91f0cc78..f4d7d8908 100644 --- a/specs/platform/openshell-gateway-oidc.spec.md +++ b/specs/platform/openshell-gateway-oidc.spec.md @@ -154,13 +154,15 @@ The GatewayReconciler SHALL detect changes to OIDC configuration and trigger a g ## CLI Authentication Flow ```bash -# Interactive users authenticate the public per-gateway client through -# Authorization Code + PKCE or Device Authorization. Automation uses a -# gateway-scoped OpenShellGatewayServiceAccount and performs Client Credentials on demand. -# It does not use or store a human username and password. +# Interactive users authenticate via Authorization Code + PKCE (browser) or +# Device Authorization (headless). Automation uses a gateway-scoped +# OpenShellGatewayServiceAccount with Client Credentials. No passwords are stored. -# 1. Login to hsctl with the user's management-plane token -hsctl login --token "$TOKEN" --url "$API_URL" --insecure-skip-tls-verify +# 1a. Login to hsctl -- browser PKCE (opens browser, exchanges code, stores tokens) +hsctl login --url "$API_URL" --issuer-url "$OIDC_ISSUER" --insecure + +# 1b. Login to hsctl -- device flow (headless / SSH; prints URL + user code, polls until done) +hsctl login --no-browser --url "$API_URL" --issuer-url "$OIDC_ISSUER" --insecure # 2. Register the OpenShell CLI with the gateway hsctl gateway setup-cli --gateway-url "$GATEWAY_URL"