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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,64 @@ If you run `auth0 login` without credentials, the CLI falls back to user login.

An agent can surface that link and code to a human to complete the login. Once approved, the command emits a final `{"logged_in":true,"tenant":"...","domain":"..."}` object and stores the credentials as usual. In agent mode the newly authenticated tenant also becomes the default automatically, because there is no human to answer the usual change-default prompt.

When you authenticate as a user, the login response also tells you how long the session is good for. In agent mode the final JSON object carries an `expires_at` timestamp (RFC 3339), and the human-readable output prints a matching "This session is valid until ..." line. Once that time passes, calls fail with an `auth` error whose reason is `session_expired`, and the tenant needs to log in again. Machine login with client credentials does not have this problem, because the CLI exchanges credentials for a fresh token whenever the stored one expires.

### Authentication without the keychain or a writable config (sandboxes and CI)

> [!IMPORTANT]
> This mode removes the keychain and config-file requirements only. It does **not** bypass network isolation. The CLI still has to reach the Auth0 Management API over the network, so if your sandbox also blocks outbound network access, no form of authentication will make the calls succeed from inside it. In that case, run the `auth0` command outside the sandbox (or in an environment that has network access), where a normal login works.

By default the CLI stores your access token in the operating system keychain and writes tenant details to a config file on disk. Some environments block both, for example the read-only, network-isolated sandboxes that coding agents run in. In those environments a normal `auth0 login` cannot save anything, and a later command that tries to read the token back reports an `auth` error. The reason is `stored_token_unavailable` when the keychain cannot be read, or `config_not_writable` when the config file cannot be written. Both errors explain what happened and point you at the escape hatch below.

For these environments the CLI supports an opt-in, non-persistent authentication mode that reads credentials straight from environment variables and never touches the keychain or the config file. Enable it by setting:

```bash
export AUTH0_CLI_AUTH_MODE=env
```

With that set, provide credentials in one of two ways:

- **A pre-minted Management API token.** Set `AUTH0_DOMAIN` to your tenant domain (for example `tenant.us.auth0.com`) and `AUTH0_API_TOKEN` to a valid Management API access token. The CLI uses the token as-is, so it keeps whatever identity minted it.

```bash
export AUTH0_CLI_AUTH_MODE=env
export AUTH0_DOMAIN=tenant.us.auth0.com
export AUTH0_API_TOKEN=<management-api-token>
```

- **Machine (M2M) client credentials.** Set `AUTH0_DOMAIN`, `AUTH0_CLIENT_ID`, and `AUTH0_CLIENT_SECRET`, and the CLI exchanges them for an access token in memory for the duration of the command.

```bash
export AUTH0_CLI_AUTH_MODE=env
export AUTH0_DOMAIN=tenant.us.auth0.com
export AUTH0_CLIENT_ID=<client-id>
export AUTH0_CLIENT_SECRET=<client-secret>
```

Because this mode never persists anything, the M2M path exchanges your client credentials for a brand new access token on **every** command invocation. That is correct and safe, but in a sandbox where an agent runs many commands it means a token request per call, which adds latency and consumes tenant rate limits. If you expect a lot of calls, the better pattern is to mint a token once yourself and pass it through `AUTH0_API_TOKEN` instead of the client credentials, so the CLI reuses the same token across calls:

```bash
# Mint one token up front (outside the sandbox, where you have network access).
export AUTH0_API_TOKEN=$(curl -s --request POST \
--url "https://tenant.us.auth0.com/oauth/token" \
--header 'content-type: application/json' \
--data '{
"client_id": "<client-id>",
"client_secret": "<client-secret>",
"audience": "https://tenant.us.auth0.com/api/v2/",
"grant_type": "client_credentials"
}' | jq -r .access_token)
# Then run the agent with AUTH0_CLI_AUTH_MODE=env and AUTH0_API_TOKEN set.
```

Just remember that a minted token expires, so refresh it and update `AUTH0_API_TOKEN` before it does. If minting and rotating a token is impractical, the alternative is to relax the sandbox policy so it can run the `auth0` CLI directly (allowing at least the read-only commands you need), rather than working around the keychain at all.

This mode is deliberately explicit. The CLI only reads these variables when `AUTH0_CLI_AUTH_MODE=env` is set, because the same `AUTH0_*` variables are also used by the Terraform provider and the sample apps this CLI scaffolds, and their mere presence must never silently replace a saved login or switch you to a different tenant. The CLI itself writes nothing to disk or the keychain in this mode; it only reads these variables. Note, though, that an exported variable lives in the parent shell or agent environment for as long as that environment does, which is beyond the single command. If that is a concern, scope the variables to a single invocation (for example prefix them on the command line rather than `export`ing them) and unset them when you are done.

In this mode the tenant is fixed by `AUTH0_DOMAIN`. If you also pass `--tenant` and it points at a different domain, the command fails with a clear error instead of silently targeting `AUTH0_DOMAIN`, so a write can never land on the wrong tenant. Drop the flag, or set `AUTH0_DOMAIN` to the tenant you mean to target.

