Repository: coreos/dex
Pull request #1022 (2017-08-11 20:38:20)

*: add "getting started" example for LDAP
This is how I tested my changes for https://github.com/coreos/dex/pull/1021

Add a getting started guild using OpenLDAP. Hopefully others find this useful.

Not urgent.

cc @rithujohn191 @estroz 

ericchiang: cc @dgonyeo you might be interested in the OpenLDAP standup script

Patch:
diff --git a/Documentation/getting-started.md b/Documentation/getting-started.md
index 5633135d..da54f835 100644
--- a/Documentation/getting-started.md
+++ b/Documentation/getting-started.md
@@ -42,9 +42,12 @@ Login to dex through the example app using the following steps.
 
 Dex is generally used as a building block to drive authentication for other apps. See [_"Writing apps that use dex"_][using-dex] for an overview of instrumenting apps to work with dex.
 
+For a primer on using LDAP to back dex's user store, see the OpenLDAP [_"Getting started"_][ldap-getting-started] example.
+
 Check out the Documentation directory for further reading on setting up different storages, interacting with the dex API, intros for OpenID Connect, and logging in through other identity providers such as Google, GitHub, or LDAP.
 
 [go-setup]: https://golang.org/doc/install
 [example-config]: ../examples/config-dev.yaml
 [oidc-discovery]: https://openid.net/specs/openid-connect-discovery-1_0-17.html#ProviderMetadata
 [using-dex]: using-dex.md
+[ldap-getting-started]: ldap-connector.md#getting-started
diff --git a/Documentation/github-connector.md b/Documentation/github-connector.md
index eae61ffd..502c462b 100644
--- a/Documentation/github-connector.md
+++ b/Documentation/github-connector.md
@@ -6,11 +6,6 @@ One of the login options for dex uses the GitHub OAuth2 flow to identify the end
 
 When a client redeems a refresh token through dex, dex will re-query GitHub to update user information in the ID Token. To do this, __dex stores a readonly GitHub access token in its backing datastore.__ Users that reject dex's access through GitHub will also revoke all dex clients which authenticated them through GitHub.
 
-## Caveats
-
-* The GitHub API calls dex uses requires a user to have their organization membership visibility set to public. This can be done on the ["request access from org" page][github-request-org-access] which GitHub will skip if the client app is an existing authorized application. The current workaround is as follows: the user should log into their GitHub account, go to Settings -> Authorized OAuth Apps, click 'Revoke' to revoke the application's grant, and restart the dex login process. This will force the "request access from org" page to be shown, allowing the user to request that the organization owner make their membership public.
-    * Note: GitHub [organizations][github-orgs] are different from GitHub [teams][github-teams]
-
 ## Configuration
 
 Register a new application with [GitHub][github-oauth2] ensuring the callback URL is `(dex issuer)/callback`. For example if dex is listening at the non-root path `https://auth.example.com/dex` the callback would be `https://auth.example.com/dex/callback`.
@@ -105,6 +100,3 @@ connectors:
 ```
 
 [github-oauth2]: https://github.com/settings/applications/new
-[github-orgs]: https://developer.github.com/v3/orgs/
-[github-teams]: https://developer.github.com/v3/orgs/teams/
-[github-request-org-access]: https://help.github.com/articles/requesting-organization-approval-for-oauth-apps/
diff --git a/Documentation/ldap-connector.md b/Documentation/ldap-connector.md
index b04eabad..761d87d5 100644
--- a/Documentation/ldap-connector.md
+++ b/Documentation/ldap-connector.md
@@ -9,6 +9,30 @@ The connector executes two primary queries:
 1. Finding the user based on the end user's credentials.
 2. Searching for groups using the user entry.
 
+## Getting started
+
+The dex repo contains a basic LDAP setup using [OpenLDAP][openldap].
+
+First start the LDAP server using the example script. This will run the OpenLDAP daemon and seed it with a initial set of users.
+
+```
+./scripts/slapd.sh
+```
+
+This script sets the LDAP daemon to debug mode, and is expected to print several error messages which are normal. Once the server is up, run dex.
+
+```
+./bin/dex serve examples/config-ldap.yaml
+```
+
+Then run the OAuth client in another terminal.
+
+```
+./bin/example-app
+```
+
+Go to [http://localhost:5555](http://localhost:5555), login and enter the username and password of the LDAP user: `janedoe@example.com`/`foo`. Add the "groups" scope as part of the initial redirect to add group information from the LDAP server.
+
 ## Security considerations
 
 Dex attempts to bind with the backing LDAP server using the end user's _plain text password_. Though some LDAP implementations allow passing hashed passwords, dex doesn't support hashing and instead _strongly recommends that all administrators just use TLS_. This can often be achieved by using port 636 instead of 389, and administrators that choose 389 are actively leaking passwords.
@@ -252,3 +276,5 @@ connectors:
 ```
 
 If the search finds an entry, it will attempt to use the provided password to bind as that user entry.
+
+[openldap]: https://www.openldap.org/
diff --git a/connector/github/github.go b/connector/github/github.go
index 0579e42e..c3111cbe 100644
--- a/connector/github/github.go
+++ b/connector/github/github.go
@@ -24,12 +24,9 @@ import (
 )
 
 const (
-	apiURL = "https://api.github.com"
-	// GitHub requires this scope to access '/user' and '/user/emails' API endpoints.
+	apiURL     = "https://api.github.com"
 	scopeEmail = "user:email"
-	// GitHub requires this scope to access '/user/teams' and '/orgs' API endpoints
-	// which are used when a client includes the 'groups' scope.
-	scopeOrgs = "read:org"
+	scopeOrgs  = "read:org"
 )
 
 // Pagination URL patterns
