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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 3 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 18 additions & 35 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,19 +151,16 @@ 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{
Use: "login",
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
Expand All @@ -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.`,
Expand All @@ -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
}
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
Expand Down
37 changes: 9 additions & 28 deletions internal/cli/auth_oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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.")
}
}
Loading