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
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
78 changes: 71 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, hostname, &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, hostname, classifyAuthError(err))
}

data := resp.GetData()
Expand Down Expand Up @@ -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")
Comment on lines +210 to +212

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like to slightly reword this (unless you disagree) and add the Terraform Enterprise condition to simplify the message for HCP Terraform users.

Suggested change
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")
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(opts.Profile.GetHostname(), ".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")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues from me. Would you like to make the change or should I?

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, "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) {
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