Compare commits

...
12 Commits
Author SHA1 Message Date
peter 87fe84076f feat(management): search users by email in any case (#443)
Unbound Release / Create Release (push) Successful in 19s
auth0mock / build (push) Successful in 1m10s
Unbound Release / Check Preconditions (push) Successful in 9s
Unbound Release / Create Tag (push) Skipped
Unbound Release / Generate Changelog and Handle PR (push) Failing after 1m7s
Release / release (push) Failing after 1m18s
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
renovate c558a2330e chore(deps): update golang:1.27 docker digest to 162be52 (#441)
Unbound Release / Create Release (push) Successful in 24s
Unbound Release / Generate Changelog and Handle PR (push) Successful in 38s
Release / release (push) Successful in 1m16s
auth0mock / build (push) Skipped
Unbound Release / Check Preconditions (push) Successful in 27s
Unbound Release / Create Tag (push) Skipped
2026-10-06 18:04:48 +00:00
renovate f00222cb8d chore(deps): update golang:1.27 docker digest to 1e93e00 (#439)
Release / release (push) Successful in 2m24s
Unbound Release / Create Release (push) Successful in 24s
auth0mock / build (push) Skipped
Unbound Release / Check Preconditions (push) Successful in 22s
Unbound Release / Create Tag (push) Skipped
Unbound Release / Generate Changelog and Handle PR (push) Successful in 43s
2026-10-06 09:05:41 +00:00
renovate d1560cc0fe chore(deps): update golang:1.27 docker digest to 23fe807 (#437)
Unbound Release / Check Preconditions (push) Successful in 22s
Unbound Release / Create Tag (push) Skipped
Unbound Release / Generate Changelog and Handle PR (push) Successful in 40s
auth0mock / build (push) Skipped
Unbound Release / Create Release (push) Successful in 37s
Release / release (push) Successful in 18m20s
2026-10-06 05:16:38 +00:00
renovate c5f5873c2c chore(deps): update golang:1.27 docker digest to e0174e5 (#435)
Unbound Release / Create Release (push) Successful in 36s
Release / release (push) Successful in 1m44s
Unbound Release / Check Preconditions (push) Successful in 31s
auth0mock / build (push) Skipped
Unbound Release / Create Tag (push) Skipped
Unbound Release / Generate Changelog and Handle PR (push) Successful in 58s
2026-09-30 09:01:41 +00:00
renovate cd6bca11a1 chore(deps): update golang:1.27 docker digest to 3680233 (#433)
Unbound Release / Create Tag (push) Skipped
Unbound Release / Check Preconditions (push) Successful in 26s
auth0mock / build (push) Skipped
Unbound Release / Generate Changelog and Handle PR (push) Successful in 58s
Unbound Release / Create Release (push) Successful in 26s
Release / release (push) Successful in 4m1s
2026-09-19 15:02:50 +00:00
renovate 961ce21fef chore(deps): update golang:1.27 docker digest to 1cfcdb1 (#431)
Unbound Release / Check Preconditions (push) Successful in 33s
Unbound Release / Create Tag (push) Skipped
Unbound Release / Create Release (push) Successful in 29s
auth0mock / build (push) Skipped
Unbound Release / Generate Changelog and Handle PR (push) Successful in 46s
Release / release (push) Successful in 2m40s
2026-09-19 08:05:32 +00:00
renovate aefe77ed1b chore(deps): update golang:1.27 docker digest to 03fd17c (#429)
Unbound Release / Check Preconditions (push) Successful in 27s
Unbound Release / Create Tag (push) Skipped
auth0mock / build (push) Skipped
Unbound Release / Generate Changelog and Handle PR (push) Successful in 42s
Release / release (push) Successful in 3m27s
Unbound Release / Create Release (push) Successful in 29s
2026-09-19 05:03:52 +00:00
renovate 556203ee18 fix(deps): update module github.com/lestrrat-go/jwx/v4 to v4.5.0 (#427)
Unbound Release / Create Release (push) Successful in 24s
Release / release (push) Successful in 1m14s
auth0mock / build (push) Skipped
Unbound Release / Check Preconditions (push) Successful in 22s
Unbound Release / Create Tag (push) Skipped
Unbound Release / Generate Changelog and Handle PR (push) Successful in 35s
2026-09-11 05:05:11 +00:00
renovate 7b7e17aa55 chore(deps): update golang:1.27 docker digest to f44f6e8 (#425)
auth0mock / build (push) Skipped
Unbound Release / Generate Changelog and Handle PR (push) Successful in 47s
Unbound Release / Check Preconditions (push) Successful in 25s
Unbound Release / Create Tag (push) Skipped
Unbound Release / Create Release (push) Successful in 24s
Release / release (push) Successful in 1m20s
2026-09-10 03:06:08 +00:00
renovate 9f6c5eea6d chore(deps): update golang:1.27 docker digest to 512690a (#423)
Unbound Release / Create Tag (push) Skipped
Unbound Release / Check Preconditions (push) Successful in 39s
auth0mock / build (push) Skipped
Unbound Release / Generate Changelog and Handle PR (push) Successful in 45s
Unbound Release / Create Release (push) Successful in 31s
Release / release (push) Successful in 2m49s
2026-09-02 18:03:06 +00:00
renovate dde19e2d12 chore(deps): update golang:1.27 docker digest to 7543a96 (#421)
Unbound Release / Check Preconditions (push) Successful in 22s
auth0mock / build (push) Skipped
Unbound Release / Create Tag (push) Skipped
Unbound Release / Generate Changelog and Handle PR (push) Successful in 37s
Unbound Release / Create Release (push) Successful in 24s
Release / release (push) Successful in 1m12s
2026-09-02 07:04:11 +00:00
6 changed files with 121 additions and 11 deletions

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.27@sha256:192b74998e350966280a2cbffbb6c40064754f7ec005096aa64f04d7ece4467e AS build
FROM golang:1.27@sha256:162be5298a40ed317005c8339c6de4d10d3eef336d66dc8e9259b03ab9d3a6d2 AS build
WORKDIR /build
ENV CGO_ENABLED=0
+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)
+2 -2
View File
@@ -5,7 +5,7 @@ go 1.26.0
require (
github.com/alecthomas/kong v1.16.1
github.com/google/uuid v1.6.0
github.com/lestrrat-go/jwx/v4 v4.4.0
github.com/lestrrat-go/jwx/v4 v4.5.0
github.com/rs/cors v1.11.1
)
@@ -13,5 +13,5 @@ require (
github.com/lestrrat-go/dsig v1.4.0 // indirect
github.com/lestrrat-go/option/v3 v3.0.0-alpha1 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
golang.org/x/crypto v0.55.0 // indirect
golang.org/x/crypto v0.56.0 // indirect
)
+8 -8
View File
@@ -10,17 +10,17 @@ github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUq
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/lestrrat-go/dsig v1.4.0 h1:g7LUjK8cT74A5DzBXJI5HzsJuLhoYN0Wzj4nuOMIrH8=
github.com/lestrrat-go/dsig v1.4.0/go.mod h1:I8Nddg/vN2cUl/h8N7SRRApLnNNeyZPIqLYpvpOtGGo=
github.com/lestrrat-go/jwx/v4 v4.4.0 h1:CzoK8+u++WF7vVEmxx9fB8VaheeXWZ698F6HZbrl6SI=
github.com/lestrrat-go/jwx/v4 v4.4.0/go.mod h1:65utsGK/iSrjgGfu6iqj/TAvSfia6SSXkRpjHcKcTyg=
github.com/lestrrat-go/jwx/v4 v4.5.0 h1:lgU8YcqaJo/iZllX4AvSaQTzT2XpWWMrDcPMe6+KzqI=
github.com/lestrrat-go/jwx/v4 v4.5.0/go.mod h1:HpoztFC5HrNj9iKkZJ3XTQQgzCY2Zto/29n89QhTC10=
github.com/lestrrat-go/option/v3 v3.0.0-alpha1 h1:dvdzLwm/Ba5CJUF3jQP7w/iNYSLfy7yyh9XXNa1WjxI=
github.com/lestrrat-go/option/v3 v3.0.0-alpha1/go.mod h1:5KSg20dfsKkNJtjDmaQRLZVXuUrzuCCcz/gbDK0pfKk=
github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA=
github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI=
github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y=
golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I=
+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)
}
}
}