feat(management): search users by email in any case (#443)
auth0mock / build (push) Successful in 1m10s
Unbound Release / Check Preconditions (push) Successful in 1m46s
Unbound Release / Create Tag (push) Skipped
Unbound Release / Create Release (push) Successful in 22s
Unbound Release / Generate Changelog and Handle PR (push) Successful in 23s
Release / release (push) Successful in 2m14s

GET /api/v2/users with q=email:"..." answers like Auth0's user search: the
email matches in any letter case, unlike users-by-email, and the answer has
the include_totals shape the Go SDK asks for by default. Any other query is
refused with 400 rather than answered wrong.

Goodfeed's backend checks whether an address is taken with this search
before it lets a user change their email.

Reviewed-on: #443
Co-authored-by: Peter Svensson <peter@sparetimecoders.com>
This commit was merged in pull request #443.
This commit is contained in:
peter authored and Joakim Olsson committed 2026-10-07 14:09:47 +00:00
1 parent c558a2330e
commit 87fe84076f
3 files changed
+110

No files matched your search

+1
View File
@@ -102,6 +102,7 @@ func main() {
// Management API endpoints
mux.HandleFunc("GET /api/v2/users-by-email", managementHandler.GetUsersByEmail)
mux.HandleFunc("GET /api/v2/users", managementHandler.SearchUsers)
mux.HandleFunc("POST /api/v2/users", managementHandler.CreateUser)
mux.HandleFunc("PATCH /api/v2/users/", managementHandler.UpdateUser)
mux.HandleFunc("POST /api/v2/tickets/password-change", managementHandler.PasswordChangeTicket)
+43
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log/slog"
"net/http"
"regexp"
"strings"
"git.unbound.se/unboundsoftware/auth0mock/store"
@@ -152,3 +153,45 @@ func (h *ManagementHandler) PasswordChangeTicket(w http.ResponseWriter, r *http.
"ticket": "https://some-url",
})
}
// emailQuery matches the one Lucene query this mock answers: an email phrase,
// email:"...", with \" and \\ escaped inside it.
var emailQuery = regexp.MustCompile(`^email:"((?:[^"\\]|\\.)*)"$`)
// SearchUsers handles GET /api/v2/users. Like Auth0's user search it matches
// email in any letter case (users-by-email does not), and it answers in the
// shape the Go SDK asks for by default (include_totals=true). Only an email
// phrase is understood; any other query is refused rather than answered wrong.
func (h *ManagementHandler) SearchUsers(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
match := emailQuery.FindStringSubmatch(q)
if match == nil {
http.Error(w, "only q=email:\"...\" is supported", http.StatusBadRequest)
return
}
email := strings.NewReplacer(`\"`, `"`, `\\`, `\`).Replace(match[1])
h.logger.Debug("searching users", "email", email)
users := []UserResponse{}
for _, user := range h.userStore.List() {
if strings.EqualFold(user.Email, email) {
users = append(users, UserResponse{
Email: user.Email,
GivenName: user.GivenName,
FamilyName: user.FamilyName,
UserID: fmt.Sprintf("auth0|%s", user.UserID),
Picture: user.Picture,
})
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"start": 0,
"limit": len(users),
"length": len(users),
"total": len(users),
"users": users,
})
}
+66
View File
@@ -0,0 +1,66 @@
package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"git.unbound.se/unboundsoftware/auth0mock/store"
)
func searchUsers(t *testing.T, h *ManagementHandler, q string) (int, []UserResponse) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/v2/users?search_engine=v3&include_totals=true&q="+url.QueryEscape(q), nil)
rec := httptest.NewRecorder()
h.SearchUsers(rec, req)
if rec.Code != http.StatusOK {
return rec.Code, nil
}
var body struct {
Total int `json:"total"`
Users []UserResponse `json:"users"`
}
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
t.Fatalf("decode: %v", err)
}
if body.Total != len(body.Users) {
t.Fatalf("total %d, users %d", body.Total, len(body.Users))
}
return rec.Code, body.Users
}
// The search matches email in any letter case, as Auth0's does: a backend that
// checks whether an address is taken must find it however it was typed.
func TestSearchUsersMatchesEmailInAnyCase(t *testing.T) {
users := store.NewUserStore()
users.Create("anna@kpmg.se", &store.User{GivenName: "Anna"})
users.Create("bob@acme.se", &store.User{GivenName: "Bob"})
h := NewManagementHandler(users, slog.New(slog.DiscardHandler))
for _, q := range []string{`email:"anna@kpmg.se"`, `email:"ANNA@KPMG.SE"`, `email:"Anna@Kpmg.se"`} {
code, found := searchUsers(t, h, q)
if code != http.StatusOK || len(found) != 1 || found[0].UserID != "auth0|anna@kpmg.se" {
t.Fatalf("%s: code %d, found %+v", q, code, found)
}
}
if _, found := searchUsers(t, h, `email:"nobody@acme.se"`); len(found) != 0 {
t.Fatalf("an unknown address found %+v", found)
}
// A quoted * is part of the address, not a wildcard.
if _, found := searchUsers(t, h, `email:"*@kpmg.se"`); len(found) != 0 {
t.Fatalf("a quoted * matched %+v", found)
}
}
func TestSearchUsersRefusesOtherQueries(t *testing.T) {
h := NewManagementHandler(store.NewUserStore(), slog.New(slog.DiscardHandler))
for _, q := range []string{"", "name:anna", `email:"a@b.se" OR name:x`} {
if code, _ := searchUsers(t, h, q); code != http.StatusBadRequest {
t.Fatalf("%q: code %d, want 400", q, code)
}
}
}