Skip to content

Commit 4e1318e

Browse files
ankddevBagToad
andauthored
feat: gh auth Automatically copy one-time OAuth code to clipboard (cli#11518)
* feat: add ability to copy one-time OAuth code while authenticating Signed-off-by: Andrey <andrekabatareika@gmail.com> * fix(docs): wrong example for gh auth refresh * chore(authflow): update message to include one-time code to it Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> * chore(authflow): improve message when copied one-time code Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> * chore(authflow): don't early return error when could not copy OAuth code Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com> * refactor(authflow): make code for working with OAuth code more readable * Adjust language in `gh auth` help for clipboard --------- Signed-off-by: Andrey <andrekabatareika@gmail.com> Co-authored-by: Kynan Ware <47394200+BagToad@users.noreply.github.com>
1 parent 204536c commit 4e1318e

7 files changed

Lines changed: 97 additions & 8 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ require (
88
github.com/AlecAivazis/survey/v2 v2.3.7
99
github.com/MakeNowJust/heredoc v1.0.0
1010
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2
11+
github.com/atotto/clipboard v0.1.4
1112
github.com/briandowns/spinner v1.23.2
1213
github.com/cenkalti/backoff/v4 v4.3.0
1314
github.com/cenkalti/backoff/v5 v5.0.2
@@ -84,7 +85,6 @@ require (
8485
github.com/Masterminds/sprig/v3 v3.3.0 // indirect
8586
github.com/alecthomas/chroma/v2 v2.19.0 // indirect
8687
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
87-
github.com/atotto/clipboard v0.1.4 // indirect
8888
github.com/avast/retry-go/v4 v4.6.1 // indirect
8989
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
9090
github.com/aymerick/douceur v0.2.0 // indirect

internal/authflow/flow.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"regexp"
1010
"strings"
1111

12+
"github.com/atotto/clipboard"
1213
"github.com/cli/cli/v2/api"
1314
"github.com/cli/cli/v2/internal/browser"
1415
"github.com/cli/cli/v2/internal/ghinstance"
@@ -29,7 +30,7 @@ var (
2930
jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
3031
)
3132

32-
func AuthFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string, isInteractive bool, b browser.Browser) (string, string, error) {
33+
func AuthFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string, isInteractive bool, b browser.Browser, isCopyToClipboard bool) (string, string, error) {
3334
w := IO.ErrOut
3435
cs := IO.ColorScheme()
3536

@@ -55,6 +56,15 @@ func AuthFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
5556
CallbackURI: getCallbackURI(oauthHost),
5657
Scopes: scopes,
5758
DisplayCode: func(code, verificationURL string) error {
59+
if isCopyToClipboard {
60+
err := clipboard.WriteAll(code)
61+
if err == nil {
62+
fmt.Fprintf(w, "%s One-time code (%s) copied to clipboard\n", cs.Yellow("!"), cs.Bold(code))
63+
return nil
64+
}
65+
fmt.Fprintf(w, "%s Failed to copy one-time code to clipboard\n", cs.Red("!"))
66+
fmt.Fprintf(w, " %s\n", err)
67+
}
5868
fmt.Fprintf(w, "%s First copy your one-time code: %s\n", cs.Yellow("!"), cs.Bold(code))
5969
return nil
6070
},

pkg/cmd/auth/login/login.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type LoginOptions struct {
3838
GitProtocol string
3939
InsecureStorage bool
4040
SkipSSHKeyPrompt bool
41+
Clipboard bool
4142
}
4243

4344
func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
@@ -95,6 +96,9 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
9596
# Start interactive setup
9697
$ gh auth login
9798
99+
# Open a browser to authenticate and copy one-time OAuth code to clipboard
100+
$ gh auth login --web --clipboard
101+
98102
# Authenticate against github.com by reading the token from a file
99103
$ gh auth login --with-token < mytoken.txt
100104
@@ -145,6 +149,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
145149
cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes to request")
146150
cmd.Flags().BoolVar(&tokenStdin, "with-token", false, "Read token from standard input")
147151
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a browser to authenticate")
152+
cmd.Flags().BoolVarP(&opts.Clipboard, "clipboard", "c", false, "Copy one-time OAuth device code to clipboard")
148153
cmdutil.StringEnumFlag(cmd, &opts.GitProtocol, "git-protocol", "p", "", []string{"ssh", "https"}, "The protocol to use for git operations on this host")
149154

150155
// secure storage became the default on 2023/4/04; this flag is left as a no-op for backwards compatibility
@@ -227,6 +232,7 @@ func loginRun(opts *LoginOptions) error {
227232
},
228233
SecureStorage: !opts.InsecureStorage,
229234
SkipSSHKeyPrompt: opts.SkipSSHKeyPrompt,
235+
CopyToClipboard: opts.Clipboard,
230236
})
231237
}
232238

