Skip to content

Commit cfbe6b3

Browse files
authored
feat(auth): interactive browser login for auth login, dark-launched behind --web / oauth_login (#57)
Adds an interactive, browser-based login to dnsimple auth login and ships it as a dark launch so it can be rolled out gradually.
1 parent 4085a14 commit cfbe6b3

22 files changed

Lines changed: 2079 additions & 29 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@
22

33
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/).
44

5+
## Unreleased
6+
7+
### Added
8+
9+
- `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)
10+
511
## 0.9.0 - 2026-05-25
612

713
### Added

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,16 +75,19 @@ dnsimple [command] [flags]
7575
The CLI supports two authentication modes that can be combined freely.
7676

7777
> [!NOTE]
78-
> 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.
78+
> 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`).
7979
8080
#### Stateful: stored contexts
8181

8282
Authenticate once and the CLI remembers a named *context* (token, account, environment) on disk. Multiple contexts can coexist and you select one as active:
8383

8484
```shell
85-
# Log in to production and store a context
85+
# Log in to production and store a context (prompts for an API token)
8686
dnsimple auth login
8787

88+
# Authenticate in the browser instead of pasting a token
89+
dnsimple auth login --web
90+
8891
# Log in to sandbox alongside it
8992
dnsimple auth login --sandbox
9093

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/dnsimple/cli
33
go 1.25.4
44

55
require (
6+
github.com/cli/browser v1.3.0
67
github.com/dnsimple/dnsimple-go/v9 v9.1.0
78
github.com/spf13/cobra v1.10.2
89
github.com/spf13/pflag v1.0.10

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
2+
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
13
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
24
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
35
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

internal/cli/auth.go

Lines changed: 39 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func (a *authStatusOutput) TemplateData() any {
6464
func newAuthCmd(f *cmdutil.Factory) *cobra.Command {
6565
cmd := &cobra.Command{
6666
Use: "auth",
67-
Short: "Authenticate with DNSimple",
67+
Short: "Manage authentication contexts",
6868
}
6969

7070
cmd.AddCommand(newAuthLoginCmd(f))
@@ -151,32 +151,47 @@ This command does not contact the DNSimple API and works without a valid token.`
151151

152152
func newAuthLoginCmd(f *cmdutil.Factory) *cobra.Command {
153153
var withToken bool
154+
var web bool
154155
var nameFlag string
155156

156157
cmd := &cobra.Command{
157158
Use: "login",
158-
Short: "Authenticate with a DNSimple API token",
159-
Long: `Authenticate with DNSimple by providing an API token and store it as a named context.
159+
Short: "Authenticate with DNSimple",
160+
Long: `Authenticate with DNSimple and store the resulting credential as a named context.
161+
162+
On a terminal, this command prompts you to paste an API token. Pass --web to
163+
authenticate in your browser instead: it opens the DNSimple authorization page
164+
and completes the login automatically once you approve, with no token to copy.
165+
Browser login can also be turned on persistently by setting 'oauth_login: true'
166+
in the config file (or DNSIMPLE_OAUTH_LOGIN=1).
160167
161168
The new context becomes the active one. To create a sandbox context, pass --sandbox.
162169
To choose a context name, pass --name; otherwise the name is derived from the
163170
environment ('production' or 'sandbox'), with the account ID appended on collision.
164171
165-
Get your token from:
172+
Headless / non-interactive use:
173+
174+
- Pass --with-token to pipe a pre-issued API token on stdin:
175+
echo "$TOKEN" | dnsimple auth login --with-token
176+
- When stdin is not a terminal (CI, redirected input), the command reads
177+
the token from stdin without requiring --with-token.
166178
167-
Production: https://dnsimple.com/user
168-
Sandbox: https://sandbox.dnsimple.com/user
179+
With --web, if the browser cannot be launched (e.g. no display server), the
180+
authorize URL is printed to stderr and the command keeps listening for the
181+
callback.
169182
170-
See https://support.dnsimple.com/articles/api-access-token/ for instructions on
171-
generating an API token.`,
183+
See https://support.dnsimple.com/articles/api-access-token/ if you need to
184+
generate an API token manually.`,
172185
RunE: func(cmd *cobra.Command, args []string) error {
173186
cfg, err := f.Config()
174187
if err != nil {
175188
return err
176189
}
177190
host := config.HostForSandbox(cfg.Sandbox)
178191

179-
token, err := readLoginToken(cmd, withToken)
192+
useOAuth := web || cfg.OAuthLogin
193+
warnIfWebIgnored(cmd, web, withToken)
194+
token, err := acquireToken(cmd, cfg, withToken, useOAuth)
180195
if err != nil {
181196
return err
182197
}
@@ -205,7 +220,7 @@ generating an API token.`,
205220
return err
206221
}
207222

208-
ctx, action, err := upsertLoginContext(creds, host, token, accountID, user, nameFlag)
223+
ctx, _, err := upsertLoginContext(creds, host, token, accountID, user, nameFlag)
209224
if err != nil {
210225
return err
211226
}
@@ -215,13 +230,24 @@ generating an API token.`,
215230
return err
216231
}
217232

218-
fmt.Fprintf(cmd.ErrOrStderr(), "%s context %q (%s, account %s) and set as active\n",
219-
action, ctx.Name, config.EnvironmentName(host), ctx.AccountID)
233+
stderr := cmd.ErrOrStderr()
234+
if user != "" {
235+
fmt.Fprintf(stderr, "Success! You're now logged in to DNSimple as %s.\n", user)
236+
} else {
237+
fmt.Fprintln(stderr, "Success! You're now logged in to DNSimple.")
238+
}
239+
240+
location := config.EnvironmentName(host)
241+
if ctx.AccountID != "" {
242+
location = fmt.Sprintf("%s, account %s", location, ctx.AccountID)
243+
}
244+
fmt.Fprintf(stderr, "Context %q (%s) is now active.\n", ctx.Name, location)
220245
return nil
221246
},
222247
}
223248

