diff --git a/.travis.yml b/.travis.yml
index 934e32e12c9550cceec0d24b3a963f734bdbe275..c5272a0bc929422dddcf958e00ae0ddbf4d8f25f 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -13,13 +13,18 @@ services:
   - docker
 
 env:
-  - DEX_POSTGRES_DATABASE=postgres DEX_POSTGRES_USER=postgres DEX_POSTGRES_HOST="localhost" DEX_ETCD_ENDPOINTS=http://localhost:2379 DEX_LDAP_TESTS=1 DEBIAN_FRONTEND=noninteractive
+  - DEX_POSTGRES_DATABASE=postgres DEX_POSTGRES_USER=postgres DEX_POSTGRES_HOST="localhost" DEX_ETCD_ENDPOINTS=http://localhost:2379 DEX_LDAP_TESTS=1 DEBIAN_FRONTEND=noninteractive DEX_KEYSTONE_URL=http://localhost:5000 DEX_KEYSTONE_ADMIN_URL=http://localhost:35357 DEX_KEYSTONE_ADMIN_USER=demo DEX_KEYSTONE_ADMIN_PASS=DEMO_PASS
 
 install:
   - sudo -E apt-get install -y --force-yes slapd time ldap-utils
   - sudo /etc/init.d/slapd stop
   - docker run -d --net=host gcr.io/etcd-development/etcd:v3.2.9
-
+  - docker run -d -p 0.0.0.0:5000:5000 -p 0.0.0.0:35357:35357 openio/openstack-keystone:pike
+  - |
+    until curl --fail http://localhost:5000/v3; do
+      echo 'Waiting for keystone...'
+      sleep 1;
+    done;
 
 script:
   - make testall
