Repository: coreos/dex
Pull request #590 (2016-10-05 23:18:07)

dev branch: add a password resource for local email/password login
Kubernetes changes tested locally.


Patch:
diff --git a/.gitignore b/.gitignore
index db3eaf3e..e493dd6e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,2 @@
 bin
 dist
-_output
diff --git a/.travis.yml b/.travis.yml
index c9dd7d63..cd20f70e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,7 +1,7 @@
 language: go
 
 go:
-  - 1.7.1
+  - 1.7
 
 services:
   - postgresql
diff --git a/Dockerfile b/Dockerfile
index 9f1b277b..51094255 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,11 +1,11 @@
-FROM alpine:3.4
+FROM alpine:latest
 
 MAINTAINER Eric Chiang <eric.chiang@coreos.com>
 
 RUN apk add --update ca-certificates 
 
-COPY _output/bin/dex /usr/local/bin/dex
+COPY bin/dex /dex
 
-ENTRYPOINT ["/usr/local/bin/dex"]
+ENTRYPOINT ["/dex"]
 
 CMD ["version"]
diff --git a/Documentation/dev-releases.md b/Documentation/dev-releases.md
deleted file mode 100644
index 3182afa5..00000000
--- a/Documentation/dev-releases.md
+++ /dev/null
@@ -1,80 +0,0 @@
-# Releases
-
-Making a dex release involves:
-
-* Tagging a git commit and pushing the tag to GitHub.
-* Building and pushing a Docker image.
-* Building, signing, and hosting an ACI.
-
-This requires the following tools.
-
-* rkt
-* Docker
-* [docker2aci](https://github.com/appc/docker2aci)
-* [acbuild](https://github.com/containers/build) (must be in your sudo user's PATH)
-
-And the following permissions.
-
-* Push access to the github.com/coreos/dex git repo.
-* Push access to the quay.io/coreos/dex Docker repo.
-* Access to the CoreOS application signing key.
-
-## Tagging the release
-
-Make sure you've [uploaded your GPG key](https://github.com/settings/keys) and
-configured git to [use that signing key](
-https://git-scm.com/book/en/v2/Git-Tools-Signing-Your-Work) either globally or
-for the Dex repo. Note that the email the key is issued for must be the email
-you use for git.
-
-```
-git config [--global] user.signingkey "{{ GPG key ID }}"
-git config [--global] user.email "{{ Email associated with key }}"
-```
-
-Create a signed tag at the commit you wish to release. This action will prompt
-you to enter a tag message, which can just be the release version.
-
-```
-git tag -s v2.1.0-alpha ea4c04fde83bd6c48f4d43862c406deb4ea9dba2
-```
-
-Push that tag to the CoreOS repo.
-
-```
-git push git@github.com:coreos/dex.git v2.1.0-alpha
-```
-
-Draft releases on GitHub and summarize the changes since the last release. See
-previous releases for the expected format.
-
-https://github.com/coreos/dex/releases
-
-## Building the Docker image
-
-Build the Docker image and push to Quay.
-
-```bash
-# checkout the tag
-git checkout tags/v2.1.0-alpha
-# rkt doesn't play nice with SELinux, see https://github.com/coreos/rkt/issues/1727
-sudo setenforce Permissive
-# will prompt for sudo password
-make docker-image
-sudo docker push quay.io/coreos/dex:v2.1.0-alpha
-```
-
-## Building the ACI
-
-```bash
-# checkout the tag
-git checkout tags/v2.1.0-alpha
-# rkt doesn't play nice with SELinux, see https://github.com/coreos/rkt/issues/1727
-sudo setenforce Permissive
-# will prompt for sudo password
-make aci
-# aci will be built at _output/image/dex.aci
-```
-
-Sign the ACI using the CoreOS application signing key. Upload the ACI and
-signature to the GitHub release.
diff --git a/Makefile b/Makefile
index 0205b6df..9e884c0c 100644
--- a/Makefile
+++ b/Makefile
@@ -5,15 +5,10 @@ export PATH := $(PWD)/bin:$(PATH)
 
 VERSION=$(shell ./scripts/git-version)
 
-DOCKER_REPO=quay.io/coreos/dex
+DOCKER_REPO=quay.io/ericchiang/dex
 DOCKER_IMAGE=$(DOCKER_REPO):$(VERSION)
 
 $( shell mkdir -p bin )
-$( shell mkdir -p _output/images )
-$( shell mkdir -p _output/bin )
-
-user=$(shell id -u -n)
-group=$(shell id -g -n)
 
 export GOBIN=$(PWD)/bin
 # Prefer ./bin instead of system packages for things like protoc, where we want
@@ -56,29 +51,16 @@ lint:
 server/templates_default.go: $(wildcard web/templates/**)
 	@go run server/templates_default_gen.go
 
-_output/bin/dex:
-	# Using rkt to build the dex binary.
-	@./scripts/rkt-build
-	@sudo chown $(user):$(group) _output/bin/dex
-
-_output/images/library-alpine-3.4.aci:
-	@mkdir -p _output/images
-	# Using docker2aci to get a base ACI to build from.
-	@docker2aci docker://alpine:3.4
-	@mv library-alpine-3.4.aci _output/images/library-alpine-3.4.aci
-
-_output/images/dex.aci: _output/bin/dex _output/images/library-alpine-3.4.aci
-	# Using acbuild to build a application container image.
-	@sudo ./scripts/build-aci ./_output/images/library-alpine-3.4.aci
-	@sudo chown $(user):$(group) _output/images/dex.aci
-
-.PHONY: aci
-aci: _output/images/dex.aci
-
-.PHONY: docker-image
-docker-image: _output/bin/dex
+.PHONY: docker-build
+docker-build: bin/dex
 	@docker build -t $(DOCKER_IMAGE) .
 
+.PHONY: docker-push
+docker-push: docker-build
+	@docker tag $(DOCKER_IMAGE) $(DOCKER_REPO):latest
+	@docker push $(DOCKER_IMAGE)
+	@docker push $(DOCKER_REPO):latest
+
 .PHONY: grpc
 grpc: api/api.pb.go
 
@@ -92,8 +74,7 @@ bin/protoc-gen-go:
 	@go install -v $(REPO_PATH)/vendor/github.com/golang/protobuf/protoc-gen-go
 
 clean:
-	@rm -rf bin/
-	@rm -rf _output/
+	@rm bin/*
 
 testall: testrace vet fmt lint
 
diff --git a/TODO.md b/TODO.md
index 58196109..03e9bc52 100644
--- a/TODO.md
+++ b/TODO.md
@@ -33,7 +33,7 @@ Documentation
 
 Storage
 
-- [ ] Add SQL storage implementation
+- [x] Add SQL storage implementation
 - [ ] Utilize fixes for third party resources in Kubernetes 1.4 
 
 UX
@@ -48,3 +48,4 @@ Backend
 
 - [ ] Improve logging, possibly switch to logrus
 - [ ] Standardize OAuth2 error handling
+- [ ] Switch to github.com/ghodss/yaml for []byte to base64 string logic
diff --git a/cmd/dex/config.go b/cmd/dex/config.go
index f50cd173..482075f3 100644
--- a/cmd/dex/config.go
+++ b/cmd/dex/config.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"encoding/base64"
 	"fmt"
 
 	"github.com/coreos/dex/connector"
@@ -26,7 +27,46 @@ type Config struct {
 
 	Templates server.TemplateConfig `yaml:"templates"`
 
+	// StaticClients cause the server to use this list of clients rather than
+	// querying the storage. Write operations, like creating a client, will fail.
 	StaticClients []storage.Client `yaml:"staticClients"`
+
+	// If enabled, the server will maintain a list of passwords which can be used
+	// to identify a user.
+	EnablePasswordDB bool `yaml:"enablePasswordDB"`
+
+	// StaticPasswords cause the server use this list of passwords rather than
+	// querying the storage. Cannot be specified without enabling a passwords
+	// database.
+	//
+	// The "password" type is identical to the storage.Password type, but does
+	// unmarshaling into []byte correctly.
+	StaticPasswords []password `yaml:"staticPasswords"`
+}
+
+type password struct {
+	Email    string `yaml:"email"`
+	Username string `yaml:"username"`
+	UserID   string `yaml:"userID"`
+
+	// Because our YAML parser doesn't base64, we have to do it ourselves.
+	//
+	// TODO(ericchiang): switch to github.com/ghodss/yaml
+	Hash string `yaml:"hash"`
+}
+
+// decode the hash appropriately and convert to the storage passwords.
+func (p password) toPassword() (storage.Password, error) {
+	hash, err := base64.StdEncoding.DecodeString(p.Hash)
+	if err != nil {
+		return storage.Password{}, fmt.Errorf("decoding hash: %v", err)
+	}
+	return storage.Password{
+		Email:    p.Email,
+		Username: p.Username,
+		UserID:   p.UserID,
+		Hash:     hash,
+	}, nil
 }
 
 // OAuth2 describes enabled OAuth2 extensions.
diff --git a/cmd/dex/serve.go b/cmd/dex/serve.go
index 08309e0f..0e35e6af 100644
--- a/cmd/dex/serve.go
+++ b/cmd/dex/serve.go
@@ -55,7 +55,8 @@ func serve(cmd *cobra.Command, args []string) error {
 		errMsg string
 	}{
 		{c.Issuer == "", "no issuer specified in config file"},
-		{len(c.Connectors) == 0, "no connectors supplied in config file"},
+		{len(c.Connectors) == 0 && !c.EnablePasswordDB, "no connectors supplied in config file"},
+		{!c.EnablePasswordDB && len(c.StaticPasswords) != 0, "cannot specify static passwords without enabling password db"},
 		{c.Storage.Config == nil, "no storage suppied in config file"},
 		{c.Web.HTTP == "" && c.Web.HTTPS == "", "must supply a HTTP/HTTPS  address to listen on"},
 		{c.Web.HTTPS != "" && c.Web.TLSCert == "", "no cert specified for HTTPS"},
@@ -103,6 +104,15 @@ func serve(cmd *cobra.Command, args []string) error {
 	if len(c.StaticClients) > 0 {
 		s = storage.WithStaticClients(s, c.StaticClients)
 	}
+	if len(c.StaticPasswords) > 0 {
+		p := make([]storage.Password, len(c.StaticPasswords))
+		for i, pw := range c.StaticPasswords {
+			if p[i], err = pw.toPassword(); err != nil {
+				return err
+			}
+		}
+		s = storage.WithStaticPasswords(s, p)
+	}
 
 	serverConfig := server.Config{
 		SupportedResponseTypes: c.OAuth2.ResponseTypes,
@@ -110,6 +120,7 @@ func serve(cmd *cobra.Command, args []string) error {
 		Connectors:             connectors,
 		Storage:                s,
 		TemplateConfig:         c.Templates,
+		EnablePasswordDB:       c.EnablePasswordDB,
 	}
 
 	serv, err := server.NewServer(serverConfig)
diff --git a/examples/config-dev.yaml b/examples/config-dev.yaml
index 2a2736b5..d771bb7c 100644
--- a/examples/config-dev.yaml
+++ b/examples/config-dev.yaml
@@ -11,16 +11,23 @@ connectors:
 - type: mockCallback
   id: mock-callback
   name: Mock
-- type: mockPassword
-  id: mock-password
-  name: Password
-  config:
-    username: "admin"
-    password: "PASSWORD"
 
+# Instead of reading from an external storage, use this list of clients.
 staticClients:
 - id: example-app
   redirectURIs:
   - 'http://127.0.0.1:5555/callback'
   name: 'Example App'
   secret: ZXhhbXBsZS1hcHAtc2VjcmV0
+
+# Let dex keep a list of passwords which can be used to login the user.
+enablePasswordDB: true
+
+# A static list of passwords to login the end user. By identifying here, dex
+# won't look in its undlying storage for passwords.
+staticPasswords:
+- email: "admin@example.com"
+  # bcrypt hash of the string "password"
+  hash: "JDJhJDE0JDh4TnlVZ3pzSmVuQm4ySlRPT2QvbmVGcUlnQzF4TEFVRFA3VlpTVzhDNWlkLnFPcmNlYUJX"
+  username: "admin"
+  userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
diff --git a/examples/k8s/thirdpartyresources.yaml b/examples/k8s/thirdpartyresources.yaml
index 65e02256..40f03027 100644
--- a/examples/k8s/thirdpartyresources.yaml
+++ b/examples/k8s/thirdpartyresources.yaml
@@ -46,3 +46,12 @@ kind: ThirdPartyResource
 description: "Refresh tokens for clients to continuously act on behalf of an end user."
 versions:
 - name: v1
+---
+
+metadata:
+  name: password.passwords.oidc.coreos.com
+apiVersion: extensions/v1beta1
+kind: ThirdPartyResource
+description: "Passwords managed by the OIDC server."
+versions:
+- name: v1
diff --git a/scripts/build-aci b/scripts/build-aci
deleted file mode 100755
index 03f232a7..00000000
--- a/scripts/build-aci
+++ /dev/null
@@ -1,30 +0,0 @@
-#!/usr/bin/env bash
-
-set -e
-
-if [ "$EUID" -ne 0 ]; then
-    echo "This script uses functionality which requires root privileges"
-    exit 1
-fi
-
-# Start the build with an empty ACI
-acbuild --debug begin $1
-
-# In the event of the script exiting, end the build
-trap "{ export EXT=$?; sudo acbuild --debug end && exit $EXT; }" EXIT
-
-# Name the ACI
-acbuild --debug set-name coreos.com/dex
-
-# Add a version label
-acbuild --debug label add version $( ./scripts/git-version )
-
-acbuild --debug run -- apk add --update ca-certificates 
-
-acbuild --debug copy _output/bin/dex /usr/local/bin/dex
-
-acbuild --debug port add www tcp 5556
-acbuild --debug port add grcp tpc 5557
-
-acbuild --debug set-exec -- /usr/local/bin/dex
-acbuild --debug write --overwrite _output/images/dex.aci
diff --git a/scripts/rkt-build b/scripts/rkt-build
deleted file mode 100755
index 55afb513..00000000
--- a/scripts/rkt-build
+++ /dev/null
@@ -1,13 +0,0 @@
-#!/bin/bash
-
-mkdir -p _output/bin
-
-sudo rkt run \
-    --volume dex,kind=host,source=$PWD \
-    --mount volume=dex,target=/go/src/github.com/coreos/dex \
-    --dns=8.8.8.8 \
-    --net=host \
-    --insecure-options=image \
-    docker://golang:1.7.1-alpine \
-    --exec=/bin/sh -- -x -c \
-	'apk add --no-cache --update alpine-sdk && go install -v github.com/coreos/dex/cmd/dex && cp /go/bin/dex /go/src/github.com/coreos/dex/_output/bin'
diff --git a/server/server.go b/server/server.go
index 81b86ec3..703af668 100644
--- a/server/server.go
+++ b/server/server.go
@@ -3,12 +3,15 @@ package server
 import (
 	"errors"
 	"fmt"
+	"log"
 	"net/http"
 	"net/url"
 	"path"
 	"sync/atomic"
 	"time"
 
+	"golang.org/x/crypto/bcrypt"
+
 	"github.com/gorilla/mux"
 
 	"github.com/coreos/dex/connector"
@@ -44,6 +47,8 @@ type Config struct {
 	// If specified, the server will use this function for determining time.
 	Now func() time.Time
 
+	EnablePasswordDB bool
+
 	TemplateConfig TemplateConfig
 }
 
@@ -91,6 +96,14 @@ func newServer(c Config, rotationStrategy rotationStrategy) (*Server, error) {
 	if err != nil {
 		return nil, fmt.Errorf("server: can't parse issuer URL")
 	}
+	if c.EnablePasswordDB {
+		c.Connectors = append(c.Connectors, Connector{
+			ID:          "local",
+			DisplayName: "Email",
+			Connector:   newPasswordDB(c.Storage),
+		})
+	}
+
 	if len(c.Connectors) == 0 {
 		return nil, errors.New("server: no connectors specified")
 	}
@@ -182,6 +195,38 @@ func (s *Server) absURL(pathItems ...string) string {
 	return u.String()
 }
 
+func newPasswordDB(s storage.Storage) interface {
+	connector.Connector
+	connector.PasswordConnector
+} {
+	return passwordDB{s}
+}
+
+type passwordDB struct {
+	s storage.Storage
+}
+
+func (db passwordDB) Close() error { return nil }
+
+func (db passwordDB) Login(email, password string) (connector.Identity, bool, error) {
+	p, err := db.s.GetPassword(email)
+	if err != nil {
+		if err != storage.ErrNotFound {
+			log.Printf("get password: %v", err)
+		}
+		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,
+		Email:         p.Email,
+		EmailVerified: true,
+	}, true, nil
+}
+
 // newKeyCacher returns a storage which caches keys so long as the next
 func newKeyCacher(s storage.Storage, now func() time.Time) storage.Storage {
 	if now == nil {
diff --git a/storage/conformance/conformance.go b/storage/conformance/conformance.go
index 9ba2ec68..a2458680 100644
--- a/storage/conformance/conformance.go
+++ b/storage/conformance/conformance.go
@@ -8,6 +8,8 @@ import (
 	"testing"
 	"time"
 
+	"golang.org/x/crypto/bcrypt"
+
 	"github.com/coreos/dex/storage"
 
 	"github.com/kylelemons/godebug/pretty"
@@ -30,6 +32,7 @@ func RunTestSuite(t *testing.T, sf StorageFactory) {
 		{"AuthRequestCRUD", testAuthRequestCRUD},
 		{"ClientCRUD", testClientCRUD},
 		{"RefreshTokenCRUD", testRefreshTokenCRUD},
+		{"PasswordCRUD", testPasswordCRUD},
 	}
 	for _, test := range tests {
 		t.Run(test.name, func(t *testing.T) {
@@ -222,5 +225,54 @@ func testRefreshTokenCRUD(t *testing.T, s storage.Storage) {
 	if _, err := s.GetRefresh(id); err != storage.ErrNotFound {
 		t.Errorf("after deleting refresh expected storage.ErrNotFound, got %v", err)
 	}
+}
 
+func testPasswordCRUD(t *testing.T, s storage.Storage) {
+	// Use bcrypt.MinCost to keep the tests short.
+	passwordHash, err := bcrypt.GenerateFromPassword([]byte("secret"), bcrypt.MinCost)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	password := storage.Password{
+		Email:    "jane@example.com",
+		Hash:     passwordHash,
+		Username: "jane",
+		UserID:   "foobar",
+	}
+	if err := s.CreatePassword(password); err != nil {
+		t.Fatalf("create password token: %v", err)
+	}
+
+	getAndCompare := func(id string, want storage.Password) {
+		gr, err := s.GetPassword(id)
+		if err != nil {
+			t.Errorf("get password %q: %v", id, err)
+			return
+		}
+		if diff := pretty.Compare(want, gr); diff != "" {
+			t.Errorf("password retrieved from storage did not match: %s", diff)
+		}
+	}
+
+	getAndCompare("jane@example.com", password)
+	getAndCompare("JANE@example.com", password) // Emails should be case insensitive
+
+	if err := s.UpdatePassword(password.Email, func(old storage.Password) (storage.Password, error) {
+		old.Username = "jane doe"
+		return old, nil
+	}); err != nil {
+		t.Fatalf("failed to update auth request: %v", err)
+	}
+
+	password.Username = "jane doe"
+	getAndCompare("jane@example.com", password)
+
+	if err := s.DeletePassword(password.Email); err != nil {
+		t.Fatalf("failed to delete password: %v", err)
+	}
+
+	if _, err := s.GetPassword(password.Email); err != storage.ErrNotFound {
+		t.Errorf("after deleting password expected storage.ErrNotFound, got %v", err)
+	}
 }
diff --git a/storage/kubernetes/client.go b/storage/kubernetes/client.go
index f6f97f17..ec703214 100644
--- a/storage/kubernetes/client.go
+++ b/storage/kubernetes/client.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"crypto/tls"
 	"crypto/x509"
+	"encoding/base64"
 	"encoding/json"
 	"errors"
 	"fmt"
@@ -165,6 +166,26 @@ func (c *client) delete(resource, name string) error {
 	return checkHTTPErr(resp, http.StatusOK)
 }
 
+func (c *client) deleteAll(resource string) error {
+	var list struct {
+		k8sapi.TypeMeta `json:",inline"`
+		k8sapi.ListMeta `json:"metadata,omitempty"`
+		Items           []struct {
+			k8sapi.TypeMeta   `json:",inline"`
+			k8sapi.ObjectMeta `json:"metadata,omitempty"`
+		} `json:"items"`
+	}
+	if err := c.list(resource, &list); err != nil {
+		return err
+	}
+	for _, item := range list.Items {
+		if err := c.delete(resource, item.Name); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 func (c *client) put(resource, name string, v interface{}) error {
 	body, err := json.Marshal(v)
 	if err != nil {
@@ -190,9 +211,9 @@ func (c *client) put(resource, name string, v interface{}) error {
 
 func newClient(cluster k8sapi.Cluster, user k8sapi.AuthInfo, namespace string) (*client, error) {
 	tlsConfig := cryptopasta.DefaultTLSConfig()
-	data := func(b []byte, file string) ([]byte, error) {
-		if b != nil {
-			return b, nil
+	data := func(b string, file string) ([]byte, error) {
+		if b != "" {
+			return base64.StdEncoding.DecodeString(b)
 		}
 		if file == "" {
 			return nil, nil
diff --git a/storage/kubernetes/k8sapi/client.go b/storage/kubernetes/k8sapi/client.go
index c8df7341..d84fa5cc 100644
--- a/storage/kubernetes/k8sapi/client.go
+++ b/storage/kubernetes/k8sapi/client.go
@@ -62,7 +62,9 @@ type Cluster struct {
 	// CertificateAuthority is the path to a cert file for the certificate authority.
 	CertificateAuthority string `yaml:"certificate-authority,omitempty"`
 	// CertificateAuthorityData contains PEM-encoded certificate authority certificates. Overrides CertificateAuthority
-	CertificateAuthorityData []byte `yaml:"certificate-authority-data,omitempty"`
+	//
+	// NOTE(ericchiang): Our yaml parser doesn't assume []byte is a base64 encoded string.
+	CertificateAuthorityData string `yaml:"certificate-authority-data,omitempty"`
 	// Extensions holds additional information. This is useful for extenders so that reads and writes don't clobber unknown fields
 	Extensions []NamedExtension `yaml:"extensions,omitempty"`
 }
@@ -72,11 +74,15 @@ type AuthInfo struct {
 	// ClientCertificate is the path to a client cert file for TLS.
 	ClientCertificate string `yaml:"client-certificate,omitempty"`
 	// ClientCertificateData contains PEM-encoded data from a client cert file for TLS. Overrides ClientCertificate
-	ClientCertificateData []byte `yaml:"client-certificate-data,omitempty"`
+	//
+	// NOTE(ericchiang): Our yaml parser doesn't assume []byte is a base64 encoded string.
+	ClientCertificateData string `yaml:"client-certificate-data,omitempty"`
 	// ClientKey is the path to a client key file for TLS.
 	ClientKey string `yaml:"client-key,omitempty"`
 	// ClientKeyData contains PEM-encoded data from a client key file for TLS. Overrides ClientKey
-	ClientKeyData []byte `yaml:"client-key-data,omitempty"`
+	//
+	// NOTE(ericchiang): Our yaml parser doesn't assume []byte is a base64 encoded string.
+	ClientKeyData string `yaml:"client-key-data,omitempty"`
 	// Token is the bearer token for authentication to the kubernetes cluster.
 	Token string `yaml:"token,omitempty"`
 	// Impersonate is the username to imperonate.  The name matches the flag.
diff --git a/storage/kubernetes/storage.go b/storage/kubernetes/storage.go
index bfb48855..44920f6b 100644
--- a/storage/kubernetes/storage.go
+++ b/storage/kubernetes/storage.go
@@ -20,6 +20,7 @@ const (
 	kindClient       = "OAuth2Client"
 	kindRefreshToken = "RefreshToken"
 	kindKeys         = "SigningKey"
+	kindPassword     = "Password"
 )
 
 const (
@@ -28,6 +29,7 @@ const (
 	resourceClient       = "oauth2clients"
 	resourceRefreshToken = "refreshtokens"
 	resourceKeys         = "signingkeies" // Kubernetes attempts to pluralize.
+	resourcePassword     = "passwords"
 )
 
 // Config values for the Kubernetes storage type.
@@ -109,6 +111,10 @@ func (cli *client) CreateAuthCode(c storage.AuthCode) error {
 	return cli.post(resourceAuthCode, cli.fromStorageAuthCode(c))
 }
 
+func (cli *client) CreatePassword(p storage.Password) error {
+	return cli.post(resourcePassword, cli.fromStoragePassword(p))
+}
+
 func (cli *client) CreateRefresh(r storage.RefreshToken) error {
 	refresh := RefreshToken{
 		TypeMeta: k8sapi.TypeMeta{
@@ -152,6 +158,14 @@ func (cli *client) GetClient(id string) (storage.Client, error) {
 	return toStorageClient(c), nil
 }
 
+func (cli *client) GetPassword(email string) (storage.Password, error) {
+	var p Password
+	if err := cli.get(resourcePassword, emailToID(email), &p); err != nil {
+		return storage.Password{}, err
+	}
+	return toStoragePassword(p), nil
+}
+
 func (cli *client) GetKeys() (storage.Keys, error) {
 	var keys Keys
 	if err := cli.get(resourceKeys, keysName, &keys); err != nil {
@@ -199,6 +213,10 @@ func (cli *client) DeleteRefresh(id string) error {
 	return cli.delete(resourceRefreshToken, id)
 }
 
+func (cli *client) DeletePassword(email string) error {
+	return cli.delete(resourcePassword, emailToID(email))
+}
+
 func (cli *client) UpdateClient(id string, updater func(old storage.Client) (storage.Client, error)) error {
 	var c Client
 	if err := cli.get(resourceClient, id, &c); err != nil {
@@ -214,6 +232,23 @@ func (cli *client) UpdateClient(id string, updater func(old storage.Client) (sto
 	return cli.put(resourceClient, id, newClient)
 }
 
+func (cli *client) UpdatePassword(email string, updater func(old storage.Password) (storage.Password, error)) error {
+	id := emailToID(email)
+	var p Password
+	if err := cli.get(resourcePassword, id, &p); err != nil {
+		return err
+	}
+
+	updated, err := updater(toStoragePassword(p))
+	if err != nil {
+		return err
+	}
+
+	newPassword := cli.fromStoragePassword(updated)
+	newPassword.ObjectMeta = p.ObjectMeta
+	return cli.put(resourcePassword, id, newPassword)
+}
+
 func (cli *client) UpdateKeys(updater func(old storage.Keys) (storage.Keys, error)) error {
 	firstUpdate := false
 	var keys Keys
diff --git a/storage/kubernetes/types.go b/storage/kubernetes/types.go
index 8bc934f2..3c914e84 100644
--- a/storage/kubernetes/types.go
+++ b/storage/kubernetes/types.go
@@ -1,6 +1,8 @@
 package kubernetes
 
 import (
+	"encoding/base32"
+	"strings"
 	"time"
 
 	jose "gopkg.in/square/go-jose.v2"
@@ -182,6 +184,60 @@ func (cli *client) fromStorageAuthRequest(a storage.AuthRequest) AuthRequest {
 	return req
 }
 
+// Password is a mirrored struct from the stroage with JSON struct tags and
+// Kubernetes type metadata.
+type Password struct {
+	k8sapi.TypeMeta   `json:",inline"`
+	k8sapi.ObjectMeta `json:"metadata,omitempty"`
+
+	// The Kubernetes name is actually an encoded version of this value.
+	//
+	// This field is IMMUTABLE. Do not change.
+	Email string `json:"email,omitempty"`
+
+	Hash     []byte `json:"hash,omitempty"`
+	Username string `json:"username,omitempty"`
+	UserID   string `json:"userID,omitempty"`
+}
+
+// Kubernetes only allows lower case letters for names.
+//
+// NOTE(ericchiang): This is currently copied from the storage package's NewID()
+// method. Once we refactor those into the storage, just use that instead.
+var encoding = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567")
+
+// Map an arbitrary email to a valid Kuberntes name.
+func emailToID(email string) string {
+	return strings.TrimRight(encoding.EncodeToString([]byte(strings.ToLower(email))), "=")
+}
+
+func (cli *client) fromStoragePassword(p storage.Password) Password {
+	email := strings.ToLower(p.Email)
+	return Password{
+		TypeMeta: k8sapi.TypeMeta{
+			Kind:       kindPassword,
+			APIVersion: cli.apiVersionForResource(resourcePassword),
+		},
+		ObjectMeta: k8sapi.ObjectMeta{
+			Name:      emailToID(email),
+			Namespace: cli.namespace,
+		},
+		Email:    email,
+		Hash:     p.Hash,
+		Username: p.Username,
+		UserID:   p.UserID,
+	}
+}
+
+func toStoragePassword(p Password) storage.Password {
+	return storage.Password{
+		Email:    p.Email,
+		Hash:     p.Hash,
+		Username: p.Username,
+		UserID:   p.UserID,
+	}
+}
+
 // AuthCode is a mirrored struct from storage with JSON struct tags and
 // Kubernetes type metadata.
 type AuthCode struct {
diff --git a/storage/memory/memory.go b/storage/memory/memory.go
index d178f20b..b85d68df 100644
--- a/storage/memory/memory.go
+++ b/storage/memory/memory.go
@@ -2,7 +2,7 @@
 package memory
 
 import (
-	"errors"
+	"strings"
 	"sync"
 
 	"github.com/coreos/dex/storage"
@@ -15,6 +15,7 @@ func New() storage.Storage {
 		authCodes:     make(map[string]storage.AuthCode),
 		refreshTokens: make(map[string]storage.RefreshToken),
 		authReqs:      make(map[string]storage.AuthRequest),
+		passwords:     make(map[string]storage.Password),
 	}
 }
 
@@ -37,6 +38,7 @@ type memStorage struct {
 	authCodes     map[string]storage.AuthCode
 	refreshTokens map[string]storage.RefreshToken
 	authReqs      map[string]storage.AuthRequest
+	passwords     map[string]storage.Password
 
 	keys storage.Keys
 }
@@ -47,28 +49,73 @@ func (s *memStorage) tx(f func()) {
 	f()
 }
 
-var errAlreadyExists = errors.New("already exists")
-
 func (s *memStorage) Close() error { return nil }
 
-func (s *memStorage) CreateClient(c storage.Client) error {
-	s.tx(func() { s.clients[c.ID] = c })
-	return nil
+func (s *memStorage) CreateClient(c storage.Client) (err error) {
+	s.tx(func() {
+		if _, ok := s.clients[c.ID]; ok {
+			err = storage.ErrAlreadyExists
+		} else {
+			s.clients[c.ID] = c
+		}
+	})
+	return
 }
 
-func (s *memStorage) CreateAuthCode(c storage.AuthCode) error {
-	s.tx(func() { s.authCodes[c.ID] = c })
-	return nil
+func (s *memStorage) CreateAuthCode(c storage.AuthCode) (err error) {
+	s.tx(func() {
+		if _, ok := s.authCodes[c.ID]; ok {
+			err = storage.ErrAlreadyExists
+		} else {
+			s.authCodes[c.ID] = c
+		}
+	})
+	return
 }
 
-func (s *memStorage) CreateRefresh(r storage.RefreshToken) error {
-	s.tx(func() { s.refreshTokens[r.RefreshToken] = r })
-	return nil
+func (s *memStorage) CreateRefresh(r storage.RefreshToken) (err error) {
+	s.tx(func() {
+		if _, ok := s.refreshTokens[r.RefreshToken]; ok {
+			err = storage.ErrAlreadyExists
+		} else {
+			s.refreshTokens[r.RefreshToken] = r
+		}
+	})
+	return
 }
 
-func (s *memStorage) CreateAuthRequest(a storage.AuthRequest) error {
-	s.tx(func() { s.authReqs[a.ID] = a })
-	return nil
+func (s *memStorage) CreateAuthRequest(a storage.AuthRequest) (err error) {
+	s.tx(func() {
+		if _, ok := s.authReqs[a.ID]; ok {
+			err = storage.ErrAlreadyExists
+		} else {
+			s.authReqs[a.ID] = a
+		}
+	})
+	return
+}
+
+func (s *memStorage) CreatePassword(p storage.Password) (err error) {
+	p.Email = strings.ToLower(p.Email)
+	s.tx(func() {
+		if _, ok := s.passwords[p.Email]; ok {
+			err = storage.ErrAlreadyExists
+		} else {
+			s.passwords[p.Email] = p
+		}
+	})
+	return
+}
+
+func (s *memStorage) GetPassword(email string) (p storage.Password, err error) {
+	email = strings.ToLower(email)
+	s.tx(func() {
+		var ok bool
+		if p, ok = s.passwords[email]; !ok {
+			err = storage.ErrNotFound
+		}
+	})
+	return
 }
 
 func (s *memStorage) GetClient(id string) (client storage.Client, err error) {
@@ -126,6 +173,18 @@ func (s *memStorage) ListRefreshTokens() (tokens []storage.RefreshToken, err err
 	return
 }
 
+func (s *memStorage) DeletePassword(email string) (err error) {
+	email = strings.ToLower(email)
+	s.tx(func() {
+		if _, ok := s.passwords[email]; !ok {
+			err = storage.ErrNotFound
+			return
+		}
+		delete(s.passwords, email)
+	})
+	return
+}
+
 func (s *memStorage) DeleteClient(id string) (err error) {
 	s.tx(func() {
 		if _, ok := s.clients[id]; !ok {
@@ -235,9 +294,24 @@ func (s *memStorage) UpdateAuthRequest(id string, updater func(old storage.AuthR
 			err = storage.ErrNotFound
 			return
 		}
-		if req, err := updater(req); err == nil {
+		if req, err = updater(req); err == nil {
 			s.authReqs[id] = req
 		}
 	})
 	return
 }
+
+func (s *memStorage) UpdatePassword(email string, updater func(p storage.Password) (storage.Password, error)) (err error) {
+	email = strings.ToLower(email)
+	s.tx(func() {
+		req, ok := s.passwords[email]
+		if !ok {
+			err = storage.ErrNotFound
+			return
+		}
+		if req, err = updater(req); err == nil {
+			s.passwords[email] = req
+		}
+	})
+	return
+}
diff --git a/storage/sql/crud.go b/storage/sql/crud.go
index 532b8648..ca941f7c 100644
--- a/storage/sql/crud.go
+++ b/storage/sql/crud.go
@@ -6,6 +6,7 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"strings"
 
 	"github.com/coreos/dex/storage"
 )
@@ -137,7 +138,7 @@ func (c *conn) UpdateAuthRequest(id string, updater func(a storage.AuthRequest)
 			a.Claims.UserID, a.Claims.Username, a.Claims.Email, a.Claims.EmailVerified,
 			encoder(a.Claims.Groups),
 			a.ConnectorID, a.ConnectorData,
-			a.Expiry, a.ID,
+			a.Expiry, r.ID,
 		)
 		if err != nil {
 			return fmt.Errorf("update auth request: %v", err)
@@ -462,14 +463,83 @@ func scanClient(s scanner) (cli storage.Client, err error) {
 	return cli, nil
 }
 
-func (c *conn) DeleteAuthRequest(id string) error { return c.delete("auth_request", id) }
-func (c *conn) DeleteAuthCode(id string) error    { return c.delete("auth_code", id) }
-func (c *conn) DeleteClient(id string) error      { return c.delete("client", id) }
-func (c *conn) DeleteRefresh(id string) error     { return c.delete("refresh_token", id) }
+func (c *conn) CreatePassword(p storage.Password) error {
+	p.Email = strings.ToLower(p.Email)
+	_, err := c.Exec(`
+		insert into password (
+			email, hash, username, user_id
+		)
+		values (
+			$1, $2, $3, $4
+		);
+	`,
+		p.Email, p.Hash, p.Username, p.UserID,
+	)
+	if err != nil {
+		return fmt.Errorf("insert password: %v", err)
+	}
+	return nil
+}
+
+func (c *conn) UpdatePassword(email string, updater func(p storage.Password) (storage.Password, error)) error {
+	return c.ExecTx(func(tx *trans) error {
+		p, err := getPassword(tx, email)
+		if err != nil {
+			return err
+		}
+
+		np, err := updater(p)
+		if err != nil {
+			return err
+		}
+		_, err = tx.Exec(`
+			update password
+			set
+				hash = $1, username = $2, user_id = $3
+			where email = $4;
+		`,
+			np.Hash, np.Username, np.UserID, p.Email,
+		)
+		if err != nil {
+			return fmt.Errorf("update password: %v", err)
+		}
+		return nil
+	})
+}
+
+func (c *conn) GetPassword(email string) (storage.Password, error) {
+	return getPassword(c, email)
+}
+
+func getPassword(q querier, email string) (p storage.Password, err error) {
+	email = strings.ToLower(email)
+	err = q.QueryRow(`
+		select
+			email, hash, username, user_id
+		from password where email = $1;
+	`, email).Scan(
+		&p.Email, &p.Hash, &p.Username, &p.UserID,
+	)
+	if err != nil {
+		if err == sql.ErrNoRows {
+			return p, storage.ErrNotFound
+		}
+		return p, fmt.Errorf("select password: %v", err)
+	}
+	return p, nil
+}
+
+func (c *conn) DeleteAuthRequest(id string) error { return c.delete("auth_request", "id", id) }
+func (c *conn) DeleteAuthCode(id string) error    { return c.delete("auth_code", "id", id) }
+func (c *conn) DeleteClient(id string) error      { return c.delete("client", "id", id) }
+func (c *conn) DeleteRefresh(id string) error     { return c.delete("refresh_token", "id", id) }
+func (c *conn) DeletePassword(email string) error {
+	return c.delete("password", "email", strings.ToLower(email))
+}
 
 // Do NOT call directly. Does not escape table.
-func (c *conn) delete(table, id string) error {
-	result, err := c.Exec(`delete from `+table+` where id = $1`, id)
+func (c *conn) delete(table, field, id string) error {
+	result, err := c.Exec(`delete from `+table+` where `+field+` = $1`, id)
 	if err != nil {
 		return fmt.Errorf("delete %s: %v", table, id)
 	}
diff --git a/storage/sql/migrate.go b/storage/sql/migrate.go
index 8754caf5..d9c254d3 100644
--- a/storage/sql/migrate.go
+++ b/storage/sql/migrate.go
@@ -137,6 +137,13 @@ var migrations = []migration{
 				connector_id text not null,
 				connector_data bytea
 			);
+
+			create table password (
+				email text not null primary key,
+				hash bytea not null,
+				username text not null,
+				user_id text not null
+			);
 		
 			-- keys is a weird table because we only ever expect there to be a single row
 			create table keys (
diff --git a/storage/static_clients.go b/storage/static.go
similarity index 58%
rename from storage/static_clients.go
rename to storage/static.go
index d7932393..8274c5f8 100644
--- a/storage/static_clients.go
+++ b/storage/static.go
@@ -1,6 +1,9 @@
 package storage
 
-import "errors"
+import (
+	"errors"
+	"strings"
+)
 
 // Tests for this code are in the "memory" package, since this package doesn't
 // define a concrete storage implementation.
@@ -53,3 +56,39 @@ func (s staticClientsStorage) DeleteClient(id string) error {
 func (s staticClientsStorage) UpdateClient(id string, updater func(old Client) (Client, error)) error {
 	return errors.New("static clients: read-only cannot update client")
 }
+
+type staticPasswordsStorage struct {
+	Storage
+
+	passwordsByEmail map[string]Password
+}
+
+// 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 {
+		p.Email = strings.ToLower(p.Email)
+		passwordsByEmail[p.Email] = p
+	}
+	return staticPasswordsStorage{s, passwordsByEmail}
+}
+
+func (s staticPasswordsStorage) GetPassword(email string) (Password, error) {
+	if password, ok := s.passwordsByEmail[strings.ToLower(email)]; ok {
+		return password, nil
+	}
+	return Password{}, ErrNotFound
+}
+
+func (s staticPasswordsStorage) CreatePassword(p Password) error {
+	return errors.New("static passwords: read-only cannot create password")
+}
+
+func (s staticPasswordsStorage) DeletePassword(id string) error {
+	return errors.New("static passwords: read-only cannot create password")
+}
+
+func (s staticPasswordsStorage) UpdatePassword(id string, updater func(old Password) (Password, error)) error {
+	return errors.New("static passwords: read-only cannot update password")
+}
diff --git a/storage/storage.go b/storage/storage.go
index 4a92485b..78c162f9 100644
--- a/storage/storage.go
+++ b/storage/storage.go
@@ -16,12 +16,12 @@ import (
 )
 
 var (
-	// stubbed out for testing
-	now = time.Now
-)
+	// ErrNotFound is the error returned by storages if a resource cannot be found.
+	ErrNotFound = errors.New("not found")
 
-// ErrNotFound is the error returned by storages if a resource cannot be found.
-var ErrNotFound = errors.New("not found")
+	// ErrAlreadyExists is the error returned by storages if a resource ID is taken during a create.
+	ErrAlreadyExists = errors.New("ID already exists")
+)
 
 // Kubernetes only allows lower case letters for names.
 //
@@ -51,6 +51,7 @@ type Storage interface {
 	CreateClient(c Client) error
 	CreateAuthCode(c AuthCode) error
 	CreateRefresh(r RefreshToken) error
+	CreatePassword(p Password) error
 
 	// TODO(ericchiang): return (T, bool, error) so we can indicate not found
 	// requests that way instead of using ErrNotFound.
@@ -59,6 +60,7 @@ type Storage interface {
 	GetClient(id string) (Client, error)
 	GetKeys() (Keys, error)
 	GetRefresh(id string) (RefreshToken, error)
+	GetPassword(email string) (Password, error)
 
 	ListClients() ([]Client, error)
 	ListRefreshTokens() ([]RefreshToken, error)
@@ -68,6 +70,7 @@ type Storage interface {
 	DeleteAuthCode(code string) error
 	DeleteClient(id string) error
 	DeleteRefresh(id string) error
+	DeletePassword(email string) error
 
 	// Update functions are assumed to be a performed within a single object transaction.
 	//
@@ -75,6 +78,7 @@ type Storage interface {
 	UpdateClient(id string, updater func(old Client) (Client, error)) error
 	UpdateKeys(updater func(old Keys) (Keys, error)) error
 	UpdateAuthRequest(id string, updater func(a AuthRequest) (AuthRequest, error)) error
+	UpdatePassword(email string, updater func(p Password) (Password, error)) error
 
 	// TODO(ericchiang): Add a GarbageCollect(now time.Time) method so conformance tests
 	// can test implementations.
@@ -217,6 +221,28 @@ type RefreshToken struct {
 	Nonce string
 }
 
+// Password is an email to password mapping managed by the storage.
+type Password struct {
+	// Email and identifying name of the password. Emails are assumed to be valid and
+	// determining that an end-user controls the address is left to an outside application.
+	//
+	// Emails are case insensitive and should be standardized by the storage.
+	//
+	// Storages that don't support an extended character set for IDs, such as '.' and '@'
+	// (cough cough, kubernetes), must map this value appropriately.
+	Email string `yaml:"email"`
+
+	// Bcrypt encoded hash of the password. This package recommends a cost value of at
+	// least 14.
+	Hash []byte `yaml:"hash"`
+
+	// Optional username to display. NOT used during login.
+	Username string `yaml:"username"`
+
+	// Randomly generated user ID. This is NOT the primary ID of the Password object.
+	UserID string `yaml:"userID"`
+}
+
 // VerificationKey is a rotated signing key which can still be used to verify
 // signatures.
 type VerificationKey struct {


Test patch:
diff --git a/server/server_test.go b/server/server_test.go
index 296f22cc..46fcc710 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -16,9 +16,12 @@ import (
 	"time"
 
 	"github.com/ericchiang/oidc"
+	"github.com/kylelemons/godebug/pretty"
+	"golang.org/x/crypto/bcrypt"
 	"golang.org/x/net/context"
 	"golang.org/x/oauth2"
 
+	"github.com/coreos/dex/connector"
 	"github.com/coreos/dex/connector/mock"
 	"github.com/coreos/dex/storage"
 	"github.com/coreos/dex/storage/memory"
@@ -381,6 +384,91 @@ func TestOAuth2ImplicitFlow(t *testing.T) {
 	}
 }
 
+func TestPasswordDB(t *testing.T) {
+	s := memory.New()
+	conn := newPasswordDB(s)
+	defer conn.Close()
+
+	pw := "hi"
+
+	h, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	s.CreatePassword(storage.Password{
+		Email:    "jane@example.com",
+		Username: "jane",
+		UserID:   "foobar",
+		Hash:     h,
+	})
+
+	tests := []struct {
+		name         string
+		username     string
+		password     string
+		wantIdentity connector.Identity
+		wantInvalid  bool
+		wantErr      bool
+	}{
+		{
+			name:     "valid password",
+			username: "jane@example.com",
+			password: pw,
+			wantIdentity: connector.Identity{
+				Email:         "jane@example.com",
+				Username:      "jane",
+				UserID:        "foobar",
+				EmailVerified: true,
+			},
+		},
+		{
+			name:     "unknown user",
+			username: "john@example.com",
+			password: pw,
+			wantErr:  true,
+		},
+		{
+			name:        "invalid password",
+			username:    "jane@example.com",
+			password:    "not the correct password",
+			wantInvalid: true,
+		},
+	}
+
+	for _, tc := range tests {
+		ident, valid, err := conn.Login(tc.username, tc.password)
+		if err != nil {
+			if !tc.wantErr {
+				t.Errorf("%s: %v", tc.name, err)
+			}
+			continue
+		}
+
+		if tc.wantErr {
+			t.Errorf("%s: expected error", tc.name)
+			continue
+		}
+
+		if !valid {
+			if !tc.wantInvalid {
+				t.Errorf("%s: expected valid password", tc.name)
+			}
+			continue
+		}
+
+		if tc.wantInvalid {
+			t.Errorf("%s: expected invalid password", tc.name)
+			continue
+		}
+
+		if diff := pretty.Compare(tc.wantIdentity, ident); diff != "" {
+			t.Errorf("%s: %s", tc.name, diff)
+		}
+	}
+
+}
+
 type storageWithKeysTrigger struct {
 	storage.Storage
 	f func()
diff --git a/storage/kubernetes/storage_test.go b/storage/kubernetes/storage_test.go
index c0011f39..f41b01b1 100644
--- a/storage/kubernetes/storage_test.go
+++ b/storage/kubernetes/storage_test.go
@@ -75,7 +75,18 @@ func TestURLFor(t *testing.T) {
 func TestStorage(t *testing.T) {
 	client := loadClient(t)
 	conformance.RunTestSuite(t, func() storage.Storage {
-		// TODO(erichiang): Tear down namespaces between each iteration.
+		for _, resource := range []string{
+			resourceAuthCode,
+			resourceAuthRequest,
+			resourceClient,
+			resourceRefreshToken,
+			resourceKeys,
+			resourcePassword,
+		} {
+			if err := client.deleteAll(resource); err != nil {
+				t.Fatalf("delete all %q failed: %v", resource, err)
+			}
+		}
 		return client
 	})
 }