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