Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
798bc18
feat(auth): add OAuth-PKCE browser flow to `auth login`
weppos May 27, 2026
d9e8bce
fix(oauth): validate state before branching on callback error
weppos May 28, 2026
4df0a57
fix(oauth): add http.Server timeouts and bounded Shutdown deadline
weppos May 28, 2026
bb7e44e
fix(oauth): strip control bytes from AuthError to prevent terminal in…
weppos May 28, 2026
bc0eacb
fix(oauth): refuse redirect-following on the token-endpoint POST
weppos May 29, 2026
2060858
fix(oauth): derive token endpoint from cfg.BaseURL instead of cfg.San…
weppos May 29, 2026
554f5fa
fix(oauth): surface Serve errors through the result channel
weppos May 29, 2026
a77741f
fix(config): split OAuth client-id env override per environment
weppos Jun 1, 2026
fe1242c
fix(oauth): validate token_type is "bearer" before accepting the resp…
weppos Jun 1, 2026
afa7dac
refactor(auth): route isStdinTTY through the shared isInteractiveInpu…
weppos Jun 1, 2026
d5656ed
chore(auth): reword `auth` Short so it does not duplicate `auth login…
weppos Jun 2, 2026
624d7be
fix(oauth): report context cancellation distinctly from deadline expiry
weppos Jun 2, 2026
d11b55b
fix(oauth): re-check state on the client side as defense in depth
weppos Jun 2, 2026
4817587
feat(config): honor DNSIMPLE_BASE_URL and DNSIMPLE_OAUTH_AUTHORIZE_UR…
weppos Jun 3, 2026
ec9fb4c
fix(oauth): send state in the token exchange request
weppos Jun 3, 2026
b75457e
feat(auth): report browser login failure instead of falling back to t…
weppos Jun 4, 2026
99afccd
feat(auth): confirm login success with a clearer message
weppos Jun 4, 2026
f1aa5f0
feat(auth): dark-launch browser login behind --web / oauth_login
weppos Jun 9, 2026
2ee9538
refactor(auth): collapse the duplicate token-prompt branches in acqui…
weppos Jun 9, 2026
0505a30
test(auth): cover the oauth_login config-toggle login path
weppos Jun 9, 2026
e604117
feat(auth): warn when --web is requested but cannot be honored
weppos Jun 9, 2026
109773a
docs(config): trim the OAuthLogin field comment to one line
weppos Jun 9, 2026
a617bfe
docs(auth): trim the acquireToken doc comment
weppos Jun 9, 2026
153c9f3
feat(oauth): embed first-party client IDs for production and sandbox
weppos Jun 9, 2026
3fb2a0e
docs(config): drop internal dnsimple-app references from comments
weppos Jun 9, 2026
c24c844
docs(changelog): note the dark-launched browser login
weppos Jun 9, 2026
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

### Added

- `auth login` can authenticate in the browser via an interactive OAuth flow (OAuth 2.0 with PKCE and a loopback redirect). The feature is dark-launched and off by default: opt in per command with `--web`, or persistently by setting `oauth_login: true` in the config file (or `DNSIMPLE_OAUTH_LOGIN=1`). Without it, `auth login` keeps prompting for a pasted API token. (dnsimple/cli#57)

## 0.9.0 - 2026-05-25

### Added
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,16 +75,19 @@ dnsimple [command] [flags]
The CLI supports two authentication modes that can be combined freely.

> [!NOTE]
> The CLI currently supports API token authentication only, including both classic and scoped API tokens. OAuth support may be considered in the future, but it is not currently on the roadmap.
> 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
# Log in to production and store a context (prompts for an API token)
dnsimple auth login

# Authenticate in the browser instead of pasting a token
dnsimple auth login --web

# Log in to sandbox alongside it
dnsimple auth login --sandbox

Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/dnsimple/cli
go 1.25.4

require (
github.com/cli/browser v1.3.0
github.com/dnsimple/dnsimple-go/v9 v9.1.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down
53 changes: 39 additions & 14 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ func (a *authStatusOutput) TemplateData() any {
func newAuthCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "Authenticate with DNSimple",
Short: "Manage authentication contexts",
}

cmd.AddCommand(newAuthLoginCmd(f))
Expand Down Expand Up @@ -151,32 +151,47 @@ 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 a DNSimple API token",
Long: `Authenticate with DNSimple by providing an API token and store it as a named context.
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).

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
environment ('production' or 'sandbox'), with the account ID appended on collision.

Get your token from:
Headless / non-interactive use:

- Pass --with-token to pipe a pre-issued API token on stdin:
echo "$TOKEN" | dnsimple auth login --with-token
- When stdin is not a terminal (CI, redirected input), the command reads
the token from stdin without requiring --with-token.

