mirror of https://github.com/docker/cli.git
364 lines
10 KiB
Go
364 lines
10 KiB
Go
|
package manager
|
||
|
|
||
|
import (
|
||
|
"context"
|
||
|
"errors"
|
||
|
"os"
|
||
|
"testing"
|
||
|
"time"
|
||
|
|
||
|
"github.com/docker/cli/cli/config/credentials"
|
||
|
"github.com/docker/cli/cli/config/types"
|
||
|
"github.com/docker/cli/cli/internal/oauth/api"
|
||
|
"gotest.tools/v3/assert"
|
||
|
)
|
||
|
|
||
|
const (
|
||
|
//nolint:lll
|
||
|
validToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6InhYa3BCdDNyV3MyRy11YjlscEpncSJ9.eyJodHRwczovL2h1Yi5kb2NrZXIuY29tIjp7ImVtYWlsIjoiYm9ya0Bkb2NrZXIuY29tIiwic2Vzc2lvbl9pZCI6ImEtc2Vzc2lvbi1pZCIsInNvdXJjZSI6InNhbWxwIiwidXNlcm5hbWUiOiJib3JrISIsInV1aWQiOiIwMTIzLTQ1Njc4OSJ9LCJpc3MiOiJodHRwczovL2xvZ2luLmRvY2tlci5jb20vIiwic3ViIjoic2FtbHB8c2FtbHAtZG9ja2VyfGJvcmtAZG9ja2VyLmNvbSIsImF1ZCI6WyJodHRwczovL2F1ZGllbmNlLmNvbSJdLCJpYXQiOjE3MTk1MDI5MzksImV4cCI6MTcxOTUwNjUzOSwic2NvcGUiOiJvcGVuaWQgb2ZmbGluZV9hY2Nlc3MifQ.VUSp-9_SOvMPWJPRrSh7p4kSPoye4DA3kyd2I0TW0QtxYSRq7xCzNj0NC_ywlPlKBFBeXKm4mh93d1vBSh79I9Heq5tj0Fr4KH77U5xJRMEpjHqoT5jxMEU1hYXX92xctnagBMXxDvzUfu3Yf0tvYSA0RRoGbGTHfdYYRwOrGbwQ75Qg1dyIxUkwsG053eYX2XkmLGxymEMgIq_gWksgAamOc40_0OCdGr-MmDeD2HyGUa309aGltzQUw7Z0zG1AKSXy3WwfMHdWNFioTAvQphwEyY3US8ybSJi78upSFTjwUcryMeHUwQ3uV9PxwPMyPoYxo1izVB-OUJxM8RqEbg"
|
||
|
)
|
||
|
|
||
|
// parsed token:
|
||
|
// {
|
||
|
// "https://hub.docker.com": {
|
||
|
// "email": "bork@docker.com",
|
||
|
// "session_id": "a-session-id",
|
||
|
// "source": "samlp",
|
||
|
// "username": "bork!",
|
||
|
// "uuid": "0123-456789"
|
||
|
// },
|
||
|
// "iss": "https://login.docker.com/",
|
||
|
// "sub": "samlp|samlp-docker|bork@docker.com",
|
||
|
// "aud": [
|
||
|
// "https://audience.com"
|
||
|
// ],
|
||
|
// "iat": 1719502939,
|
||
|
// "exp": 1719506539,
|
||
|
// "scope": "openid offline_access"
|
||
|
// }
|
||
|
|
||
|
func TestLoginDevice(t *testing.T) {
|
||
|
t.Run("valid token", func(t *testing.T) {
|
||
|
expectedState := api.State{
|
||
|
DeviceCode: "device-code",
|
||
|
UserCode: "0123-4567",
|
||
|
VerificationURI: "an-url",
|
||
|
ExpiresIn: 300,
|
||
|
}
|
||
|
var receivedAudience string
|
||
|
getDeviceToken := func(audience string) (api.State, error) {
|
||
|
receivedAudience = audience
|
||
|
return expectedState, nil
|
||
|
}
|
||
|
var receivedState api.State
|
||
|
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
|
||
|
receivedState = state
|
||
|
return api.TokenResponse{
|
||
|
AccessToken: validToken,
|
||
|
RefreshToken: "refresh-token",
|
||
|
}, nil
|
||
|
}
|
||
|
var receivedAccessToken, getPatReceivedAudience string
|
||
|
getAutoPat := func(audience string, res api.TokenResponse) (string, error) {
|
||
|
receivedAccessToken = res.AccessToken
|
||
|
getPatReceivedAudience = audience
|
||
|
return "a-pat", nil
|
||
|
}
|
||
|
api := &testAPI{
|
||
|
getDeviceToken: getDeviceToken,
|
||
|
waitForDeviceToken: waitForDeviceToken,
|
||
|
getAutoPAT: getAutoPat,
|
||
|
}
|
||
|
store := newStore(map[string]types.AuthConfig{})
|
||
|
manager := OAuthManager{
|
||
|
store: credentials.NewFileStore(store),
|
||
|
audience: "https://hub.docker.com",
|
||
|
api: api,
|
||
|
openBrowser: func(url string) error {
|
||
|
return nil
|
||
|
},
|
||
|
}
|
||
|
|
||
|
authConfig, err := manager.LoginDevice(context.Background(), os.Stderr)
|
||
|
assert.NilError(t, err)
|
||
|
|
||
|
assert.Equal(t, receivedAudience, "https://hub.docker.com")
|
||
|
assert.Equal(t, receivedState, expectedState)
|
||
|
assert.DeepEqual(t, authConfig, &types.AuthConfig{
|
||
|
Username: "bork!",
|
||
|
Password: "a-pat",
|
||
|
ServerAddress: "https://index.docker.io/v1/",
|
||
|
})
|
||
|
assert.Equal(t, receivedAccessToken, validToken)
|
||
|
assert.Equal(t, getPatReceivedAudience, "https://hub.docker.com")
|
||
|
})
|
||
|
|
||
|
t.Run("stores in cred store", func(t *testing.T) {
|
||
|
getDeviceToken := func(audience string) (api.State, error) {
|
||
|
return api.State{
|
||
|
DeviceCode: "device-code",
|
||
|
UserCode: "0123-4567",
|
||
|
}, nil
|
||
|
}
|
||
|
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
|
||
|
return api.TokenResponse{
|
||
|
AccessToken: validToken,
|
||
|
RefreshToken: "refresh-token",
|
||
|
}, nil
|
||
|
}
|
||
|
getAutoPAT := func(audience string, res api.TokenResponse) (string, error) {
|
||
|
return "a-pat", nil
|
||
|
}
|
||
|
a := &testAPI{
|
||
|
getDeviceToken: getDeviceToken,
|
||
|
waitForDeviceToken: waitForDeviceToken,
|
||
|
getAutoPAT: getAutoPAT,
|
||
|
}
|
||
|
store := newStore(map[string]types.AuthConfig{})
|
||
|
manager := OAuthManager{
|
||
|
clientID: "client-id",
|
||
|
store: credentials.NewFileStore(store),
|
||
|
api: a,
|
||
|
openBrowser: func(url string) error {
|
||
|
return nil
|
||
|
},
|
||
|
}
|
||
|
|
||
|
authConfig, err := manager.LoginDevice(context.Background(), os.Stderr)
|
||
|
assert.NilError(t, err)
|
||
|
|
||
|
assert.Equal(t, authConfig.Password, "a-pat")
|
||
|
assert.Equal(t, authConfig.Username, "bork!")
|
||
|
|
||
|
assert.Equal(t, len(store.configs), 2)
|
||
|
assert.Equal(t, store.configs["https://index.docker.io/v1/access-token"].Password, validToken)
|
||
|
assert.Equal(t, store.configs["https://index.docker.io/v1/refresh-token"].Password, "refresh-token..client-id")
|
||
|
})
|
||
|
|
||
|
t.Run("timeout", func(t *testing.T) {
|
||
|
getDeviceToken := func(audience string) (api.State, error) {
|
||
|
return api.State{
|
||
|
DeviceCode: "device-code",
|
||
|
UserCode: "0123-4567",
|
||
|
VerificationURI: "an-url",
|
||
|
ExpiresIn: 300,
|
||
|
}, nil
|
||
|
}
|
||
|
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
|
||
|
return api.TokenResponse{}, api.ErrTimeout
|
||
|
}
|
||
|
a := &testAPI{
|
||
|
getDeviceToken: getDeviceToken,
|
||
|
waitForDeviceToken: waitForDeviceToken,
|
||
|
}
|
||
|
manager := OAuthManager{
|
||
|
api: a,
|
||
|
openBrowser: func(url string) error {
|
||
|
return nil
|
||
|
},
|
||
|
}
|
||
|
|
||
|
_, err := manager.LoginDevice(context.Background(), os.Stderr)
|
||
|
assert.ErrorContains(t, err, "failed waiting for authentication: timed out waiting for device token")
|
||
|
})
|
||
|
|
||
|
t.Run("canceled context", func(t *testing.T) {
|
||
|
getDeviceToken := func(audience string) (api.State, error) {
|
||
|
return api.State{
|
||
|
DeviceCode: "device-code",
|
||
|
UserCode: "0123-4567",
|
||
|
}, nil
|
||
|
}
|
||
|
waitForDeviceToken := func(state api.State) (api.TokenResponse, error) {
|
||
|
// make sure that the context is cancelled before this returns
|
||
|
time.Sleep(500 * time.Millisecond)
|
||
|
return api.TokenResponse{
|
||
|
AccessToken: validToken,
|
||
|
RefreshToken: "refresh-token",
|
||
|
}, nil
|
||
|
}
|
||
|
a := &testAPI{
|
||
|
getDeviceToken: getDeviceToken,
|
||
|
waitForDeviceToken: waitForDeviceToken,
|
||
|
}
|
||
|
manager := OAuthManager{
|
||
|
api: a,
|
||
|
openBrowser: func(url string) error {
|
||
|
return nil
|
||
|
},
|
||
|
}
|
||
|
|
||
|
ctx, cancel := context.WithCancel(context.Background())
|
||
|
cancel()
|
||
|
_, err := manager.LoginDevice(ctx, os.Stderr)
|
||
|
assert.ErrorContains(t, err, "login canceled")
|
||
|
})
|
||
|
}
|
||
|
|
||
|
func TestLogout(t *testing.T) {
|
||
|
t.Run("successfully revokes token", func(t *testing.T) {
|
||
|
var receivedToken string
|
||
|
a := &testAPI{
|
||
|
revokeToken: func(token string) error {
|
||
|
receivedToken = token
|
||
|
return nil
|
||
|
},
|
||
|
}
|
||
|
store := newStore(map[string]types.AuthConfig{
|
||
|
"https://index.docker.io/v1/access-token": {
|
||
|
Password: validToken,
|
||
|
},
|
||
|
"https://index.docker.io/v1/refresh-token": {
|
||
|
Password: "a-refresh-token..client-id",
|
||
|
},
|
||
|
})
|
||
|
manager := OAuthManager{
|
||
|
store: credentials.NewFileStore(store),
|
||
|
api: a,
|
||
|
}
|
||
|
|
||
|
err := manager.Logout(context.Background())
|
||
|
assert.NilError(t, err)
|
||
|
|
||
|
assert.Equal(t, receivedToken, "a-refresh-token")
|
||
|
assert.Equal(t, len(store.configs), 0)
|
||
|
})
|
||
|
|
||
|
t.Run("error revoking token", func(t *testing.T) {
|
||
|
a := &testAPI{
|
||
|
revokeToken: func(token string) error {
|
||
|
return errors.New("couldn't reach tenant")
|
||
|
},
|
||
|
}
|
||
|
store := newStore(map[string]types.AuthConfig{
|
||
|
"https://index.docker.io/v1/access-token": {
|
||
|
Password: validToken,
|
||
|
},
|
||
|
"https://index.docker.io/v1/refresh-token": {
|
||
|
Password: "a-refresh-token..client-id",
|
||
|
},
|
||
|
})
|
||
|
manager := OAuthManager{
|
||
|
store: credentials.NewFileStore(store),
|
||
|
api: a,
|
||
|
}
|
||
|
|
||
|
err := manager.Logout(context.Background())
|
||
|
assert.ErrorContains(t, err, "credentials erased successfully, but there was a failure to revoke the OAuth refresh token with the tenant: couldn't reach tenant")
|
||
|
|
||
|
assert.Equal(t, len(store.configs), 0)
|
||
|
})
|
||
|
|
||
|
t.Run("invalid refresh token", func(t *testing.T) {
|
||
|
var triedRevoke bool
|
||
|
a := &testAPI{
|
||
|
revokeToken: func(token string) error {
|
||
|
triedRevoke = true
|
||
|
return nil
|
||
|
},
|
||
|
}
|
||
|
store := newStore(map[string]types.AuthConfig{
|
||
|
"https://index.docker.io/v1/access-token": {
|
||
|
Password: validToken,
|
||
|
},
|
||
|
"https://index.docker.io/v1/refresh-token": {
|
||
|
Password: "a-refresh-token-without-client-id",
|
||
|
},
|
||
|
})
|
||
|
manager := OAuthManager{
|
||
|
store: credentials.NewFileStore(store),
|
||
|
api: a,
|
||
|
}
|
||
|
|
||
|
err := manager.Logout(context.Background())
|
||
|
assert.NilError(t, err)
|
||
|
|
||
|
assert.Check(t, !triedRevoke)
|
||
|
})
|
||
|
|
||
|
t.Run("no refresh token", func(t *testing.T) {
|
||
|
a := &testAPI{}
|
||
|
var triedRevoke bool
|
||
|
revokeToken := func(token string) error {
|
||
|
triedRevoke = true
|
||
|
return nil
|
||
|
}
|
||
|
a.revokeToken = revokeToken
|
||
|
store := newStore(map[string]types.AuthConfig{})
|
||
|
manager := OAuthManager{
|
||
|
store: credentials.NewFileStore(store),
|
||
|
api: a,
|
||
|
}
|
||
|
|
||
|
err := manager.Logout(context.Background())
|
||
|
assert.NilError(t, err)
|
||
|
|
||
|
assert.Check(t, !triedRevoke)
|
||
|
})
|
||
|
}
|
||
|
|
||
|
var _ api.OAuthAPI = &testAPI{}
|
||
|
|
||
|
type testAPI struct {
|
||
|
getDeviceToken func(audience string) (api.State, error)
|
||
|
waitForDeviceToken func(state api.State) (api.TokenResponse, error)
|
||
|
refresh func(token string) (api.TokenResponse, error)
|
||
|
revokeToken func(token string) error
|
||
|
getAutoPAT func(audience string, res api.TokenResponse) (string, error)
|
||
|
}
|
||
|
|
||
|
func (t *testAPI) GetDeviceCode(_ context.Context, audience string) (api.State, error) {
|
||
|
if t.getDeviceToken != nil {
|
||
|
return t.getDeviceToken(audience)
|
||
|
}
|
||
|
return api.State{}, nil
|
||
|
}
|
||
|
|
||
|
func (t *testAPI) WaitForDeviceToken(_ context.Context, state api.State) (api.TokenResponse, error) {
|
||
|
if t.waitForDeviceToken != nil {
|
||
|
return t.waitForDeviceToken(state)
|
||
|
}
|
||
|
return api.TokenResponse{}, nil
|
||
|
}
|
||
|
|
||
|
func (t *testAPI) Refresh(_ context.Context, token string) (api.TokenResponse, error) {
|
||
|
if t.refresh != nil {
|
||
|
return t.refresh(token)
|
||
|
}
|
||
|
return api.TokenResponse{}, nil
|
||
|
}
|
||
|
|
||
|
func (t *testAPI) RevokeToken(_ context.Context, token string) error {
|
||
|
if t.revokeToken != nil {
|
||
|
return t.revokeToken(token)
|
||
|
}
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (t *testAPI) GetAutoPAT(_ context.Context, audience string, res api.TokenResponse) (string, error) {
|
||
|
if t.getAutoPAT != nil {
|
||
|
return t.getAutoPAT(audience, res)
|
||
|
}
|
||
|
return "", nil
|
||
|
}
|
||
|
|
||
|
type fakeStore struct {
|
||
|
configs map[string]types.AuthConfig
|
||
|
}
|
||
|
|
||
|
func (f *fakeStore) Save() error {
|
||
|
return nil
|
||
|
}
|
||
|
|
||
|
func (f *fakeStore) GetAuthConfigs() map[string]types.AuthConfig {
|
||
|
return f.configs
|
||
|
}
|
||
|
|
||
|
func (f *fakeStore) GetFilename() string {
|
||
|
return "/tmp/docker-fakestore"
|
||
|
}
|
||
|
|
||
|
func newStore(auths map[string]types.AuthConfig) *fakeStore {
|
||
|
return &fakeStore{configs: auths}
|
||
|
}
|