@@ -136,22 +133,16 @@ type githubConnector struct {
 	httpClient *http.Client
 }
 
-// groupsRequired returns whether dex requires GitHub's 'read:org' scope. Dex
-// needs 'read:org' if 'orgs' or 'org' fields are populated in a config file.
-// Clients can require 'groups' scope without setting 'orgs'/'org'.
-func (c *githubConnector) groupsRequired(groupScope bool) bool {
-	return len(c.orgs) > 0 || c.org != "" || groupScope
-}
-
 func (c *githubConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
-	// 'read:org' scope is required by the GitHub API, and thus for dex to ensure
-	// a user is a member of orgs and teams provided in configs.
-	githubScopes := []string{scopeEmail}
-	if c.groupsRequired(scopes.Groups) {
-		githubScopes = append(githubScopes, scopeOrgs)
+	var githubScopes []string
+	if scopes.Groups {
+		githubScopes = []string{scopeEmail, scopeOrgs}
+	} else {
+		githubScopes = []string{scopeEmail}
 	}
 
 	endpoint := github.Endpoint
+
 	// case when it is a GitHub Enterprise account.
 	if c.hostName != "" {
 		endpoint = oauth2.Endpoint{
@@ -253,11 +244,23 @@ func (c *githubConnector) HandleCallback(s connector.Scopes, r *http.Request) (i
 		EmailVerified: true,
 	}
 
-	// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified.
-	if c.groupsRequired(s.Groups) {
-		groups, err := c.getGroups(ctx, client, s.Groups, user.Login)
-		if err != nil {
-			return identity, err
+	if s.Groups {
+		var groups []string
+		if len(c.orgs) > 0 {
+			if groups, err = c.listGroups(ctx, client, username); err != nil {
+				return identity, err
+			}
+		} else if c.org != "" {
+			inOrg, err := c.userInOrg(ctx, client, username, c.org)
+			if err != nil {
+				return identity, err
+			}
+			if !inOrg {
+				return identity, fmt.Errorf("github: user %q not a member of org %q", username, c.org)
+			}
+			if groups, err = c.teams(ctx, client, c.org); err != nil {
+				return identity, fmt.Errorf("github: get teams: %v", err)
+			}
 		}
 		identity.Groups = groups
 	}
@@ -297,11 +300,23 @@ func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, ident
 	identity.Username = username
 	identity.Email = user.Email
 
-	// Only set identity.Groups if 'orgs', 'org', or 'groups' scope are specified.
-	if c.groupsRequired(s.Groups) {
-		groups, err := c.getGroups(ctx, client, s.Groups, user.Login)
-		if err != nil {
-			return identity, err
+	if s.Groups {
+		var groups []string
+		if len(c.orgs) > 0 {
+			if groups, err = c.listGroups(ctx, client, username); err != nil {
+				return identity, err
+			}
+		} else if c.org != "" {
+			inOrg, err := c.userInOrg(ctx, client, username, c.org)
+			if err != nil {
+				return identity, err
+			}
+			if !inOrg {
+				return identity, fmt.Errorf("github: user %q not a member of org %q", username, c.org)
+			}
+			if groups, err = c.teams(ctx, client, c.org); err != nil {
+				return identity, fmt.Errorf("github: get teams: %v", err)
+			}
 		}
 		identity.Groups = groups
 	}
@@ -309,23 +324,13 @@ func (c *githubConnector) Refresh(ctx context.Context, s connector.Scopes, ident
 	return identity, nil
 }
 
-// getGroups retrieves GitHub orgs and teams a user is in, if any.
-func (c *githubConnector) getGroups(ctx context.Context, client *http.Client, groupScope bool, userLogin string) ([]string, error) {
-	if len(c.orgs) > 0 {
-		return c.groupsForOrgs(ctx, client, userLogin)
-	} else if c.org != "" {
-		return c.teamsForOrg(ctx, client, c.org)
-	}
-	return nil, nil
-}
-
-// groupsForOrgs enforces org and team constraints on user authorization
+// listGroups enforces org and team constraints on user authorization
 // Cases in which user is authorized:
 // 	N orgs, no teams: user is member of at least 1 org
 // 	N orgs, M teams per org: user is member of any team from at least 1 org
 // 	N-1 orgs, M teams per org, 1 org with no teams: user is member of any team
 // from at least 1 org, or member of org with no teams
-func (c *githubConnector) groupsForOrgs(ctx context.Context, client *http.Client, userName string) (groups []string, err error) {
+func (c *githubConnector) listGroups(ctx context.Context, client *http.Client, userName string) (groups []string, err error) {
 	var inOrgNoTeams bool
 	for _, org := range c.orgs {
 		inOrg, err := c.userInOrg(ctx, client, userName, org.Name)
@@ -336,7 +341,7 @@ func (c *githubConnector) groupsForOrgs(ctx context.Context, client *http.Client
 			continue
 		}
 
-		teams, err := c.teamsForOrg(ctx, client, org.Name)
+		teams, err := c.teams(ctx, client, org.Name)
 		if err != nil {
 			return groups, err
 		}
@@ -345,8 +350,9 @@ func (c *githubConnector) groupsForOrgs(ctx context.Context, client *http.Client
 		// 'teams' list in config.
 		if len(org.Teams) == 0 {
 			inOrgNoTeams = true
+			c.logger.Debugf("github: user %q in org %q", userName, org.Name)
 		} else if teams = filterTeams(teams, org.Teams); len(teams) == 0 {
-			c.logger.Infof("github: user %q in org %q but no teams", userName, org.Name)
+			c.logger.Debugf("github: user %q in org %q but no teams", userName, org.Name)
 		}
 
 		// Orgs might have the same team names. We append orgPrefix to team name,
@@ -354,6 +360,7 @@ func (c *githubConnector) groupsForOrgs(ctx context.Context, client *http.Client
 		orgPrefix := org.Name + ":"
 		for _, teamName := range teams {
 			groups = append(groups, orgPrefix+teamName)
+			c.logger.Debugf("github: user %q in org %q team %q", userName, org.Name, teamName)
 		}
 	}
 	if inOrgNoTeams || len(groups) > 0 {
@@ -496,20 +503,6 @@ func (c *githubConnector) userEmail(ctx context.Context, client *http.Client) (s
 		}
 
 		for _, email := range emails {
-			/*
-				if GitHub Enterprise, set email.Verified to true
-				This change being made because GitHub Enterprise does not
-				support email verification. CircleCI indicated that GitHub
-				advised them not to check for verified emails
-				(https://circleci.com/enterprise/changelog/#1-47-1).
-				In addition, GitHub Enterprise support replied to a support
-				ticket with "There is no way to verify an email address in 
-				GitHub Enterprise."			
-			*/
-			if c.hostName != "" {
-				email.Verified = true
-			}
-			
 			if email.Verified && email.Primary {
 				return email.Email, nil
 			}
@@ -549,7 +542,7 @@ func (c *githubConnector) userInOrg(ctx context.Context, client *http.Client, us
 	switch resp.StatusCode {
 	case http.StatusNoContent:
 	case http.StatusFound, http.StatusNotFound:
-		c.logger.Infof("github: user %q not in org %q or application not authorized to read org data", userName, orgName)
+		c.logger.Debugf("github: user %q not in org %q", userName, orgName)
 	default:
 		err = fmt.Errorf("github: unexpected return status: %q", resp.Status)
 	}
@@ -567,11 +560,11 @@ type team struct {
 	} `json:"organization"`
 }
 
-// teamsForOrg queries the GitHub API for team membership within a specific organization.
+// teams queries the GitHub API for team membership within a specific organization.
 //
 // The HTTP passed client is expected to be constructed by the golang.org/x/oauth2 package,
 // which inserts a bearer token as part of the request.
-func (c *githubConnector) teamsForOrg(ctx context.Context, client *http.Client, orgName string) ([]string, error) {
+func (c *githubConnector) teams(ctx context.Context, client *http.Client, orgName string) ([]string, error) {
 	apiURL, groups := c.apiURL+"/user/teams", []string{}
 	for {
 		// https://developer.github.com/v3/orgs/teams/#list-user-teams
@@ -580,7 +573,7 @@ func (c *githubConnector) teamsForOrg(ctx context.Context, client *http.Client,
 			err   error
 		)
 		if apiURL, err = get(ctx, client, apiURL, &teams); err != nil {
-			return nil, fmt.Errorf("github: get teams: %v", err)
+			return nil, err
 		}
 
 		for _, team := range teams {
diff --git a/connector/gitlab/gitlab.go b/connector/gitlab/gitlab.go
index 60d2cdc2..df9b13b6 100644
--- a/connector/gitlab/gitlab.go
+++ b/connector/gitlab/gitlab.go
@@ -17,9 +17,8 @@ import (
 )
 
 const (
-	// https://docs.gitlab.com/ee/integration/oauth_provider.html#authorized-applications
-	scopeUser = "read_user"
-	scopeAPI  = "api"
+	scopeEmail = "user:email"
+	scopeOrgs  = "read:org"
 )
 
 // Config holds configuration options for gilab logins.
@@ -79,11 +78,7 @@ type gitlabConnector struct {
 }
 
 func (c *gitlabConnector) oauth2Config(scopes connector.Scopes) *oauth2.Config {
-	gitlabScopes := []string{scopeUser}
-	if scopes.Groups {
-		gitlabScopes = []string{scopeAPI}
-	}
-
+	gitlabScopes := []string{"api"}
 	gitlabEndpoint := oauth2.Endpoint{AuthURL: c.baseURL + "/oauth/authorize", TokenURL: c.baseURL + "/oauth/token"}
 	return &oauth2.Config{
 		ClientID:     c.clientID,
@@ -203,7 +198,7 @@ func (c *gitlabConnector) Refresh(ctx context.Context, s connector.Scopes, ident
 // a bearer token as part of the request.
 func (c *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlabUser, error) {
 	var u gitlabUser
-	req, err := http.NewRequest("GET", c.baseURL+"/api/v4/user", nil)
+	req, err := http.NewRequest("GET", c.baseURL+"/api/v3/user", nil)
 	if err != nil {
 		return u, fmt.Errorf("gitlab: new req: %v", err)
 	}
@@ -234,7 +229,7 @@ func (c *gitlabConnector) user(ctx context.Context, client *http.Client) (gitlab
 // which inserts a bearer token as part of the request.
 func (c *gitlabConnector) groups(ctx context.Context, client *http.Client) ([]string, error) {
 
-	apiURL := c.baseURL + "/api/v4/groups"
+	apiURL := c.baseURL + "/api/v3/groups"
 
 	reNext := regexp.MustCompile("<(.*)>; rel=\"next\"")
 	reLast := regexp.MustCompile("<(.*)>; rel=\"last\"")
diff --git a/connector/ldap/ldap.go b/connector/ldap/ldap.go
index 01125ae7..9107031a 100644
--- a/connector/ldap/ldap.go
+++ b/connector/ldap/ldap.go
@@ -127,19 +127,6 @@ type Config struct {
 	} `json:"groupSearch"`
 }
 
-func scopeString(i int) string {
-	switch i {
-	case ldap.ScopeBaseObject:
-		return "base"
-	case ldap.ScopeSingleLevel:
-		return "one"
-	case ldap.ScopeWholeSubtree:
-		return "sub"
-	default:
-		return ""
-	}
-}
-
 func parseScope(s string) (int, bool) {
 	// NOTE(ericchiang): ScopeBaseObject doesn't really make sense for us because we
 	// never know the user's or group's DN.
@@ -355,9 +342,6 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E
 	if c.UserSearch.NameAttr != "" {
 		req.Attributes = append(req.Attributes, c.UserSearch.NameAttr)
 	}
-
-	c.logger.Infof("performing ldap search %s %s %s",
-		req.BaseDN, scopeString(req.Scope), req.Filter)
 	resp, err := conn.Search(req)
 	if err != nil {
 		return ldap.Entry{}, false, fmt.Errorf("ldap: search with filter %q failed: %v", req.Filter, err)
@@ -368,9 +352,7 @@ func (c *ldapConnector) userEntry(conn *ldap.Conn, username string) (user ldap.E
 		c.logger.Errorf("ldap: no results returned for filter: %q", filter)
 		return ldap.Entry{}, false, nil
 	case 1:
-		user = *resp.Entries[0]
-		c.logger.Infof("username %q mapped to entry %s", username, user.DN)
-		return user, true, nil
+		return *resp.Entries[0], true, nil
 	default:
 		return ldap.Entry{}, false, fmt.Errorf("ldap: filter returned multiple (%d) results: %q", n, filter)
 	}
@@ -511,8 +493,6 @@ func (c *ldapConnector) groups(ctx context.Context, user ldap.Entry) ([]string,
 
 		gotGroups := false
 		if err := c.do(ctx, func(conn *ldap.Conn) error {
-			c.logger.Infof("performing ldap search %s %s %s",
-				req.BaseDN, scopeString(req.Scope), req.Filter)
 			resp, err := conn.Search(req)
 			if err != nil {
 				return fmt.Errorf("ldap: search failed: %v", err)
diff --git a/connector/saml/saml.go b/connector/saml/saml.go
index f35f0a81..706ba95b 100644
--- a/connector/saml/saml.go
+++ b/connector/saml/saml.go
@@ -366,11 +366,6 @@ func (p *provider) HandlePOST(s connector.Scopes, samlResponse, inResponseTo str
 		return ident, fmt.Errorf("response did not contain a AttributeStatement")
 	}
 
-	// Log the actual attributes we got back from the server. This helps debug
-	// configuration errors on the server side, where the SAML server doesn't
-	// send us the correct attributes.
-	p.logger.Infof("parsed and verified saml response attributes %s", attributes)
-
 	// Grab the email.
 	if ident.Email, _ = attributes.get(p.emailAttr); ident.Email == "" {
 		return ident, fmt.Errorf("no attribute with name %q: %s", p.emailAttr, attributes.names())
diff --git a/connector/saml/types.go b/connector/saml/types.go
index c8d7e7f3..1f7d919e 100644
--- a/connector/saml/types.go
+++ b/connector/saml/types.go
@@ -1,7 +1,6 @@
 package saml
 
 import (
-	"bytes"
 	"encoding/xml"
 	"fmt"
 	"time"
@@ -234,18 +233,6 @@ func (a *attributeStatement) names() []string {
 	return s
 }
 
-// String is a formatter for logging an attribute statement's sub statements.
-func (a *attributeStatement) String() string {
-	buff := new(bytes.Buffer)
-	for i, attr := range a.Attributes {
-		if i != 0 {
-			buff.WriteString(", ")
-		}
-		buff.WriteString(attr.String())
-	}
-	return buff.String()
-}
-
 type attribute struct {
 	XMLName xml.Name `xml:"urn:oasis:names:tc:SAML:2.0:assertion Attribute"`
 
@@ -261,17 +248,3 @@ type attributeValue struct {
 	XMLName xml.Name `xml:"AttributeValue"`
 	Value   string   `xml:",chardata"`
 }
-
-func (a attribute) String() string {
-	if len(a.AttributeValues) == 1 {
-		// "email" = "jane.doe@coreos.com"
-		return fmt.Sprintf("%q = %q", a.Name, a.AttributeValues[0].Value)
-	}
-	values := make([]string, len(a.AttributeValues))
-	for i, av := range a.AttributeValues {
-		values[i] = av.Value
-	}
-
-	// "groups" = ["engineering", "docs"]
-	return fmt.Sprintf("%q = %q", a.Name, values)
-}
diff --git a/examples/config-ldap.ldif b/examples/config-ldap.ldif
new file mode 100644
index 00000000..55cc81f9
--- /dev/null
+++ b/examples/config-ldap.ldif
@@ -0,0 +1,42 @@
+dn: dc=example,dc=org
+objectClass: dcObject
+objectClass: organization
+o: Example Company
+dc: example
+
+dn: ou=People,dc=example,dc=org
+objectClass: organizationalUnit
+ou: People
+
+dn: cn=jane,ou=People,dc=example,dc=org
+objectClass: person
+objectClass: inetOrgPerson
+sn: doe
+cn: jane
+mail: janedoe@example.com
+userpassword: foo
+
+dn: cn=john,ou=People,dc=example,dc=org
+objectClass: person
+objectClass: inetOrgPerson
+sn: doe
+cn: john
+mail: johndoe@example.com
+userpassword: bar
+
+# Group definitions.
+
+dn: ou=Groups,dc=example,dc=org
+objectClass: organizationalUnit
+ou: Groups
+
+dn: cn=admins,ou=Groups,dc=example,dc=org
+objectClass: groupOfNames
+cn: admins
+member: cn=john,ou=People,dc=example,dc=org
+member: cn=jane,ou=People,dc=example,dc=org
+
+dn: cn=developers,ou=Groups,dc=example,dc=org
+objectClass: groupOfNames
+cn: developers
+member: cn=jane,ou=People,dc=example,dc=org
diff --git a/examples/config-ldap.yaml b/examples/config-ldap.yaml
new file mode 100644
index 00000000..513a0005
--- /dev/null
+++ b/examples/config-ldap.yaml
@@ -0,0 +1,51 @@
+issuer: http://127.0.0.1:5556/dex
+storage:
+  type: sqlite3
+  config:
+    file: examples/dex.db
+web:
+  http: 0.0.0.0:5556
+
+connectors:
+- type: ldap
+  name: OpenLDAP
+  id: ldap
+  config:
+    host: localhost:10389
+
+    # No TLS for this setup.
+    insecureNoSSL: true
+    
+    # This would normally be a read-only user.
+    bindDN: cn=admin,dc=example,dc=org
+    bindPW: admin
+    
+    userSearch:
+      baseDN: ou=People,dc=example,dc=org
+      filter: "(objectClass=person)"
+      username: mail
+      # "DN" (case sensitive) is a special attribute name. It indicates that
+      # this value should be taken from the entity's DN not an attribute on
+      # the entity.
+      idAttr: DN
+      emailAttr: mail
+      nameAttr: cn
+    
+    groupSearch:
+      baseDN: ou=Groups,dc=example,dc=org
+      filter: "(objectClass=groupOfNames)"
+      
+      # A user is a member of a group when their DN matches
+      # the value of a "member" attribute on the group entity.
+      userAttr: DN
+      groupAttr: member
+    
+      # The group name should be the "cn" value.
+      nameAttr: cn
+
+staticClients:
+- id: example-app
+  redirectURIs:
+  - 'http://127.0.0.1:5555/callback'
+  name: 'Example App'
+  secret: ZXhhbXBsZS1hcHAtc2VjcmV0
diff --git a/scripts/slapd.sh b/scripts/slapd.sh
new file mode 100755
index 00000000..36a9cbbc
--- /dev/null
+++ b/scripts/slapd.sh
@@ -0,0 +1,98 @@
+#!/bin/bash -e
+
+if ! [[ "$0" =~ "scripts/slapd.sh" ]]; then
+	echo "This script must be run in a toplevel dex directory"
+	exit 255
+fi
+
+command -v slapd >/dev/null 2>&1 || { 
+    echo >&2 "OpenLDAP not installed. Install using one of the following commands:
+
+   brew install openldap
+
+   sudo dnf -y install openldap-servers openldap-clients 
+
+   sudo apt-get install slapd ldap-utils
+"; exit 1;
+}
+
+TEMPDIR=$( mktemp -d )
+
+trap "{ rm -r $TEMPDIR ; exit 255; }" EXIT
+
+CONFIG_DIR=$PWD/connector/ldap/testdata
+
+# Include the schema files in the connector test directory. Installing OpenLDAP installs
+# these in /etc somewhere, but the path isn't reliable across installs. Easier to ship
+# the schema files directly.
+for config in $( ls $CONFIG_DIR/*.schema ); do
+    echo "include $config" >> $TEMPDIR/config
+done
+
+DATA_DIR=$TEMPDIR/data
+mkdir $DATA_DIR
+
+# Config template copied from:
+# http://www.zytrax.com/books/ldap/ch5/index.html#step1-slapd
+cat << EOF >> $TEMPDIR/config
+# MODULELOAD definitions
+# not required (comment out) before version 2.3
+moduleload back_bdb.la
+
+database bdb
+suffix "dc=example,dc=org"
+
+# root or superuser
+rootdn "cn=admin,dc=example,dc=org"
+rootpw admin
+# The database directory MUST exist prior to running slapd AND 
+# change path as necessary
+directory	$DATA_DIR
+
+# Indices to maintain for this directory
+# unique id so equality match only
+index	uid	eq
+# allows general searching on commonname, givenname and email
+index	cn,gn,mail eq,sub
+# allows multiple variants on surname searching
+index sn eq,sub
+# sub above includes subintial,subany,subfinal
+# optimise department searches
+index ou eq
+# if searches will include objectClass uncomment following
+# index objectClass eq
+# shows use of default index parameter
+index default eq,sub
+# indices missing - uses default eq,sub
+index telephonenumber
+
+# other database parameters
+# read more in slapd.conf reference section
+cachesize 10000
+checkpoint 128 15
+EOF
+
+SLAPD_PID=""
+trap "kill $SLAPD_PID" SIGINT
+
+# Background the LDAP daemon so we can run an LDAP add command.
+slapd \
+    -d any \
+    -h "ldap://localhost:10389/" \
+    -f $TEMPDIR/config &
+SLAPD_PID=$!
+
+# Wait for server to come up.
+time sleep 1
+
+# Seed the initial set of users. Edit these values to change the initial
+# set of users.
+ldapadd \
+    -x \
+    -D "cn=admin,dc=example,dc=org" \
+    -w admin \
+    -H ldap://localhost:10389/ \
+    -f $PWD/examples/config-ldap.ldif 
+
+# Wait for slapd to exit.
+wait $SLAPD_PID
diff --git a/server/api.go b/server/api.go
index 15266994..5e8fad76 100644
--- a/server/api.go
+++ b/server/api.go
@@ -21,16 +21,8 @@ import (
 // to determine if the server supports specific features.
 const apiVersion = 2
 
-const (
-	// recCost is the recommended bcrypt cost, which balances hash strength and
-	// efficiency.
-	recCost = 12
-
-	// upBoundCost is a sane upper bound on bcrypt cost determined by benchmarking:
-	// high enough to ensure secure encryption, low enough to not put unnecessary
-	// load on a dex server.
-	upBoundCost = 16
-)
+// recCost is the recommended bcrypt cost, which balances hash strength and time
+const recCost = 12
 
 // NewAPI returns a server which implements the gRPC API interface.
 func NewAPI(s storage.Storage, logger logrus.FieldLogger) api.DexServer {
@@ -91,20 +83,16 @@ func (d dexAPI) DeleteClient(ctx context.Context, req *api.DeleteClientReq) (*ap
 	return &api.DeleteClientResp{}, nil
 }
 
-// checkCost returns an error if the hash provided does not meet lower or upper
-// bound cost requirements.
-func checkCost(hash []byte) error {
+// checkCost returns an error if the hash provided does not meet minimum cost requirement, and the actual bcrypt cost
+func checkCost(hash []byte) (int, error) {
 	actual, err := bcrypt.Cost(hash)
 	if err != nil {
-		return fmt.Errorf("parsing bcrypt hash: %v", err)
+		return 0, fmt.Errorf("parsing bcrypt hash: %v", err)
 	}
 	if actual < bcrypt.DefaultCost {
-		return fmt.Errorf("given hash cost = %d does not meet minimum cost requirement = %d", actual, bcrypt.DefaultCost)
-	}
-	if actual > upBoundCost {
-		return fmt.Errorf("given hash cost = %d is above upper bound cost = %d, recommended cost = %d", actual, upBoundCost, recCost)
+		return actual, fmt.Errorf("given hash cost = %d, does not meet minimum cost requirement = %d", actual, bcrypt.DefaultCost)
 	}
-	return nil
+	return actual, nil
 }
 
 func (d dexAPI) CreatePassword(ctx context.Context, req *api.CreatePasswordReq) (*api.CreatePasswordResp, error) {
@@ -115,9 +103,13 @@ func (d dexAPI) CreatePassword(ctx context.Context, req *api.CreatePasswordReq)
 		return nil, errors.New("no user ID supplied")
 	}
 	if req.Password.Hash != nil {
-		if err := checkCost(req.Password.Hash); err != nil {
+		cost, err := checkCost(req.Password.Hash)
+		if err != nil {
 			return nil, err
 		}
+		if cost > recCost {
+			d.logger.Warnln("bcrypt cost = %d, password encryption might timeout. Recommended bcrypt cost is 12", cost)
+		}
 	} else {
 		return nil, errors.New("no hash of password supplied")
 	}
@@ -148,9 +140,13 @@ func (d dexAPI) UpdatePassword(ctx context.Context, req *api.UpdatePasswordReq)
 	}
 
 	if req.NewHash != nil {
-		if err := checkCost(req.NewHash); err != nil {
+		cost, err := checkCost(req.NewHash)
+		if err != nil {
 			return nil, err
 		}
+		if cost > recCost {
+			d.logger.Warnln("bcrypt cost = %d, password encryption might timeout. Recommended bcrypt cost is 12", cost)
+		}
 	}
 
 	updater := func(old storage.Password) (storage.Password, error) {
diff --git a/server/handlers.go b/server/handlers.go
index a70858a9..683c6c20 100644
--- a/server/handlers.go
+++ b/server/handlers.go
@@ -390,8 +390,6 @@ func (s *Server) handleConnectorCallback(w http.ResponseWriter, r *http.Request)
 	http.Redirect(w, r, redirectURL, http.StatusSeeOther)
 }
 
-// finalizeLogin associates the user's identity with the current AuthRequest, then returns
-// the approval page's path.
 func (s *Server) finalizeLogin(identity connector.Identity, authReq storage.AuthRequest, conn connector.Connector) (string, error) {
 	claims := storage.Claims{
 		UserID:        identity.UserID,
@@ -410,15 +408,6 @@ func (s *Server) finalizeLogin(identity connector.Identity, authReq storage.Auth
 	if err := s.storage.UpdateAuthRequest(authReq.ID, updater); err != nil {
 		return "", fmt.Errorf("failed to update auth request: %v", err)
 	}
-
-	email := claims.Email
-	if !claims.EmailVerified {
-		email = email + " (unverified)"
-	}
-
-	s.logger.Infof("login successful: connector %q, username=%q, email=%q, groups=%q",
-		authReq.ConnectorID, claims.Username, email, claims.Groups)
-
 	return path.Join(s.issuerURL.Path, "/approval") + "?req=" + authReq.ID, nil
 }
 
diff --git a/server/server.go b/server/server.go
index aabe7abe..41c28853 100644
--- a/server/server.go
+++ b/server/server.go
@@ -288,14 +288,25 @@ func (db passwordDB) Login(ctx context.Context, s connector.Scopes, email, passw
 		}
 		return connector.Identity{}, false, nil
 	}
-	// This check prevents dex users from logging in using static passwords
-	// configured with hash costs that are too high or low.
-	if err := checkCost(p.Hash); err != nil {
+
+	// Return an error if password-hash comparison takes longer than 10 seconds
+	errCh := make(chan error, 1)
+	go func() {
+		errCh <- bcrypt.CompareHashAndPassword(p.Hash, []byte(password))
+	}()
+	select {
+	case err = <-errCh:
+		if err != nil {
+			return connector.Identity{}, false, nil
+		}
+	case <-time.After(time.Second * 10):
+		var cost int
+		if cost, err = bcrypt.Cost(p.Hash); err == nil {
+			err = fmt.Errorf("password-hash comparison timeout: your bcrypt cost = %d, recommended cost = %d", cost, recCost)
+		}
 		return connector.Identity{}, false, err
 	}
-	if err := bcrypt.CompareHashAndPassword(p.Hash, []byte(password)); err != nil {
-		return connector.Identity{}, false, nil
-	}
+
 	return connector.Identity{
 		UserID:        p.UserID,
 		Username:      p.Username,


Test patch:
diff --git a/Documentation/dev-integration-tests.md b/Documentation/dev-integration-tests.md
index 38d90c89..366b4ae3 100644
--- a/Documentation/dev-integration-tests.md
+++ b/Documentation/dev-integration-tests.md
@@ -50,9 +50,16 @@ $ sudo ./storage/sql/standup.sh destroy postgres
 
 ## LDAP
 
-To run LDAP tests locally, you require a container running OpenLDAP.
+The LDAP integration tests require [OpenLDAP][openldap] installed on the host machine. To run them, use `go test`:
 
-Run OpenLDAP docker image:
+```
+export DEX_LDAP_TESTS=1
+go test -v ./connector/ldap/
+```
+
+To quickly stand up a LDAP server for development, see the LDAP [_"Getting started"_][ldap-getting-started] example. This also requires OpenLDAP installed on the host.
+
+To stand up a containerized LDAP server run the OpenLDAP docker image:
 
 ```
 $ sudo docker run --hostname ldap.example.org --name openldap-container --detach osixia/openldap:1.1.6
@@ -136,3 +143,5 @@ connectors:
 Start both dex and the example app, and try logging in (requires not requesting a refresh token).
 
 [okta-sign-up]: https://www.okta.com/developer/signup/
+[openldap]: https://www.openldap.org/
+[ldap-getting-started]: ldap-connector.md#getting-started
diff --git a/server/api_test.go b/server/api_test.go
index bea5d225..e9e2ea7d 100644
--- a/server/api_test.go
+++ b/server/api_test.go
@@ -132,15 +132,16 @@ func TestCheckCost(t *testing.T) {
 	defer client.Close()
 
 	tests := []struct {
-		name      string
-		inputHash []byte
-
-		wantErr bool
+		name         string
+		inputHash    []byte
+		expectedCost int
+		wantErr      bool
 	}{
 		{
 			name: "valid cost",
-			// bcrypt hash of the value "test1" with cost 12 (default)
-			inputHash: []byte("$2a$12$M2Ot95Qty1MuQdubh1acWOiYadJDzeVg3ve4n5b.dgcgPdjCseKx2"),
+			// bcrypt hash of the value "test1" with cost 12
+			inputHash:    []byte("$2a$12$M2Ot95Qty1MuQdubh1acWOiYadJDzeVg3ve4n5b.dgcgPdjCseKx2"),
+			expectedCost: recCost,
 		},
 		{
 			name:      "invalid hash",
@@ -155,14 +156,15 @@ func TestCheckCost(t *testing.T) {
 		},
 		{
 			name: "cost above recommendation",
-			// bcrypt hash of the value "test1" with cost 17
-			inputHash: []byte("$2a$17$tWuZkTxtSmRyWZAGWVHQE.7npdl.TgP8adjzLJD.SyjpFznKBftPe"),
-			wantErr:   true,
+			// bcrypt hash of the value "test1" with cost 20
+			inputHash:    []byte("$2a$20$yODn5quqK9MZdePqYLs6Y.Jr4cOO1P0aXsKz0eTa2rxOmu8e7ETpi"),
+			expectedCost: 20,
 		},
 	}
 
 	for _, tc := range tests {
-		if err := checkCost(tc.inputHash); err != nil {
+		cost, err := checkCost(tc.inputHash)
+		if err != nil {
 			if !tc.wantErr {
 				t.Errorf("%s: %s", tc.name, err)
 			}
@@ -173,6 +175,10 @@ func TestCheckCost(t *testing.T) {
 			t.Errorf("%s: expected err", tc.name)
 			continue
 		}
+
+		if cost != tc.expectedCost {
+			t.Errorf("%s: exepcted cost = %d but got cost = %d", tc.name, tc.expectedCost, cost)
+		}
 	}
 }
 
diff --git a/server/server_test.go b/server/server_test.go
index 88110087..a77c92bf 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -818,7 +818,7 @@ func TestPasswordDB(t *testing.T) {
 
 	pw := "hi"
 
-	h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
+	h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -896,6 +896,116 @@ func TestPasswordDB(t *testing.T) {
 
 }
 
+// A warning message should be logged if password-hash comparison takes longer than 10s
+func TestLoginTimeout(t *testing.T) {
+	s := memory.New(logger)
+	conn := newPasswordDB(s)
+
+	pw := "test1"
+
+	tests := []struct {
+		name, email, password string
+		pwHash                []byte
+		wantIdentity          connector.Identity
+		wantInvalid           bool
+		wantedErr             string
+	}{
+		{
+			name:     "valid password min cost",
+			email:    "jane@example.com",
+			password: pw,
+			// bcrypt hash of the value "test1" with cost 4
+			pwHash: []byte("$2a$04$lGqOe5gnlpsfreQ1OJHxGOO7f5FyyESyICkswSFATM1cnBVgCyyuG"),
+			wantIdentity: connector.Identity{
+				Email:         "jane@example.com",
+				Username:      "jane",
+				UserID:        "foobar",
+				EmailVerified: true,
+			},
+		},
+		{
+			name:     "valid password reccomended cost",
+			email:    "jane@example.com",
+			password: pw,
+			// bcrypt hash of the value "test1" with cost 12
+			pwHash: []byte("$2a$12$VZNNjuCUGX2NG5S1ci.3Ku9mI9DmA9XeXyrr7YzJuyTxuVBGdRAbm"),
+			wantIdentity: connector.Identity{
+				Email:         "jane@example.com",
+				Username:      "jane",
+				UserID:        "foobar",
+				EmailVerified: true,
+			},
+		},
+		{
+			name:     "valid password timeout cost",
+			email:    "jane@example.com",
+			password: pw,
+			// bcrypt hash of the value "test1" with cost 20
+			pwHash:    []byte("$2a$20$yODn5quqK9MZdePqYLs6Y.Jr4cOO1P0aXsKz0eTa2rxOmu8e7ETpi"),
+			wantedErr: fmt.Sprintf("password-hash comparison timeout: your bcrypt cost = 20, recommended cost = %d", recCost),
+		},
+		{
+			name:     "invalid password min cost",
+			email:    "jane@example.com",
+			password: pw,
+			// bcrypt hash of the value "test2" with cost 4
+			pwHash:      []byte("$2a$04$pX8wwwpxw8xlXrToYaEgZemK0JIibMZYXPsgau7aPDoGyHPF73br."),
+			wantInvalid: true,
+		},
+		{
+			name:     "invalid password timeout cost",
+			email:    "jane@example.com",
+			password: pw,
+			// bcrypt hash of the value "test2" with cost 20
+			pwHash:    []byte("$2a$20$WBD9cs63Zf0zqS99yyrQhODoDXphWw8MlYqVYRiftJH.lRJ1stnAa"),
+			wantedErr: fmt.Sprintf("password-hash comparison timeout: your bcrypt cost = 20, recommended cost = %d", recCost),
+		},
+	}
+
+	sEmail, sUsername, sUserID := "jane@example.com", "jane", "foobar"
+	for _, tc := range tests {
+		// Clean up before new test case
+		s.DeletePassword(sEmail)
+
+		s.CreatePassword(storage.Password{
+			Email:    sEmail,
+			Username: sUsername,
+			UserID:   sUserID,
+			Hash:     tc.pwHash,
+		})
+
+		ident, valid, err := conn.Login(context.Background(), connector.Scopes{}, tc.email, tc.password)
+		if err != nil {
+			if err.Error() != tc.wantedErr {
+				t.Errorf("%s: error was incorrect:\n%v", tc.name, err)
+			}
+			continue
+		}
+
+		if tc.wantedErr != "" {
+			t.Errorf("%s: expected error", tc.name)
+			continue
+		}
+
+		if !valid {
+			if !tc.wantInvalid {
+				t.Errorf("%s: expected valid response", tc.name)
+			}
+			continue
+		}
+
+		if tc.wantInvalid {
+			t.Errorf("%s: expected invalid response", tc.name)
+			continue
+		}
+
+		if diff := pretty.Compare(tc.wantIdentity, ident); diff != "" {
+			t.Errorf("%s: %s", tc.email, diff)
+		}
+	}
+
+}
+
 type storageWithKeysTrigger struct {
 	storage.Storage
 	f func()