diff --git a/internal/adapters/dex/dex.go b/internal/adapters/dex/dex.go index 6b43ba3..ac35f33 100644 --- a/internal/adapters/dex/dex.go +++ b/internal/adapters/dex/dex.go @@ -23,7 +23,6 @@ import ( "io" "net/http" "os" - "regexp" "strings" "time" ) @@ -158,22 +157,25 @@ func (c *PasswordClient) CreatePassword(ctx context.Context, email, bcryptHash, } } -// invalidNameChars matches anything not allowed in an RFC-1123 subdomain segment -// after the explicit @/. substitutions, so any stray character becomes '-'. -var invalidNameChars = regexp.MustCompile(`[^a-z0-9-]`) - -// passwordName maps an email to a valid, deterministic Kubernetes object name: -// lowercase, '@' -> '-at-', '.' -> '-dot-', any remaining invalid char -> '-', -// with leading/trailing '-' trimmed. Deterministic so a re-invite targets the -// same CR (and so Dex's 409 is meaningful). +// passwordName maps an email to the Kubernetes object name Dex uses internally +// when looking up a Password CR by email (Dex storage/kubernetes passwordID()). +// Dex maps every character that is not [a-z0-9-] to '-' — it does NOT use +// human-readable substitutions like '-at-' or '-dot-'. Using a different scheme +// creates a name mismatch: Tapir writes the CR under one name, Dex looks it up +// under another, and every login returns "Invalid credentials". func passwordName(email string) string { n := strings.ToLower(strings.TrimSpace(email)) - n = strings.ReplaceAll(n, "@", "-at-") - n = strings.ReplaceAll(n, ".", "-dot-") - n = invalidNameChars.ReplaceAllString(n, "-") - n = strings.Trim(n, "-") - if n == "" { - n = "user" + var b strings.Builder + for _, r := range n { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } else { + b.WriteRune('-') + } } - return n + result := strings.Trim(b.String(), "-") + if result == "" { + return "user" + } + return result } diff --git a/internal/adapters/dex/dex_test.go b/internal/adapters/dex/dex_test.go index 578b391..aa6501b 100644 --- a/internal/adapters/dex/dex_test.go +++ b/internal/adapters/dex/dex_test.go @@ -42,7 +42,7 @@ func TestCreatePasswordSuccess(t *testing.T) { 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, "new-user-example-com", gotBody.Metadata["name"]) require.Equal(t, "auth", gotBody.Metadata["namespace"]) // Hash is stored as the raw bcrypt string — Dex compares it directly. @@ -90,10 +90,14 @@ func TestNewPasswordClientNotInCluster(t *testing.T) { } func TestPasswordName(t *testing.T) { + // Must match Dex's internal passwordID() — maps every non-[a-z0-9-] to '-'. + // Using a different scheme (e.g. '-at-', '-dot-') causes a name mismatch: + // Tapir writes the CR under one name, Dex looks it up under another. 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", + "Alice@Example.com": "alice-example-com", + "a.b+c@gmail.com": "a-b-c-gmail-com", + "UPPER@DOMAIN.IO": "upper-domain-io", + "mathias@d-ma.be": "mathias-d-ma-be", } for in, want := range cases { require.Equal(t, want, passwordName(in), in)