Keep in mind that env-based auth solves the keychain and config problem, but it does not solve network isolation. If the sandbox also blocks outbound network access, the CLI still cannot reach the Auth0 Management API from inside it. In that case, run the `auth0` command outside the sandbox (or in an environment with network access and write permissions) where a normal login can succeed.

**Commands that need a browser or editor:** a few commands are inherently interactive and cannot run in agent mode. `auth0 universal-login customize`, `auth0 universal-login templates update`, and `auth0 acul dev` open a browser or terminal editor and block on a local server, so in agent mode they fail fast with a clear error instead of hanging. Use `auth0 acul config` and `auth0 api` to manage the same configuration non-interactively.

## Usage Analytics Disclosure
Expand Down
10 changes: 10 additions & 0 deletions internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,16 @@ func GetAccessTokenFromClientCreds(ctx context.Context, args ClientCredentials)
return Result{}, err
}

// Guard against a domain that parses to a different host (for example
// "tenant.example@evil.example"), which would POST the client secret to that
// other host. Require it to be a bare host that parses back to itself.
if u.User != nil || u.Host != args.Domain || u.Path != "" {
return Result{}, fmt.Errorf(
"invalid tenant domain %q: expected a bare host such as tenant.us.auth0.com",
args.Domain,
)
}

credsConfig := &clientcredentials.Config{
ClientID: args.ClientID,
ClientSecret: args.ClientSecret,
Expand Down
24 changes: 24 additions & 0 deletions internal/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,3 +235,27 @@ func TestParseTenant(t *testing.T) {
})
}
}

func TestGetAccessTokenFromClientCredsRejectsUnsafeDomain(t *testing.T) {
// A domain that parses to a host other than itself must be rejected before any
// token request, so the client secret can never be POSTed to another host.
tests := []struct {
name string
domain string
}{
{name: "userinfo redirects the host", domain: "tenant.example@evil.example"},
{name: "path redirects the request", domain: "tenant.us.auth0.com/oauth/token"},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := GetAccessTokenFromClientCreds(context.Background(), ClientCredentials{
ClientID: "client-id",
ClientSecret: "client-secret",
Domain: test.domain,
})
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid tenant domain")
})
}
}
4 changes: 1 addition & 3 deletions internal/cli/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -726,9 +726,7 @@ func openActionCmd(cli *cli) *cobra.Command {
inputs.ID = args[0]
}

openManageURL(cli, cli.Config.DefaultTenant, formatActionDetailsPath(url.PathEscape(inputs.ID)))

return nil
return openManageURL(cli, cli.tenant, formatActionDetailsPath(url.PathEscape(inputs.ID)))
},
}

Expand Down
4 changes: 1 addition & 3 deletions internal/cli/apis.go
Original file line number Diff line number Diff line change
Expand Up @@ -711,9 +711,7 @@ func openAPICmd(cli *cli) *cobra.Command {
}
}

openManageURL(cli, cli.Config.DefaultTenant, formatAPISettingsPath(inputs.ID))

return nil
return openManageURL(cli, cli.tenant, formatAPISettingsPath(inputs.ID))
},
}

Expand Down
59 changes: 45 additions & 14 deletions internal/cli/apps.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
"os"
"slices"
"strings"

