Skip to content

Commit

Permalink
Update buf registry login to use browser by default (#3167)
Browse files Browse the repository at this point in the history
  • Loading branch information
emcfarlane authored Aug 1, 2024
1 parent e1ee7da commit 06c2619
Show file tree
Hide file tree
Showing 6 changed files with 216 additions and 29 deletions.
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions private/buf/cmd/buf/command/registry/registrylogin/client.go
Original file line number Diff line number Diff line change
@@ -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()
}
Original file line number Diff line number Diff line change
@@ -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
}
157 changes: 139 additions & 18 deletions private/buf/cmd/buf/command/registry/registrylogin/registrylogin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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.
Expand All @@ -48,9 +54,8 @@ func NewCommand(
return &appcmd.Command{
Use: name + " <domain>",
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 <domain> 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 <domain> 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)
Expand All @@ -63,6 +68,7 @@ The <domain> argument will default to buf.build if not specified.`, netrc.Filena
type flags struct {
Username string
TokenStdin bool
Prompt bool
}

func newFlags() *flags {
Expand All @@ -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,
),
)
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
19 changes: 13 additions & 6 deletions private/pkg/oauth2/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions private/pkg/oauth2/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 06c2619

Please sign in to comment.