| 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), |
| ) |
| } |