Files
docker-cli/cli/internal/oauth/manager/manager.go
Alano Terblanche 6d7afd48a4 login: improve text on already authenticated and on OAuth login
Users have trouble understanding the different login paths on the CLI.
The default login is performed through an OAuth flow with the option to
fallback to a username and PAT login using the docker login -u <username>
option.

This patch improves the text around docker login, indicating:
- The username is shown when already authenticated
- Steps the user can take to switch user accounts are printed when
  authenticated in an info.
- When not authenticated, the OAuth login flow explains the fallback
  clearly to the user in an info.
- The password prompt now explicitly states that it accepts a PAT in an
  info.

Signed-off-by: Alano Terblanche <18033717+Benehiko@users.noreply.github.com>
2025-02-05 12:32:24 +01:00

215 lines
6.2 KiB
Go

package manager
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/docker/cli/cli/config/credentials"
"github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/internal/oauth"
"github.com/docker/cli/cli/internal/oauth/api"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/internal/tui"
"github.com/docker/docker/registry"
"github.com/morikuni/aec"
"github.com/sirupsen/logrus"
"github.com/pkg/browser"
)
// OAuthManager is the manager responsible for handling authentication
// flows with the oauth tenant.
type OAuthManager struct {
store credentials.Store
tenant string
audience string
clientID string
api api.OAuthAPI
openBrowser func(string) error
}
// OAuthManagerOptions are the options used for New to create a new auth manager.
type OAuthManagerOptions struct {
Store credentials.Store
Audience string
ClientID string
Scopes []string
Tenant string
DeviceName string
OpenBrowser func(string) error
}
func New(options OAuthManagerOptions) *OAuthManager {
scopes := []string{"openid", "offline_access"}
if len(options.Scopes) > 0 {
scopes = options.Scopes
}
openBrowser := options.OpenBrowser
if openBrowser == nil {
// Prevent errors from missing binaries (like xdg-open) from
// cluttering the output. We can handle errors ourselves.
browser.Stdout = io.Discard
browser.Stderr = io.Discard
openBrowser = browser.OpenURL
}
return &OAuthManager{
clientID: options.ClientID,
audience: options.Audience,
tenant: options.Tenant,
store: options.Store,
api: api.API{
TenantURL: "https://" + options.Tenant,
ClientID: options.ClientID,
Scopes: scopes,
},
openBrowser: openBrowser,
}
}
var ErrDeviceLoginStartFail = errors.New("failed to start device code flow login")
// LoginDevice launches the device authentication flow with the tenant,
// printing instructions to the provided writer and attempting to open the
// browser for the user to authenticate.
// After the user completes the browser login, LoginDevice uses the retrieved
// tokens to create a Hub PAT which is returned to the caller.
// The retrieved tokens are stored in the credentials store (under a separate
// key), and the refresh token is concatenated with the client ID.
func (m *OAuthManager) LoginDevice(ctx context.Context, w io.Writer) (*types.AuthConfig, error) {
state, err := m.api.GetDeviceCode(ctx, m.audience)
if err != nil {
logrus.Debugf("failed to start device code login: %v", err)
return nil, ErrDeviceLoginStartFail
}
if state.UserCode == "" {
logrus.Debugf("failed to start device code login: missing user code")
return nil, ErrDeviceLoginStartFail
}
_, _ = fmt.Fprintln(w, aec.Bold.Apply("\nUSING WEB-BASED LOGIN"))
var out tui.Output
switch stream := w.(type) {
case *streams.Out:
out = tui.NewOutput(stream)
default:
out = tui.NewOutput(streams.NewOut(w))
}
out.PrintNote("To sign in with credentials on the command line, use 'docker login -u <username>'\n")
_, _ = fmt.Fprintf(w, "\nYour one-time device confirmation code is: "+aec.Bold.Apply("%s\n"), state.UserCode)
_, _ = fmt.Fprintf(w, aec.Bold.Apply("Press ENTER")+" to open your browser or submit your device code here: "+aec.Underline.Apply("%s\n"), strings.Split(state.VerificationURI, "?")[0])
tokenResChan := make(chan api.TokenResponse)
waitForTokenErrChan := make(chan error)
go func() {
tokenRes, err := m.api.WaitForDeviceToken(ctx, state)
if err != nil {
waitForTokenErrChan <- err
return
}
tokenResChan <- tokenRes
}()
go func() {
reader := bufio.NewReader(os.Stdin)
_, _ = reader.ReadString('\n')
_ = m.openBrowser(state.VerificationURI)
}()
_, _ = fmt.Fprint(w, "\nWaiting for authentication in the browser…\n")
var tokenRes api.TokenResponse
select {
case <-ctx.Done():
return nil, errors.New("login canceled")
case err := <-waitForTokenErrChan:
return nil, fmt.Errorf("failed waiting for authentication: %w", err)
case tokenRes = <-tokenResChan:
}
claims, err := oauth.GetClaims(tokenRes.AccessToken)
if err != nil {
return nil, fmt.Errorf("failed to parse token claims: %w", err)
}
err = m.storeTokensInStore(tokenRes, claims.Domain.Username)
if err != nil {
return nil, fmt.Errorf("failed to store tokens: %w", err)
}
pat, err := m.api.GetAutoPAT(ctx, m.audience, tokenRes)
if err != nil {
return nil, err
}
return &types.AuthConfig{
Username: claims.Domain.Username,
Password: pat,
ServerAddress: registry.IndexServer,
}, nil
}
// Logout fetches the refresh token from the store and revokes it
// with the configured oauth tenant. The stored access and refresh
// tokens are then erased from the store.
// If the refresh token is not found in the store, an error is not
// returned.
func (m *OAuthManager) Logout(ctx context.Context) error {
refreshConfig, err := m.store.Get(refreshTokenKey)
if err != nil {
return err
}
if refreshConfig.Password == "" {
return nil
}
parts := strings.Split(refreshConfig.Password, "..")
if len(parts) != 2 {
// the token wasn't stored by the CLI, so don't revoke it
// or erase it from the store/error
return nil
}
// erase the token from the store first, that way
// if the revoke fails, the user can try to logout again
if err := m.eraseTokensFromStore(); err != nil {
return fmt.Errorf("failed to erase tokens: %w", err)
}
if err := m.api.RevokeToken(ctx, parts[0]); err != nil {
return fmt.Errorf("credentials erased successfully, but there was a failure to revoke the OAuth refresh token with the tenant: %w", err)
}
return nil
}
const (
accessTokenKey = registry.IndexServer + "access-token"
refreshTokenKey = registry.IndexServer + "refresh-token"
)
func (m *OAuthManager) storeTokensInStore(tokens api.TokenResponse, username string) error {
return errors.Join(
m.store.Store(types.AuthConfig{
Username: username,
Password: tokens.AccessToken,
ServerAddress: accessTokenKey,
}),
m.store.Store(types.AuthConfig{
Username: username,
Password: tokens.RefreshToken + ".." + m.clientID,
ServerAddress: refreshTokenKey,
}),
)
}
func (m *OAuthManager) eraseTokensFromStore() error {
return errors.Join(
m.store.Erase(accessTokenKey),
m.store.Erase(refreshTokenKey),
)
}