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
3 changes: 3 additions & 0 deletions .changes/unreleased/ENHANCEMENTS-20260717-015613.yaml
Original file line number Diff line number Diff line change
@@ -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
16 changes: 8 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
)
32 changes: 16 additions & 16 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down
83 changes: 76 additions & 7 deletions internal/commands/auth/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -73,22 +77,25 @@ 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 {
hostname := opts.Profile.GetHostname()

// No token configured at all.
if opts.Profile.GetToken() == "" {
return displayUnauthorized(opts, hostname)
return displayAuthFailure(opts, &authFailure{reason: reasonNoToken})
}

apiClient := opts.APIClient

// 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()
Expand Down Expand Up @@ -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
}

Expand Down
70 changes: 66 additions & 4 deletions internal/commands/auth/status_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down