pkg/cmd/auth/login/login_test.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,26 @@ func Test_NewCmdLogin(t *testing.T) {
129129
Interactive: true,
130130
},
131131
},
132+
{
133+
name: "tty web and clipboard",
134+
stdinTTY: true,
135+
cli: "--web --clipboard",
136+
wants: LoginOptions{
137+
Hostname: "github.com",
138+
Web: true,
139+
Interactive: true,
140+
Clipboard: true,
141+
},
142+
},
143+
{
144+
name: "nontty web and clipboard",
145+
cli: "--web --clipboard",
146+
wants: LoginOptions{
147+
Hostname: "github.com",
148+
Web: true,
149+
Clipboard: true,
150+
},
151+
},
132152
{
133153
name: "tty web",
134154
stdinTTY: true,
@@ -273,6 +293,7 @@ func Test_NewCmdLogin(t *testing.T) {
273293
assert.Equal(t, tt.wants.Web, gotOpts.Web)
274294
assert.Equal(t, tt.wants.Interactive, gotOpts.Interactive)
275295
assert.Equal(t, tt.wants.Scopes, gotOpts.Scopes)
296+
assert.Equal(t, tt.wants.Clipboard, gotOpts.Clipboard)
276297
})
277298
}
278299
}

pkg/cmd/auth/refresh/refresh.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,19 @@ type RefreshOptions struct {
3333
Scopes []string
3434
RemoveScopes []string
3535
ResetScopes bool
36-
AuthFlow func(*iostreams.IOStreams, string, []string, bool) (token, username, error)
36+
AuthFlow func(*iostreams.IOStreams, string, []string, bool, bool) (token, username, error)
3737

3838
Interactive bool
3939
InsecureStorage bool
40+
Clipboard bool
4041
}
4142

