From a7d8489976a162b1f343f82943fa1ae14e57ae8b Mon Sep 17 00:00:00 2001 From: Jordan English <6087717+jordanenglish@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:57:03 -0400 Subject: [PATCH 1/2] auth status: explain why authentication failed instead of a bare "Unauthorized" `auth status` collapsed three distinct failures into one opaque "Unauthorized for " line: no token configured, a token the server rejected (401), and the request never reaching the server (network/DNS, or some other HTTP status). Users could not tell an expired token from a connectivity blip, and the message suggested no remedy. Classify the /account/details error with errors.As on the go-tfe *APIError (it arrives wrapped in a *url.Error) and print a cause-specific, actionable message: - no token -> run `tfctl auth login` - rejected (401) -> token expired/revoked (auth login), or an SSO session that has lapsed on SSO-protected Terraform Enterprise (re-authenticate in the browser) - other status -> " returned HTTP N" (not an auth problem) - unreachable -> "could not reach : " JSON/agent output gains a machine-readable `reason` field so scripts can branch on the cause. --- .../ENHANCEMENTS-20260717-015613.yaml | 3 + internal/commands/auth/status.go | 83 +++++++++++++++++-- internal/commands/auth/status_test.go | 70 +++++++++++++++- 3 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 .changes/unreleased/ENHANCEMENTS-20260717-015613.yaml diff --git a/.changes/unreleased/ENHANCEMENTS-20260717-015613.yaml b/.changes/unreleased/ENHANCEMENTS-20260717-015613.yaml new file mode 100644 index 0000000..7f721c7 --- /dev/null +++ b/.changes/unreleased/ENHANCEMENTS-20260717-015613.yaml @@ -0,0 +1,3 @@ +kind: ENHANCEMENTS +body: '`tfctl auth status` now explains why authentication failed instead of printing a bare "Unauthorized". It distinguishes a missing token, a token the server rejected (401), and a request that never reached the server, and prints the matching remedy. On SSO-protected Terraform Enterprise the 401 message also calls out a lapsed browser SSO session. JSON and agent output gain a machine-readable `reason` field.' +time: 2026-07-17T01:56:13-04:00 diff --git a/internal/commands/auth/status.go b/internal/commands/auth/status.go index 5df4571..029af7b 100644 --- a/internal/commands/auth/status.go +++ b/internal/commands/auth/status.go @@ -6,11 +6,15 @@ package auth import ( "context" "encoding/json" + "errors" "fmt" "io" + "net/http" "strings" "time" + tfe "github.com/hashicorp/go-tfe/v2" + "github.com/hashicorp/tfctl-cli/internal/pkg/client" "github.com/hashicorp/tfctl-cli/internal/pkg/cmd" "github.com/hashicorp/tfctl-cli/internal/pkg/format" @@ -73,6 +77,9 @@ type StatusResult struct { TokenType string `json:"token_type,omitempty"` ExpiresAt *time.Time `json:"expires_at,omitempty"` Active bool `json:"active"` + // Reason is a machine-readable cause when Active is false: one of + // "no_token", "rejected", "server_error", or "unreachable". + Reason string `json:"reason,omitempty"` } func runStatus(ctx context.Context, opts *StatusOpts) error { @@ -80,7 +87,7 @@ func runStatus(ctx context.Context, opts *StatusOpts) error { // No token configured at all. if opts.Profile.GetToken() == "" { - return displayUnauthorized(opts, hostname) + return displayAuthFailure(opts, &authFailure{reason: reasonNoToken}) } apiClient := opts.APIClient @@ -88,7 +95,7 @@ func runStatus(ctx context.Context, opts *StatusOpts) error { // Call /account/details. resp, err := apiClient.TFE.API.Account().Details().Get(ctx, nil) if err != nil { - return displayUnauthorized(opts, hostname) + return displayAuthFailure(opts, classifyAuthError(err)) } data := resp.GetData() @@ -146,17 +153,79 @@ func runStatus(ctx context.Context, opts *StatusOpts) error { return opts.Output.Display(&statusDisplayer{result: result, io: opts.IO}) } -// displayUnauthorized emits a machine-readable inactive result for JSON/agent -// consumers and writes the human-readable failure message to stderr. It always +// Machine-readable failure reasons surfaced in StatusResult.Reason. +const ( + reasonNoToken = "no_token" + reasonRejected = "rejected" + reasonServerError = "server_error" + reasonUnreachable = "unreachable" +) + +// authFailure describes why `auth status` could not confirm an active session. +type authFailure struct { + reason string // one of the reason* constants + status int // HTTP status when known (0 otherwise) + err error // underlying transport/server error, when relevant +} + +// classifyAuthError turns an /account/details error into an authFailure. A 401 +// means the token was rejected — expired or revoked, or (on SAML-SSO-protected +// Terraform Enterprise) a browser SSO session that has lapsed. Any other HTTP +// status is a server-side problem, and an error carrying no HTTP status is a +// connectivity problem; neither of those is an authentication failure, so we +// say so rather than reporting a misleading "unauthorized". +// +// The go-tfe *APIError is wrapped in a *url.Error, so we rely on errors.As to +// walk the chain rather than matching the concrete top-level type. +func classifyAuthError(err error) *authFailure { + var apiErr *tfe.APIError + if errors.As(err, &apiErr) { + if apiErr.StatusCode == http.StatusUnauthorized { + return &authFailure{reason: reasonRejected, status: apiErr.StatusCode} + } + return &authFailure{reason: reasonServerError, status: apiErr.StatusCode, err: err} + } + return &authFailure{reason: reasonUnreachable, err: err} +} + +// displayAuthFailure emits a machine-readable inactive result for JSON/agent +// consumers and writes a cause-specific, actionable message to stderr. It always // returns cmd.ErrUnderlyingError so callers can tail-call it. -func displayUnauthorized(opts *StatusOpts, hostname string) error { +func displayAuthFailure(opts *StatusOpts, f *authFailure) error { + hostname := opts.Profile.GetHostname() + if opts.Output.GetFormat().IsJSONOrAgent() { - result := &StatusResult{Active: false, Hostname: hostname} + result := &StatusResult{Active: false, Hostname: hostname, Reason: f.reason} // Best-effort: ignore display errors since we are already in a failure path. _ = opts.Output.Display(&statusDisplayer{result: result, io: opts.IO}) } + cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.Err(), "%s Unauthorized for %s\n", cs.FailureIcon(), hostname) + w := opts.IO.Err() + icon := cs.FailureIcon() + + switch f.reason { + case reasonNoToken: + fmt.Fprintf(w, "%s No token configured for %s. Run '%s auth login' to authenticate.\n", + icon, hostname, version.Name) + case reasonRejected: + fmt.Fprintf(w, "%s Token for %s was invalid (HTTP 401).\n", icon, hostname) + fmt.Fprintf(w, " - The token may be expired, revoked, or disabled: run '%s auth login' to create a new one.\n", version.Name) + if !strings.HasSuffix(hostname, ".terraform.io") { + fmt.Fprintf(w, " - Your Terraform Enterprise SSO session may have expired: sign in again, then retry.\n") + } + fmt.Fprintf(w, " - Ensure you are using the intended token configuration by adding '--debug' to this command.\n") + case reasonServerError: + fmt.Fprintf(w, "%s %s returned HTTP %d (not an authentication problem). Retry, or check the instance status.\n", + icon, hostname, f.status) + default: // reasonUnreachable + if f.err != nil { + fmt.Fprintf(w, "%s Could not reach %s: %v\n", icon, hostname, f.err) + } else { + fmt.Fprintf(w, "%s Could not reach %s.\n", icon, hostname) + } + } + return cmd.ErrUnderlyingError } diff --git a/internal/commands/auth/status_test.go b/internal/commands/auth/status_test.go index 3fc426b..c3078e6 100644 --- a/internal/commands/auth/status_test.go +++ b/internal/commands/auth/status_test.go @@ -5,11 +5,14 @@ package auth import ( "context" + "errors" "fmt" "net/http" "net/http/httptest" + "net/url" "testing" + tfe "github.com/hashicorp/go-tfe/v2" "github.com/stretchr/testify/require" "github.com/hashicorp/tfctl-cli/internal/pkg/client" @@ -200,8 +203,12 @@ func TestStatus_Unauthorized(t *testing.T) { err := runStatus(context.Background(), opts) r.Error(err) - r.Contains(io.Error.String(), "Unauthorized") - r.Contains(io.Error.String(), srv.URL) + out := io.Error.String() + r.Contains(out, "invalid") + r.Contains(out, "HTTP 401") + r.Contains(out, srv.URL) + r.Contains(out, "SSO") + r.Contains(out, "auth login") } func TestStatus_NoToken(t *testing.T) { @@ -224,8 +231,63 @@ func TestStatus_NoToken(t *testing.T) { err := runStatus(context.Background(), opts) r.Error(err) - r.Contains(io.Error.String(), "Unauthorized") - r.Contains(io.Error.String(), "app.terraform.io") + out := io.Error.String() + r.Contains(out, "No token configured") + r.Contains(out, "app.terraform.io") + r.Contains(out, "auth login") +} + +func TestStatus_Unauthorized_JSON(t *testing.T) { + t.Parallel() + r := require.New(t) + + srv := newFakeStatusTFE(t, "", "", "", "") + p := profile.TestProfile(t) + p.Hostname = srv.URL + p.Token = "bad-token" + r.NoError(p.Write()) + + io := iostreams.Test() + output := format.New(io) + output.SetFormat(format.JSON) + + opts := &StatusOpts{ + IO: io, + Profile: p, + Output: output, + APIClient: newStatusClient(t, srv), + } + + err := runStatus(context.Background(), opts) + r.Error(err) + out := io.Output.String() + r.Contains(out, `"active"`) + r.Contains(out, `"reason"`) + r.Contains(out, `"rejected"`) +} + +func TestClassifyAuthError(t *testing.T) { + t.Parallel() + r := require.New(t) + + // A 401 means the token itself was rejected. + f := classifyAuthError(&tfe.APIError{StatusCode: http.StatusUnauthorized}) + r.Equal(reasonRejected, f.reason) + r.Equal(http.StatusUnauthorized, f.status) + + // The real error is wrapped in a *url.Error; errors.As must still find it. + wrapped := &url.Error{Op: "Get", URL: "https://tfe.example.com", Err: &tfe.APIError{StatusCode: http.StatusUnauthorized}} + f = classifyAuthError(wrapped) + r.Equal(reasonRejected, f.reason) + + // Any other HTTP status is a server-side problem, not an auth failure. + f = classifyAuthError(&tfe.APIError{StatusCode: http.StatusInternalServerError}) + r.Equal(reasonServerError, f.reason) + r.Equal(http.StatusInternalServerError, f.status) + + // An error with no HTTP status is a connectivity problem. + f = classifyAuthError(errors.New("dial tcp: connection refused")) + r.Equal(reasonUnreachable, f.reason) } func TestStatus_JSONOutput(t *testing.T) { From 1d0bfa7f573e9953246b0653fbe026c1642d1c02 Mon Sep 17 00:00:00 2001 From: Brandon Croft Date: Wed, 2 Sep 2026 12:48:02 -0600 Subject: [PATCH 2/2] bump x/crypto and grpc --- go.mod | 16 ++++++++-------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/go.mod b/go.mod index c61a3aa..1a6a451 100644 --- a/go.mod +++ b/go.mod @@ -35,10 +35,10 @@ require ( go.opentelemetry.io/otel/sdk v1.44.0 go.opentelemetry.io/otel/trace v1.44.0 golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f - golang.org/x/net v0.57.0 + golang.org/x/net v0.58.0 golang.org/x/sys v0.47.0 golang.org/x/term v0.45.0 - golang.org/x/text v0.40.0 + golang.org/x/text v0.41.0 ) require ( @@ -107,13 +107,13 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect - golang.org/x/crypto v0.54.0 // indirect - golang.org/x/mod v0.37.0 // indirect + golang.org/x/crypto v0.56.0 // indirect + golang.org/x/mod v0.38.0 // indirect golang.org/x/sync v0.22.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect - google.golang.org/grpc v1.82.1 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.12 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 150c140..ea47f09 100644 --- a/go.sum +++ b/go.sum @@ -267,18 +267,18 @@ go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXd go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo= golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak= golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -296,12 +296,12 @@ golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= gonum.org/v1/gonum v0.7.0/go.mod h1:L02bwd0sqlsvRv41G7wGWFCsVNZFv/k1xzGIxeANHGM= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -310,12 +310,12 @@ gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6d gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=