| 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/internal/oauth/api" |
| "gotest.tools/v3/assert" |
| ) |
| |
| const ( |
| //nolint:revive // ignore line-length-limit |
| 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 (*fakeStore) Save() error { |
| return nil |
| } |
| |
| func (f *fakeStore) GetAuthConfigs() map[string]types.AuthConfig { |
| return f.configs |
| } |
| |
| func (*fakeStore) GetFilename() string { |
| return "/tmp/docker-fakestore" |
| } |
| |
| func newStore(auths map[string]types.AuthConfig) *fakeStore { |
| return &fakeStore{configs: auths} |
| } |