Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 139 additions & 0 deletions cli/command/registry/device_login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package registry

import (
"bufio"
"context"
"errors"
"fmt"
"net/http"
"time"

"github.com/docker/cli/cli/command"
"github.com/docker/cli/internal/registry"
"github.com/docker/cli/internal/tui"
registrytypes "github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client"
"github.com/morikuni/aec"
"github.com/pkg/browser"
"github.com/sirupsen/logrus"
)

// tryRegistryDeviceLogin attempts a browser login when the registry advertises one.
// The boolean is false when the caller should fall back to a password prompt.
func tryRegistryDeviceLogin(ctx context.Context, dockerCLI command.Cli, serverAddress string) (string, error, bool) {
svc, err := registry.NewService(registry.ServiceOptions{})
if err != nil {
logrus.WithError(err).Debug("device login unavailable")
return "", nil, false
}
endpoints, err := svc.Endpoints(ctx, serverAddress)
if err != nil || len(endpoints) == 0 {
return "", nil, false
}

var offer registry.DeviceLogin
var found bool
for _, endpoint := range endpoints {
offer, err = registry.DiscoverDeviceLogin(ctx, endpoint, command.UserAgent())
if err == nil {
found = true
break
}
if errors.Is(err, registry.ErrDeviceLoginStartFail) {
logrus.WithError(err).Debug("device login challenge was not usable")
_, _ = fmt.Fprint(dockerCLI.Err(), "Failed to start web-based login - falling back to command line login...\n\n")
return "", nil, false
}
logrus.WithError(err).Debug("registry did not offer device login")
}
if !found {
return "", nil, false
}

httpClient := &http.Client{Timeout: 15 * time.Second}
code, err := registry.RequestDeviceCode(ctx, httpClient, offer.DeviceEndpoint)
if err != nil {
logrus.WithError(err).Debug("failed to start registry device login")
_, _ = fmt.Fprint(dockerCLI.Err(), "Failed to start web-based login - falling back to command line login...\n\n")
return "", nil, false
}

cred, err := waitForDeviceCredential(ctx, dockerCLI, httpClient, offer.TokenEndpoint, code)
if err != nil {
return "", err, true
}

response, err := loginWithRegistry(ctx, dockerCLI.Client(), client.RegistryLoginOptions{
Username: cred.Username,
Password: cred.Secret,
ServerAddress: serverAddress,
IdentityToken: cred.IdentityToken,
})
if err != nil {
return "", err, true
}
stored := registrytypes.AuthConfig{
Username: cred.Username,
Password: cred.Secret,
ServerAddress: serverAddress,
IdentityToken: cred.IdentityToken,
}
if response.Auth.IdentityToken != "" {
stored.Password = ""
stored.IdentityToken = response.Auth.IdentityToken
}
if err := storeCredentials(dockerCLI.ConfigFile(), stored); err != nil {
return "", err, true
}
return response.Auth.Status, nil, true
}

func waitForDeviceCredential(ctx context.Context, dockerCLI command.Cli, httpClient *http.Client, tokenEndpoint string, code registry.DeviceCode) (registry.DeviceCredential, error) {
ctx, cancel := context.WithTimeout(ctx, code.ExpiresIn)
defer cancel()

out := tui.NewOutput(dockerCLI.Err())
out.PrintNote("Using web-based login. To sign in with credentials on the command line, use 'docker login -u <username>'\n")
_, _ = fmt.Fprintln(dockerCLI.Err(), "Your one-time device confirmation code is:", aec.Bold.Apply(code.UserCode))
_, _ = fmt.Fprintln(dockerCLI.Err(), "Open this URL to authenticate:", aec.Underline.Apply(code.VerificationURI))
_, _ = fmt.Fprintln(dockerCLI.Err(), "\n"+aec.Bold.Apply("Press ENTER")+" to open the URL in your browser, or "+aec.Bold.Apply("Ctrl+C")+" to cancel.")

openURL := code.VerificationURI
if code.VerificationURIComplete != "" {
openURL = code.VerificationURIComplete
}

credCh := make(chan registry.DeviceCredential, 1)
errCh := make(chan error, 1)
go func() {
cred, err := registry.PollDeviceToken(ctx, httpClient, tokenEndpoint, code.DeviceCode, code.Interval, nil)
if err != nil {
errCh <- err
return
}
credCh <- cred
}()
go func() {
reader := bufio.NewReader(dockerCLI.In())
if _, err := reader.ReadString('\n'); err != nil || ctx.Err() != nil {
return
}
if err := browser.OpenURL(openURL); err != nil {
_, _ = fmt.Fprintln(dockerCLI.Err(), "Open the URL above manually to continue.")
return
}
_, _ = fmt.Fprintln(dockerCLI.Err(), "\nOpening browser…")
}()

select {
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return registry.DeviceCredential{}, errors.New("timed out waiting for the browser login")
}
return registry.DeviceCredential{}, errors.New("login canceled")
case err := <-errCh:
return registry.DeviceCredential{}, fmt.Errorf("failed waiting for authentication: %w", err)
case cred := <-credCh:
return cred, nil
}
}
19 changes: 12 additions & 7 deletions cli/command/registry/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,15 +239,20 @@ func loginUser(ctx context.Context, dockerCLI command.Cli, opts loginOptions, de
return "", errors.New("error: cannot perform an interactive login from a non-TTY device")
}

// If we're logging into the index server and the user didn't provide a username or password, use the device flow
if serverAddress == registry.IndexServer && opts.user == "" && opts.password == "" {
msg, err := loginWithDeviceCodeFlow(ctx, dockerCLI)
if err == nil || !errors.Is(err, manager.ErrDeviceLoginStartFail) {
// With no username or password, Docker Hub uses its own device-code login.
// Any other registry can advertise the same kind of browser login on GET /v2/.
if opts.user == "" && opts.password == "" {
if serverAddress == registry.IndexServer {
msg, err := loginWithDeviceCodeFlow(ctx, dockerCLI)
if err == nil || !errors.Is(err, manager.ErrDeviceLoginStartFail) {
return msg, err
}

logrus.WithError(err).Debug("failed to start web-based login")
_, _ = fmt.Fprint(dockerCLI.Err(), "Failed to start web-based login - falling back to command line login...\n\n")
} else if msg, err, handled := tryRegistryDeviceLogin(ctx, dockerCLI, serverAddress); handled {
return msg, err
}

logrus.WithError(err).Debug("failed to start web-based login")
_, _ = fmt.Fprint(dockerCLI.Err(), "Failed to start web-based login - falling back to command line login...\n\n")
}

return loginWithUsernameAndPassword(ctx, dockerCLI, opts, defaultUsername, serverAddress)
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/commandline/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,14 @@ adding the server name.
$ docker login registry.example.com
```

If that registry answers `GET /v2/` with a bearer challenge that includes
`device_authorization_endpoint`, and you did not pass a username or password,
`docker login` uses the same browser device-code flow as Docker Hub. The
registry's token response must be a username plus a secret, or an identity
token. Those credentials are stored in the usual credential store. A registry
that does not advertise the endpoint still asks for a username and password.
Passing `-u` or `--password-stdin` always uses the password login.

By default, the `docker login` command assumes that the registry listens on
port 443 or 80. If the registry listens on a different port, you can specify it
by adding the port number to the server name.
Expand Down
Loading