Files
peter 87fe84076f
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
feat(management): search users by email in any case (#443)
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>
2026-10-07 14:09:47 +00:00

198 lines
5.3 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"regexp"
"strings"
"git.unbound.se/unboundsoftware/auth0mock/store"
)
// ManagementHandler handles Auth0 Management API endpoints
type ManagementHandler struct {
userStore *store.UserStore
logger *slog.Logger
}
// NewManagementHandler creates a new management handler
func NewManagementHandler(userStore *store.UserStore, logger *slog.Logger) *ManagementHandler {
return &ManagementHandler{
userStore: userStore,
logger: logger,
}
}
// UserResponse represents the user response format
type UserResponse struct {
Email string `json:"email,omitempty"`
GivenName string `json:"given_name,omitempty"`
FamilyName string `json:"family_name,omitempty"`
UserID string `json:"user_id"`
Picture string `json:"picture,omitempty"`
}
// GetUsersByEmail handles GET /api/v2/users-by-email
func (h *ManagementHandler) GetUsersByEmail(w http.ResponseWriter, r *http.Request) {
email := r.URL.Query().Get("email")
h.logger.Debug("getting user by email", "email", email)
user, ok := h.userStore.GetByEmail(email)
if !ok {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode([]interface{}{})
return
}
response := []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(response)
}
// CreateUser handles POST /api/v2/users
func (h *ManagementHandler) CreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Email == "" {
http.Error(w, "Email is required", http.StatusBadRequest)
return
}
user := &store.User{
Email: req.Email,
GivenName: req.GivenName,
FamilyName: req.FamilyName,
UserID: req.Email,
}
// Set defaults if not provided
if user.GivenName == "" {
user.GivenName = "Given"
}
if user.FamilyName == "" {
user.FamilyName = "Last"
}
h.userStore.Create(req.Email, user)
h.logger.Info("created user", "email", req.Email)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"user_id": fmt.Sprintf("auth0|%s", req.Email),
})
}
// UpdateUser handles PATCH /api/v2/users/{userid}
func (h *ManagementHandler) UpdateUser(w http.ResponseWriter, r *http.Request) {
// Extract user ID from path - format: /api/v2/users/auth0|email@example.com
path := r.URL.Path
userID := strings.TrimPrefix(path, "/api/v2/users/")
// Strip "auth0|" prefix to get email
email := strings.TrimPrefix(userID, "auth0|")
h.logger.Debug("patching user", "userid", userID, "email", email)
var req struct {
GivenName string `json:"given_name"`
FamilyName string `json:"family_name"`
Picture string `json:"picture"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
updates := &store.User{
GivenName: req.GivenName,
FamilyName: req.FamilyName,
Picture: req.Picture,
}
_, ok := h.userStore.Update(email, updates)
if !ok {
http.Error(w, "User not found", http.StatusNotFound)
return
}
h.logger.Info("updated user", "email", email)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"user_id": fmt.Sprintf("auth0|%s", email),
})
}
// PasswordChangeTicket handles POST /api/v2/tickets/password-change
func (h *ManagementHandler) PasswordChangeTicket(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"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,
})
}