From 269f5a8a1997171e3009d3a90d4549531502750a Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Fri, 29 May 2026 08:20:36 +0200 Subject: [PATCH] fix(auth): tolerate full hostnames in --account input Telemetry showed 9 distinct users got a TLS x509 error during "dhq auth login" because they typed their full account hostname (e.g. "uniportal.deployhq.com") at the "Account subdomain:" prompt. The CLI naively appended ".deployhq.com" and built a request to https://uniportal.deployhq.com.deployhq.com/projects, which is covered by the *.deployhq.com cert only at depth 1, hence the verification failure. - Add a normalizeAccount helper that strips scheme, paths, and a trailing "." suffix, then apply it in runAuthLogin after collecting --account (from either flag or prompt) so the stored value is canonical. - Defensively strip ".deployhq.com" in sdk.New so existing users who already stored a polluted account get healed on next run without needing to re-login. - Mirror the strip in config.BaseURL for staging/self-hosted hosts. - Clarify the interactive prompt with the example from the flag help so users know to enter the bare subdomain. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/commands/auth.go | 26 +++++++++++++++++++++++++- internal/commands/auth_test.go | 32 ++++++++++++++++++++++++++++++++ internal/config/config.go | 2 ++ pkg/sdk/client.go | 4 ++++ 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 internal/commands/auth_test.go diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 48f9b5c..8c49536 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -67,6 +67,29 @@ func newAuthLoginCmd() *cobra.Command { return cmd } +// normalizeAccount accepts user-supplied account values in any of these forms +// and returns just the subdomain: +// +// mycompany +// mycompany.deployhq.com +// https://mycompany.deployhq.com/ +// +// host is the configured DeployHQ host (defaults to "deployhq.com") so the +// same logic works for staging/self-hosted setups. +func normalizeAccount(input, host string) string { + s := strings.TrimSpace(input) + s = strings.TrimPrefix(s, "https://") + s = strings.TrimPrefix(s, "http://") + if i := strings.IndexAny(s, "/?#"); i >= 0 { + s = s[:i] + } + if host == "" { + host = "deployhq.com" + } + s = strings.TrimSuffix(s, "."+host) + return s +} + func runAuthLogin(opts *AuthLoginOptions) error { env := cliCtx.Envelope reader := bufio.NewReader(os.Stdin) @@ -79,10 +102,11 @@ func runAuthLogin(opts *AuthLoginOptions) error { Hint: "Use --account flag", } } - fmt.Fprint(env.Stderr, "Account subdomain: ") //nolint:errcheck // best-effort stderr + fmt.Fprint(env.Stderr, "Account subdomain (e.g. 'mycompany' for mycompany.deployhq.com): ") //nolint:errcheck // best-effort stderr input, _ := reader.ReadString('\n') opts.Account = strings.TrimSpace(input) } + opts.Account = normalizeAccount(opts.Account, cliCtx.Config.Host) if opts.Email == "" { if env.NonInteractive { diff --git a/internal/commands/auth_test.go b/internal/commands/auth_test.go new file mode 100644 index 0000000..774047e --- /dev/null +++ b/internal/commands/auth_test.go @@ -0,0 +1,32 @@ +package commands + +import "testing" + +func TestNormalizeAccount(t *testing.T) { + tests := []struct { + name string + input string + host string + want string + }{ + {"bare subdomain", "mycompany", "", "mycompany"}, + {"full hostname", "mycompany.deployhq.com", "", "mycompany"}, + {"hostname with scheme", "https://mycompany.deployhq.com", "", "mycompany"}, + {"hostname with scheme and trailing slash", "https://mycompany.deployhq.com/", "", "mycompany"}, + {"hostname with path", "https://mycompany.deployhq.com/projects", "", "mycompany"}, + {"http scheme", "http://mycompany.deployhq.com", "", "mycompany"}, + {"surrounding whitespace", " mycompany.deployhq.com ", "", "mycompany"}, + {"custom host", "mycompany.deployhq.dev", "deployhq.dev", "mycompany"}, + {"custom host with default host suffix is not stripped", "mycompany.deployhq.com", "deployhq.dev", "mycompany.deployhq.com"}, + {"hyphenated subdomain", "my-company.deployhq.com", "", "my-company"}, + {"query string ignored", "mycompany.deployhq.com?foo=bar", "", "mycompany"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := normalizeAccount(tt.input, tt.host) + if got != tt.want { + t.Errorf("normalizeAccount(%q, %q) = %q, want %q", tt.input, tt.host, got, tt.want) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 3b2c131..00553be 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -126,6 +126,8 @@ func (cfg *Config) BaseURL(account string) string { if cfg.Host == "" { return "" } + // Tolerate accounts saved with the host suffix already attached. + account = strings.TrimSuffix(account, "."+cfg.Host) return fmt.Sprintf("https://%s.%s", account, cfg.Host) } diff --git a/pkg/sdk/client.go b/pkg/sdk/client.go index 87f9b6d..fcaa263 100644 --- a/pkg/sdk/client.go +++ b/pkg/sdk/client.go @@ -76,6 +76,10 @@ func New(account, email, apiKey string, opts ...Option) (*Client, error) { return nil, fmt.Errorf("deployhq: api key is required") } + // Tolerate users passing a full hostname (e.g. "mycompany.deployhq.com") + // instead of just the subdomain — otherwise we'd build .deployhq.com.deployhq.com. + account = strings.TrimSuffix(account, ".deployhq.com") + base, err := url.Parse(fmt.Sprintf("https://%s.deployhq.com", account)) if err != nil { return nil, fmt.Errorf("deployhq: invalid account name %q: %w", account, err)