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>
198 lines
5.3 KiB
Go
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,
|
|
})
|
|
}
|