Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update buf registry login to use browser by default #3167

Merged
merged 19 commits into from
Aug 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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{
emcfarlane marked this conversation as resolved.
Show resolved Hide resolved
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.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once this PR is merged, make sure to merge main into #3123 and delete this

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did this FYI

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