mirror of
https://github.com/docker/cli.git
synced 2026-01-26 15:41:42 +03:00
This adds an internal fork of [github.com/docker/docker/registry], taken
at commit [moby@f651a5d]. Git history was not preserved in this fork,
but can be found using the URLs provided.
This fork was created to remove the dependency on the "Moby" codebase,
and because the CLI only needs a subset of its features. The original
package was written specifically for use in the daemon code, and includes
functionality that cannot be used in the CLI.
[github.com/docker/docker/registry]: https://pkg.go.dev/github.com/docker/docker@v28.3.2+incompatible/registry
[moby@49306c6]: 49306c607b/registry
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
215 lines
6.2 KiB
Go
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/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/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),
|
|
)
|
|
}
|