diff --git a/CHANGELOG.md b/CHANGELOG.md index 06630429fa..e4708d5741 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,12 @@ ## [Unreleased] - Fix git input handling of annotated tags. +- Update `buf registry login` to complete the login flow in the browser by default. This allows + users to login with their browser and have the token automatically provided to the CLI. ## [v1.35.1] - 2024-07-24 -- Fix the git input parameter `ref` to align with the `git` notion of a ref. This allows for the use +- Fix the git input parameter `ref` to align with the `git` notion of a ref. This allows for the use of branch names, tag names, and commit hashes. - Fix unexpected `buf build` errors with absolute path directory inputs without workspace and/or module configurations (e.g. `buf.yaml`, `buf.work.yaml`) and proto file paths set to the `--path` flag. diff --git a/private/buf/cmd/buf/command/registry/registrylogin/client.go b/private/buf/cmd/buf/command/registry/registrylogin/client.go new file mode 100644 index 0000000000..c15ea207bb --- /dev/null +++ b/private/buf/cmd/buf/command/registry/registrylogin/client.go @@ -0,0 +1,24 @@ +// Copyright 2020-2024 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !darwin +// +build !darwin + +package registrylogin + +import "os" + +func getClientName() (string, error) { + return os.Hostname() +} diff --git a/private/buf/cmd/buf/command/registry/registrylogin/client_darwin.go b/private/buf/cmd/buf/command/registry/registrylogin/client_darwin.go new file mode 100644 index 0000000000..f4d7703a9c --- /dev/null +++ b/private/buf/cmd/buf/command/registry/registrylogin/client_darwin.go @@ -0,0 +1,33 @@ +// Copyright 2020-2024 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build darwin +// +build darwin + +package registrylogin + +import ( + "os" + "strings" +) + +func getClientName() (string, error) { + hostname, err := os.Hostname() + if err != nil { + return "", err + } + // macOS uses .local for the hostname. + hostname = strings.TrimSuffix(hostname, ".local") + return hostname, nil +} diff --git a/private/buf/cmd/buf/command/registry/registrylogin/registrylogin.go b/private/buf/cmd/buf/command/registry/registrylogin/registrylogin.go index 11e4a8934e..9fdc3f9a82 100644 --- a/private/buf/cmd/buf/command/registry/registrylogin/registrylogin.go +++ b/private/buf/cmd/buf/command/registry/registrylogin/registrylogin.go @@ -20,8 +20,10 @@ import ( "fmt" "io" "strings" + "time" "connectrpc.com/connect" + "github.com/bufbuild/buf/private/buf/bufapp" "github.com/bufbuild/buf/private/buf/bufcli" "github.com/bufbuild/buf/private/bufpkg/bufconnect" "github.com/bufbuild/buf/private/gen/proto/connect/buf/alpha/registry/v1alpha1/registryv1alpha1connect" @@ -31,12 +33,16 @@ import ( "github.com/bufbuild/buf/private/pkg/connectclient" "github.com/bufbuild/buf/private/pkg/netext" "github.com/bufbuild/buf/private/pkg/netrc" + "github.com/bufbuild/buf/private/pkg/oauth2" + "github.com/bufbuild/buf/private/pkg/transport/http/httpclient" + "github.com/pkg/browser" "github.com/spf13/pflag" ) const ( usernameFlagName = "username" tokenStdinFlagName = "token-stdin" + promptFlagName = "prompt" ) // NewCommand returns a new Command. @@ -48,9 +54,8 @@ func NewCommand( return &appcmd.Command{ Use: name + " ", Short: `Log in to the Buf Schema Registry`, - Long: fmt.Sprintf(`This prompts for your BSR token and updates your %s file with these credentials. -The argument will default to buf.build if not specified.`, netrc.Filename), - Args: appcmd.MaximumNArgs(1), + 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 argument will default to buf.build if not specified.`, promptFlagName, tokenStdinFlagName, netrc.Filename), + Args: appcmd.MaximumNArgs(1), Run: builder.NewRunFunc( func(ctx context.Context, container appext.Container) error { return run(ctx, container, flags) @@ -63,6 +68,7 @@ The argument will default to buf.build if not specified.`, netrc.Filena type flags struct { Username string TokenStdin bool + Prompt bool } func newFlags() *flags { @@ -82,7 +88,19 @@ func (f *flags) Bind(flagSet *pflag.FlagSet) { &f.TokenStdin, tokenStdinFlagName, false, - "Read the token from stdin. This command prompts for a token by default", + fmt.Sprintf( + "Read the token from stdin. This command prompts for a token by default. Exclusive with the flag --%s.", + promptFlagName, + ), + ) + flagSet.BoolVar( + &f.Prompt, + promptFlagName, + false, + fmt.Sprintf( + "Prompt for the token. The device must be a TTY. Exclusive with the flag --%s.", + tokenStdinFlagName, + ), ) } @@ -140,31 +158,33 @@ func inner( return err } } - // Do not print unless we are prompting - if !flags.TokenStdin { - if _, err := fmt.Fprintf( - container.Stdout(), - "Enter the BSR token created at https://%s/settings/user.\n\n", - remote, - ); err != nil { - return err - } + if flags.TokenStdin && flags.Prompt { + return appcmd.NewInvalidArgumentErrorf("cannot use both --%s and --%s flags", tokenStdinFlagName, promptFlagName) } var token string if flags.TokenStdin { data, err := io.ReadAll(container.Stdin()) if err != nil { - return err + return fmt.Errorf("unable to read token from stdin: %w", err) } token = string(data) + } else if flags.Prompt { + var err error + token, err = doPromptLogin(ctx, container, remote) + if err != nil { + return err + } } else { var err error - token, err = bufcli.PromptUserForPassword(container, "Token: ") + token, err = doBrowserLogin(ctx, container, remote) if err != nil { - if errors.Is(err, bufcli.ErrNotATTY) { - return errors.New("cannot perform an interactive login from a non-TTY device") + if !errors.Is(err, oauth2.ErrUnsupported) { + return fmt.Errorf("unable to complete authorize device grant: %w", err) + } + token, err = doPromptLogin(ctx, container, remote) + if err != nil { + return err } - return err } } // Remove leading and trailing spaces from user-supplied token to avoid @@ -219,3 +239,104 @@ func inner( } return nil } + +// doPromptLogin prompts the user for a token. +func doPromptLogin( + _ context.Context, + container appext.Container, + remote string, +) (string, error) { + if _, err := fmt.Fprintf( + container.Stdout(), + "Enter the BSR token created at https://%s/settings/user.\n\n", + remote, + ); err != nil { + return "", err + } + var err error + token, err := bufcli.PromptUserForPassword(container, "Token: ") + if err != nil { + if errors.Is(err, bufcli.ErrNotATTY) { + return "", errors.New("cannot perform an interactive login from a non-TTY device") + } + return "", err + } + return token, nil +} + +// doBrowserLogin performs the device authorization grant flow via the browser. +func doBrowserLogin( + ctx context.Context, + container appext.Container, + remote string, +) (string, error) { + baseURL := "https://" + remote + clientName, err := getClientName() + if err != nil { + return "", err + } + externalConfig := bufapp.ExternalConfig{} + if err := appext.ReadConfig(container, &externalConfig); err != nil { + return "", err + } + appConfig, err := bufapp.NewConfig(container, externalConfig) + if err != nil { + return "", err + } + client := httpclient.NewClient(appConfig.TLS) + oauth2Client := oauth2.NewClient(baseURL, client) + // Register the device. + deviceRegistration, err := oauth2Client.RegisterDevice(ctx, &oauth2.DeviceRegistrationRequest{ + ClientName: clientName, + }) + if err != nil { + var oauth2Err *oauth2.Error + if errors.As(err, &oauth2Err) { + return "", fmt.Errorf("authorization failed: %s", oauth2Err.ErrorDescription) + } + return "", err + } + // Request a device authorization code. + deviceAuthorization, err := oauth2Client.AuthorizeDevice(ctx, &oauth2.DeviceAuthorizationRequest{ + ClientID: deviceRegistration.ClientID, + ClientSecret: deviceRegistration.ClientSecret, + }) + if err != nil { + var oauth2Err *oauth2.Error + if errors.As(err, &oauth2Err) { + return "", fmt.Errorf("authorization failed: %s", oauth2Err.ErrorDescription) + } + return "", err + } + // Open the browser to the verification URI. + if err := browser.OpenURL(deviceAuthorization.VerificationURIComplete); err != nil { + return "", fmt.Errorf("failed to open browser: %w", err) + } + if _, err := fmt.Fprintf( + container.Stdout(), + `Opening your browser to complete authorization process. + +If your browser doesn't open automatically, please open this URL in a browser to complete the process: + +%s +`, + deviceAuthorization.VerificationURIComplete, + ); err != nil { + return "", err + } + // Poll the token endpoint until the user has authorized the device. + deviceToken, err := oauth2Client.AccessDeviceToken(ctx, &oauth2.DeviceAccessTokenRequest{ + ClientID: deviceRegistration.ClientID, + ClientSecret: deviceRegistration.ClientSecret, + DeviceCode: deviceAuthorization.DeviceCode, + GrantType: oauth2.DeviceAuthorizationGrantType, + }, oauth2.AccessDeviceTokenWithPollingInterval(time.Duration(deviceAuthorization.Interval)*time.Second)) + if err != nil { + var oauth2Err *oauth2.Error + if errors.As(err, &oauth2Err) { + return "", fmt.Errorf("authorization failed: %s", oauth2Err.ErrorDescription) + } + return "", err + } + return deviceToken.AccessToken, nil +} diff --git a/private/pkg/oauth2/client.go b/private/pkg/oauth2/client.go index f375cec08a..79a9fed85b 100644 --- a/private/pkg/oauth2/client.go +++ b/private/pkg/oauth2/client.go @@ -18,6 +18,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "mime" @@ -28,6 +29,13 @@ import ( "go.uber.org/multierr" ) +var ( + // ErrUnsupported is returned when we receive an unsupported response from the server. + // + // TODO(go1.21): replace by errors.ErrUnsupported once it is available. + ErrUnsupported = errors.New("unsupported operation") +) + const ( defaultPollingInterval = 5 * time.Second incrementPollingInterval = 5 * time.Second @@ -149,13 +157,13 @@ func (c *Client) AccessDeviceToken( return nil, fmt.Errorf("oauth2: polling interval must be less than or equal to %v", maxPollingInterval) } encodedValues := deviceAccessTokenRequest.ToValues().Encode() - ticker := time.NewTicker(pollingInterval) - defer ticker.Stop() + timer := time.NewTimer(pollingInterval) + defer timer.Stop() for { select { case <-ctx.Done(): return nil, ctx.Err() - case <-ticker.C: + case <-timer.C: body := strings.NewReader(encodedValues) request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+DeviceTokenPath, body) if err != nil { @@ -188,16 +196,15 @@ func (c *Client) AccessDeviceToken( case ErrorCodeSlowDown: // If the server is rate limiting the client, increase the polling interval. pollingInterval += incrementPollingInterval - ticker.Reset(pollingInterval) case ErrorCodeAuthorizationPending: // If the user has not yet authorized the device, continue polling. - continue case ErrorCodeAccessDenied, ErrorCodeExpiredToken: // If the user has denied the device or the token has expired, return the error. return nil, &payload.Error default: return nil, &payload.Error } + timer.Reset(pollingInterval) } } } @@ -232,7 +239,7 @@ func parseJSONResponse(response *http.Response, payload any) error { return fmt.Errorf("oauth2: failed to read response body: %w", err) } if contentType, _, _ := mime.ParseMediaType(response.Header.Get("Content-Type")); contentType != "application/json" { - return fmt.Errorf("oauth2: invalid response: %d %s", response.StatusCode, body) + return fmt.Errorf("oauth2: %w: %d %s", ErrUnsupported, response.StatusCode, body) } if err := json.Unmarshal(body, &payload); err != nil { return fmt.Errorf("oauth2: failed to unmarshal response: %w: %s", err, body) diff --git a/private/pkg/oauth2/client_test.go b/private/pkg/oauth2/client_test.go index 4db5220348..c027345b70 100644 --- a/private/pkg/oauth2/client_test.go +++ b/private/pkg/oauth2/client_test.go @@ -82,12 +82,12 @@ func TestRegisterDevice(t *testing.T) { input: &DeviceRegistrationRequest{ClientName: "nameOfClient"}, transport: func(t *testing.T, r *http.Request) (*http.Response, error) { return &http.Response{ - Status: "500 Internal Server Error", - StatusCode: http.StatusInternalServerError, - Body: io.NopCloser(strings.NewReader(`server error`)), + Status: "501 Not Implemented", + StatusCode: http.StatusNotImplemented, + Body: io.NopCloser(strings.NewReader(`not implemented`)), }, nil }, - err: fmt.Errorf("oauth2: invalid response: 500 server error"), + err: fmt.Errorf("oauth2: %w: 501 not implemented", ErrUnsupported), }} for _, test := range tests { test := test