Skip to content
Open
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
4 changes: 4 additions & 0 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ func runLogin(cmd *cobra.Command, args []string) error {
}
}

if err := cmdutil.ValidateAPIOverride(); err != nil {
return err
}

tokenURL := tokenURLForEnvironment(environment)

// Step 1: Verify credentials
Expand Down
9 changes: 9 additions & 0 deletions internal/auth/oauth.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package auth

import (
"bytes"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -82,6 +83,14 @@ func (tm *TokenManager) fetchToken() (string, error) {
return "", fmt.Errorf("token exchange failed (HTTP %d): %s", resp.StatusCode, string(body))
}

// A 2xx whose body isn't a JSON object means we reached something other than
// the identity service — typically a proxy interstitial or a misrouted host
// (e.g. an environment pointed at the wrong place). Surface that plainly
// rather than a cryptic JSON parse error on the leading '<'.
if trimmed := bytes.TrimSpace(body); len(trimmed) == 0 || trimmed[0] != '{' {
return "", fmt.Errorf("token endpoint returned a non-JSON response (HTTP %d) — you may be behind a proxy or pointed at the wrong host; check your environment and BW_API_URL", resp.StatusCode)
}

var tr tokenResponse
if err := json.Unmarshal(body, &tr); err != nil {
return "", fmt.Errorf("parsing token response: %w", err)
Expand Down
24 changes: 24 additions & 0 deletions internal/auth/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,27 @@ func TestTokenManager_ThreadSafe(t *testing.T) {
t.Errorf("concurrent GetToken() error: %v", err)
}
}

// TestTokenManager_NonJSON2xx covers a proxy/misroute returning HTTP 200 with
// an HTML/XML body: fetchToken must fail with a legible message, not a raw
// JSON parse error on the leading '<'.
func TestTokenManager_NonJSON2xx(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("<!DOCTYPE html><html><body>Proxy login</body></html>"))
}))
defer srv.Close()

tm := NewTokenManager("client-id", "client-secret", srv.URL)
_, err := tm.GetToken()
if err == nil {
t.Fatal("expected an error for a non-JSON 2xx body, got nil")
}
if strings.Contains(err.Error(), "parsing token response") {
t.Errorf("got raw parse error, want the friendly non-JSON message: %v", err)
}
if !strings.Contains(err.Error(), "non-JSON response") {
t.Errorf("error should explain the non-JSON response, got: %v", err)
}
}
32 changes: 31 additions & 1 deletion internal/cmdutil/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmdutil

import (
"fmt"
"net/url"
"os"
"strings"

Expand Down Expand Up @@ -34,10 +35,33 @@ func resolveEnvironment(profileEnv string) (string, error) {
case "", "prod", "test", "uat":
return env, nil
default:
// A non-built-in environment is allowed only with an explicit custom
// route via BW_API_URL. Requiring the override preserves the original
// guarantee — an unrecognized value never silently resolves to prod —
// while still supporting hosts the binary does not hardcode.
if os.Getenv("BW_API_URL") != "" {
return env, nil
}
return "", fmt.Errorf("unknown environment %q (expected one of: prod, test, uat)", env)
}
}

// ValidateAPIOverride rejects a BW_API_URL that is set but missing a scheme
// (e.g. "stage.api.bandwidth.com" instead of "https://stage.api.bandwidth.com").
// A schemeless value reaches net/http as the opaque error "unsupported protocol
// scheme \"\"", which gives no hint at the cause; catch it up front with an
// actionable message. An unset BW_API_URL is valid.
func ValidateAPIOverride() error {
raw := os.Getenv("BW_API_URL")
if raw == "" {
return nil
}
if u, err := url.Parse(raw); err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("BW_API_URL must be a full URL including scheme, e.g. https://host.example.com (got %q)", raw)
}
return nil
}

// apiHostForEnvironment maps an environment name to its API host.
// Non-production environments can be overridden with BW_API_URL.
func apiHostForEnvironment(env string) string {
Expand Down Expand Up @@ -153,6 +177,9 @@ func authenticate(accountIDOverride string) (*auth.TokenManager, string, string,
return nil, "", "", err
}

if err := ValidateAPIOverride(); err != nil {
return nil, "", "", err
}
env, err := resolveEnvironment(p.Environment)
if err != nil {
return nil, "", "", err
Expand Down Expand Up @@ -227,7 +254,10 @@ func PlatformClient(accountIDOverride string) (*api.Client, string, error) {
// no test host). Worded to be accurate for any messaging command (not just
// sends). Returns "" when no warning is needed.
func messagingProdOnlyWarning(env string) string {
if env == "test" || env == "uat" {
// Warn for any non-production environment, not just test/uat: a custom
// environment (routed via BW_API_URL) still hits the prod messaging host,
// so the user must know sends are real and billable.
if env != "" && env != "prod" {
return "Bandwidth Messaging has no test environment — this request hits PRODUCTION regardless of --environment (any sends are real and billable)."
}
return ""
Expand Down
41 changes: 39 additions & 2 deletions internal/cmdutil/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,19 +92,56 @@ func TestResolveEnvironment(t *testing.T) {
t.Run("unknown env is an error (no silent prod fall-through)", func(t *testing.T) {
EnvironmentOverride = "staging"
t.Cleanup(func() { EnvironmentOverride = "" })
t.Setenv("BW_API_URL", "")
if _, err := resolveEnvironment("prod"); err == nil {
t.Error("expected error for unknown env, got nil")
}
})
t.Run("unknown env is allowed with an explicit BW_API_URL route", func(t *testing.T) {
EnvironmentOverride = "staging"
t.Cleanup(func() { EnvironmentOverride = "" })
t.Setenv("BW_API_URL", "https://custom.example.com")
got, err := resolveEnvironment("prod")
if err != nil || got != "staging" {
t.Errorf("got %q, err %v; want staging, nil (explicit route opts in)", got, err)
}
})
}

func TestValidateAPIOverride(t *testing.T) {
t.Run("unset is valid", func(t *testing.T) {
t.Setenv("BW_API_URL", "")
if err := ValidateAPIOverride(); err != nil {
t.Errorf("unset BW_API_URL should be valid, got %v", err)
}
})
for _, ok := range []string{"https://stage.api.bandwidth.com", "http://localhost:8080", "https://host.example.com/"} {
t.Run("valid "+ok, func(t *testing.T) {
t.Setenv("BW_API_URL", ok)
if err := ValidateAPIOverride(); err != nil {
t.Errorf("BW_API_URL=%q should be valid, got %v", ok, err)
}
})
}
for _, bad := range []string{"stage.api.bandwidth.com", "api.bandwidth.com/api/v1", " "} {
t.Run("invalid "+bad, func(t *testing.T) {
t.Setenv("BW_API_URL", bad)
if err := ValidateAPIOverride(); err == nil {
t.Errorf("BW_API_URL=%q should be rejected (missing scheme), got nil", bad)
}
})
}
}

func TestMessagingProdOnlyWarning(t *testing.T) {
for _, env := range []string{"test", "uat"} {
// Any non-prod environment must warn — including a custom env reached via
// BW_API_URL, which still hits the prod messaging host.
for _, env := range []string{"test", "uat", "staging"} {
if messagingProdOnlyWarning(env) == "" {
t.Errorf("expected a warning for env %q", env)
}
}
for _, env := range []string{"", "prod", "staging"} {
for _, env := range []string{"", "prod"} {
if messagingProdOnlyWarning(env) != "" {
t.Errorf("expected NO warning for env %q, got one", env)
}
Expand Down
Loading