mirror of
https://github.com/docker/cli.git
synced 2026-11-06 13:10:41 -04:00
Wrap errors in the OAuth manager instead of logging them, preserving the underlying cause while allowing callers to detect startup failures. Log failures at the fallback handler and scope the device-code login result to its branch. Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
228 lines
6.5 KiB
Go
228 lines
6.5 KiB
Go
package manager
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/docker/cli/cli/config/credentials"
|
|
"github.com/docker/cli/cli/config/types"
|
|
"github.com/docker/cli/cli/streams"
|
|
"github.com/docker/cli/internal/oauth"
|
|
"github.com/docker/cli/internal/oauth/api"
|
|
"github.com/docker/cli/internal/registry"
|
|
"github.com/docker/cli/internal/tui"
|
|
"github.com/morikuni/aec"
|
|
"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) {
|
|
ctx, cancel := context.WithCancel(ctx)
|
|
defer cancel()
|
|
|
|
state, err := m.api.GetDeviceCode(ctx, m.audience)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %w", ErrDeviceLoginStartFail, err)
|
|
}
|
|
|
|
if state.UserCode == "" {
|
|
return nil, fmt.Errorf("%w: missing user code", ErrDeviceLoginStartFail)
|
|
}
|
|
|
|
var out tui.Output
|
|
switch stream := w.(type) {
|
|
case *streams.Out:
|
|
out = tui.NewOutput(stream)
|
|
default:
|
|
out = tui.NewOutput(streams.NewOut(w))
|
|
}
|
|
out.PrintNote("Using web-based login. To sign in with credentials on the command line, use 'docker login -u <username>'\n")
|
|
_, _ = fmt.Fprintln(w, "Your one-time device confirmation code is:", aec.Bold.Apply(state.UserCode))
|
|
_, _ = fmt.Fprintln(w, "Open this URL to authenticate:", aec.Underline.Apply(state.VerificationURI))
|
|
_, _ = fmt.Fprintln(w, "\n"+aec.Bold.Apply("Press ENTER")+" to open the URL in your browser, or "+aec.Bold.Apply("Ctrl+C")+" to cancel.")
|
|
|
|
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)
|
|
if _, err := reader.ReadString('\n'); err != nil || ctx.Err() != nil {
|
|
return
|
|
}
|
|
err := m.openBrowser(state.VerificationURI)
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, exec.ErrNotFound) {
|
|
_, _ = fmt.Fprintln(w, "No browser found. Open the URL above manually to continue.")
|
|
} else {
|
|
_, _ = fmt.Fprintln(w, "Failed to open browser:", err)
|
|
_, _ = fmt.Fprintln(w, "Open the URL above manually to continue.")
|
|
}
|
|
return
|
|
}
|
|
_, _ = fmt.Fprintln(w, "\nOpening browser…")
|
|
}()
|
|
|
|
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),
|
|
)
|
|
}
|