224249
cmd.Flags().BoolVar(&withToken, "with-token", false, "Read token from stdin")
250+
cmd.Flags().BoolVar(&web, "web", false, "Authenticate in a browser instead of pasting a token")
225251
cmd.Flags().StringVar(&nameFlag, "name", "", "Name for the new context (auto-derived if omitted)")
226252

227253
return cmd
@@ -327,8 +353,7 @@ func resolveLoginAccount(c *dnsimple.Client, whoami *dnsimple.WhoamiResponse, in
327353
// - same (host, token) anywhere → refresh that context (re-login).
328354
// - otherwise → create with an auto-derived name.
329355
//
330-
// The returned action is "Created" or "Refreshed" for use in the success
331-
// message.
356+
// The returned action is "Created" or "Refreshed".
332357
func upsertLoginContext(creds *config.Credentials, host, token, accountID, user, explicitName string) (*config.Context, string, error) {
333358
if explicitName != "" {
334359
existing := creds.Find(explicitName)

internal/cli/auth_oauth.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package cli
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"io"
8+
9+
"github.com/cli/browser"
10+
"github.com/dnsimple/cli/internal/config"
11+
"github.com/dnsimple/cli/internal/oauth"
12+
"github.com/spf13/cobra"
13+
)
14+
15+
// loginViaOAuth runs the interactive OAuth browser flow and returns an
16+
// access token. Production wiring constructs an oauth.Client from the
17+
// active config and delegates to its Login method. Tests override this
18+
// var directly to skip the listener / browser / token exchange.
19+
var loginViaOAuth = defaultLoginViaOAuth
20+
21+
// isStdinTTY reports whether the command's stdin is a real terminal.
22+
// Tests override it directly so they can drive the OAuth branch without
23+
// faking a PTY. The underlying check delegates to isInteractiveInput
24+
// (see confirm.go) so the OAuth branch stays in lockstep with how
25+
// destructive-action prompts decide interactivity.
26+
var isStdinTTY = func(cmd *cobra.Command) bool {
27+
return isInteractiveInput(cmd.InOrStdin())
28+
}
29+
30+
// defaultLoginViaOAuth is the production implementation of the OAuth flow.
31+
// It is wired through `loginViaOAuth` so the integration test in
32+
// auth_oauth_test.go can swap it for a stub.
33+
func defaultLoginViaOAuth(ctx context.Context, cfg *config.Config, errOut io.Writer) (string, error) {
34+
clientID := config.OAuthClientID(cfg.Sandbox)
35+
if clientID == "" {
36+
return "", oauth.ErrNotProvisioned
37+
}
38+
c := &oauth.Client{
39+
ClientID: clientID,
40+
AuthorizeBase: config.AuthorizeURL(cfg.Sandbox),
41+
TokenURL: config.OAuthTokenURL(cfg.BaseURL),
42+
BrowserOpener: browser.OpenURL,
43+
Stderr: errOut,
44+
}
45+
return c.Login(ctx)
46+
}
47+
48+
// acquireToken obtains the access token for a fresh `auth login`. It reads a
49+
// token from stdin for --with-token or non-TTY input; on a TTY it runs the
50+
// OAuth browser flow when useOAuth is set, otherwise it prompts for a pasted
51+
// token. A browser-login failure is returned as-is (no paste fallback); the
52+
// error tells the user to retry or pass --with-token.
53+
func acquireToken(cmd *cobra.Command, cfg *config.Config, withToken, useOAuth bool) (string, error) {
54+
switch {
55+
case withToken:
56+
return readLoginToken(cmd, true)
57+
case !isStdinTTY(cmd) || !useOAuth:
58+
return readLoginToken(cmd, false)
59+
}
60+
61+
token, err := loginViaOAuth(context.Background(), cfg, cmd.ErrOrStderr())
62+
switch {
63+
case err == nil:
64+
return token, nil
65+
case errors.Is(err, context.Canceled):
66+
return "", err
67+
case errors.Is(err, oauth.ErrNotProvisioned):
68+
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")
69+
default:
70+
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)
71+
}
72+
}
73+
74+
// warnIfWebIgnored notes that an explicit --web was not honored, mirroring the
75+
// precedence in acquireToken: --with-token wins, and the browser flow needs an
76+
// interactive terminal. It keys off the actual flag value (not just whether it
77+
// was set) so `--web=false` stays silent, and it ignores the persistent
78+
// oauth_login toggle, which is meant to fall back to the prompt without noise.
79+
func warnIfWebIgnored(cmd *cobra.Command, web, withToken bool) {
80+
if !web {
81+
return
82+
}
83+
switch {
84+
case withToken:
85+
fmt.Fprintln(cmd.ErrOrStderr(), "Warning: --web is ignored when --with-token is set.")
86+
case !isStdinTTY(cmd):
87+
fmt.Fprintln(cmd.ErrOrStderr(), "Warning: browser login (--web) needs an interactive terminal; reading the token from stdin instead.")
88+
}
89+
}

0 commit comments

Comments
 (0)