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/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")) _, _ = fmt.Fprintln(w, "To sign in with credentials on the command line, use 'docker login -u '") _, _ = 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), ) }