Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go.work.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
12 changes: 6 additions & 6 deletions modules/core/auth/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions modules/core/auth/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)

Expand All @@ -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}
}
Expand Down
26 changes: 11 additions & 15 deletions pkg/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"strings"
Expand Down Expand Up @@ -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://<host>.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://<host>.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
}
Expand Down
80 changes: 80 additions & 0 deletions pkg/auth/auth_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}