Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
auth: add support for oauth device-code login
Browse files Browse the repository at this point in the history
This commit adds support for the oauth [device-code](https://auth0.com/docs/get-started/authentication-and-authorization-flow/device-authorization-flow)
login flow when authenticating against the official registry.

This is achieved by adding `cli/internal/oauth`, which contains code to manage
interacting with the Docker OAuth tenant (`login.docker.com`), including launching
the device-code flow, refreshing access using the refresh-token, and logging out.

The `OAuthManager` introduced here is also made available through the `command.Cli`
interface method `OAuthManager()`.

In order to manage storage and retrieval of the oauth credentials, as well as ensuring
that the access token retrieved from the credentials store is valid when accessed, this
commit also introduces a new credential store wrapper, `OAuthStore`, which defers storage
to an underlying store (such as the file or native/keychain/pass/wincred store) and
gets a new access token using the refresh token when accessed.

Signed-off-by: Laura Brehm <laurabrehm@hey.com>
laurazard committed Jul 9, 2024
1 parent 9bb1a62 commit 526fe36
Showing 57 changed files with 11,830 additions and 9 deletions.
10 changes: 10 additions & 0 deletions cli/command/cli.go
Original file line number Diff line number Diff line change
@@ -21,7 +21,9 @@ import (
"github.com/docker/cli/cli/context/store"
"github.com/docker/cli/cli/debug"
cliflags "github.com/docker/cli/cli/flags"
"github.com/docker/cli/cli/internal/oauth/manager"
manifeststore "github.com/docker/cli/cli/manifest/store"
"github.com/docker/cli/cli/oauth"
registryclient "github.com/docker/cli/cli/registry/client"
"github.com/docker/cli/cli/streams"
"github.com/docker/cli/cli/trust"
@@ -66,6 +68,7 @@ type Cli interface {
CurrentContext() string
DockerEndpoint() docker.Endpoint
TelemetryClient
OAuthManager() oauth.Manager
}

// DockerCli is an instance the docker command line client.
@@ -86,6 +89,7 @@ type DockerCli struct {
dockerEndpoint docker.Endpoint
contextStoreConfig store.Config
initTimeout time.Duration
oauthManager oauth.Manager
res telemetryResource

// baseCtx is the base context used for internal operations. In the future
@@ -96,6 +100,10 @@ type DockerCli struct {
enableGlobalMeter, enableGlobalTracer bool
}

func (cli *DockerCli) OAuthManager() oauth.Manager {
return cli.oauthManager
}

// DefaultVersion returns api.defaultVersion.
func (cli *DockerCli) DefaultVersion() string {
return api.DefaultVersion
@@ -293,6 +301,8 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions, ops ...CLIOption)
cli.createGlobalTracerProvider(cli.baseCtx)
}

cli.oauthManager = manager.NewManager()

return nil
}

38 changes: 34 additions & 4 deletions cli/command/registry/login.go
Original file line number Diff line number Diff line change
@@ -121,9 +121,39 @@ func runLogin(ctx context.Context, dockerCli command.Cli, opts loginOptions) err
response, err = loginWithCredStoreCreds(ctx, dockerCli, &authConfig)
}
if err != nil || authConfig.Username == "" || authConfig.Password == "" {
err = command.ConfigureAuth(ctx, dockerCli, opts.user, opts.password, &authConfig, isDefaultRegistry)
if err != nil {
return err
if isDefaultRegistry && opts.user == "" && opts.password == "" {
// todo(laurazard: clean this up
tokenRes, err := dockerCli.OAuthManager().LoginDevice(ctx, dockerCli.Err())
if err != nil {
return err
}
authConfig.Username = tokenRes.Claims.Domain.Username
authConfig.Password = tokenRes.AccessToken
authConfig.Email = tokenRes.Claims.Domain.Email
authConfig.ServerAddress = serverAddress

response, err = clnt.RegistryLogin(ctx, authConfig)
if err != nil && client.IsErrConnectionFailed(err) {
// If the server isn't responding (yet) attempt to login purely client side
response, err = loginClientSide(ctx, authConfig)
}
// If we (still) have an error, give up
if err != nil {
return err
}

authConfig.Password = authConfig.Password + ".." + tokenRes.RefreshToken

creds := dockerCli.ConfigFile().GetCredentialsStore(serverAddress)
if err := creds.Store(configtypes.AuthConfig(authConfig)); err != nil {
return errors.Errorf("Error saving credentials: %v", err)
}
return nil
} else {
err = command.ConfigureAuth(ctx, dockerCli, opts.user, opts.password, &authConfig, isDefaultRegistry)
if err != nil {
return err
}
}

response, err = clnt.RegistryLogin(ctx, authConfig)
@@ -142,7 +172,7 @@ func runLogin(ctx context.Context, dockerCli command.Cli, opts loginOptions) err
}

creds := dockerCli.ConfigFile().GetCredentialsStore(serverAddress)

// todo(laurazard): this will no longer trigger even when the store is a file store
store, isDefault := creds.(isFileStore)
// Display a warning if we're storing the users password (not a token)
if isDefault && authConfig.Password != "" {
4 changes: 3 additions & 1 deletion cli/command/registry/login_test.go
Original file line number Diff line number Diff line change
@@ -213,7 +213,9 @@ func TestLoginTermination(t *testing.T) {

runErr := make(chan error)
go func() {
runErr <- runLogin(ctx, cli, loginOptions{})
runErr <- runLogin(ctx, cli, loginOptions{
user: "test-user",
})
}()

// Let the prompt get canceled by the context
8 changes: 6 additions & 2 deletions cli/config/configfile/file.go
Original file line number Diff line number Diff line change
@@ -10,6 +10,7 @@ import (

"github.com/docker/cli/cli/config/credentials"
"github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/internal/oauth/manager"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
@@ -254,10 +255,13 @@ func decodeAuth(authStr string) (string, string, error) {
// GetCredentialsStore returns a new credentials store from the settings in the
// configuration file
func (configFile *ConfigFile) GetCredentialsStore(registryHostname string) credentials.Store {
var credsStore credentials.Store
if helper := getConfiguredCredentialStore(configFile, registryHostname); helper != "" {
return newNativeStore(configFile, helper)
credsStore = newNativeStore(configFile, helper)
} else {
credsStore = credentials.NewFileStore(configFile)
}
return credentials.NewFileStore(configFile)
return credentials.NewOAuthStore(credsStore, manager.NewManager())
}

// var for unit testing.
143 changes: 143 additions & 0 deletions cli/config/credentials/oauth_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package credentials

import (
"context"
"errors"
"strings"
"time"

"github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/oauth"
"github.com/docker/docker/registry"
)

// oauthStore wraps an existing store that transparently handles oauth
// flows, managing authentication/token refresh and piggybacking off an
// existing store for storage/retrieval.
type oauthStore struct {
backingStore Store
manager oauth.Manager
}

// NewOAuthStore creates a new oauthStore backed by the provided store.
func NewOAuthStore(backingStore Store, manager oauth.Manager) Store {
return &oauthStore{
backingStore: backingStore,
manager: manager,
}
}

const minimumTokenLifetime = 50 * time.Minute

// Get retrieves the credentials from the backing store, refreshing the
// access token if the retrieved token is valid for less than 50 minutes.
// If there are no credentials in the backing store, the device code flow
// is initiated with the tenant in order to log the user in and get
func (c *oauthStore) Get(serverAddress string) (types.AuthConfig, error) {
if serverAddress != registry.IndexServer {
return c.backingStore.Get(serverAddress)
}

auth, err := c.backingStore.Get(serverAddress)
if err != nil {
// If an error happens here, it's not due to the backing store not
// containing credentials, but rather an actual issue with the backing
// store itself. This should be propagated up.
return types.AuthConfig{}, err
}

tokenRes, err := c.parseToken(auth.Password)
// if the password is not a token, return the auth config as is
if err != nil {
return auth, nil
}

// if the access token is valid for less than 50 minutes, refresh it
if tokenRes.RefreshToken != "" && tokenRes.Claims.Expiry.Time().Before(time.Now().Add(minimumTokenLifetime)) {
refreshRes, err := c.manager.RefreshToken(context.TODO(), tokenRes.RefreshToken)
if err != nil {
return types.AuthConfig{}, err
}
tokenRes = refreshRes
}

err = c.storeInBackingStore(tokenRes)
if err != nil {
return types.AuthConfig{}, err
}

return types.AuthConfig{
Username: tokenRes.Claims.Domain.Username,
Password: tokenRes.AccessToken,
Email: tokenRes.Claims.Domain.Email,
ServerAddress: registry.IndexServer,
}, nil
}

// GetAll returns a map containing solely the auth config for the official
// registry, parsed from the backing store and refreshed if necessary.
func (c *oauthStore) GetAll() (map[string]types.AuthConfig, error) {
allAuths, err := c.backingStore.GetAll()
if err != nil {
return nil, err
}

if _, ok := allAuths[registry.IndexServer]; !ok {
return allAuths, nil
}

auth, err := c.Get(registry.IndexServer)
if err != nil {
return nil, err
}
allAuths[registry.IndexServer] = auth
return allAuths, err
}

// Erase removes the credentials from the backing store, logging out of the
// tenant if running
func (c *oauthStore) Erase(serverAddress string) error {
if serverAddress == registry.IndexServer {
// todo(laurazard): should this log out from the tenant
_ = c.manager.Logout(context.TODO())
}
return c.backingStore.Erase(serverAddress)
}

// Store stores the provided credentials in the backing store, without any
// additional processing.
func (c *oauthStore) Store(auth types.AuthConfig) error {
return c.backingStore.Store(auth)
}

func (c *oauthStore) parseToken(password string) (oauth.TokenResult, error) {
parts := strings.Split(password, "..")
if len(parts) != 2 {
return oauth.TokenResult{}, errors.New("failed to parse token")
}
accessToken := parts[0]
refreshToken := parts[1]
claims, err := oauth.GetClaims(parts[0])
if err != nil {
return oauth.TokenResult{}, err
}
return oauth.TokenResult{
AccessToken: accessToken,
RefreshToken: refreshToken,
Claims: claims,
}, nil
}

func (c *oauthStore) storeInBackingStore(tokenRes oauth.TokenResult) error {
auth := types.AuthConfig{
Username: tokenRes.Claims.Domain.Username,
Password: c.concat(tokenRes.AccessToken, tokenRes.RefreshToken),
Email: tokenRes.Claims.Domain.Email,
ServerAddress: registry.IndexServer,
}
return c.backingStore.Store(auth)
}

func (c *oauthStore) concat(accessToken, refreshToken string) string {
return accessToken + ".." + refreshToken
}
Loading

0 comments on commit 526fe36

Please sign in to comment.