diff --git a/go.work.sum b/go.work.sum index e64497f..52cc6ce 100644 --- a/go.work.sum +++ b/go.work.sum @@ -25,6 +25,7 @@ github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/ github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bits-and-blooms/bitset v1.24.6/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= diff --git a/modules/core/auth/login_test.go b/modules/core/auth/login_test.go index 218eae5..d1309c6 100644 --- a/modules/core/auth/login_test.go +++ b/modules/core/auth/login_test.go @@ -221,8 +221,8 @@ func TestLoginHandler_IsPty_badAPIURL(t *testing.T) { if err == nil { t.Fatal("expected error for invalid API URL") } - if !strings.Contains(err.Error(), "not a valid Harness API URL") { - t.Fatalf("error = %q, want %q", err, "not a valid Harness API URL") + if !strings.Contains(err.Error(), "not a valid URL") { + t.Fatalf("error = %q, want %q", err, "not a valid URL") } } @@ -252,10 +252,10 @@ func TestLoginHandler_configLoadError(t *testing.T) { func TestLoginHandler_nonInteractive_validateTokenCalled(t *testing.T) { // Without --no-validate, validateToken is called. Point it at a test server // that returns 401 so we can confirm the validation error is surfaced. - // NOTE: api-url must pass ValidateAPIURL (requires *.harness.io host) which - // prevents using a local httptest.Server URL here. The remaining happy-path - // branches (lines 165-198) that require a valid api-url + network are covered - // by validateToken and fetchRegistryURL unit tests below instead. + // NOTE: api-url must pass ValidateAPIURL (requires an https:// scheme) which + // prevents using a local httptest.Server URL (http://) here. The remaining + // happy-path branches (lines 165-198) that require a valid api-url + network + // are covered by validateToken and fetchRegistryURL unit tests below instead. ctx := isolatedCtx(t, map[string]any{ "api-token": validToken, // api-url empty → defaults to https://app.harness.io; validateToken will diff --git a/modules/core/auth/status.go b/modules/core/auth/status.go index b747055..fbffb46 100644 --- a/modules/core/auth/status.go +++ b/modules/core/auth/status.go @@ -144,7 +144,7 @@ func runStatusChecks(profileFlag string) statusResult { r.Status.Profile = checkResult{OK: true} // An explicit HARNESS_SSO_BASE_URL override (present during SSO login) means the - // non-standard host was deliberate, so a format mismatch is not even a warning. + // value was deliberate, so a format mismatch is not even a warning. overridden := os.Getenv(hbase.EnvSSOBaseURL) != "" apiCheck := checkAPIUrl(resolved.APIUrl, overridden) r.Status.API = apiCheck @@ -342,8 +342,8 @@ func printStatus(r statusResult) { } profileSuffix := "" - if r.TokenType == "SSO" || r.TokenType == "SAT" { - profileSuffix = fmt.Sprintf(" (%s token)", r.TokenType) + if r.TokenType != "" { + profileSuffix = fmt.Sprintf(" (%s)", r.TokenType) } if r.Source == auth.SourceEnv { add("Mode", sv(r.Status.Profile, "env vars"+profileSuffix)) @@ -475,10 +475,10 @@ func currentUserFields(u any) (email, uuid string) { } // checkAPIUrl validates the API URL's format and reachability. Reachability is the -// authoritative gate: an unreachable host is a hard failure that cascades. A non-standard -// but reachable host is a soft warning ("non-standard host") so downstream checks still run — -// unless overridden is set (an explicit HARNESS_SSO_BASE_URL override), in which case the -// deliberate host passes cleanly. +// authoritative gate: an unreachable host is a hard failure that cascades. A malformed +// but reachable URL (e.g. missing scheme, from a hand-edited HARNESS_API_URL) is a soft +// warning so downstream checks still run — unless overridden is set (an explicit +// HARNESS_SSO_BASE_URL override), in which case the deliberate value passes cleanly. func checkAPIUrl(apiURL string, overridden bool) checkResult { formatErr := auth.ValidateAPIURL(apiURL) @@ -491,7 +491,7 @@ func checkAPIUrl(apiURL string, overridden bool) checkResult { } if formatErr != nil && !overridden { - return checkResult{Warn: true, Error: "non-standard host"} + return checkResult{Warn: true, Error: "malformed API URL"} } return checkResult{OK: true} } diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index c906246..b2474a6 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "os" "regexp" "strings" @@ -221,33 +222,28 @@ func resolveProfile(name string) (*ResolvedAuth, error) { return r, nil } -var ( - hostLabelRE = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$`) - harnessHostRE = regexp.MustCompile(`^https://([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+harness\.io(/[a-z0-9_-]+)*$`) - harnessNameRE = regexp.MustCompile(`^([A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?\.)+harness\.io$`) -) +var hostLabelRE = regexp.MustCompile(`^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$`) -// NormalizeAPIURL recognizes two shorthand forms and expands them: -// - bare label (e.g. "harness0") → "https://harness0.harness.io" -// - FQDN under harness.io (e.g. "app.harness.io") → "https://app.harness.io" -// -// Any other input is returned unchanged; ValidateAPIURL will reject it. +// NormalizeAPIURL expands a bare label shorthand (e.g. "harness0" → "https://harness0.harness.io") +// and, for anything else that has no scheme, prepends "https://" (e.g. "harness.onefiserv.net" → +// "https://harness.onefiserv.net"). Input that already has a scheme is returned unchanged. func NormalizeAPIURL(s string) string { s = strings.TrimSpace(s) if hostLabelRE.MatchString(s) { return "https://" + s + ".harness.io" } - if harnessNameRE.MatchString(s) { + if !strings.Contains(s, "://") { return "https://" + s } return s } -// ValidateAPIURL returns an error if apiURL is not a valid Harness API URL -// of the form https://.harness.io (no path, no trailing slash). +// ValidateAPIURL returns an error if apiURL is not a well-formed https:// URL with a host. +// It no longer restricts the host to *.harness.io, so vanity and on-prem domains are accepted. func ValidateAPIURL(apiURL string) error { - if !harnessHostRE.MatchString(apiURL) { - return fmt.Errorf("%q is not a valid Harness API URL — expected https://.harness.io", apiURL) + u, err := url.Parse(apiURL) + if err != nil || u.Scheme != "https" || u.Host == "" { + return fmt.Errorf("%q is not a valid URL — expected an https:// URL with a host", apiURL) } return nil } diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go new file mode 100644 index 0000000..7e4f9e2 --- /dev/null +++ b/pkg/auth/auth_test.go @@ -0,0 +1,80 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package auth + +import "testing" + +func TestNormalizeAPIURL(t *testing.T) { + cases := []struct { + in string + want string + }{ + // Original *.harness.io shorthands — must keep working unchanged. + {"harness0", "https://harness0.harness.io"}, + {"qa", "https://qa.harness.io"}, + {"app.harness.io", "https://app.harness.io"}, + {"qa.harness.io", "https://qa.harness.io"}, + {"https://app.harness.io", "https://app.harness.io"}, + // New: bare vanity/on-prem host gets a scheme prepended. + {"harness.onefiserv.net", "https://harness.onefiserv.net"}, + // Already has a scheme — left alone either way. + {"https://harness.onefiserv.net", "https://harness.onefiserv.net"}, + {"http://harness.onefiserv.net", "http://harness.onefiserv.net"}, + } + for _, c := range cases { + if got := NormalizeAPIURL(c.in); got != c.want { + t.Errorf("NormalizeAPIURL(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestValidateAPIURL(t *testing.T) { + valid := []string{ + // Original standard forms. + "https://app.harness.io", + "https://qa.harness.io", + "https://harness0.harness.io", + // Vanity/on-prem domains, now also accepted. + "https://harness.onefiserv.net", + // SSO/MCP gateway URLs carry a "/cli" path segment — must still validate. + "https://mcp.harness.io/cli", + } + for _, u := range valid { + if err := ValidateAPIURL(u); err != nil { + t.Errorf("ValidateAPIURL(%q) = %v, want nil", u, err) + } + } + + invalid := []string{ + "ftp://bad.url", + "http://not-https.example.com", + "not a url", + "", + } + for _, u := range invalid { + if err := ValidateAPIURL(u); err == nil { + t.Errorf("ValidateAPIURL(%q) = nil, want error", u) + } + } +} + +// TestNormalizeThenValidateAPIURL exercises the exact pipeline login.go and the +// wizard use: raw user input → NormalizeAPIURL → ValidateAPIURL. It confirms +// every shorthand a user could type for the standard Harness SaaS host still +// resolves to a URL that passes validation, alongside the new vanity/on-prem case. +func TestNormalizeThenValidateAPIURL(t *testing.T) { + inputs := []string{ + "harness0", + "app.harness.io", + "https://app.harness.io", + "harness.onefiserv.net", + "https://harness.onefiserv.net", + } + for _, in := range inputs { + normalized := NormalizeAPIURL(in) + if err := ValidateAPIURL(normalized); err != nil { + t.Errorf("NormalizeAPIURL(%q) = %q, which ValidateAPIURL rejected: %v", in, normalized, err) + } + } +}