From c812c71ecc2ecda318b46b3ddf7e1c3e79c9ff41 Mon Sep 17 00:00:00 2001 From: Mathias Date: Sun, 7 Jun 2026 09:28:11 +0200 Subject: [PATCH] fix(dex): store raw bcrypt hash in Password CR, not base64-encoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original NOTE claimed Dex's kubernetes storage types Hash as []byte, requiring the bcrypt string to be base64-encoded before storage. This was wrong: Dex v2.41 stores and compares the hash field as a plain string. The base64-encoding caused every invite login to fail with 'Invalid credentials' because Dex passed the base64 bytes (starting with 'J' not '$') directly to bcrypt. Static passwords in the configmap always used raw bcrypt strings and worked fine — confirming the dynamic CR encoding was the bug. --- internal/adapters/dex/dex.go | 19 +++++++++---------- internal/adapters/dex/dex_test.go | 7 ++----- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/internal/adapters/dex/dex.go b/internal/adapters/dex/dex.go index 479e4f5..6b43ba3 100644 --- a/internal/adapters/dex/dex.go +++ b/internal/adapters/dex/dex.go @@ -17,7 +17,6 @@ import ( "context" "crypto/tls" "crypto/x509" - "encoding/base64" "encoding/json" "errors" "fmt" @@ -98,11 +97,12 @@ func newClient(server, token string, hc *http.Client) *PasswordClient { return &PasswordClient{server: server, token: token, http: hc} } -// password is the wire form of a Dex Password CR. NOTE: Dex's kubernetes storage -// types the hash as []byte, which Kubernetes JSON-marshals as base64. So the -// `hash` field must carry the base64 encoding of the bcrypt string, NOT the raw -// bcrypt string — store the raw string and Dex's base64-decode on login yields -// garbage and every login fails. CreatePassword does that encoding. +// password is the wire form of a Dex Password CR. The hash field is a plain +// bcrypt string (e.g. "$2a$12$..."). Dex v2.41+ stores and compares it as-is — +// it does NOT base64-decode the field. Earlier code base64-encoded the hash +// based on a misread of Dex's internal []byte type; that caused every dynamic +// invite login to fail with "Invalid credentials" while static passwords (set as +// plain strings in the configmap) worked fine. type password struct { APIVersion string `json:"apiVersion"` Kind string `json:"kind"` @@ -123,10 +123,9 @@ func (c *PasswordClient) CreatePassword(ctx context.Context, email, bcryptHash, Kind: "Password", Metadata: map[string]string{"name": passwordName(email), "namespace": "auth"}, Email: email, - // base64 of the bcrypt string — see the password type's NOTE. - Hash: base64.StdEncoding.EncodeToString([]byte(bcryptHash)), - Username: email, - UserID: userID, + Hash: bcryptHash, // raw bcrypt string — Dex compares it directly + Username: email, + UserID: userID, }) if err != nil { return fmt.Errorf("dex: marshal password: %w", err) diff --git a/internal/adapters/dex/dex_test.go b/internal/adapters/dex/dex_test.go index 8235535..578b391 100644 --- a/internal/adapters/dex/dex_test.go +++ b/internal/adapters/dex/dex_test.go @@ -2,7 +2,6 @@ package dex import ( "context" - "encoding/base64" "encoding/json" "io" "net/http" @@ -46,10 +45,8 @@ func TestCreatePasswordSuccess(t *testing.T) { 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)) + // Hash is stored as the raw bcrypt string — Dex compares it directly. + require.Equal(t, "$2a$12$abcdefghijklmnopqrstuv", gotBody.Hash) } func TestCreatePasswordConflict(t *testing.T) {