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
2 changes: 2 additions & 0 deletions internal/cli/auth_env.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ func (c *cli) setupWithEnvAuthentication(ctx context.Context) error {

c.api = auth0.NewAPI(api)
c.apiv3 = auth0.NewAPIV3(apiv3)
c.accessToken = accessToken
c.sessionDomain = domain

return nil
}
Expand Down
35 changes: 35 additions & 0 deletions internal/cli/auth_env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ import (
"errors"
"fmt"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
gokeyring "github.com/zalando/go-keyring"

"github.com/auth0/auth0-cli/internal/config"
"github.com/auth0/auth0-cli/internal/display"
Expand Down Expand Up @@ -194,6 +197,38 @@ func TestSetupWithEnvAuthenticationNoSavedConfig(t *testing.T) {
assert.Equal(t, "tenant.us.auth0.com", c.tenant)
assert.NotNil(t, c.api)
assert.NotNil(t, c.apiv3)
assert.Equal(t, "direct-token", c.accessToken, "a plugin run hands this token to its proxy")
assert.Equal(t, "tenant.us.auth0.com", c.sessionDomain)
}

func TestSetupWithAuthenticationKeepsTheSession(t *testing.T) {
// A saved tenant with a token that has not expired is used as it is: nothing
// is refreshed and no login starts. The mock keyring and the temporary home
// keep the test away from the real keychain and config.
gokeyring.MockInit()
t.Setenv("HOME", t.TempDir())

for _, name := range []string{authModeEnvVar, envDomain, envAPIToken, envClientID, envClientSecret} {
t.Setenv(name, "")
}

const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL2F1dGgwLmF1dGgwLmNvbS8iLCJpYXQiOjE2ODExNDcwNjAsImV4cCI6OTY4MTgzMzQ2MH0.DsEpQkL0MIWcGJOIfEY8vr3MVS_E0GYsachNLQwBu5Q"

saveTenant(t, config.Tenant{
Domain: "saved.us.auth0.com",
AccessToken: token,
ExpiresAt: time.Now().Add(time.Hour),
ClientID: "client",
})

c := &cli{renderer: &display.Renderer{}}

require.NoError(t, c.setupWithAuthentication(context.Background()))

assert.Equal(t, token, c.accessToken, "a plugin run hands this token to its proxy")
assert.Equal(t, "saved.us.auth0.com", c.sessionDomain)
assert.NotNil(t, c.api)
assert.NotNil(t, c.apiv3)
}

func TestResolveInstallIDForTrackingSkipsDiskInEnvMode(t *testing.T) {
Expand Down
13 changes: 13 additions & 0 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ type cli struct {
detectedAgent string
executedCommandPath string

// The access token and tenant domain of the session that
// setupWithAuthentication opened, kept in memory only, so that a plugin run
// can hand the token to its proxy without reading the keyring again. They are
// empty until a session was set up. The token must never be printed.
accessToken string
sessionDomain string

// The names of the installed plugins that a core command shadows, with the
// plugin ID, see registerPluginCommands.
shadowedPlugins map[string]string

Config config.Config
}

Expand Down Expand Up @@ -225,6 +236,8 @@ func (c *cli) setupWithAuthentication(ctx context.Context) error {

c.api = auth0.NewAPI(api)
c.apiv3 = auth0.NewAPIV3(apiv3)
c.accessToken = accessToken
c.sessionDomain = tenant.Domain
return nil
}

Expand Down
6 changes: 6 additions & 0 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,12 @@ func renderNamespaceJSONHelp(root *cobra.Command, args []string) bool {
return false
}

// A plugin command does not parse its flags, so a JSON flag on it is for the
// plugin to run with, not a request for help.
if target.Annotations[pluginCommandAnnotation] == "true" {
return false
}

// Cobra adds the --help/-h flag lazily during execution; add it now so a
// `--help --json` combination parses instead of tripping on "unknown flag: --help".
target.InitDefaultHelpFlag()
Expand Down
5 changes: 5 additions & 0 deletions internal/cli/doc-gen.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ const (
// them.
skipDocsAnnotation = "skipDocs"

// The annotation with this key, set to "true" on a command, keeps the root
// pre-run hook from authenticating for the command. The command does its own
// checks first and logs in only when it needs a session.
skipAuthAnnotation = "skipAuth"

associatedCommandsFragment = `{{range .AssociatedCommands}}- [{{.CommandPath}}]({{ link .CommandPath }}) - {{.Short}}
{{end}}`

Expand Down
44 changes: 44 additions & 0 deletions internal/cli/exit.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,40 @@ func (e validationError) Unwrap() error { return e.err }

func (e validationError) ErrorDetails() json.RawMessage { return e.details }

// pluginError is a failure of a plugin run: the plugin exited with a non-zero
// code, ran out of time, was interrupted, or could not be started. It classifies
// as "plugin" in the JSON error envelope and carries which plugin it was and the
// exit code of the plugin in the envelope's "details", so a script can tell the
// failure of a plugin from a failure of the CLI. The process itself exits with the
// generic code, see exitCodeForError.
type pluginError struct {
err error
reason string
pluginID string
version string

// ExitCode is the code the plugin exited with, or zero when it did not exit by
// itself.
exitCode int
}

func (e pluginError) Error() string { return e.err.Error() }

func (e pluginError) Unwrap() error { return e.err }

func (e pluginError) ErrorDetails() json.RawMessage {
details, err := json.Marshal(struct {
PluginID string `json:"pluginId"`
Version string `json:"version"`
ExitCode int `json:"exitCode,omitempty"`
}{e.pluginID, e.version, e.exitCode})
if err != nil {
return nil
}

return details
}

// errorClass returns a stable, machine-readable classification for an error. It
// is the single source of truth shared by exit-code mapping, the JSON error
// envelope, and analytics tracking.
Expand All @@ -117,6 +151,11 @@ func errorClass(err error) string {
return "validation"
}

var pluginErr pluginError
if errors.As(err, &pluginErr) {
return "plugin"
}

if errors.Is(err, config.ErrInvalidToken) ||
errors.Is(err, config.ErrMalformedToken) ||
errors.Is(err, config.ErrStoredTokenUnavailable) ||
Expand Down Expand Up @@ -201,6 +240,11 @@ func errorReason(err error) string {
return "local_validation"
}

var pluginErr pluginError
if errors.As(err, &pluginErr) {
return pluginErr.reason
}

if status, ok := managementHTTPStatus(err); ok {
return reasonForHTTPStatus(status)
}
Expand Down
21 changes: 21 additions & 0 deletions internal/cli/exit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ func TestErrorClass(t *testing.T) {
{name: "wrapped auth error", err: authError{err: fmt.Errorf("wrap: %w", errors.New("token expired"))}, expected: "auth"},
{name: "local validation error", err: validationError{err: errors.New("schema validation failed")}, expected: "validation"},
{name: "wrapped validation error", err: fmt.Errorf("wrap: %w", validationError{err: errors.New("bad json")}), expected: "validation"},
{name: "plugin error", err: pluginError{err: errors.New("exited"), reason: "plugin_exit"}, expected: "plugin"},
{name: "wrapped plugin error", err: fmt.Errorf("wrap: %w", pluginError{err: errors.New("slow"), reason: "plugin_timeout"}), expected: "plugin"},
{name: "missing scopes", err: config.ErrTokenMissingRequiredScopes{MissingScopes: []string{"read:users"}}, expected: "auth"},
{name: "401 unauthorized", err: fakeManagementError{status: 401}, expected: "auth"},
{name: "403 forbidden", err: fakeManagementError{status: 403}, expected: "auth"},
Expand Down Expand Up @@ -109,6 +111,9 @@ func TestErrorReason(t *testing.T) {
{name: "malformed token sentinel", err: config.ErrMalformedToken, expected: "token_malformed"},
{name: "missing scopes", err: config.ErrTokenMissingRequiredScopes{MissingScopes: []string{"read:users"}}, expected: "missing_scopes"},
{name: "validation default", err: validationError{err: errors.New("bad json")}, expected: "local_validation"},
{name: "plugin exited", err: pluginError{err: errors.New("exited"), reason: "plugin_exit"}, expected: "plugin_exit"},
{name: "plugin timed out", err: pluginError{err: errors.New("slow"), reason: "plugin_timeout"}, expected: "plugin_timeout"},
{name: "plugin interrupted", err: pluginError{err: errors.New("stopped"), reason: "plugin_interrupted"}, expected: "plugin_interrupted"},
{name: "401 unauthorized", err: fakeManagementError{status: 401}, expected: "unauthorized"},
{name: "403 forbidden", err: fakeManagementError{status: 403}, expected: "forbidden"},
{name: "400 invalid request", err: fakeManagementError{status: 400}, expected: "invalid_request"},
Expand Down Expand Up @@ -145,6 +150,7 @@ func TestExitCodeForError(t *testing.T) {
{name: "auth error", err: config.ErrInvalidToken, expected: exitGeneric},
{name: "server validation error", err: fakeManagementError{status: 400}, expected: exitGeneric},
{name: "local validation error", err: validationError{err: errors.New("bad payload")}, expected: exitGeneric},
{name: "plugin error", err: pluginError{err: errors.New("exited"), reason: "plugin_exit", exitCode: 7}, expected: exitGeneric},
{name: "not found error", err: fakeManagementError{status: 404}, expected: exitGeneric},
{name: "rate limit error", err: fakeManagementError{status: 429}, expected: exitGeneric},
{name: "api error", err: fakeManagementError{status: 500}, expected: exitGeneric},
Expand All @@ -169,6 +175,10 @@ func TestAgentModeHelpMatchesExitCodes(t *testing.T) {

assert.Contains(t, agentModeHelp, "exits 1", "help should state the coarse failure exit code")
assert.Contains(t, agentModeHelp, `"code" field`, "help should point the failure class at the JSON envelope")

for _, class := range []string{"usage", "auth", "validation", "not_found", "conflict", "rate_limit", "api", "plugin"} {
assert.Contains(t, agentModeHelp, class, "help should list every failure class of the envelope")
}
}

func TestBuildErrorEnvelope(t *testing.T) {
Expand Down Expand Up @@ -227,6 +237,17 @@ func TestBuildErrorEnvelope(t *testing.T) {
assert.JSONEq(t, `{"suggestions":["apps","apis"]}`, string(envelope.Error.Details))
})

t.Run("carries the plugin and its exit code as details", func(t *testing.T) {
envelope := buildErrorEnvelope(pluginError{
err: errors.New("checkmate 1.0.0 exited with code 7"), reason: "plugin_exit",
pluginID: "auth0/checkmate", version: "1.0.0", exitCode: 7,
})

assert.Equal(t, "plugin", envelope.Error.Code)
assert.Equal(t, "plugin_exit", envelope.Error.Reason)
assert.JSONEq(t, `{"pluginId":"auth0/checkmate","version":"1.0.0","exitCode":7}`, string(envelope.Error.Details))
})

t.Run("omits details when the unknown command has no suggestions", func(t *testing.T) {
envelope := buildErrorEnvelope(unknownCommandError{token: "zzz", parent: "auth0"})

Expand Down
79 changes: 79 additions & 0 deletions internal/cli/interrupt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package cli

import (
"context"
"sync"
)

// interruptGuard lets a command that started other processes stop them before
// the CLI exits on Ctrl-C. Without a holder the interrupt ends the process at
// once, as it always did. While there is one, the interrupt only cancels the
// context, the holder cleans up and returns, and Execute then exits with the code
// of an interrupt.
type interruptGuard struct {
mu sync.Mutex
holders int
interrupted bool
}

type interruptGuardKey struct{}

// withInterruptGuard returns a context that carries the guard.
func withInterruptGuard(ctx context.Context, guard *interruptGuard) context.Context {
return context.WithValue(ctx, interruptGuardKey{}, guard)
}

// holdInterrupt makes an interrupt wait for the caller, until the function it
// returns is called. A context without a guard, as in tests, has nothing to hold.
func holdInterrupt(ctx context.Context) (release func()) {
guard, ok := ctx.Value(interruptGuardKey{}).(*interruptGuard)
if !ok {
return func() {}
}

guard.mu.Lock()
guard.holders++
guard.mu.Unlock()

var once sync.Once

return func() {
once.Do(func() {
guard.mu.Lock()
guard.holders--
guard.mu.Unlock()
})
}
}

// interrupt reports whether a holder will deal with the interrupt. When it
// returns false, nothing is running that needs to be stopped and the caller ends
// the process.
func (g *interruptGuard) interrupt() bool {
g.mu.Lock()
defer g.mu.Unlock()

if g.holders == 0 {
return false
}

g.interrupted = true

return true
}

// wasInterrupted reports whether an interrupt arrived while a holder ran.
func (g *interruptGuard) wasInterrupted() bool {
g.mu.Lock()
defer g.mu.Unlock()

return g.interrupted
}

// interrupted reports whether an interrupt arrived while a command held the
// guard of the context.
func interrupted(ctx context.Context) bool {
guard, ok := ctx.Value(interruptGuardKey{}).(*interruptGuard)

return ok && guard.wasInterrupted()
}
17 changes: 17 additions & 0 deletions internal/cli/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -1245,6 +1246,16 @@ type pluginService struct {
// Confirmer asks the person a yes or no question. When it is nil, the prompt
// of the CLI is used. Tests set it for the same reason.
confirmer func(message string) bool

// Login opens the session that a plugin runs with. When it is nil, the
// authentication of the CLI is used. Tests set it, because they have no tenant
// to log in to.
login func(ctx context.Context, c *cli) error

// Transport carries the calls that the proxy of a plugin makes to the tenant.
// When it is nil, the default transport is used. Tests set it, because they
// have no tenant.
transport http.RoundTripper
}

// canPrompt reports whether a person can answer at the terminal of the command.
Expand Down Expand Up @@ -1457,6 +1468,12 @@ func checkPluginNameAvailable(root *cobra.Command, name string) error {
reserved := name == "help" || name == "completion"

for _, command := range root.Commands() {
// The commands of installed plugins do not count: a plugin can be installed
// again, and an update replaces it.
if command.Annotations[pluginCommandAnnotation] == "true" {
continue
}

if command.Name() == name || slices.Contains(command.Aliases, name) {
reserved = true
}
Expand Down
11 changes: 11 additions & 0 deletions internal/cli/plugin_fixture_exit_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package cli

import "syscall"

// exitFixture ends the process of the fixture plugin without the exit hook of
// os.Exit. In a test binary built with coverage that hook warns on stderr that
// GOCOVERDIR is not set, because the fixture runs with a minimal environment, and
// the warning would end up in the output that the tests compare.
func exitFixture(code int) {
syscall.Exit(code)
}
Loading
Loading