feat(dex): in-cluster Password CR client for local-password accounts

Writes passwords.dex.coreos.com CRs against the in-cluster Kubernetes
API using the pod's service-account token + cluster CA (no kubectl /
client-go dependency). NewPasswordClient returns ErrNotInCluster off
cluster so the web layer degrades gracefully in dev.

Load-bearing: Dex's kubernetes storage types Password.Hash as []byte,
which k8s JSON-marshals as base64 — so the `hash` field carries the
base64 of the bcrypt string, not the raw string. Storing the raw string
makes Dex's base64-decode-on-login produce garbage and every login fail.

409 -> ErrPasswordExists, 401/403 -> ErrForbidden (RBAC missing) so the
handler can give precise messages. Tested against an httptest TLS server.

bcrypt cost-12 hashing lives in the web handler; golang.org/x/crypto was
already a transitive dep (now promoted in go.sum).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 23:14:08 +02:00
co-authored by Claude Opus 4.8
parent 8b7ef07ba3
commit e44485df16
3 changed files with 286 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
package dex
import (
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
)
// newTestClient points a PasswordClient at an httptest server, using that
// server's TLS client so the in-cluster TLS path is exercised without a real CA.
func newTestClient(srv *httptest.Server) *PasswordClient {
return newClient(srv.URL, "test-token", srv.Client())
}
func TestCreatePasswordSuccess(t *testing.T) {
var gotAuth, gotPath, gotMethod string
var gotBody password
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth, gotPath, gotMethod = r.Header.Get("Authorization"), r.URL.Path, r.Method
b, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(b, &gotBody)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"kind":"Password"}`))
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(),
"New.User@Example.com", "$2a$12$abcdefghijklmnopqrstuv", "user-uuid-1")
require.NoError(t, err)
require.Equal(t, http.MethodPost, gotMethod)
require.Equal(t, passwordsPath, gotPath)
require.Equal(t, "Bearer test-token", gotAuth)
// Email/username carry the raw address; the CR name is sanitised + lowercased.
require.Equal(t, "New.User@Example.com", gotBody.Email)
require.Equal(t, "New.User@Example.com", gotBody.Username)
require.Equal(t, "user-uuid-1", gotBody.UserID)
require.Equal(t, "new-dot-user-at-example-dot-com", gotBody.Metadata["name"])
require.Equal(t, "auth", gotBody.Metadata["namespace"])
// The hash is the BASE64 of the bcrypt string (Dex stores hash as []byte).
decoded, err := base64.StdEncoding.DecodeString(gotBody.Hash)
require.NoError(t, err)
require.Equal(t, "$2a$12$abcdefghijklmnopqrstuv", string(decoded))
}
func TestCreatePasswordConflict(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusConflict)
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(), "dup@example.com", "$2a$12$x", "u")
require.ErrorIs(t, err, ErrPasswordExists)
}
func TestCreatePasswordForbidden(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u")
require.ErrorIs(t, err, ErrForbidden)
}
func TestCreatePasswordUnexpectedStatus(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
err := newTestClient(srv).CreatePassword(context.Background(), "x@example.com", "$2a$12$x", "u")
require.Error(t, err)
require.NotErrorIs(t, err, ErrPasswordExists)
require.NotErrorIs(t, err, ErrForbidden)
require.Contains(t, err.Error(), "500")
}
func TestNewPasswordClientNotInCluster(t *testing.T) {
// In the test environment the SA token mount does not exist.
_, err := NewPasswordClient()
require.ErrorIs(t, err, ErrNotInCluster)
}
func TestPasswordName(t *testing.T) {
cases := map[string]string{
"Alice@Example.com": "alice-at-example-dot-com",
"a.b+c@gmail.com": "a-dot-b-c-at-gmail-dot-com",
"UPPER@DOMAIN.IO": "upper-at-domain-dot-io",
}
for in, want := range cases {
require.Equal(t, want, passwordName(in), in)
}
}