Skip to content

Commit 06c2619

Browse files
authored
Update buf registry login to use browser by default (#3167)
1 parent e1ee7da commit 06c2619

File tree

6 files changed

+216
-29
lines changed

6 files changed

+216
-29
lines changed

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
## [Unreleased]
44

55
- Fix git input handling of annotated tags.
6+
- Update `buf registry login` to complete the login flow in the browser by default. This allows
7+
users to login with their browser and have the token automatically provided to the CLI.
68

79
## [v1.35.1] - 2024-07-24
810

9-
- Fix the git input parameter `ref` to align with the `git` notion of a ref. This allows for the use
11+
- Fix the git input parameter `ref` to align with the `git` notion of a ref. This allows for the use
1012
of branch names, tag names, and commit hashes.
1113
- Fix unexpected `buf build` errors with absolute path directory inputs without workspace and/or
1214
module configurations (e.g. `buf.yaml`, `buf.work.yaml`) and proto file paths set to the `--path` flag.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright 2020-2024 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//go:build !darwin
16+
// +build !darwin
17+
18+
package registrylogin
19+
20+
import "os"
21+
22+
func getClientName() (string, error) {
23+
return os.Hostname()
24+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright 2020-2024 Buf Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
//go:build darwin
16+
// +build darwin
17+
18+
package registrylogin
19+
20+
import (
21+
"os"
22+
"strings"
23+
)
24+
25+
func getClientName() (string, error) {
26+
hostname, err := os.Hostname()
27+
if err != nil {
28+
return "", err
29+
}
30+
// macOS uses .local for the hostname.
31+
hostname = strings.TrimSuffix(hostname, ".local")
32+
return hostname, nil
33+
}

private/buf/cmd/buf/command/registry/registrylogin/registrylogin.go

Lines changed: 139 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ import (
2020
"fmt"
2121
"io"
2222
"strings"
23+
"time"
2324

2425
"connectrpc.com/connect"
26+
"github.com/bufbuild/buf/private/buf/bufapp"
2527
"github.com/bufbuild/buf/private/buf/bufcli"
2628
"github.com/bufbuild/buf/private/bufpkg/bufconnect"
2729
"github.com/bufbuild/buf/private/gen/proto/connect/buf/alpha/registry/v1alpha1/registryv1alpha1connect"
@@ -31,12 +33,16 @@ import (
3133
"github.com/bufbuild/buf/private/pkg/connectclient"
3234
"github.com/bufbuild/buf/private/pkg/netext"
3335
"github.com/bufbuild/buf/private/pkg/netrc"
36+
"github.com/bufbuild/buf/private/pkg/oauth2"
37+
"github.com/bufbuild/buf/private/pkg/transport/http/httpclient"
38+
"github.com/pkg/browser"
3439
"github.com/spf13/pflag"
3540
)
3641

3742
const (
3843
usernameFlagName = "username"
3944
tokenStdinFlagName = "token-stdin"
45+
promptFlagName = "prompt"
4046
)
4147

4248
// NewCommand returns a new Command.
@@ -48,9 +54,8 @@ func NewCommand(
4854
return &appcmd.Command{
4955
Use: name + " <domain>",
5056
Short: `Log in to the Buf Schema Registry`,
51-
Long: fmt.Sprintf(`This prompts for your BSR token and updates your %s file with these credentials.
52-
The <domain> argument will default to buf.build if not specified.`, netrc.Filename),
53-
Args: appcmd.MaximumNArgs(1),
57+
Long: fmt.Sprintf(`This command will open a browser to complete the login process. Use the flags --%s or --%s to complete an alternative login flow. The token is saved to your %s file. The <domain> argument will default to buf.build if not specified.`, promptFlagName, tokenStdinFlagName, netrc.Filename),
58+
Args: appcmd.MaximumNArgs(1),
5459
Run: builder.NewRunFunc(
5560
func(ctx context.Context, container appext.Container) error {
5661
return run(ctx, container, flags)
@@ -63,6 +68,7 @@ The <domain> argument will default to buf.build if not specified.`, netrc.Filena
6368
type flags struct {
6469
Username string
6570
TokenStdin bool
71+
Prompt bool
6672
}
6773

6874
func newFlags() *flags {
@@ -82,7 +88,19 @@ func (f *flags) Bind(flagSet *pflag.FlagSet) {
8288
&f.TokenStdin,
8389
tokenStdinFlagName,
8490
false,
85-
"Read the token from stdin. This command prompts for a token by default",
91+
fmt.Sprintf(
92+
"Read the token from stdin. This command prompts for a token by default. Exclusive with the flag --%s.",
93+
promptFlagName,
94+
),
95+
)
96+
flagSet.BoolVar(
97+
&f.Prompt,
98+
promptFlagName,
99+
false,
100+
fmt.Sprintf(
101+
"Prompt for the token. The device must be a TTY. Exclusive with the flag --%s.",
102+
tokenStdinFlagName,
103+
),
86104
)
87105
}
88106

@@ -140,31 +158,33 @@ func inner(
140158
return err
141159
}
142160
}
143-
// Do not print unless we are prompting
144-
if !flags.TokenStdin {
145-
if _, err := fmt.Fprintf(
146-
container.Stdout(),
147-
"Enter the BSR token created at https://%s/settings/user.\n\n",
148-
remote,
149-
); err != nil {
150-
return err
151-
}
161+
if flags.TokenStdin && flags.Prompt {
162+
return appcmd.NewInvalidArgumentErrorf("cannot use both --%s and --%s flags", tokenStdinFlagName, promptFlagName)
152163
}
153164
var token string
154165
if flags.TokenStdin {
155166
data, err := io.ReadAll(container.Stdin())
156167
if err != nil {
157-
return err
168+
return fmt.Errorf("unable to read token from stdin: %w", err)
158169
}
159170
token = string(data)
171+
} else if flags.Prompt {
172+
var err error
173+
token, err = doPromptLogin(ctx, container, remote)
174+
if err != nil {
175+
return err
176+
}
160177
} else {
161178
var err error
162-
token, err = bufcli.PromptUserForPassword(container, "Token: ")
179+
token, err = doBrowserLogin(ctx, container, remote)
163180
if err != nil {
164-
if errors.Is(err, bufcli.ErrNotATTY) {
165-
return errors.New("cannot perform an interactive login from a non-TTY device")
181+
if !errors.Is(err, oauth2.ErrUnsupported) {
182+
return fmt.Errorf("unable to complete authorize device grant: %w", err)
183+
}
184+
token, err = doPromptLogin(ctx, container, remote)
185+
if err != nil {
186+
return err
166187
}
167-
return err
168188
}
169189
}
170190
// Remove leading and trailing spaces from user-supplied token to avoid
@@ -219,3 +239,104 @@ func inner(
219239
}
220240
return nil
221241
}
242+
243+
// doPromptLogin prompts the user for a token.
244+
func doPromptLogin(
245+
_ context.Context,
246+
container appext.Container,
247+
remote string,
248+
) (string, error) {
249+
if _, err := fmt.Fprintf(
250+
container.Stdout(),
251+
"Enter the BSR token created at https://%s/settings/user.\n\n",
252+
remote,
253+
); err != nil {
254+
return "", err
255+
}
256+
var err error
257+
token, err := bufcli.PromptUserForPassword(container, "Token: ")
258+
if err != nil {
259+
if errors.Is(err, bufcli.ErrNotATTY) {
260+
return "", errors.New("cannot perform an interactive login from a non-TTY device")
261+
}
262+
return "", err
263+
}
264+
return token, nil
265+
}
266+
267+
// doBrowserLogin performs the device authorization grant flow via the browser.
268+
func doBrowserLogin(
269+
ctx context.Context,
270+
container appext.Container,
271+
remote string,
272+
) (string, error) {
273+
baseURL := "https://" + remote
274+
clientName, err := getClientName()
275+
if err != nil {
276+
return "", err
277+
}
278+
externalConfig := bufapp.ExternalConfig{}
279+
if err := appext.ReadConfig(container, &externalConfig); err != nil {
280+
return "", err
281+
}
282+
appConfig, err := bufapp.NewConfig(container, externalConfig)
283+
if err != nil {
284+
return "", err
285+
}
286+
client := httpclient.NewClient(appConfig.TLS)
287+
oauth2Client := oauth2.NewClient(baseURL, client)
288+
// Register the device.
289+
deviceRegistration, err := oauth2Client.RegisterDevice(ctx, &oauth2.DeviceRegistrationRequest{
290+
ClientName: clientName,
291+
})
292+
if err != nil {
293+
var oauth2Err *oauth2.Error
294+
if errors.As(err, &oauth2Err) {
295+
return "", fmt.Errorf("authorization failed: %s", oauth2Err.ErrorDescription)
296+
}
297+
return "", err
298+
}
299+
// Request a device authorization code.
300+
deviceAuthorization, err := oauth2Client.AuthorizeDevice(ctx, &oauth2.DeviceAuthorizationRequest{
301+
ClientID: deviceRegistration.ClientID,
302+
ClientSecret: deviceRegistration.ClientSecret,
303+
})
304+
if err != nil {
305+
var oauth2Err *oauth2.Error
306+
if errors.As(err, &oauth2Err) {
307+
return "", fmt.Errorf("authorization failed: %s", oauth2Err.ErrorDescription)
308+
}
309+
return "", err
310+
}
311+
// Open the browser to the verification URI.
312+
if err := browser.OpenURL(deviceAuthorization.VerificationURIComplete); err != nil {
313+
return "", fmt.Errorf("failed to open browser: %w", err)
314+
}
315+
if _, err := fmt.Fprintf(
316+
container.Stdout(),
317+
`Opening your browser to complete authorization process.
318+
319+
If your browser doesn't open automatically, please open this URL in a browser to complete the process:
320+
321+
%s
322+
`,
323+
deviceAuthorization.VerificationURIComplete,
324+
); err != nil {
325+
return "", err
326+
}
327+
// Poll the token endpoint until the user has authorized the device.
328+
deviceToken, err := oauth2Client.AccessDeviceToken(ctx, &oauth2.DeviceAccessTokenRequest{
329+
ClientID: deviceRegistration.ClientID,
330+
ClientSecret: deviceRegistration.ClientSecret,
331+
DeviceCode: deviceAuthorization.DeviceCode,
332+
GrantType: oauth2.DeviceAuthorizationGrantType,
333+
}, oauth2.AccessDeviceTokenWithPollingInterval(time.Duration(deviceAuthorization.Interval)*time.Second))
334+
if err != nil {
335+
var oauth2Err *oauth2.Error
336+
if errors.As(err, &oauth2Err) {
337+
return "", fmt.Errorf("authorization failed: %s", oauth2Err.ErrorDescription)
338+
}
339+
return "", err
340+
}
341+
return deviceToken.AccessToken, nil
342+
}

private/pkg/oauth2/client.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"bytes"
1919
"context"
2020
"encoding/json"
21+
"errors"
2122
"fmt"
2223
"io"
2324
"mime"
@@ -28,6 +29,13 @@ import (
2829
"go.uber.org/multierr"
2930
)
3031

32+
var (
33+
// ErrUnsupported is returned when we receive an unsupported response from the server.
34+
//
35+
// TODO(go1.21): replace by errors.ErrUnsupported once it is available.
36+
ErrUnsupported = errors.New("unsupported operation")
37+
)
38+
3139
const (
3240
defaultPollingInterval = 5 * time.Second
3341
incrementPollingInterval = 5 * time.Second
@@ -149,13 +157,13 @@ func (c *Client) AccessDeviceToken(
149157
return nil, fmt.Errorf("oauth2: polling interval must be less than or equal to %v", maxPollingInterval)
150158
}
151159
encodedValues := deviceAccessTokenRequest.ToValues().Encode()
152-
ticker := time.NewTicker(pollingInterval)
153-
defer ticker.Stop()
160+
timer := time.NewTimer(pollingInterval)
161+
defer timer.Stop()
154162
for {
155163
select {
156164
case <-ctx.Done():
157165
return nil, ctx.Err()
158-
case <-ticker.C:
166+
case <-timer.C:
159167
body := strings.NewReader(encodedValues)
160168
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+DeviceTokenPath, body)
161169
if err != nil {
@@ -188,16 +196,15 @@ func (c *Client) AccessDeviceToken(
188196
case ErrorCodeSlowDown:
189197
// If the server is rate limiting the client, increase the polling interval.
190198
pollingInterval += incrementPollingInterval
191-
ticker.Reset(pollingInterval)
192199
case ErrorCodeAuthorizationPending:
193200
// If the user has not yet authorized the device, continue polling.
194-
continue
195201
case ErrorCodeAccessDenied, ErrorCodeExpiredToken:
196202
// If the user has denied the device or the token has expired, return the error.
197203
return nil, &payload.Error
198204
default:
199205
return nil, &payload.Error
200206
}
207+
timer.Reset(pollingInterval)
201208
}
202209
}
203210
}
@@ -232,7 +239,7 @@ func parseJSONResponse(response *http.Response, payload any) error {
232239
return fmt.Errorf("oauth2: failed to read response body: %w", err)
233240
}
234241
if contentType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type")); contentType != "application/json" {
235-
return fmt.Errorf("oauth2: invalid response: %d %s", response.StatusCode, body)
242+
return fmt.Errorf("oauth2: %w: %d %s", ErrUnsupported, response.StatusCode, body)
236243
}
237244
if err := json.Unmarshal(body, &payload); err != nil {
238245
return fmt.Errorf("oauth2: failed to unmarshal response: %w: %s", err, body)

private/pkg/oauth2/client_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,12 +82,12 @@ func TestRegisterDevice(t *testing.T) {
8282
input: &DeviceRegistrationRequest{ClientName: "nameOfClient"},
8383
transport: func(t *testing.T, r *http.Request) (*http.Response, error) {
8484
return &http.Response{
85-
Status: "500 Internal Server Error",
86-
StatusCode: http.StatusInternalServerError,
87-
Body: io.NopCloser(strings.NewReader(`server error`)),
85+
Status: "501 Not Implemented",
86+
StatusCode: http.StatusNotImplemented,
87+
Body: io.NopCloser(strings.NewReader(`not implemented`)),
8888
}, nil
8989
},
90-
err: fmt.Errorf("oauth2: invalid response: 500 server error"),
90+
err: fmt.Errorf("oauth2: %w: 501 not implemented", ErrUnsupported),
9191
}}
9292
for _, test := range tests {
9393
test := test

0 commit comments

Comments
 (0)