Skip to content

Commit bbce5a0

Browse files
authored
Merge pull request docker#5344 from laurazard/auth-device-flow-pat
auth: add support for oauth device-code login
2 parents 35666cf + c3fe7bc commit bbce5a0

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

62 files changed

+11889
-60
lines changed

cli/command/registry.go

+50-26
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ func RegistryAuthenticationPrivilegedFunc(cli Cli, index *registrytypes.IndexInf
4141
default:
4242
}
4343

44-
err = ConfigureAuth(ctx, cli, "", "", &authConfig, isDefaultRegistry)
44+
authConfig, err = PromptUserForCredentials(ctx, cli, "", "", authConfig.Username, indexServer)
4545
if err != nil {
4646
return "", err
4747
}
@@ -86,8 +86,32 @@ func GetDefaultAuthConfig(cfg *configfile.ConfigFile, checkCredStore bool, serve
8686
return registrytypes.AuthConfig(authconfig), nil
8787
}
8888

89-
// ConfigureAuth handles prompting of user's username and password if needed
90-
func ConfigureAuth(ctx context.Context, cli Cli, flUser, flPassword string, authconfig *registrytypes.AuthConfig, isDefaultRegistry bool) error {
89+
// ConfigureAuth handles prompting of user's username and password if needed.
90+
// Deprecated: use PromptUserForCredentials instead.
91+
func ConfigureAuth(ctx context.Context, cli Cli, flUser, flPassword string, authConfig *registrytypes.AuthConfig, _ bool) error {
92+
defaultUsername := authConfig.Username
93+
serverAddress := authConfig.ServerAddress
94+
95+
newAuthConfig, err := PromptUserForCredentials(ctx, cli, flUser, flPassword, defaultUsername, serverAddress)
96+
if err != nil {
97+
return err
98+
}
99+
100+
authConfig.Username = newAuthConfig.Username
101+
authConfig.Password = newAuthConfig.Password
102+
return nil
103+
}
104+
105+
// PromptUserForCredentials handles the CLI prompt for the user to input
106+
// credentials.
107+
// If argUser is not empty, then the user is only prompted for their password.
108+
// If argPassword is not empty, then the user is only prompted for their username
109+
// If neither argUser nor argPassword are empty, then the user is not prompted and
110+
// an AuthConfig is returned with those values.
111+
// If defaultUsername is not empty, the username prompt includes that username
112+
// and the user can hit enter without inputting a username to use that default
113+
// username.
114+
func PromptUserForCredentials(ctx context.Context, cli Cli, argUser, argPassword, defaultUsername, serverAddress string) (authConfig registrytypes.AuthConfig, err error) {
91115
// On Windows, force the use of the regular OS stdin stream.
92116
//
93117
// See:
@@ -107,13 +131,14 @@ func ConfigureAuth(ctx context.Context, cli Cli, flUser, flPassword string, auth
107131
// Linux will hit this if you attempt `cat | docker login`, and Windows
108132
// will hit this if you attempt docker login from mintty where stdin
109133
// is a pipe, not a character based console.
110-
if flPassword == "" && !cli.In().IsTerminal() {
111-
return errors.Errorf("Error: Cannot perform an interactive login from a non TTY device")
134+
if argPassword == "" && !cli.In().IsTerminal() {
135+
return authConfig, errors.Errorf("Error: Cannot perform an interactive login from a non TTY device")
112136
}
113137

114-
authconfig.Username = strings.TrimSpace(authconfig.Username)
138+
isDefaultRegistry := serverAddress == registry.IndexServer
139+
defaultUsername = strings.TrimSpace(defaultUsername)
115140

116-
if flUser = strings.TrimSpace(flUser); flUser == "" {
141+
if argUser = strings.TrimSpace(argUser); argUser == "" {
117142
if isDefaultRegistry {
118143
// if this is a default registry (docker hub), then display the following message.
119144
fmt.Fprintln(cli.Out(), "Log in with your Docker ID or email address to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com/ to create one.")
@@ -124,44 +149,43 @@ func ConfigureAuth(ctx context.Context, cli Cli, flUser, flPassword string, auth
124149
}
125150

126151
var prompt string
127-
if authconfig.Username == "" {
152+
if defaultUsername == "" {
128153
prompt = "Username: "
129154
} else {
130-
prompt = fmt.Sprintf("Username (%s): ", authconfig.Username)
155+
prompt = fmt.Sprintf("Username (%s): ", defaultUsername)
131156
}
132-
var err error
133-
flUser, err = PromptForInput(ctx, cli.In(), cli.Out(), prompt)
157+
argUser, err = PromptForInput(ctx, cli.In(), cli.Out(), prompt)
134158
if err != nil {
135-
return err
159+
return authConfig, err
136160
}
137-
if flUser == "" {
138-
flUser = authconfig.Username
161+
if argUser == "" {
162+
argUser = defaultUsername
139163
}
140164
}
141-
if flUser == "" {
142-
return errors.Errorf("Error: Non-null Username Required")
165+
if argUser == "" {
166+
return authConfig, errors.Errorf("Error: Non-null Username Required")
143167
}
144-
if flPassword == "" {
168+
if argPassword == "" {
145169
restoreInput, err := DisableInputEcho(cli.In())
146170
if err != nil {
147-
return err
171+
return authConfig, err
148172
}
149173
defer restoreInput()
150174

151-
flPassword, err = PromptForInput(ctx, cli.In(), cli.Out(), "Password: ")
175+
argPassword, err = PromptForInput(ctx, cli.In(), cli.Out(), "Password: ")
152176
if err != nil {
153-
return err
177+
return authConfig, err
154178
}
155179
fmt.Fprint(cli.Out(), "\n")
156-
if flPassword == "" {
157-
return errors.Errorf("Error: Password Required")
180+
if argPassword == "" {
181+
return authConfig, errors.Errorf("Error: Password Required")
158182
}
159183
}
160184

161-
authconfig.Username = flUser
162-
authconfig.Password = flPassword
163-
164-
return nil
185+
authConfig.Username = argUser
186+
authConfig.Password = argPassword
187+
authConfig.ServerAddress = serverAddress
188+
return authConfig, nil
165189
}
166190

167191
// RetrieveAuthTokenFromImage retrieves an encoded auth token given a complete

cli/command/registry/login.go

+104-28
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/docker/cli/cli/command"
1111
"github.com/docker/cli/cli/command/completion"
1212
configtypes "github.com/docker/cli/cli/config/types"
13+
"github.com/docker/cli/cli/internal/oauth/manager"
1314
registrytypes "github.com/docker/docker/api/types/registry"
1415
"github.com/docker/docker/client"
1516
"github.com/docker/docker/errdefs"
@@ -79,70 +80,145 @@ func verifyloginOptions(dockerCli command.Cli, opts *loginOptions) error {
7980
return nil
8081
}
8182

82-
func runLogin(ctx context.Context, dockerCli command.Cli, opts loginOptions) error { //nolint:gocyclo
83-
clnt := dockerCli.Client()
83+
func runLogin(ctx context.Context, dockerCli command.Cli, opts loginOptions) error {
8484
if err := verifyloginOptions(dockerCli, &opts); err != nil {
8585
return err
8686
}
8787
var (
8888
serverAddress string
89-
response registrytypes.AuthenticateOKBody
89+
response *registrytypes.AuthenticateOKBody
9090
)
91-
if opts.serverAddress != "" && opts.serverAddress != registry.DefaultNamespace {
91+
if opts.serverAddress != "" &&
92+
opts.serverAddress != registry.DefaultNamespace &&
93+
opts.serverAddress != registry.DefaultRegistryHost {
9294
serverAddress = opts.serverAddress
9395
} else {
9496
serverAddress = registry.IndexServer
9597
}
96-
9798
isDefaultRegistry := serverAddress == registry.IndexServer
99+
100+
// attempt login with current (stored) credentials
98101
authConfig, err := command.GetDefaultAuthConfig(dockerCli.ConfigFile(), opts.user == "" && opts.password == "", serverAddress, isDefaultRegistry)
99102
if err == nil && authConfig.Username != "" && authConfig.Password != "" {
100-
response, err = loginWithCredStoreCreds(ctx, dockerCli, &authConfig)
103+
response, err = loginWithStoredCredentials(ctx, dockerCli, authConfig)
101104
}
105+
106+
// if we failed to authenticate with stored credentials (or didn't have stored credentials),
107+
// prompt the user for new credentials
102108
if err != nil || authConfig.Username == "" || authConfig.Password == "" {
103-
err = command.ConfigureAuth(ctx, dockerCli, opts.user, opts.password, &authConfig, isDefaultRegistry)
109+
response, err = loginUser(ctx, dockerCli, opts, authConfig.Username, serverAddress)
104110
if err != nil {
105111
return err
106112
}
113+
}
114+
115+
if response != nil && response.Status != "" {
116+
_, _ = fmt.Fprintln(dockerCli.Out(), response.Status)
117+
}
118+
return nil
119+
}
107120

108-
response, err = clnt.RegistryLogin(ctx, authConfig)
109-
if err != nil && client.IsErrConnectionFailed(err) {
110-
// If the server isn't responding (yet) attempt to login purely client side
111-
response, err = loginClientSide(ctx, authConfig)
121+
func loginWithStoredCredentials(ctx context.Context, dockerCli command.Cli, authConfig registrytypes.AuthConfig) (*registrytypes.AuthenticateOKBody, error) {
122+
_, _ = fmt.Fprintf(dockerCli.Out(), "Authenticating with existing credentials...\n")
123+
response, err := dockerCli.Client().RegistryLogin(ctx, authConfig)
124+
if err != nil {
125+
if errdefs.IsUnauthorized(err) {
126+
_, _ = fmt.Fprintf(dockerCli.Err(), "Stored credentials invalid or expired\n")
127+
} else {
128+
_, _ = fmt.Fprintf(dockerCli.Err(), "Login did not succeed, error: %s\n", err)
112129
}
113-
// If we (still) have an error, give up
114-
if err != nil {
115-
return err
130+
}
131+
132+
if response.IdentityToken != "" {
133+
authConfig.Password = ""
134+
authConfig.IdentityToken = response.IdentityToken
135+
}
136+
137+
if err := storeCredentials(dockerCli, authConfig); err != nil {
138+
return nil, err
139+
}
140+
141+
return &response, err
142+
}
143+
144+
func loginUser(ctx context.Context, dockerCli command.Cli, opts loginOptions, defaultUsername, serverAddress string) (*registrytypes.AuthenticateOKBody, error) {
145+
// If we're logging into the index server and the user didn't provide a username or password, use the device flow
146+
if serverAddress == registry.IndexServer && opts.user == "" && opts.password == "" {
147+
response, err := loginWithDeviceCodeFlow(ctx, dockerCli)
148+
// if the error represents a failure to initiate the device-code flow,
149+
// then we fallback to regular cli credentials login
150+
if !errors.Is(err, manager.ErrDeviceLoginStartFail) {
151+
return response, err
116152
}
153+
fmt.Fprint(dockerCli.Err(), "Failed to start web-based login - falling back to command line login...\n\n")
117154
}
155+
156+
return loginWithUsernameAndPassword(ctx, dockerCli, opts, defaultUsername, serverAddress)
157+
}
158+
159+
func loginWithUsernameAndPassword(ctx context.Context, dockerCli command.Cli, opts loginOptions, defaultUsername, serverAddress string) (*registrytypes.AuthenticateOKBody, error) {
160+
// Prompt user for credentials
161+
authConfig, err := command.PromptUserForCredentials(ctx, dockerCli, opts.user, opts.password, defaultUsername, serverAddress)
162+
if err != nil {
163+
return nil, err
164+
}
165+
166+
response, err := loginWithRegistry(ctx, dockerCli, authConfig)
167+
if err != nil {
168+
return nil, err
169+
}
170+
118171
if response.IdentityToken != "" {
119172
authConfig.Password = ""
120173
authConfig.IdentityToken = response.IdentityToken
121174
}
175+
if err = storeCredentials(dockerCli, authConfig); err != nil {
176+
return nil, err
177+
}
178+
179+
return &response, nil
180+
}
181+
182+
func loginWithDeviceCodeFlow(ctx context.Context, dockerCli command.Cli) (*registrytypes.AuthenticateOKBody, error) {
183+
store := dockerCli.ConfigFile().GetCredentialsStore(registry.IndexServer)
184+
authConfig, err := manager.NewManager(store).LoginDevice(ctx, dockerCli.Err())
185+
if err != nil {
186+
return nil, err
187+
}
188+
189+
response, err := loginWithRegistry(ctx, dockerCli, registrytypes.AuthConfig(*authConfig))
190+
if err != nil {
191+
return nil, err
192+
}
193+
194+
if err = storeCredentials(dockerCli, registrytypes.AuthConfig(*authConfig)); err != nil {
195+
return nil, err
196+
}
122197

123-
creds := dockerCli.ConfigFile().GetCredentialsStore(serverAddress)
198+
return &response, nil
199+
}
200+
201+
func storeCredentials(dockerCli command.Cli, authConfig registrytypes.AuthConfig) error {
202+
creds := dockerCli.ConfigFile().GetCredentialsStore(authConfig.ServerAddress)
124203
if err := creds.Store(configtypes.AuthConfig(authConfig)); err != nil {
125204
return errors.Errorf("Error saving credentials: %v", err)
126205
}
127206

128-
if response.Status != "" {
129-
fmt.Fprintln(dockerCli.Out(), response.Status)
130-
}
131207
return nil
132208
}
133209

134-
func loginWithCredStoreCreds(ctx context.Context, dockerCli command.Cli, authConfig *registrytypes.AuthConfig) (registrytypes.AuthenticateOKBody, error) {
135-
fmt.Fprintf(dockerCli.Out(), "Authenticating with existing credentials...\n")
136-
cliClient := dockerCli.Client()
137-
response, err := cliClient.RegistryLogin(ctx, *authConfig)
210+
func loginWithRegistry(ctx context.Context, dockerCli command.Cli, authConfig registrytypes.AuthConfig) (registrytypes.AuthenticateOKBody, error) {
211+
response, err := dockerCli.Client().RegistryLogin(ctx, authConfig)
212+
if err != nil && client.IsErrConnectionFailed(err) {
213+
// If the server isn't responding (yet) attempt to login purely client side
214+
response, err = loginClientSide(ctx, authConfig)
215+
}
216+
// If we (still) have an error, give up
138217
if err != nil {
139-
if errdefs.IsUnauthorized(err) {
140-
fmt.Fprintf(dockerCli.Err(), "Stored credentials invalid or expired\n")
141-
} else {
142-
fmt.Fprintf(dockerCli.Err(), "Login did not succeed, error: %s\n", err)
143-
}
218+
return registrytypes.AuthenticateOKBody{}, err
144219
}
145-
return response, err
220+
221+
return response, nil
146222
}
147223

148224
func loginClientSide(ctx context.Context, auth registrytypes.AuthConfig) (registrytypes.AuthenticateOKBody, error) {

cli/command/registry/login_test.go

+4-2
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ func TestLoginWithCredStoreCreds(t *testing.T) {
7474
cli := test.NewFakeCli(&fakeClient{})
7575
errBuf := new(bytes.Buffer)
7676
cli.SetErr(streams.NewOut(errBuf))
77-
loginWithCredStoreCreds(ctx, cli, &tc.inputAuthConfig)
77+
loginWithStoredCredentials(ctx, cli, tc.inputAuthConfig)
7878
outputString := cli.OutBuffer().String()
7979
assert.Check(t, is.Equal(tc.expectedMsg, outputString))
8080
errorString := errBuf.String()
@@ -213,7 +213,9 @@ func TestLoginTermination(t *testing.T) {
213213

214214
runErr := make(chan error)
215215
go func() {
216-
runErr <- runLogin(ctx, cli, loginOptions{})
216+
runErr <- runLogin(ctx, cli, loginOptions{
217+
user: "test-user",
218+
})
217219
}()
218220

219221
// Let the prompt get canceled by the context

cli/command/registry/logout.go

+9-1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/docker/cli/cli"
88
"github.com/docker/cli/cli/command"
99
"github.com/docker/cli/cli/config/credentials"
10+
"github.com/docker/cli/cli/internal/oauth/manager"
1011
"github.com/docker/docker/registry"
1112
"github.com/spf13/cobra"
1213
)
@@ -34,7 +35,7 @@ func NewLogoutCommand(dockerCli command.Cli) *cobra.Command {
3435
return cmd
3536
}
3637

37-
func runLogout(_ context.Context, dockerCli command.Cli, serverAddress string) error {
38+
func runLogout(ctx context.Context, dockerCli command.Cli, serverAddress string) error {
3839
var isDefaultRegistry bool
3940

4041
if serverAddress == "" {
@@ -53,6 +54,13 @@ func runLogout(_ context.Context, dockerCli command.Cli, serverAddress string) e
5354
regsToLogout = append(regsToLogout, hostnameAddress, "http://"+hostnameAddress, "https://"+hostnameAddress)
5455
}
5556

57+
if isDefaultRegistry {
58+
store := dockerCli.ConfigFile().GetCredentialsStore(registry.IndexServer)
59+
if err := manager.NewManager(store).Logout(ctx); err != nil {
60+
fmt.Fprintf(dockerCli.Err(), "WARNING: %v\n", err)
61+
}
62+
}
63+
5664
fmt.Fprintf(dockerCli.Out(), "Removing login credentials for %s\n", hostnameAddress)
5765
errs := make(map[string]error)
5866
for _, r := range regsToLogout {

cli/config/credentials/file_store.go

+7-1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"net/url"
77
"os"
88
"strings"
9+
"sync/atomic"
910

1011
"github.com/docker/cli/cli/config/types"
1112
)
@@ -65,6 +66,10 @@ Configure a credential helper to remove this warning. See
6566
https://docs.docker.com/go/credential-store/
6667
`
6768

69+
// alreadyPrinted ensures that we only print the unencryptedWarning once per
70+
// CLI invocation (no need to warn the user multiple times per command).
71+
var alreadyPrinted atomic.Bool
72+
6873
// Store saves the given credentials in the file store.
6974
func (c *fileStore) Store(authConfig types.AuthConfig) error {
7075
authConfigs := c.file.GetAuthConfigs()
@@ -73,11 +78,12 @@ func (c *fileStore) Store(authConfig types.AuthConfig) error {
7378
return err
7479
}
7580

76-
if authConfig.Password != "" {
81+
if !alreadyPrinted.Load() && authConfig.Password != "" {
7782
// Display a warning if we're storing the users password (not a token).
7883
//
7984
// FIXME(thaJeztah): make output configurable instead of hardcoding to os.Stderr
8085
_, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf(unencryptedWarning, c.file.GetFilename()))
86+
alreadyPrinted.Store(true)
8187
}
8288

8389
return nil

0 commit comments

Comments
 (0)