4243
func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command {
4344
opts := &RefreshOptions{
4445
IO: f.IOStreams,
4546
Config: f.Config,
46-
AuthFlow: func(io *iostreams.IOStreams, hostname string, scopes []string, interactive bool) (token, username, error) {
47-
t, u, err := authflow.AuthFlow(hostname, io, "", scopes, interactive, f.Browser)
47+
AuthFlow: func(io *iostreams.IOStreams, hostname string, scopes []string, interactive bool, clipboard bool) (token, username, error) {
48+
t, u, err := authflow.AuthFlow(hostname, io, "", scopes, interactive, f.Browser, clipboard)
4849
return token(t), username(u), err
4950
},
5051
HttpClient: &http.Client{},
@@ -89,6 +90,9 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
8990
9091
# Open a browser to re-authenticate with the default minimum scopes
9192
$ gh auth refresh --reset-scopes
93+
94+
# Open a browser to re-authenticate and copy one-time OAuth code to clipboard
95+
$ gh auth refresh --clipboard
9296
`),
9397
RunE: func(cmd *cobra.Command, args []string) error {
9498
opts.Interactive = opts.IO.CanPrompt()
@@ -109,6 +113,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
109113
cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes for gh to have")
110114
cmd.Flags().StringSliceVarP(&opts.RemoveScopes, "remove-scopes", "r", nil, "Authentication scopes to remove from gh")
111115
cmd.Flags().BoolVar(&opts.ResetScopes, "reset-scopes", false, "Reset authentication scopes to the default minimum set of scopes")
116+
cmd.Flags().BoolVarP(&opts.Clipboard, "clipboard", "c", false, "Copy one-time OAuth device code to clipboard")
112117
// secure storage became the default on 2023/4/04; this flag is left as a no-op for backwards compatibility
113118
var secureStorage bool
114119
cmd.Flags().BoolVar(&secureStorage, "secure-storage", false, "Save authentication credentials in secure credential store")
@@ -199,7 +204,7 @@ func refreshRun(opts *RefreshOptions) error {
199204

200205
additionalScopes.RemoveValues(opts.RemoveScopes)
201206

202-
authedToken, authedUser, err := opts.AuthFlow(opts.IO, hostname, additionalScopes.ToSlice(), opts.Interactive)
207+
authedToken, authedUser, err := opts.AuthFlow(opts.IO, hostname, additionalScopes.ToSlice(), opts.Interactive, opts.Clipboard)
203208
if err != nil {
204209
return err
205210
}

pkg/cmd/auth/refresh/refresh_test.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,43 @@ func Test_NewCmdRefresh(t *testing.T) {
3333
Hostname: "",
3434
},
3535
},
36+
{
37+
name: "tty clipboard",
38+
tty: true,
39+
cli: "-c",
40+
wants: RefreshOptions{
41+
Hostname: "",
42+
Clipboard: true,
43+
},
44+
},
3645
{
3746
name: "nontty no arguments",
3847
wantsErr: true,
3948
},
49+
{
50+
name: "nontty hostname and clipboard",
51+
cli: "-h aline.cedrac -c",
52+
wants: RefreshOptions{
53+
Hostname: "aline.cedrac",
54+
Clipboard: true,
55+
},
56+
},
4057
{
4158
name: "nontty hostname",
4259
cli: "-h aline.cedrac",
4360
wants: RefreshOptions{
4461
Hostname: "aline.cedrac",
4562
},
4663
},
64+
{
65+
name: "tty hostname and clipboard",
66+
tty: true,
67+
cli: "-h aline.cedrac -c",
68+
wants: RefreshOptions{
69+
Hostname: "aline.cedrac",
70+
Clipboard: true,
71+
},
72+
},
4773
{
4874
name: "tty hostname",
4975
tty: true,
@@ -166,6 +192,7 @@ func Test_NewCmdRefresh(t *testing.T) {
166192
require.NoError(t, err)
167193
require.Equal(t, tt.wants.Hostname, gotOpts.Hostname)
168194
require.Equal(t, tt.wants.Scopes, gotOpts.Scopes)
195+
require.Equal(t, tt.wants.Clipboard, gotOpts.Clipboard)
169196
})
170197
}
171198
}
@@ -174,6 +201,7 @@ type authArgs struct {
174201
hostname string
175202
scopes []string
176203
interactive bool
204+
clipboard bool
177205
secureStorage bool
178206
}
179207

@@ -226,6 +254,22 @@ func Test_refreshRun(t *testing.T) {
226254
secureStorage: true,
227255
},
228256
},
257+
{
258+
name: "no hostname, one host configured, clipboard enabled",
259+
cfgHosts: []string{
260+
"github.com",
261+
},
262+
opts: &RefreshOptions{
263+
Hostname: "",
264+
Clipboard: true,
265+
},
266+
wantAuthArgs: authArgs{
267+
hostname: "github.com",
268+
scopes: []string{},
269+
secureStorage: true,
270+
clipboard: true,
271+
},
272+
},
229273
{
230274
name: "no hostname, one host configured",
231275
cfgHosts: []string{
@@ -427,10 +471,11 @@ func Test_refreshRun(t *testing.T) {
427471
for _, tt := range tests {
428472
t.Run(tt.name, func(t *testing.T) {
429473
aa := authArgs{}
430-
tt.opts.AuthFlow = func(_ *iostreams.IOStreams, hostname string, scopes []string, interactive bool) (token, username, error) {
474+
tt.opts.AuthFlow = func(_ *iostreams.IOStreams, hostname string, scopes []string, interactive bool, clipboard bool) (token, username, error) {
431475
aa.hostname = hostname
432476
aa.scopes = scopes
433477
aa.interactive = interactive
478+
aa.clipboard = clipboard
434479
if tt.authOut != (authOut{}) {
435480
return token(tt.authOut.token), username(tt.authOut.username), tt.authOut.err
436481
}
@@ -488,6 +533,7 @@ func Test_refreshRun(t *testing.T) {
488533
require.Equal(t, tt.wantAuthArgs.hostname, aa.hostname)
489534
require.Equal(t, tt.wantAuthArgs.scopes, aa.scopes)
490535
require.Equal(t, tt.wantAuthArgs.interactive, aa.interactive)
536+
require.Equal(t, tt.wantAuthArgs.clipboard, aa.clipboard)
491537

492538
authCfg := cfg.Authentication()
493539
activeUser, _ := authCfg.ActiveUser(aa.hostname)

pkg/cmd/auth/shared/login_flow.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ type LoginOptions struct {
4040
CredentialFlow *GitCredentialFlow
4141
SecureStorage bool
4242
SkipSSHKeyPrompt bool
43+
CopyToClipboard bool
4344

4445
sshContext ssh.Context
4546
}
@@ -148,7 +149,7 @@ func Login(opts *LoginOptions) error {
148149

149150
if authMode == 0 {
150151
var err error
151-
authToken, username, err = authflow.AuthFlow(hostname, opts.IO, "", append(opts.Scopes, additionalScopes...), opts.Interactive, opts.Browser)
152+
authToken, username, err = authflow.AuthFlow(hostname, opts.IO, "", append(opts.Scopes, additionalScopes...), opts.Interactive, opts.Browser, opts.CopyToClipboard)
152153
if err != nil {
153154
return fmt.Errorf("failed to authenticate via web browser: %w", err)
154155
}

0 commit comments

Comments
 (0)