diff --git a/connector/keystone/keystone.go b/connector/keystone/keystone.go
new file mode 100644
index 0000000000000000000000000000000000000000..a86e957d577fc35872dc8e44419fe52d7dde3c2b
--- /dev/null
+++ b/connector/keystone/keystone.go
@@ -0,0 +1,271 @@
+// Package keystone provides authentication strategy using Keystone.
+package keystone
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+
+	"github.com/sirupsen/logrus"
+
+	"github.com/dexidp/dex/connector"
+)
+
+type conn struct {
+	Domain        string
+	Host          string
+	AdminUsername string
+	AdminPassword string
+	Logger        logrus.FieldLogger
+}
+
+type userKeystone struct {
+	Domain domainKeystone `json:"domain"`
+	ID     string         `json:"id"`
+	Name   string         `json:"name"`
+}
+
+type domainKeystone struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
+// Config holds the configuration parameters for Keystone connector.
+// Keystone should expose API v3
+// An example config:
+//	connectors:
+//		type: keystone
+//		id: keystone
+//		name: Keystone
+//		config:
+//			keystoneHost: http://example:5000
+//			domain: default
+//      keystoneUsername: demo
+//      keystonePassword: DEMO_PASS
+type Config struct {
+	Domain        string `json:"domain"`
+	Host          string `json:"keystoneHost"`
+	AdminUsername string `json:"keystoneUsername"`
+	AdminPassword string `json:"keystonePassword"`
+}
+
+type loginRequestData struct {
+	auth `json:"auth"`
+}
+
+type auth struct {
+	Identity identity `json:"identity"`
+}
+
+type identity struct {
+	Methods  []string `json:"methods"`
+	Password password `json:"password"`
+}
+
+type password struct {
+	User user `json:"user"`
+}
+
+type user struct {
+	Name     string `json:"name"`
+	Domain   domain `json:"domain"`
+	Password string `json:"password"`
+}
+
+type domain struct {
+	ID string `json:"id"`
+}
+
+type token struct {
+	User userKeystone `json:"user"`
+}
+
+type tokenResponse struct {
+	Token token `json:"token"`
+}
+
+type group struct {
+	ID   string `json:"id"`
+	Name string `json:"name"`
+}
+
+type groupsResponse struct {
+	Groups []group `json:"groups"`
+}
+
+var (
+	_ connector.PasswordConnector = &conn{}
+	_ connector.RefreshConnector  = &conn{}
+)
+
+// Open returns an authentication strategy using Keystone.
+func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector, error) {
+	return &conn{
+		c.Domain,
+		c.Host,
+		c.AdminUsername,
+		c.AdminPassword,
+		logger}, nil
+}
+
+func (p *conn) Close() error { return nil }
+
+func (p *conn) Login(ctx context.Context, scopes connector.Scopes, username, password string) (identity connector.Identity, validPassword bool, err error) {
+	resp, err := p.getTokenResponse(ctx, username, password)
+	if err != nil {
+		return identity, false, fmt.Errorf("keystone: error %v", err)
+	}
+	if resp.StatusCode/100 != 2 {
+		return identity, false, fmt.Errorf("keystone login: error %v", resp.StatusCode)
+	}
+	if resp.StatusCode != 201 {
+		return identity, false, nil
+	}
+	token := resp.Header.Get("X-Subject-Token")
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return identity, false, err
+	}
+	defer resp.Body.Close()
+	var tokenResp = new(tokenResponse)
+	err = json.Unmarshal(data, &tokenResp)
+	if err != nil {
+		return identity, false, fmt.Errorf("keystone: invalid token response: %v", err)
+	}
+	if scopes.Groups {
+		groups, err := p.getUserGroups(ctx, tokenResp.Token.User.ID, token)
+		if err != nil {
+			return identity, false, err
+		}
+		identity.Groups = groups
+	}
+	identity.Username = username
+	identity.UserID = tokenResp.Token.User.ID
+	return identity, true, nil
+}
+
+func (p *conn) Prompt() string { return "username" }
+
+func (p *conn) Refresh(
+	ctx context.Context, scopes connector.Scopes, identity connector.Identity) (connector.Identity, error) {
+
+	token, err := p.getAdminToken(ctx)
+	if err != nil {
+		return identity, fmt.Errorf("keystone: failed to obtain admin token: %v", err)
+	}
+	ok, err := p.checkIfUserExists(ctx, identity.UserID, token)
+	if err != nil {
+		return identity, err
+	}
+	if !ok {
+		return identity, fmt.Errorf("keystone: user %q does not exist", identity.UserID)
+	}
+	if scopes.Groups {
+		groups, err := p.getUserGroups(ctx, identity.UserID, token)
+		if err != nil {
+			return identity, err
+		}
+		identity.Groups = groups
+	}
+	return identity, nil
+}
+
+func (p *conn) getTokenResponse(ctx context.Context, username, pass string) (response *http.Response, err error) {
+	client := &http.Client{}
+	jsonData := loginRequestData{
+		auth: auth{
+			Identity: identity{
+				Methods: []string{"password"},
+				Password: password{
+					User: user{
+						Name:     username,
+						Domain:   domain{ID: p.Domain},
+						Password: pass,
+					},
+				},
+			},
+		},
+	}
+	jsonValue, err := json.Marshal(jsonData)
+	if err != nil {
+		return nil, err
+	}
+	// https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization
+	authTokenURL := p.Host + "/v3/auth/tokens/"
+	req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(jsonValue))
+	if err != nil {
+		return nil, err
+	}
+
+	req.Header.Set("Content-Type", "application/json")
+	req = req.WithContext(ctx)
+
+	return client.Do(req)
+}
+
+func (p *conn) getAdminToken(ctx context.Context) (string, error) {
+	resp, err := p.getTokenResponse(ctx, p.AdminUsername, p.AdminPassword)
+	if err != nil {
+		return "", err
+	}
+	token := resp.Header.Get("X-Subject-Token")
+	return token, nil
+}
+
+func (p *conn) checkIfUserExists(ctx context.Context, userID string, token string) (bool, error) {
+	// https://developer.openstack.org/api-ref/identity/v3/#show-user-details
+	userURL := p.Host + "/v3/users/" + userID
+	client := &http.Client{}
+	req, err := http.NewRequest("GET", userURL, nil)
+	if err != nil {
+		return false, err
+	}
+
+	req.Header.Set("X-Auth-Token", token)
+	req = req.WithContext(ctx)
+	resp, err := client.Do(req)
+	if err != nil {
+		return false, err
+	}
+
+	if resp.StatusCode == 200 {
+		return true, nil
+	}
+	return false, err
+}
+
+func (p *conn) getUserGroups(ctx context.Context, userID string, token string) ([]string, error) {
+	client := &http.Client{}
+	// https://developer.openstack.org/api-ref/identity/v3/#list-groups-to-which-a-user-belongs
+	groupsURL := p.Host + "/v3/users/" + userID + "/groups"
+	req, err := http.NewRequest("GET", groupsURL, nil)
+	req.Header.Set("X-Auth-Token", token)
+	req = req.WithContext(ctx)
+	resp, err := client.Do(req)
+	if err != nil {
+		p.Logger.Errorf("keystone: error while fetching user %q groups\n", userID)
+		return nil, err
+	}
+
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+
+	var groupsResp = new(groupsResponse)
+
+	err = json.Unmarshal(data, &groupsResp)
+	if err != nil {
+		return nil, err
+	}
+
+	groups := make([]string, len(groupsResp.Groups))
+	for i, group := range groupsResp.Groups {
+		groups[i] = group.Name
+	}
+	return groups, nil
+}
diff --git a/connector/keystone/keystone_test.go b/connector/keystone/keystone_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..d5d65ef1271324d7e01b81a3e217b2f4ef007a72
--- /dev/null
+++ b/connector/keystone/keystone_test.go
@@ -0,0 +1,404 @@
+package keystone
+
+import (
+	"bytes"
+	"context"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"os"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/dexidp/dex/connector"
+)
+
+const (
+	invalidPass = "WRONG_PASS"
+
+	testUser   = "test_user"
+	testPass   = "test_pass"
+	testEmail  = "test@example.com"
+	testGroup  = "test_group"
+	testDomain = "default"
+)
+
+var (
+	keystoneURL      = ""
+	keystoneAdminURL = ""
+	adminUser        = ""
+	adminPass        = ""
+	authTokenURL     = ""
+	usersURL         = ""
+	groupsURL        = ""
+)
+
+type userResponse struct {
+	User struct {
+		ID string `json:"id"`
+	} `json:"user"`
+}
+
+type groupResponse struct {
+	Group struct {
+		ID string `json:"id"`
+	} `json:"group"`
+}
+
+func getAdminToken(t *testing.T, adminName, adminPass string) (token, id string) {
+	t.Helper()
+	client := &http.Client{}
+
+	jsonData := loginRequestData{
+		auth: auth{
+			Identity: identity{
+				Methods: []string{"password"},
+				Password: password{
+					User: user{
+						Name:     adminName,
+						Domain:   domain{ID: testDomain},
+						Password: adminPass,
+					},
+				},
+			},
+		},
+	}
+
+	body, err := json.Marshal(jsonData)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(body))
+	if err != nil {
+		t.Fatalf("keystone: failed to obtain admin token: %v\n", err)
+	}
+
+	req.Header.Set("Content-Type", "application/json")
+	resp, err := client.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	token = resp.Header.Get("X-Subject-Token")
+
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	var tokenResp = new(tokenResponse)
+	err = json.Unmarshal(data, &tokenResp)
+	if err != nil {
+		t.Fatal(err)
+	}
+	return token, tokenResp.Token.User.ID
+}
+
+func createUser(t *testing.T, token, userName, userEmail, userPass string) string {
+	t.Helper()
+	client := &http.Client{}
+
+	createUserData := map[string]interface{}{
+		"user": map[string]interface{}{
+			"name":     userName,
+			"email":    userEmail,
+			"enabled":  true,
+			"password": userPass,
+			"roles":    []string{"admin"},
+		},
+	}
+
+	body, err := json.Marshal(createUserData)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, err := http.NewRequest("POST", usersURL, bytes.NewBuffer(body))
+	if err != nil {
+		t.Fatal(err)
+	}
+	req.Header.Set("X-Auth-Token", token)
+	req.Header.Add("Content-Type", "application/json")
+	resp, err := client.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	var userResp = new(userResponse)
+	err = json.Unmarshal(data, &userResp)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	return userResp.User.ID
+}
+
+// delete group or user
+func delete(t *testing.T, token, id, uri string) {
+	t.Helper()
+	client := &http.Client{}
+
+	deleteURI := uri + id
+	req, err := http.NewRequest("DELETE", deleteURI, nil)
+	if err != nil {
+		t.Fatalf("error: %v", err)
+	}
+	req.Header.Set("X-Auth-Token", token)
+	client.Do(req)
+}
+
+func createGroup(t *testing.T, token, description, name string) string {
+	t.Helper()
+	client := &http.Client{}
+
+	createGroupData := map[string]interface{}{
+		"group": map[string]interface{}{
+			"name":        name,
+			"description": description,
+		},
+	}
+
+	body, err := json.Marshal(createGroupData)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, err := http.NewRequest("POST", groupsURL, bytes.NewBuffer(body))
+	if err != nil {
+		t.Fatal(err)
+	}
+	req.Header.Set("X-Auth-Token", token)
+	req.Header.Add("Content-Type", "application/json")
+	resp, err := client.Do(req)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	data, err := ioutil.ReadAll(resp.Body)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+
+	var groupResp = new(groupResponse)
+	err = json.Unmarshal(data, &groupResp)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	return groupResp.Group.ID
+}
+
+func addUserToGroup(t *testing.T, token, groupID, userID string) error {
+	t.Helper()
+	uri := groupsURL + groupID + "/users/" + userID
+	client := &http.Client{}
+	req, err := http.NewRequest("PUT", uri, nil)
+	if err != nil {
+		return err
+	}
+	req.Header.Set("X-Auth-Token", token)
+	client.Do(req)
+	return nil
+}
+
+func TestIncorrectCredentialsLogin(t *testing.T) {
+	setupVariables(t)
+	c := conn{Host: keystoneURL, Domain: testDomain,
+		AdminUsername: adminUser, AdminPassword: adminPass}
+	s := connector.Scopes{OfflineAccess: true, Groups: true}
+	_, validPW, err := c.Login(context.Background(), s, adminUser, invalidPass)
+
+	if validPW {
+		t.Fatal("Incorrect password check")
+	}
+
+	if err == nil {
+		t.Fatal("Error should be returned when invalid password is provided")
+	}
+
+	if !strings.Contains(err.Error(), "401") {
+		t.Fatal("Unrecognized error, expecting 401")
+	}
+}
+
+func TestValidUserLogin(t *testing.T) {
+	setupVariables(t)
+	token, _ := getAdminToken(t, adminUser, adminPass)
+	userID := createUser(t, token, testUser, testEmail, testPass)
+	c := conn{Host: keystoneURL, Domain: testDomain,
+		AdminUsername: adminUser, AdminPassword: adminPass}
+	s := connector.Scopes{OfflineAccess: true, Groups: true}
+	identity, validPW, err := c.Login(context.Background(), s, testUser, testPass)
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+	t.Log(identity)
+
+	if !validPW {
+		t.Fatal("Valid password was not accepted")
+	}
+	delete(t, token, userID, usersURL)
+}
+
+func TestUseRefreshToken(t *testing.T) {
+	setupVariables(t)
+	token, adminID := getAdminToken(t, adminUser, adminPass)
+	groupID := createGroup(t, token, "Test group description", testGroup)
+	addUserToGroup(t, token, groupID, adminID)
+
+	c := conn{Host: keystoneURL, Domain: testDomain,
+		AdminUsername: adminUser, AdminPassword: adminPass}
+	s := connector.Scopes{OfflineAccess: true, Groups: true}
+
+	identityLogin, _, err := c.Login(context.Background(), s, adminUser, adminPass)
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+
+	identityRefresh, err := c.Refresh(context.Background(), s, identityLogin)
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+
+	delete(t, token, groupID, groupsURL)
+
+	expectEquals(t, 1, len(identityRefresh.Groups))
+	expectEquals(t, testGroup, string(identityRefresh.Groups[0]))
+}
+
+func TestUseRefreshTokenUserDeleted(t *testing.T) {
+	setupVariables(t)
+	token, _ := getAdminToken(t, adminUser, adminPass)
+	userID := createUser(t, token, testUser, testEmail, testPass)
+
+	c := conn{Host: keystoneURL, Domain: testDomain,
+		AdminUsername: adminUser, AdminPassword: adminPass}
+	s := connector.Scopes{OfflineAccess: true, Groups: true}
+
+	identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass)
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+
+	_, err = c.Refresh(context.Background(), s, identityLogin)
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+
+	delete(t, token, userID, usersURL)
+	_, err = c.Refresh(context.Background(), s, identityLogin)
+
+	if !strings.Contains(err.Error(), "does not exist") {
+		t.Errorf("unexpected error: %s", err.Error())
+	}
+}
+
+func TestUseRefreshTokenGroupsChanged(t *testing.T) {
+	setupVariables(t)
+	token, _ := getAdminToken(t, adminUser, adminPass)
+	userID := createUser(t, token, testUser, testEmail, testPass)
+
+	c := conn{Host: keystoneURL, Domain: testDomain,
+		AdminUsername: adminUser, AdminPassword: adminPass}
+	s := connector.Scopes{OfflineAccess: true, Groups: true}
+
+	identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass)
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+
+	identityRefresh, err := c.Refresh(context.Background(), s, identityLogin)
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+
+	expectEquals(t, 0, len(identityRefresh.Groups))
+
+	groupID := createGroup(t, token, "Test group", testGroup)
+	addUserToGroup(t, token, groupID, userID)
+
+	identityRefresh, err = c.Refresh(context.Background(), s, identityLogin)
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+
+	delete(t, token, groupID, groupsURL)
+	delete(t, token, userID, usersURL)
+
+	expectEquals(t, 1, len(identityRefresh.Groups))
+}
+
+func TestNoGroupsInScope(t *testing.T) {
+	setupVariables(t)
+	token, _ := getAdminToken(t, adminUser, adminPass)
+	userID := createUser(t, token, testUser, testEmail, testPass)
+
+	c := conn{Host: keystoneURL, Domain: testDomain,
+		AdminUsername: adminUser, AdminPassword: adminPass}
+	s := connector.Scopes{OfflineAccess: true, Groups: false}
+
+	groupID := createGroup(t, token, "Test group", testGroup)
+	addUserToGroup(t, token, groupID, userID)
+
+	identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass)
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+	expectEquals(t, 0, len(identityLogin.Groups))
+
+	identityRefresh, err := c.Refresh(context.Background(), s, identityLogin)
+	if err != nil {
+		t.Fatal(err.Error())
+	}
+	expectEquals(t, 0, len(identityRefresh.Groups))
+
+	delete(t, token, groupID, groupsURL)
+	delete(t, token, userID, usersURL)
+}
+
+func setupVariables(t *testing.T) {
+	keystoneURLEnv := "DEX_KEYSTONE_URL"
+	keystoneAdminURLEnv := "DEX_KEYSTONE_ADMIN_URL"
+	keystoneAdminUserEnv := "DEX_KEYSTONE_ADMIN_USER"
+	keystoneAdminPassEnv := "DEX_KEYSTONE_ADMIN_PASS"
+	keystoneURL = os.Getenv(keystoneURLEnv)
+	if keystoneURL == "" {
+		t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneURLEnv))
+		return
+	}
+	keystoneAdminURL = os.Getenv(keystoneAdminURLEnv)
+	if keystoneAdminURL == "" {
+		t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneAdminURLEnv))
+		return
+	}
+	adminUser = os.Getenv(keystoneAdminUserEnv)
+	if adminUser == "" {
+		t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneAdminUserEnv))
+		return
+	}
+	adminPass = os.Getenv(keystoneAdminPassEnv)
+	if adminPass == "" {
+		t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneAdminPassEnv))
+		return
+	}
+	authTokenURL = keystoneURL + "/v3/auth/tokens/"
+	usersURL = keystoneAdminURL + "/v3/users/"
+	groupsURL = keystoneAdminURL + "/v3/groups/"
+}
+
+func expectEquals(t *testing.T, a interface{}, b interface{}) {
+	if !reflect.DeepEqual(a, b) {
+		t.Errorf("Expected %v to be equal %v", a, b)
+	}
+}
diff --git a/server/server.go b/server/server.go
index cf9f7b47f260096aad2ce810a24a4da6e67f164b..ee3355b55dade515403a8ac64be621349699597f 100644
--- a/server/server.go
+++ b/server/server.go
@@ -27,6 +27,7 @@ import (
 	"github.com/dexidp/dex/connector/bitbucketcloud"
 	"github.com/dexidp/dex/connector/github"
 	"github.com/dexidp/dex/connector/gitlab"
+	"github.com/dexidp/dex/connector/keystone"
 	"github.com/dexidp/dex/connector/ldap"
 	"github.com/dexidp/dex/connector/linkedin"
 	"github.com/dexidp/dex/connector/microsoft"
@@ -433,6 +434,7 @@ type ConnectorConfig interface {
 // ConnectorsConfig variable provides an easy way to return a config struct
 // depending on the connector type.
 var ConnectorsConfig = map[string]func() ConnectorConfig{
+	"keystone":        func() ConnectorConfig { return new(keystone.Config) },
 	"mockCallback":    func() ConnectorConfig { return new(mock.CallbackConfig) },
 	"mockPassword":    func() ConnectorConfig { return new(mock.PasswordConfig) },
 	"ldap":            func() ConnectorConfig { return new(ldap.Config) },