Repository: coreos/dex
Pull request #1031 (2017-08-17 00:06:40)

Documentation: fix redirect caveat description
Part of the caveat currently described by GitHub connector documentation is no longer an issue, but the part relating to re-authorization of an application after being added to an organization is still valid.


Patch:
diff --git a/Documentation/getting-started.md b/Documentation/getting-started.md
index da54f835..5633135d 100644
--- a/Documentation/getting-started.md
+++ b/Documentation/getting-started.md
@@ -42,12 +42,9 @@ 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..d7a15b67 100644
--- a/Documentation/github-connector.md
+++ b/Documentation/github-connector.md
@@ -8,8 +8,7 @@ When a client redeems a refresh token through dex, dex will re-query GitHub to u
 
 ## 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]
+* A user must explicitly [request][github-request-org-access] an [organization][github-orgs] give dex [resource access][github-approve-org-access]. Dex will not have the correct permissions to determine if the user is in that organization otherwise, and the user will not be able to log in. This request mechanism is a feature of the GitHub API.
 
 ## Configuration
 
@@ -50,7 +49,7 @@ connectors:
     - name: my-organization-with-teams
       # A white list of teams. Only include group claims for these teams.
       teams:
-      - read-team
+      - red-team
       - blue-team
 ```
 
@@ -93,7 +92,7 @@ connectors:
     - name: my-organization-with-teams
       # A white list of teams. Only include group claims for these teams.
       teams:
-      - read-team
+      - red-team
       - blue-team
     # Required ONLY for GitHub Enterprise.
     # This is the Hostname of the GitHub Enterprise account listed on the
@@ -106,5 +105,5 @@ 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/
+[github-approve-org-access]: https://help.github.com/articles/approving-oauth-apps-for-your-organization/
diff --git a/Documentation/ldap-connector.md b/Documentation/ldap-connector.md
index 761d87d5..b04eabad 100644
--- a/Documentation/ldap-connector.md
+++ b/Documentation/ldap-connector.md
@@ -9,30 +9,6 @@ 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.
@@ -276,5 +252,3 @@ 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/Documentation/oidc-certification-setup.md b/Documentation/oidc-certification-setup.md
deleted file mode 100644
index 1fb1959e..00000000
--- a/Documentation/oidc-certification-setup.md
+++ /dev/null
@@ -1,176 +0,0 @@
-# OpenID Connect Provider Certification
-
-The OpenID Foundation provides a set of [conformance test profiles][oidc-conf-profiles] that test both Relying Party and OpenID Provider (OP) OpenID Connect implementations. Upon submission of [results][oidc-result-submission] and an affirmative response, the affirmed OP will be listed as a [certified OP][oidc-certified-ops] on the OpenID Connect website and allowed to use the [certification mark][oidc-cert-mark] according to the certification [terms and conditions][oidc-terms-conds], section 3(d).
-
-## Basic OpenID Provider Tests
-
-Dex is an OP that strives to implement the [mandatory set][oidc-core-spec-mandatory] of OpenID Connect features, and can be tested against the Basic OpenID Provider profile ([profile outline][oidc-conf-profiles], section 2.1.1). These tests ensure that all features required by a [basic client][oidc-basic-client-spec] work as expected.
-
-Features are currently under development to fully comply with the Basic profile, as dex currently does not. The following issues track our progress:
-
-Issue number | Relates to
-:---: | :---:
-[\#376][dex-issue-376] | userinfo_endpoint
-[\#1052][dex-issue-1052] | auth_time
-
-[dex-issue-376]: https://github.com/coreos/dex/issues/376
-[dex-issue-1052]: https://github.com/coreos/dex/issues/1052
-
-### Setup
-
-There are two ways to set up an OpenID test instance:
-1. Configure a test instance provided by The OpenID Foundation by following [instructions][oidc-test-config] on their website.
-1. Download their test runner from [GitHub][oidc-github] and follow the instructions in the [README][oidc-github-readme].
-    * Requires `docker` and `docker-compose`
-
-Configuration is essentially the same for either type of OpenID test instance. We will proceed with option 1 in this example, and set up an [AWS EC2 instance][aws-ec2-instance] to deploy dex:
-* Create an [AWS EC2 instance][aws-ec2-quick-start] and connect to your instance using [SSH][aws-ec2-ssh].
-* Install [dex][dex-install].
-* Ensure whatever port dex is listening on (usually 5556) is open to ingress traffic in your security group configuration.
-* In this example the public DNS name, automatically assigned to each internet-facing EC2 instance, is **my-test-ec2-server.com**. You can find your instances' in the AWS EC2 management console.
-
-### Configuring an OpenID test instance
-
-1. Navigate to [https://op.certification.openid.net:60000][oidc-test-start].
-1. Click 'New' configuration.
-1. Input your issuer url: `http://my-test-ec2-server.com:5556/dex`.
-1. Select `code` as the response type.
-1. Click 'Create' to further configure your OpenID instance.
-1. On the next page, copy and paste the `redirect_uris` into the `redirectURIs` config field (see below).
-1. At this point we can run dex, as we have all the information necessary to create a config file (`oidc-config.yaml` in this example):
-    ```yaml
-    issuer: http://my-test-ec2-server.com:5556/dex
-    storage:
-      type: sqlite3
-      config:
-        file: examples/dex.db
-    web:
-      http: 0.0.0.0:5556
-    oauth2:
-      skipApprovalScreen: true
-    staticClients:
-    - id: example-app
-      redirectURIs:
-      - 'https://op.certification.openid.net:${OPENID_SERVER_PORT}/authz_cb'
-      name: 'Example App'
-      secret: ZXhhbXBsZS1hcHAtc2VjcmV0
-    connectors:
-    - type: mockCallback
-      id: mock
-      name: Example
-    ```
-    * Substitute `OPENID_SERVER_PORT` for your OpenID test instance port number, assigned after configuring that instance.
-    * Set the `oauth2` field `skipApprovalScreen: true` to automate some clicking.
-1. Run dex:
-    ```bash
-    $ ./bin/dex serve oidc-config.yaml
-    time="2017-08-25T06:34:57Z" level=info msg="config issuer: http://my-test-ec2-server.com:5556/dex"
-    ...
-    ```
-1. Input `client_id` and `client_secret` from your config file.
-    * The `id` and `secret` used here are from the example config file [`staticClients` field](../examples/config-dev.yaml#L50-L55).
-1. Use data returned by the `GET /.well-known/openid-configuration` API call to fill in the rest of the configuration forms:
-    ```bash
-    [home@localhost ~]$ curl http://my-test-ec2-server.com:5556/dex/.well-known/openid-configuration
-    {
-      "issuer": "http://my-test-ec2-server.com:5556/dex",
-      "authorization_endpoint": "http://my-test-ec2-server.com:5556/dex/auth",
-      "token_endpoint": "http://my-test-ec2-server.com:5556/dex/token",
-      "jwks_uri": "http://my-test-ec2-server.com:5556/dex/keys",
-      "response_types_supported": [
-        "code"
-      ],
-      "subject_types_supported": [
-        "public"
-      ],
-      "id_token_signing_alg_values_supported": [
-        "RS256"
-      ],
-      "scopes_supported": [
-        "openid",
-        "email",
-        "groups",
-        "profile",
-        "offline_access"
-      ],
-      "token_endpoint_auth_methods_supported": [
-        "client_secret_basic"
-      ],
-      "claims_supported": [
-        "aud",
-        "email",
-        "email_verified",
-        "exp",
-        "iat",
-        "iss",
-        "locale",
-        "name",
-        "sub"
-      ]
-    }
-    ```
-    * Fill in all configuration information that the `/.well-known/openid-configuration` endpoint returns, althgouh this is not strictly necessary. We should give the test cases as much information about dex's OP implementation as possible.
-1. Press the 'Save and Start' button to start your OpenID test instance.
-1. Follow the provided link.
-1. Run through each test case, following all instructions given by individual cases.
-    * In order to pass certain cases, screenshots of OP responses might be required.
-
-## Results and Submission
-
-Dex does not fully pass the Basic profile test suite yet. The following table contains the current state of test results.
-
-Test case ID | Result type | Cause | Relates to
---- | --- | --- | ---
-OP-Response-Missing | Incomplete | Expected |
-OP-Response-code | Succeeded | |
-OP-Response-form_post | Succeeded | |
-OP-IDToken-C-Signature | Succeeded | |
-OP-ClientAuth-Basic-Static | Succeeded | |
-OP-ClientAuth-SecretPost-Static | Warning | Unsupported | client_secret_post
-OP-Token-refresh | Incomplete | Unsupported | userinfo_endpoint
-OP-UserInfo-Body | Incomplete | Unsupported | userinfo_endpoint
-OP-UserInfo-Endpoint | Incomplete | Unsupported | userinfo_endpoint
-OP-UserInfo-Header | Incomplete | Unsupported | userinfo_endpoint
-OP-claims-essential | Incomplete | Unsupported | userinfo_endpoint
-OP-display-page | Succeeded | |
-OP-display-popup | Succeeded | |
-OP-nonce-NoReq-code | Succeeded | |
-OP-nonce-code | Succeeded | |
-OP-prompt-login | Succeeded | |
-OP-prompt-none-LoggedIn | Succeeded | |
-OP-prompt-none-NotLoggedIn | Incomplete | Error expected
-OP-redirect_uri-NotReg | Incomplete | Requires screenshot
-OP-scope-All | Incomplete | Unsupported | address, phone
-OP-scope-address | Incomplete | Unsupported | address
-OP-scope-email | Incomplete | Unsupported | userinfo_endpoint
-OP-scope-phone | Incomplete | Unsupported | phone
-OP-scope-profile | Incomplete | Unsupported | userinfo_endpoint
-OP-Req-NotUnderstood | Succeeded | |
-OP-Req-acr_values | Warning | No acr value | id_token
-OP-Req-claims_locales | Incomplete | Unsupported | userinfo_endpoint
-OP-Req-id_token_hint | Succeeded | |
-OP-Req-login_hint | Incomplete | Missing configuration field | login_hint
-OP-Req-max_age=1 | Failed | Missing configuration field | auth_time
-OP-Req-max_age=10000 | Failed | Missing configuration field | auth_time
-OP-Req-ui_locales | Succeeded | |
-OP-OAuth-2nd | Warning | Unexpected error response | invalid_request
-OP-OAuth-2nd-30s | Warning | Unexpected error response | invalid_request
-OP-OAuth-2nd-Revokes | Incomplete | Unsupported | userinfo_endpoint
-
-Once all test cases pass, submit your results by following instructions listed [on the website][oidc-result-submission].
-
-[dex-install]: https://github.com/coreos/dex/blob/master/Documentation/getting-started.md#building-the-dex-binary
-[aws-ec2-instance]: http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/concepts.htmlSSH
-[aws-ec2-ssh]: http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/AccessingInstancesLinux.html
-[aws-ec2-quick-start]: http://docs.aws.amazon.com/quickstarts/latest/vmlaunch/step-1-launch-instance.html
-[oidc-core-spec-mandatory]: http://openid.net/specs/openid-connect-core-1_0.html#ServerMTI
-[oidc-basic-client-spec]: http://openid.net/specs/openid-connect-basic-1_0.html
-[oidc-conf-profiles]: http://openid.net/wordpress-content/uploads/2016/12/OpenID-Connect-Conformance-Profiles.pdf
-[oidc-test-config]: http://openid.net/certification/testing/
-[oidc-test-start]: https://op.certification.openid.net:60000
-[oidc-result-submission]: http://openid.net/certification/submission/
-[oidc-cert-mark]: http://openid.net/certification/mark/
-[oidc-certified-ops]: http://openid.net/developers/certified/
-[oidc-terms-conds]: http://openid.net/wordpress-content/uploads/2015/03/OpenID-Certification-Terms-and-Conditions.pdf
-[oidc-github]: https://github.com/openid-certification/oidctest
-[oidc-github-readme]: https://github.com/openid-certification/oidctest/blob/master/README.md
diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go
index 86d20603..f2221c71 100644
--- a/cmd/dex/serve.go
+++ b/cmd/dex/serve.go
@@ -144,7 +144,7 @@ func serve(cmd *cobra.Command, args []string) error {
 		for i, p := range c.StaticPasswords {
 			passwords[i] = storage.Password(p)
 		}
-		s = storage.WithStaticPasswords(s, passwords, logger)
+		s = storage.WithStaticPasswords(s, passwords)
 	}
 
 	storageConnectors := make([]storage.Connector, len(c.StaticConnectors))
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/examples/config-ldap.ldif b/examples/config-ldap.ldif
deleted file mode 100644
index 55cc81f9..00000000
--- a/examples/config-ldap.ldif
+++ /dev/null
@@ -1,42 +0,0 @@
-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
deleted file mode 100644
index 513a0005..00000000
--- a/examples/config-ldap.yaml
+++ /dev/null
@@ -1,51 +0,0 @@
-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
deleted file mode 100755
index 36a9cbbc..00000000
--- a/scripts/slapd.sh
+++ /dev/null
@@ -1,98 +0,0 @@
-#!/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/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,
diff --git a/storage/memory/memory.go b/storage/memory/memory.go
index ed80778b..90d3d85a 100644
--- a/storage/memory/memory.go
+++ b/storage/memory/memory.go
@@ -128,12 +128,12 @@ func (s *memStorage) CreateAuthRequest(a storage.AuthRequest) (err error) {
 }
 
 func (s *memStorage) CreatePassword(p storage.Password) (err error) {
-	lowerEmail := strings.ToLower(p.Email)
+	p.Email = strings.ToLower(p.Email)
 	s.tx(func() {
-		if _, ok := s.passwords[lowerEmail]; ok {
+		if _, ok := s.passwords[p.Email]; ok {
 			err = storage.ErrAlreadyExists
 		} else {
-			s.passwords[lowerEmail] = p
+			s.passwords[p.Email] = p
 		}
 	})
 	return
diff --git a/storage/static.go b/storage/static.go
index 5ae4f783..53bd9bfe 100644
--- a/storage/static.go
+++ b/storage/static.go
@@ -3,8 +3,6 @@ package storage
 import (
 	"errors"
 	"strings"
-
-	"github.com/sirupsen/logrus"
 )
 
 // Tests for this code are in the "memory" package, since this package doesn't
@@ -27,7 +25,6 @@ func WithStaticClients(s Storage, staticClients []Client) Storage {
 	for _, client := range staticClients {
 		clientsByID[client.ID] = client
 	}
-
 	return staticClientsStorage{s, staticClients, clientsByID}
 }
 
@@ -85,26 +82,19 @@ type staticPasswordsStorage struct {
 	Storage
 
 	// A read-only set of passwords.
-	passwords []Password
-	// A map of passwords that is indexed by lower-case email ids
+	passwords        []Password
 	passwordsByEmail map[string]Password
-
-	logger logrus.FieldLogger
 }
 
-// WithStaticPasswords returns a storage with a read-only set of passwords.
-func WithStaticPasswords(s Storage, staticPasswords []Password, logger logrus.FieldLogger) Storage {
+// WithStaticPasswords returns a storage with a read-only set of passwords. Write actions,
+// such as creating other passwords, will fail.
+func WithStaticPasswords(s Storage, staticPasswords []Password) Storage {
 	passwordsByEmail := make(map[string]Password, len(staticPasswords))
 	for _, p := range staticPasswords {
-		//Enable case insensitive email comparison.
-		lowerEmail := strings.ToLower(p.Email)
-		if _, ok := passwordsByEmail[lowerEmail]; ok {
-			logger.Errorf("Attempting to create StaticPasswords with the same email id: %s", p.Email)
-		}
-		passwordsByEmail[lowerEmail] = p
+		p.Email = strings.ToLower(p.Email)
+		passwordsByEmail[p.Email] = p
 	}
-
-	return staticPasswordsStorage{s, staticPasswords, passwordsByEmail, logger}
+	return staticPasswordsStorage{s, staticPasswords, passwordsByEmail}
 }
 
 func (s staticPasswordsStorage) isStatic(email string) bool {


Test patch:
diff --git a/Documentation/dev-integration-tests.md b/Documentation/dev-integration-tests.md
index 366b4ae3..38d90c89 100644
--- a/Documentation/dev-integration-tests.md
+++ b/Documentation/dev-integration-tests.md
@@ -50,16 +50,9 @@ $ sudo ./storage/sql/standup.sh destroy postgres
 
 ## LDAP
 
-The LDAP integration tests require [OpenLDAP][openldap] installed on the host machine. To run them, use `go test`:
+To run LDAP tests locally, you require a container running OpenLDAP.
 
-```
-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:
+Run OpenLDAP docker image:
 
 ```
 $ sudo docker run --hostname ldap.example.org --name openldap-container --detach osixia/openldap:1.1.6
@@ -143,5 +136,3 @@ 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()
diff --git a/storage/memory/static_test.go b/storage/memory/static_test.go
index df990ebb..17ddf7c8 100644
--- a/storage/memory/static_test.go
+++ b/storage/memory/static_test.go
@@ -108,10 +108,9 @@ func TestStaticPasswords(t *testing.T) {
 	p1 := storage.Password{Email: "foo@example.com", Username: "foo_secret"}
 	p2 := storage.Password{Email: "bar@example.com", Username: "bar_secret"}
 	p3 := storage.Password{Email: "spam@example.com", Username: "spam_secret"}
-	p4 := storage.Password{Email: "Spam@example.com", Username: "Spam_secret"}
 
 	backing.CreatePassword(p1)
-	s := storage.WithStaticPasswords(backing, []storage.Password{p2}, logger)
+	s := storage.WithStaticPasswords(backing, []storage.Password{p2})
 
 	tests := []struct {
 		name    string
@@ -161,39 +160,22 @@ func TestStaticPasswords(t *testing.T) {
 			},
 		},
 		{
-			name: "create passwords",
-			action: func() error {
-				if err := s.CreatePassword(p4); err != nil {
-					return err
-				}
-				return s.CreatePassword(p3)
-			},
-			wantErr: true,
-		},
-		{
-			name: "get password",
+			name: "list passwords",
 			action: func() error {
-				p, err := s.GetPassword(p4.Email)
+				passwords, err := s.ListPasswords()
 				if err != nil {
 					return err
 				}
-				if strings.Compare(p.Email, p4.Email) != 0 {
-					return fmt.Errorf("expected %s passwords got %s", p4.Email, p.Email)
+				if n := len(passwords); n != 2 {
+					return fmt.Errorf("expected 2 passwords got %d", n)
 				}
 				return nil
 			},
 		},
 		{
-			name: "list passwords",
+			name: "create password",
 			action: func() error {
-				passwords, err := s.ListPasswords()
-				if err != nil {
-					return err
-				}
-				if n := len(passwords); n != 3 {
-					return fmt.Errorf("expected 3 passwords got %d", n)
-				}
-				return nil
+				return s.CreatePassword(p3)
 			},
 		},
 	}