Production: https://dnsimple.com/user
Sandbox: https://sandbox.dnsimple.com/user
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.

See https://support.dnsimple.com/articles/api-access-token/ for instructions on
generating an API token.`,
See https://support.dnsimple.com/articles/api-access-token/ if you need to
generate an API token manually.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
host := config.HostForSandbox(cfg.Sandbox)

token, err := readLoginToken(cmd, withToken)
useOAuth := web || cfg.OAuthLogin
warnIfWebIgnored(cmd, web, withToken)
token, err := acquireToken(cmd, cfg, withToken, useOAuth)
if err != nil {
return err
}
Expand Down Expand Up @@ -205,7 +220,7 @@ generating an API token.`,
return err
}

ctx, action, err := upsertLoginContext(creds, host, token, accountID, user, nameFlag)
ctx, _, err := upsertLoginContext(creds, host, token, accountID, user, nameFlag)
if err != nil {
return err
}
Expand All @@ -215,13 +230,24 @@ generating an API token.`,
return err
}

fmt.Fprintf(cmd.ErrOrStderr(), "%s context %q (%s, account %s) and set as active\n",
action, ctx.Name, config.EnvironmentName(host), ctx.AccountID)
stderr := cmd.ErrOrStderr()
if user != "" {
fmt.Fprintf(stderr, "Success! You're now logged in to DNSimple as %s.\n", user)
} else {
fmt.Fprintln(stderr, "Success! You're now logged in to DNSimple.")
}

location := config.EnvironmentName(host)
if ctx.AccountID != "" {
location = fmt.Sprintf("%s, account %s", location, ctx.AccountID)
}
fmt.Fprintf(stderr, "Context %q (%s) is now active.\n", ctx.Name, location)
return nil
},
}

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().StringVar(&nameFlag, "name", "", "Name for the new context (auto-derived if omitted)")

return cmd
Expand Down Expand Up @@ -327,8 +353,7 @@ func resolveLoginAccount(c *dnsimple.Client, whoami *dnsimple.WhoamiResponse, in
// - same (host, token) anywhere → refresh that context (re-login).
// - otherwise → create with an auto-derived name.
//
// The returned action is "Created" or "Refreshed" for use in the success
// message.
// The returned action is "Created" or "Refreshed".
func upsertLoginContext(creds *config.Credentials, host, token, accountID, user, explicitName string) (*config.Context, string, error) {
if explicitName != "" {
existing := creds.Find(explicitName)
Expand Down
89 changes: 89 additions & 0 deletions internal/cli/auth_oauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package cli

import (
"context"
"errors"
"fmt"
"io"

"github.com/cli/browser"
"github.com/dnsimple/cli/internal/config"
"github.com/dnsimple/cli/internal/oauth"
"github.com/spf13/cobra"
)

// loginViaOAuth runs the interactive OAuth browser flow and returns an
// access token. Production wiring constructs an oauth.Client from the
// active config and delegates to its Login method. Tests override this
// var directly to skip the listener / browser / token exchange.
var loginViaOAuth = defaultLoginViaOAuth

// isStdinTTY reports whether the command's stdin is a real terminal.
// Tests override it directly so they can drive the OAuth branch without
// faking a PTY. The underlying check delegates to isInteractiveInput
// (see confirm.go) so the OAuth branch stays in lockstep with how
// destructive-action prompts decide interactivity.
var isStdinTTY = func(cmd *cobra.Command) bool {
return isInteractiveInput(cmd.InOrStdin())
}

// defaultLoginViaOAuth is the production implementation of the OAuth flow.
// It is wired through `loginViaOAuth` so the integration test in
// auth_oauth_test.go can swap it for a stub.
func defaultLoginViaOAuth(ctx context.Context, cfg *config.Config, errOut io.Writer) (string, error) {
clientID := config.OAuthClientID(cfg.Sandbox)
if clientID == "" {
return "", oauth.ErrNotProvisioned
}
c := &oauth.Client{
ClientID: clientID,
AuthorizeBase: config.AuthorizeURL(cfg.Sandbox),
TokenURL: config.OAuthTokenURL(cfg.BaseURL),
BrowserOpener: browser.OpenURL,
Stderr: errOut,
}
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)
}

token, err := loginViaOAuth(context.Background(), cfg, cmd.ErrOrStderr())
switch {
case err == nil:
return token, nil
case errors.Is(err, context.Canceled):
return "", err
case errors.Is(err, oauth.ErrNotProvisioned):
return "", errors.New("interactive browser login is not available in this build\n\nRun `dnsimple auth login --with-token` to authenticate with an API token instead")
default:
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