diff --git a/CHANGELOG.md b/CHANGELOG.md index 8199b80..fa77c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ This project uses [Semantic Versioning 2.0.0](http://semver.org/), the format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## Unreleased + +### Changed + +- `auth login` now defaults to the interactive browser login (OAuth) on a terminal. To authenticate with an API token instead, pass `--with-token` and paste it when prompted. The `--web` flag that opted into the browser flow, the `oauth_login` config setting, and the `DNSIMPLE_OAUTH_LOGIN` environment variable that gated the dark launch have all been removed. + ## 0.9.1 - 2026-06-10 ### Added diff --git a/README.md b/README.md index 24d2d28..4d21852 100644 --- a/README.md +++ b/README.md @@ -74,19 +74,16 @@ dnsimple [command] [flags] The CLI supports two authentication modes that can be combined freely. -> [!NOTE] -> By default `auth login` authenticates with an API token (classic or scoped), which you paste when prompted. An interactive browser login (OAuth) is being rolled out and is off by default for now. Opt in per command with `--web`, or persistently by setting `oauth_login: true` in the config file (or `DNSIMPLE_OAUTH_LOGIN=1`). - #### Stateful: stored contexts Authenticate once and the CLI remembers a named *context* (token, account, environment) on disk. Multiple contexts can coexist and you select one as active: ```shell -# Log in to production and store a context (prompts for an API token) +# Log in to production and store a context (opens the browser to authenticate) dnsimple auth login -# Authenticate in the browser instead of pasting a token -dnsimple auth login --web +# Authenticate with an API token instead of the browser +dnsimple auth login --with-token # Log in to sandbox alongside it dnsimple auth login --sandbox diff --git a/internal/cli/auth.go b/internal/cli/auth.go index aa9beb1..b83ab42 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -151,7 +151,6 @@ This command does not contact the DNSimple API and works without a valid token.` func newAuthLoginCmd(f *cmdutil.Factory) *cobra.Command { var withToken bool - var web bool var nameFlag string cmd := &cobra.Command{ @@ -159,11 +158,9 @@ func newAuthLoginCmd(f *cmdutil.Factory) *cobra.Command { Short: "Authenticate with DNSimple", Long: `Authenticate with DNSimple and store the resulting credential as a named context. -On a terminal, this command prompts you to paste an API token. Pass --web to -authenticate in your browser instead: it opens the DNSimple authorization page -and completes the login automatically once you approve, with no token to copy. -Browser login can also be turned on persistently by setting 'oauth_login: true' -in the config file (or DNSIMPLE_OAUTH_LOGIN=1). +On a terminal, this opens the DNSimple authorization page in your browser and +completes the login automatically once you approve, with no token to copy. Pass +--with-token to paste an API token instead. The new context becomes the active one. To create a sandbox context, pass --sandbox. To choose a context name, pass --name; otherwise the name is derived from the @@ -176,9 +173,8 @@ Headless / non-interactive use: - When stdin is not a terminal (CI, redirected input), the command reads the token from stdin without requiring --with-token. -With --web, if the browser cannot be launched (e.g. no display server), the -authorize URL is printed to stderr and the command keeps listening for the -callback. +If the browser cannot be launched (e.g. no display server), the authorize URL is +printed to stderr and the command keeps listening for the callback. See https://support.dnsimple.com/articles/api-access-token/ if you need to generate an API token manually.`, @@ -189,9 +185,7 @@ generate an API token manually.`, } host := config.HostForSandbox(cfg.Sandbox) - useOAuth := web || cfg.OAuthLogin - warnIfWebIgnored(cmd, web, withToken) - token, err := acquireToken(cmd, cfg, withToken, useOAuth) + token, err := acquireToken(cmd, cfg, withToken) if err != nil { return err } @@ -246,35 +240,24 @@ generate an API token manually.`, }, } - cmd.Flags().BoolVar(&withToken, "with-token", false, "Read token from stdin") - cmd.Flags().BoolVar(&web, "web", false, "Authenticate in a browser instead of pasting a token") + cmd.Flags().BoolVar(&withToken, "with-token", false, "Authenticate with an API token instead of the browser") cmd.Flags().StringVar(&nameFlag, "name", "", "Name for the new context (auto-derived if omitted)") return cmd } -// readLoginToken reads a token from the command's stdin and trims whitespace. +// readLoginToken reads an API token from the command's stdin and trims +// whitespace. // -// With --with-token, input is read as a single line (the typical piping case) -// and is not masked. -// -// Interactive (no --with-token), when stdin is a real TTY, the input is read -// with terminal echo disabled so the token is not displayed. When stdin is -// not a real TTY (tests, redirected input), the function falls back to a -// plain line scan so behaviour stays predictable. -func readLoginToken(cmd *cobra.Command, withToken bool) (string, error) { - if withToken { - token, err := scanLine(cmd.InOrStdin()) - if err != nil || token == "" { - return "", fmt.Errorf("no token provided on stdin") - } - return token, nil - } - - fmt.Fprintln(cmd.ErrOrStderr(), "Follow the instructions at https://support.dnsimple.com/articles/api-access-token/ to generate an API token.") - fmt.Fprint(cmd.ErrOrStderr(), "Paste your API token: ") - +// When stdin is a real TTY, it prints a prompt and reads with terminal echo +// disabled so the token is not displayed. When stdin is not a real TTY (piped +// input, CI, tests), it reads a single line without prompting so behaviour +// stays predictable for scripting. +func readLoginToken(cmd *cobra.Command) (string, error) { if f, ok := cmd.InOrStdin().(*os.File); ok && term.IsTerminal(int(f.Fd())) { + fmt.Fprintln(cmd.ErrOrStderr(), "Follow the instructions at https://support.dnsimple.com/articles/api-access-token/ to generate an API token.") + fmt.Fprint(cmd.ErrOrStderr(), "Paste your API token: ") + raw, err := term.ReadPassword(int(f.Fd())) // ReadPassword leaves the cursor on the prompt line; emit a newline // so subsequent output starts cleanly. @@ -291,7 +274,7 @@ func readLoginToken(cmd *cobra.Command, withToken bool) (string, error) { token, err := scanLine(cmd.InOrStdin()) if err != nil || token == "" { - return "", fmt.Errorf("no token provided") + return "", fmt.Errorf("no token provided on stdin") } return token, nil } diff --git a/internal/cli/auth_oauth.go b/internal/cli/auth_oauth.go index 18dd59e..06aa741 100644 --- a/internal/cli/auth_oauth.go +++ b/internal/cli/auth_oauth.go @@ -45,17 +45,15 @@ func defaultLoginViaOAuth(ctx context.Context, cfg *config.Config, errOut io.Wri return c.Login(ctx) } -// acquireToken obtains the access token for a fresh `auth login`. It reads a -// token from stdin for --with-token or non-TTY input; on a TTY it runs the -// OAuth browser flow when useOAuth is set, otherwise it prompts for a pasted -// token. A browser-login failure is returned as-is (no paste fallback); the -// error tells the user to retry or pass --with-token. -func acquireToken(cmd *cobra.Command, cfg *config.Config, withToken, useOAuth bool) (string, error) { - switch { - case withToken: - return readLoginToken(cmd, true) - case !isStdinTTY(cmd) || !useOAuth: - return readLoginToken(cmd, false) +// acquireToken obtains the access token for a fresh `auth login`. On a terminal +// it runs the interactive OAuth browser flow by default; pass --with-token to +// paste an API token instead. When stdin is not a terminal (CI, redirected +// input) it reads the token from stdin without requiring --with-token. A +// browser-login failure is returned as-is (no paste fallback); the error tells +// the user to retry or pass --with-token. +func acquireToken(cmd *cobra.Command, cfg *config.Config, withToken bool) (string, error) { + if withToken || !isStdinTTY(cmd) { + return readLoginToken(cmd) } token, err := loginViaOAuth(context.Background(), cfg, cmd.ErrOrStderr()) @@ -70,20 +68,3 @@ func acquireToken(cmd *cobra.Command, cfg *config.Config, withToken, useOAuth bo return "", fmt.Errorf("browser login failed: %w\n\nRetry `dnsimple auth login`, or run `dnsimple auth login --with-token` to authenticate with an API token instead", err) } } - -// warnIfWebIgnored notes that an explicit --web was not honored, mirroring the -// precedence in acquireToken: --with-token wins, and the browser flow needs an -// interactive terminal. It keys off the actual flag value (not just whether it -// was set) so `--web=false` stays silent, and it ignores the persistent -// oauth_login toggle, which is meant to fall back to the prompt without noise. -func warnIfWebIgnored(cmd *cobra.Command, web, withToken bool) { - if !web { - return - } - switch { - case withToken: - fmt.Fprintln(cmd.ErrOrStderr(), "Warning: --web is ignored when --with-token is set.") - case !isStdinTTY(cmd): - fmt.Fprintln(cmd.ErrOrStderr(), "Warning: browser login (--web) needs an interactive terminal; reading the token from stdin instead.") - } -} diff --git a/internal/cli/auth_oauth_test.go b/internal/cli/auth_oauth_test.go index d00abe3..8854b0d 100644 --- a/internal/cli/auth_oauth_test.go +++ b/internal/cli/auth_oauth_test.go @@ -41,7 +41,7 @@ func TestAcquireTokenWithTokenFlagReadsFromStdin(t *testing.T) { cmd.SetIn(strings.NewReader("tok-from-stdin\n")) cmd.SetErr(io.Discard) - got, err := acquireToken(cmd, &config.Config{}, true, false) + got, err := acquireToken(cmd, &config.Config{}, true) if !assert.NoError(t, err) { return } @@ -54,7 +54,7 @@ func TestAcquireTokenNonTTYReadsFromStdin(t *testing.T) { cmd.SetIn(strings.NewReader("tok-piped\n")) cmd.SetErr(io.Discard) - got, err := acquireToken(cmd, &config.Config{}, false, false) + got, err := acquireToken(cmd, &config.Config{}, false) if !assert.NoError(t, err) { return } @@ -74,7 +74,7 @@ func TestAcquireTokenTTYRunsOAuth(t *testing.T) { cmd.SetIn(strings.NewReader("")) // OAuth path must not consume stdin cmd.SetErr(io.Discard) - got, err := acquireToken(cmd, &config.Config{Sandbox: true}, false, true) + got, err := acquireToken(cmd, &config.Config{Sandbox: true}, false) if !assert.NoError(t, err) { return } @@ -82,13 +82,12 @@ func TestAcquireTokenTTYRunsOAuth(t *testing.T) { assert.True(t, capturedSandbox, "OAuth flow should receive cfg.Sandbox=true") } -// TestAcquireTokenTTYWithoutOAuthPromptsForToken pins the dark-launch default: -// on a TTY with OAuth disabled, the command reads a pasted token and never -// starts the browser flow. -func TestAcquireTokenTTYWithoutOAuthPromptsForToken(t *testing.T) { +// TestAcquireTokenWithTokenOnTTYReadsToken pins that --with-token takes the +// token path and never starts the browser flow, even on a terminal. +func TestAcquireTokenWithTokenOnTTYReadsToken(t *testing.T) { forceTTY(t, true) stubLoginViaOAuth(t, func(context.Context, *config.Config, io.Writer) (string, error) { - t.Fatal("OAuth flow must not run when useOAuth is false") + t.Fatal("OAuth flow must not run when --with-token is set") return "", nil }) @@ -96,7 +95,7 @@ func TestAcquireTokenTTYWithoutOAuthPromptsForToken(t *testing.T) { cmd.SetIn(strings.NewReader("tok-paste\n")) cmd.SetErr(io.Discard) - got, err := acquireToken(cmd, &config.Config{}, false, false) + got, err := acquireToken(cmd, &config.Config{}, true) if !assert.NoError(t, err) { return } @@ -113,7 +112,7 @@ func TestAcquireTokenErrorsOnErrNotProvisioned(t *testing.T) { cmd.SetIn(strings.NewReader("would-be-pasted\n")) // must not be consumed cmd.SetErr(io.Discard) - _, err := acquireToken(cmd, &config.Config{}, false, true) + _, err := acquireToken(cmd, &config.Config{}, false) if !assert.Error(t, err) { return } @@ -131,7 +130,7 @@ func TestAcquireTokenErrorsOnTransientOAuthError(t *testing.T) { cmd.SetIn(strings.NewReader("would-be-pasted\n")) // must not be consumed cmd.SetErr(io.Discard) - _, err := acquireToken(cmd, &config.Config{}, false, true) + _, err := acquireToken(cmd, &config.Config{}, false) if !assert.Error(t, err) { return } @@ -152,7 +151,7 @@ func TestAcquireTokenAbortsOnAccessDenied(t *testing.T) { cmd.SetIn(strings.NewReader("would-be-pasted\n")) cmd.SetErr(io.Discard) - _, err := acquireToken(cmd, &config.Config{}, false, true) + _, err := acquireToken(cmd, &config.Config{}, false) if !assert.Error(t, err) { return } @@ -171,7 +170,7 @@ func TestAcquireTokenAbortsOnStateMismatch(t *testing.T) { cmd.SetIn(strings.NewReader("would-be-pasted\n")) cmd.SetErr(io.Discard) - _, err := acquireToken(cmd, &config.Config{}, false, true) + _, err := acquireToken(cmd, &config.Config{}, false) assert.ErrorIs(t, err, oauth.ErrStateMismatch) } @@ -185,44 +184,14 @@ func TestAcquireTokenAbortsOnContextCancellation(t *testing.T) { cmd.SetIn(strings.NewReader("would-be-pasted\n")) cmd.SetErr(io.Discard) - _, err := acquireToken(cmd, &config.Config{}, false, true) + _, err := acquireToken(cmd, &config.Config{}, false) assert.ErrorIs(t, err, context.Canceled) } -// --- warnIfWebIgnored --- - -func TestWarnIfWebIgnored(t *testing.T) { - warnOutput := func(t *testing.T, web, withToken bool) string { - t.Helper() - cmd := &cobra.Command{} - var errb bytes.Buffer - cmd.SetErr(&errb) - warnIfWebIgnored(cmd, web, withToken) - return errb.String() - } - - t.Run("with-token wins over --web", func(t *testing.T) { - assert.Contains(t, warnOutput(t, true, true), "--with-token") - }) - - t.Run("non-TTY needs a terminal", func(t *testing.T) { - forceTTY(t, false) - assert.Contains(t, warnOutput(t, true, false), "interactive terminal") - }) - - t.Run("no --web is silent", func(t *testing.T) { - forceTTY(t, false) - assert.Empty(t, warnOutput(t, false, false)) - }) - - t.Run("--web on a TTY is silent", func(t *testing.T) { - forceTTY(t, true) - assert.Empty(t, warnOutput(t, true, false)) - }) -} - // --- end-to-end: auth login via OAuth --- +// TestAuthLoginViaOAuthEndToEnd pins the default: on a TTY, with no flags, `auth +// login` runs the browser flow and stores the OAuth-issued token. func TestAuthLoginViaOAuthEndToEnd(t *testing.T) { isolateConfigHomeForCLI(t) forceTTY(t, true) @@ -236,9 +205,6 @@ func TestAuthLoginViaOAuthEndToEnd(t *testing.T) { f := cmdutil.NewFactory("test") cmd := buildLoginCmdWithBaseURL(t, f, server.URL) - if !assert.NoError(t, cmd.Flags().Set("web", "true")) { // opt into the browser flow - return - } var stderr bytes.Buffer cmd.SetIn(strings.NewReader("")) // OAuth path should not consume stdin @@ -266,11 +232,10 @@ func TestAuthLoginViaOAuthEndToEnd(t *testing.T) { assert.Contains(t, stderr.String(), "is now active") } -// TestAuthLoginViaOAuthConfigToggle pins the persistent opt-in: with -// oauth_login enabled in config (cfg.OAuthLogin) and no --web flag, `auth -// login` on a TTY runs the browser flow. This exercises the cfg.OAuthLogin -// operand of `useOAuth := web || cfg.OAuthLogin`, which the --web tests do not. -func TestAuthLoginViaOAuthConfigToggle(t *testing.T) { +// TestAuthLoginWithTokenStoresContext pins the token path at the command level: +// with --with-token on a TTY, `auth login` reads the token and stores a context +// without ever starting the browser flow. +func TestAuthLoginWithTokenStoresContext(t *testing.T) { isolateConfigHomeForCLI(t) forceTTY(t, true) @@ -278,49 +243,15 @@ func TestAuthLoginViaOAuthConfigToggle(t *testing.T) { defer server.Close() stubLoginViaOAuth(t, func(context.Context, *config.Config, io.Writer) (string, error) { - return "tok-cfg-toggle", nil - }) - - f := cmdutil.NewFactory("test") - cfg := &config.Config{BaseURL: server.URL, OAuthLogin: true} - f.Config = func() (*config.Config, error) { return cfg, nil } - cmd := newAuthLoginCmd(f) - - cmd.SetIn(strings.NewReader("would-be-pasted\n")) // OAuth path must not consume stdin - cmd.SetErr(io.Discard) - cmd.SetOut(io.Discard) - - if err := cmd.RunE(cmd, nil); !assert.NoError(t, err) { - return - } - - creds, err := config.LoadCredentials() - if !assert.NoError(t, err) { - return - } - if assert.Len(t, creds.Contexts, 1) { - assert.Equal(t, "tok-cfg-toggle", creds.Contexts[0].Token, "stored token should come from the OAuth flow enabled by oauth_login") - } - assert.Equal(t, "production", creds.ActiveContext) -} - -// TestAuthLoginDefaultPromptsForToken pins the dark-launch default: with the -// browser flow off (no --web, oauth_login unset), `auth login` on a TTY reads -// a pasted token and stores a context. The OAuth flow must not run. -func TestAuthLoginDefaultPromptsForToken(t *testing.T) { - isolateConfigHomeForCLI(t) - forceTTY(t, true) - - server := newWhoamiServer(t, `{"data":{"user":{"id":1,"email":"alice@example.com"},"account":{"id":981,"email":"acct@example.com"}}}`) - defer server.Close() - - stubLoginViaOAuth(t, func(context.Context, *config.Config, io.Writer) (string, error) { - t.Fatal("OAuth flow must not run without --web / oauth_login") + t.Fatal("OAuth flow must not run when --with-token is set") return "", nil }) f := cmdutil.NewFactory("test") cmd := buildLoginCmdWithBaseURL(t, f, server.URL) + if !assert.NoError(t, cmd.Flags().Set("with-token", "true")) { + return + } cmd.SetIn(strings.NewReader("tok-paste\n")) cmd.SetErr(io.Discard) @@ -335,14 +266,14 @@ func TestAuthLoginDefaultPromptsForToken(t *testing.T) { return } if assert.Len(t, creds.Contexts, 1) { - assert.Equal(t, "tok-paste", creds.Contexts[0].Token, "stored token should come from the paste prompt") + assert.Equal(t, "tok-paste", creds.Contexts[0].Token, "stored token should come from --with-token") } assert.Equal(t, "production", creds.ActiveContext) } -// TestAuthLoginViaOAuthNotProvisionedErrors pins that once the browser flow is -// opted into (--web) but the build is not provisioned, the command reports the -// failure and exits instead of falling back to a paste prompt. +// TestAuthLoginViaOAuthNotProvisionedErrors pins that when the default browser +// flow runs but the build is not provisioned, the command reports the failure +// and exits instead of falling back to a paste prompt. func TestAuthLoginViaOAuthNotProvisionedErrors(t *testing.T) { isolateConfigHomeForCLI(t) forceTTY(t, true) @@ -356,9 +287,6 @@ func TestAuthLoginViaOAuthNotProvisionedErrors(t *testing.T) { f := cmdutil.NewFactory("test") cmd := buildLoginCmdWithBaseURL(t, f, server.URL) - if !assert.NoError(t, cmd.Flags().Set("web", "true")) { - return - } cmd.SetIn(strings.NewReader("tok-paste\n")) // must not be consumed cmd.SetErr(io.Discard) diff --git a/internal/config/config.go b/internal/config/config.go index 0a8bf1a..ef0a280 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,10 +38,6 @@ type Config struct { // PerPage is the default number of items per page for list commands. PerPage int - - // OAuthLogin opts `auth login` into the interactive browser flow; off by - // default during the dark-launch rollout (see --web / DNSIMPLE_OAUTH_LOGIN). - OAuthLogin bool } // Dir returns the configuration directory path. @@ -71,7 +67,6 @@ func Load() (*Config, error) { v.SetDefault("sandbox", false) v.SetDefault("per_page", defaultPerPage) v.SetDefault("default_account", "") - v.SetDefault("oauth_login", false) // Config file is optional if err := v.ReadInConfig(); err != nil { @@ -88,7 +83,6 @@ func Load() (*Config, error) { Sandbox: v.GetBool("sandbox"), DefaultAccount: v.GetString("default_account"), PerPage: v.GetInt("per_page"), - OAuthLogin: v.GetBool("oauth_login"), } cfg.BaseURL = baseURLForSandbox(cfg.Sandbox) @@ -131,7 +125,6 @@ func (c *Config) Save() error { c.v.Set("sandbox", c.Sandbox) c.v.Set("default_account", c.DefaultAccount) c.v.Set("per_page", c.PerPage) - c.v.Set("oauth_login", c.OAuthLogin) return c.v.WriteConfigAs(filepath.Join(dir, configFileName+".yml")) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bda548e..c671208 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -26,7 +26,6 @@ func TestLoadDefaults(t *testing.T) { assert.Equal(t, defaultBaseURL, cfg.BaseURL) assert.Empty(t, cfg.DefaultAccount) assert.Equal(t, defaultPerPage, cfg.PerPage) - assert.False(t, cfg.OAuthLogin) } func TestLoadFromEnvironment(t *testing.T) { @@ -34,7 +33,6 @@ func TestLoadFromEnvironment(t *testing.T) { t.Setenv("DNSIMPLE_SANDBOX", "true") t.Setenv("DNSIMPLE_DEFAULT_ACCOUNT", "1010") t.Setenv("DNSIMPLE_PER_PAGE", "75") - t.Setenv("DNSIMPLE_OAUTH_LOGIN", "true") cfg, err := Load() if !assert.NoError(t, err) { @@ -45,7 +43,6 @@ func TestLoadFromEnvironment(t *testing.T) { assert.Equal(t, sandboxBaseURL, cfg.BaseURL) assert.Equal(t, "1010", cfg.DefaultAccount) assert.Equal(t, 75, cfg.PerPage) - assert.True(t, cfg.OAuthLogin) } func TestSaveAndReload(t *testing.T) { @@ -59,7 +56,6 @@ func TestSaveAndReload(t *testing.T) { cfg.SetSandbox(true) cfg.DefaultAccount = "2020" cfg.PerPage = 99 - cfg.OAuthLogin = true if !assert.NoError(t, cfg.Save()) { return @@ -74,7 +70,6 @@ func TestSaveAndReload(t *testing.T) { assert.Equal(t, sandboxBaseURL, reloaded.BaseURL) assert.Equal(t, "2020", reloaded.DefaultAccount) assert.Equal(t, 99, reloaded.PerPage) - assert.True(t, reloaded.OAuthLogin) } func TestSetSandboxUpdatesBaseURL(t *testing.T) {