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
22 changes: 20 additions & 2 deletions cmd/entire/cli/activity_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"context"
"errors"
"fmt"
"io"
"net/url"
Expand All @@ -12,6 +13,7 @@ import (
"time"

"github.com/entireio/cli/cmd/entire/cli/api"
"github.com/entireio/cli/cmd/entire/cli/auth"
"github.com/entireio/cli/cmd/entire/cli/interactive"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
Expand Down Expand Up @@ -58,8 +60,24 @@ func newActivityCmd() *cobra.Command {
func runActivity(ctx context.Context, w, errW io.Writer) error {
client, err := NewAuthenticatedAPIClient(ctx, false)
if err != nil {
fmt.Fprintln(errW, "Not logged in. Run 'entire login' to authenticate.")
return NewSilentError(err)
// Ctrl+C during the keyring read or STS exchange surfaces as
// context.Canceled / DeadlineExceeded. Silence it to match the
// codebase convention (clean.go, explain.go, explain_export.go) —
// printing "context canceled" at a user who just hit Ctrl+C is
// noise, not diagnostic.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return NewSilentError(err)
}
// Only the "no core token in keyring" sentinel gets the friendly
// login hint. Other failures (STS exchange rejected, network
// error, malformed env config) used to be swallowed under the
// same hint, which sent users on wild goose chases trying to
// "re-login" their way out of unrelated server-side problems.
if errors.Is(err, auth.ErrNotLoggedIn) {
fmt.Fprintln(errW, "Not logged in. Run 'entire login' to authenticate.")
return NewSilentError(err)
}
return err
Comment thread
Soph marked this conversation as resolved.
}

// Non-interactive fallback: piped output or accessibility mode
Expand Down
79 changes: 79 additions & 0 deletions cmd/entire/cli/activity_cmd_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,91 @@
package cli

import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"

"github.com/entireio/auth-go/sts"
"github.com/entireio/auth-go/tokens"
"github.com/entireio/cli/cmd/entire/cli/auth"
)

func strPtr(v string) *string { return &v }

// TestRunActivity_SilencesContextCanceled pins the codebase convention
// (clean.go, explain.go, explain_export.go) for Ctrl+C during the auth
// resolution: NewSilentError wraps the cancellation so cobra doesn't
// print "context canceled" at a user who just chose to stop.
//
// Pre-PR runActivity silenced *every* auth-resolution error under the
// "Not logged in" hint; that was wrong because real STS / network
// failures got mis-labeled. This PR surfaces real errors but has to
// keep the cancellation case silent.
func TestRunActivity_SilencesContextCanceled(t *testing.T) {
// No t.Parallel: SetManagerForTest mutates package-level auth state.
store := newAuthMemStore()
saveCoreToken(t, store, authResolveTestIssuer, "opaque-core-token")

mgr := newResolveTestManager(t, store, func(context.Context, sts.ExchangeRequest) (*tokens.TokenSet, error) {
// Simulate the user hitting Ctrl+C mid-exchange. The real
// transport would return ctx.Err() wrapped; both shapes flow
// through errors.Is(err, context.Canceled) identically.
return nil, context.Canceled
})
t.Cleanup(auth.SetManagerForTest(t, mgr))

var out, errOut bytes.Buffer
err := runActivity(t.Context(), &out, &errOut)
if err == nil {
t.Fatal("expected error when STS exchange is cancelled")
}
if !errors.Is(err, context.Canceled) {
t.Errorf("error chain missing context.Canceled: %v", err)
}
var silent *SilentError
if !errors.As(err, &silent) {
t.Errorf("error = %v, want SilentError wrap so cobra suppresses output", err)
}
if errOut.Len() != 0 {
t.Errorf("errOut = %q, want empty (no 'Not logged in' hint on cancellation)", errOut.String())
}
}

// TestRunActivity_PrintsLoginHintOnNotLoggedIn pins the other half of
// the same branch: a missing keyring entry still produces the friendly
// hint and a SilentError so the raw "not logged in" string doesn't
// also print via cobra.
func TestRunActivity_PrintsLoginHintOnNotLoggedIn(t *testing.T) {
// No t.Parallel: SetManagerForTest mutates package-level auth state.
store := newAuthMemStore() // empty: LookupCoreToken returns "" → ErrNotLoggedIn

mgr := newResolveTestManager(t, store, func(context.Context, sts.ExchangeRequest) (*tokens.TokenSet, error) {
t.Fatal("exchange should not run when no core token is stored")
return nil, errors.New("unreachable")
})
t.Cleanup(auth.SetManagerForTest(t, mgr))

var out, errOut bytes.Buffer
err := runActivity(t.Context(), &out, &errOut)
if err == nil {
t.Fatal("expected error when not logged in")
}
if !errors.Is(err, auth.ErrNotLoggedIn) {
t.Errorf("error chain missing ErrNotLoggedIn: %v", err)
}
var silent *SilentError
if !errors.As(err, &silent) {
t.Errorf("error = %v, want SilentError wrap", err)
}
wantHint := "Not logged in. Run 'entire login' to authenticate."
if got := errOut.String(); !strings.Contains(got, wantHint) {
t.Errorf("errOut = %q, want hint %q", got, wantHint)
}
}

func TestNormalizeAgentString(t *testing.T) {
t.Parallel()
tests := []struct {
Expand Down
9 changes: 8 additions & 1 deletion cmd/entire/cli/api_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,14 @@ func NewAuthenticatedAPIClient(ctx context.Context, insecureHTTP bool) (*api.Cli
token, err := auth.TokenForResource(ctx, api.OriginOnly(dataURL))
if err != nil {
if errors.Is(err, auth.ErrNotLoggedIn) {
return nil, errors.New("not logged in (run 'entire login' first)")
// Wrap the original err (not the sentinel) so any context
// the tokenmanager attached — keyring backend message,
// expired-token reason — survives to the caller. The
// errors.Is(err, auth.ErrNotLoggedIn) chain is preserved
// because err already wraps the sentinel; replacing it
// with the bare sentinel would drop that context for
// zero behavioural gain.
return nil, fmt.Errorf("not logged in (run 'entire login' first): %w", err)
}
return nil, fmt.Errorf("resolve API token: %w", err)
}
Expand Down
100 changes: 83 additions & 17 deletions cmd/entire/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ import (
"github.com/spf13/cobra"
)

// authTokenLister lists API tokens for the authenticated user.
type authTokenLister func(ctx context.Context, token string) ([]api.Token, error)
// authTokenLister lists API tokens for the authenticated user. The
// implementation resolves its own data-API bearer via
// auth.TokenForResource (RFC 8693 exchange in split-host setups, same-
// host shortcut otherwise); callers don't pass a bearer through, which
// removes the temptation to forward the wrong-audience keyring token.
type authTokenLister func(ctx context.Context) ([]api.Token, error)

// authTokenRevoker revokes a single API token by id.
type authTokenRevoker func(ctx context.Context, callerToken, id string) error
// authTokenRevoker revokes a single API token by id. Same bearer-
// resolution contract as authTokenLister.
type authTokenRevoker func(ctx context.Context, id string) error

// User-visible placeholder strings. Promoted to constants so tests and
// production share a single source of truth.
Expand All @@ -40,8 +45,14 @@ const (
// auth host for login + auth-token management, and to the data host for
// search/activity/dispatch/etc. Single-host deployments (ENTIRE_AUTH_BASE_URL
// unset) skip the redundant second parse.
//
// When the opt-in flag is set, the tokenmanager's matching HTTP guard is
// also relaxed via auth.EnableInsecureHTTP — otherwise an STS exchange
// against a private-network http:// auth host would fail at the
// tokenmanager layer even though the per-command TLS check was waived.
func requireSecureBaseURL(insecureHTTPAuth bool) error {
if insecureHTTPAuth {
auth.EnableInsecureHTTP()
return nil
}
dataURL, authURL := api.BaseURL(), api.AuthBaseURL()
Expand All @@ -59,16 +70,63 @@ func requireSecureBaseURL(insecureHTTPAuth bool) error {

// newAPITokensClient builds an api.Client for the auth-token management
// endpoints (list / revoke / current). API tokens live on the data API
// regardless of split-host config — the auth host (entire-core in v2) mints
// OAuth tokens but doesn't manage application API tokens — so this targets
// api.BaseURL(). The bearer is whatever the caller already extracted from
// the keyring (keyed by api.AuthBaseURL()); the data API validates it via
// ENTIRE_CORE_BEARER_ENABLED in split-host setups.
// regardless of split-host config — the auth host (entire-core in v2)
// mints OAuth tokens but doesn't host application API token management
// endpoints — so this targets api.BaseURL().
//
// The supplied token must already be scoped for api.BaseURL(). Callers
// must obtain it via resolveDataAPIToken (or auth.TokenForResource
// directly) rather than handing through the raw keyring entry — the
// keyring stores the auth-host-issued core token, which the data API
// rejects in split-host setups.
func newAPITokensClient(token string) *api.Client {
return api.NewClientWithBaseURL(token, api.BaseURL()).
WithAuthTokensPath(auth.CurrentProvider().AuthTokensPath)
}

// resolveDataAPIToken returns a bearer scoped for the data API. In
// split-host setups this triggers an RFC 8693 exchange against the
// auth host's STS endpoint; in single-host setups the tokenmanager
// hits the same-host shortcut and returns the core token unchanged.
// Centralised so the audience-mismatch bug that motivated this fix
// can't be reintroduced piecemeal at individual call sites.
func resolveDataAPIToken(ctx context.Context) (string, error) {
token, err := auth.TokenForResource(ctx, api.OriginOnly(api.BaseURL()))
if err != nil {
return "", fmt.Errorf("resolve API token: %w", err)
}
return token, nil
}
Comment thread
Soph marked this conversation as resolved.

// isKeychainTokenRejected reports whether err indicates the stored
// keyring token can't authenticate against the data API. Three failure
// modes collapse into this single "the user must re-login" branch:
//
// - data API returned 401 (single-host, or after a successful STS
// exchange whose result the data API then rejected),
// - tokenmanager's preflight rejected an expired core token JWT
// (surfacing as auth.ErrNotLoggedIn even though the keyring entry
// is still present),
// - the STS endpoint rejected the core token during exchange in a
// split-host setup. auth-go's sts package returns the response as
// "token exchange: status 4xx: <code>[: <desc>]" with no typed
// sentinel exposed, so detection has to be by prefix. The "status
// 4" anchor catches the entire 4xx range — every 4xx from STS is
// a credential problem, none are retryable without user action.
//
// Other shapes (network errors, malformed STS response, manager
// construction failures) deliberately don't match — the user sees the
// real diagnostic instead of a misleading "re-login" hint.
func isKeychainTokenRejected(err error) bool {
if api.IsHTTPErrorStatus(err, http.StatusUnauthorized) {
return true
}
if errors.Is(err, auth.ErrNotLoggedIn) {
return true
}
return strings.Contains(err.Error(), "token exchange: status 4")
}

// addInsecureHTTPAuthFlag attaches the hidden --insecure-http-auth flag used
// by every authenticated command for local development.
func addInsecureHTTPAuthFlag(cmd *cobra.Command, target *bool) {
Expand Down Expand Up @@ -115,7 +173,11 @@ func newAuthStatusCmd() *cobra.Command {
return cmd
}

func defaultListTokens(ctx context.Context, token string) ([]api.Token, error) {
func defaultListTokens(ctx context.Context) ([]api.Token, error) {
token, err := resolveDataAPIToken(ctx)
if err != nil {
return nil, err
}
return newAPITokensClient(token).ListTokens(ctx) //nolint:wrapcheck // ListTokens already wraps with action context
}

Expand All @@ -130,9 +192,9 @@ func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, list auth
return nil
}

tokens, err := list(ctx, token)
tokens, err := list(ctx)
Comment thread
Soph marked this conversation as resolved.
if err != nil {
if api.IsHTTPErrorStatus(err, http.StatusUnauthorized) {
if isKeychainTokenRejected(err) {
fmt.Fprintf(w, "Token in keychain for %s is no longer valid.\n", baseURL)
fmt.Fprintln(w, "Run 'entire login' to re-authenticate.")
return nil
Expand Down Expand Up @@ -176,7 +238,7 @@ func runAuthList(ctx context.Context, w io.Writer, store tokenStore, list authTo
return fmt.Errorf("not logged in to %s; run 'entire login' first", baseURL)
}

tokens, err := list(ctx, token)
tokens, err := list(ctx)
if err != nil {
return err
}
Expand Down Expand Up @@ -441,8 +503,12 @@ func newAuthRevokeCmd() *cobra.Command {
return cmd
}

func defaultRevokeTokenByID(ctx context.Context, callerToken, id string) error {
return newAPITokensClient(callerToken).RevokeToken(ctx, id) //nolint:wrapcheck // RevokeToken already wraps with action context
func defaultRevokeTokenByID(ctx context.Context, id string) error {
token, err := resolveDataAPIToken(ctx)
if err != nil {
return err
}
return newAPITokensClient(token).RevokeToken(ctx, id) //nolint:wrapcheck // RevokeToken already wraps with action context
}

func runAuthRevoke(
Expand All @@ -469,14 +535,14 @@ func runAuthRevoke(
return runLogout(ctx, outW, errW, store, revokeCurrent, baseURL)
}

if err := revokeByID(ctx, token, id); err != nil {
if err := revokeByID(ctx, id); err != nil {
return err
}

// The list endpoint requires bearer auth, so a 401 here means the id we
// just revoked was the same one this CLI is using — the keychain entry is
// now stale and would otherwise produce confusing 401s on every command.
if _, listErr := list(ctx, token); listErr != nil && api.IsHTTPErrorStatus(listErr, http.StatusUnauthorized) {
if _, listErr := list(ctx); listErr != nil && api.IsHTTPErrorStatus(listErr, http.StatusUnauthorized) {
if delErr := store.DeleteToken(baseURL); delErr != nil {
return fmt.Errorf("revoked token %s but failed to remove local copy: %w", id, delErr)
}
Expand Down
Loading
Loading