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..910fa5d 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, hostname, &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, hostname, classifyAuthError(err)) } data := resp.GetData() @@ -146,17 +153,74 @@ 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, hostname string, f *authFailure) error { 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 rejected (HTTP 401).\n", icon, hostname) + fmt.Fprintf(w, " - The token may be expired or revoked: run '%s auth login' to create a new one.\n", version.Name) + fmt.Fprintf(w, " - On SSO-protected Terraform Enterprise, your browser SSO session may have lapsed: re-authenticate in the browser, then retry.\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..53dcfa6 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, "rejected") + 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) {