Expand Down Expand Up @@ -304,6 +305,20 @@ func useAppCmd(cli *cli) *cobra.Command {
auth0 apps use --none
auth0 apps use <app-id>`,
RunE: func(cmd *cobra.Command, args []string) error {
// 'apps use' only saves a default app to the on-disk config, which env
// auth mode does not use. Fail fast instead of surfacing a confusing
// config-write error.
if envAuthEnabled(os.Getenv) {
return authError{
err: fmt.Errorf(
"'auth0 apps use' saves a default application to the on-disk config, which is not used in %s=%s mode. "+
"Pass the application ID directly to each command instead",
authModeEnvVar, authModeEnv,
),
reason: "env_auth_not_persistable",
}
}

if inputs.None {
inputs.ID = ""
} else {
Expand Down Expand Up @@ -785,9 +800,7 @@ func createAppCmd(cli *cli) *cobra.Command {
return fmt.Errorf("failed to create application: %w", err)
}

if err := cli.Config.SetDefaultAppIDForTenant(cli.tenant, a.GetClientID()); err != nil {
return err
}
persistDefaultAppID(cli, a.GetClientID())

cli.renderer.ApplicationCreate(a, inputs.RevealSecrets)

Expand Down Expand Up @@ -1140,6 +1153,21 @@ func updateAppCmd(cli *cli) *cobra.Command {
return cmd
}

// persistDefaultAppID records a newly created app as the tenant's default. It is
// a local convenience only: the app already exists in Auth0, so a failed
// preference write must never turn a successful create into a failure. It warns
// and continues.
func persistDefaultAppID(cli *cli, clientID string) {
// Env auth mode writes nothing to disk, so skip the save entirely.
if envAuthEnabled(os.Getenv) {
return
}

if err := cli.Config.SetDefaultAppIDForTenant(cli.tenant, clientID); err != nil {
cli.renderer.Warnf("Application created, but it could not be saved as the default for this tenant: %v", err)
}
}

func createAppFromJSON(cli *cli, cmd *cobra.Command, dataStr string, revealSecrets bool) error {
client, err := runJSONWrite[management.Client](cli, cmd, jsonWriteSpec{
Method: http.MethodPost,
Expand All @@ -1152,10 +1180,9 @@ func createAppFromJSON(cli *cli, cmd *cobra.Command, dataStr string, revealSecre
return fmt.Errorf("failed to create application: %w", err)
}

// Preserve the interactive path's side effect: the created app becomes the tenant default.
if err := cli.Config.SetDefaultAppIDForTenant(cli.tenant, client.GetClientID()); err != nil {
return err
}
// Preserve the interactive path's side effect: the created app becomes the
// tenant default (best-effort and non-fatal, see persistDefaultAppID).
persistDefaultAppID(cli, client.GetClientID())

cli.renderer.ApplicationCreate(client, revealSecrets)
return nil
Expand Down Expand Up @@ -1198,9 +1225,7 @@ func openAppCmd(cli *cli) *cobra.Command {
inputs.ID = args[0]
}

openManageURL(cli, cli.Config.DefaultTenant, formatAppSettingsPath(inputs.ID))

return nil
return openManageURL(cli, cli.tenant, formatAppSettingsPath(inputs.ID))
},
}

Expand Down Expand Up @@ -1358,9 +1383,15 @@ func (c *cli) appPickerOptions(requestOpts ...management.RequestOption) pickerOp
return nil, fmt.Errorf("failed to list applications: %w", err)
}

tenant, err := c.Config.GetTenant(c.tenant)
if err != nil {
return nil, err
// Env auth mode has no saved default app to prioritize; only look one up in
// normal mode.
var defaultAppID string
if !envAuthEnabled(os.Getenv) {
tenant, err := c.Config.GetTenant(c.tenant)
if err != nil {
return nil, err
}
defaultAppID = tenant.DefaultAppID
}

var priorityOpts, opts pickerOptions
Expand All @@ -1374,7 +1405,7 @@ func (c *cli) appPickerOptions(requestOpts ...management.RequestOption) pickerOp
)
option := pickerOption{value: value, label: label}

if tenant.DefaultAppID == client.GetClientID() {
if defaultAppID == client.GetClientID() {
priorityOpts = append(priorityOpts, option)
} else {
opts = append(opts, option)
Expand Down
65 changes: 65 additions & 0 deletions internal/cli/apps_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cli
import (
"bytes"
"context"
"errors"
"io"
"testing"

Expand Down Expand Up @@ -160,6 +161,70 @@ func TestAppsCreateCmd(t *testing.T) {
}
}

// TestAppsCreateCmdSucceedsWithoutSavedConfig guards the fix for a create that
// touches the API before persisting the default app locally: with no saved
// config (an empty HOME, as in a read-only sandbox), the app is still created
// via the API and the command must succeed, treating the failed local
// default-app write as a non-fatal warning rather than reporting failure for an
// app that already exists.
func TestAppsCreateCmdSucceedsWithoutSavedConfig(t *testing.T) {
// Point config resolution at an empty temp home so no real config is read or
// written and SetDefaultAppIDForTenant fails the way it would in a sandbox.
t.Setenv("HOME", t.TempDir())

ctrl := gomock.NewController(t)
defer ctrl.Finish()

clientAPI := mock.NewMockClientAPI(ctrl)
clientAPI.EXPECT().
Create(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ context.Context, c *management.Client, _ ...management.RequestOption) error {
c.ClientID = auth0.String("created-client-id")
return nil
})

cli := &cli{
noInput: true,
renderer: &display.Renderer{
MessageWriter: io.Discard,
ResultWriter: io.Discard,
},
api: &auth0.API{Client: clientAPI},
}

cmd := createAppCmd(cli)
cmd.SetArgs([]string{"--name", "My App", "--type", "regular"})

assert.NoError(t, cmd.Execute())
}

// TestAppsUseCmdRejectedInEnvAuthMode guards that 'apps use', whose only job is
// to persist a default application into the on-disk config, fails fast with a
// clear, tagged error in env auth mode (which does not use that config) instead
// of surfacing a confusing "config file is missing" error from the write.
func TestAppsUseCmdRejectedInEnvAuthMode(t *testing.T) {
t.Setenv("HOME", t.TempDir())
t.Setenv(authModeEnvVar, authModeEnv)

cli := &cli{
noInput: true,
renderer: &display.Renderer{
MessageWriter: io.Discard,
ResultWriter: io.Discard,
},
}

cmd := useAppCmd(cli)
cmd.SetArgs([]string{"--none"})

err := cmd.Execute()
assert.Error(t, err)

var authErr authError
assert.True(t, errors.As(err, &authErr))
assert.Equal(t, "env_auth_not_persistable", authErr.reason)
}

func TestAppsUpdateCmdOrganizationFlags(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading
Loading