feat: migrate auth0mock from Node.js to Go

Refactor the application to a Go-based architecture for improved
performance and maintainability. Replace the Dockerfile to utilize a
multi-stage build process, enhancing image efficiency. Implement
comprehensive session store tests to ensure reliability and create
new OAuth handlers for managing authentication efficiently. Update 
documentation to reflect these structural changes.
This commit is contained in:
2025-12-29 16:30:37 +01:00
parent 96453e1d15
commit 9992fb4ef1
25 changed files with 1976 additions and 1991 deletions
+77
View File
@@ -0,0 +1,77 @@
package handlers
import (
"encoding/json"
"net/http"
"gitlab.com/unboundsoftware/auth0mock/auth"
)
// DiscoveryHandler handles OIDC discovery endpoints
type DiscoveryHandler struct {
jwtService *auth.JWTService
}
// NewDiscoveryHandler creates a new discovery handler
func NewDiscoveryHandler(jwtService *auth.JWTService) *DiscoveryHandler {
return &DiscoveryHandler{
jwtService: jwtService,
}
}
// OpenIDConfiguration returns the OIDC discovery document
func (h *DiscoveryHandler) OpenIDConfiguration(w http.ResponseWriter, r *http.Request) {
issuer := h.jwtService.Issuer()
config := map[string]interface{}{
"issuer": issuer,
"authorization_endpoint": issuer + "authorize",
"token_endpoint": issuer + "oauth/token",
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "private_key_jwt"},
"token_endpoint_auth_signing_alg_values_supported": []string{"RS256"},
"userinfo_endpoint": issuer + "userinfo",
"check_session_iframe": issuer + "check_session",
"end_session_endpoint": issuer + "end_session",
"jwks_uri": issuer + ".well-known/jwks.json",
"registration_endpoint": issuer + "register",
"scopes_supported": []string{"openid", "profile", "email", "address", "phone", "offline_access"},
"response_types_supported": []string{"code", "code id_token", "id_token", "id_token token"},
"acr_values_supported": []string{},
"subject_types_supported": []string{"public", "pairwise"},
"userinfo_signing_alg_values_supported": []string{"RS256", "ES256", "HS256"},
"userinfo_encryption_alg_values_supported": []string{"RSA-OAEP-256", "A128KW"},
"userinfo_encryption_enc_values_supported": []string{"A128CBC-HS256", "A128GCM"},
"id_token_signing_alg_values_supported": []string{"RS256", "ES256", "HS256"},
"id_token_encryption_alg_values_supported": []string{"RSA-OAEP-256", "A128KW"},
"id_token_encryption_enc_values_supported": []string{"A128CBC-HS256", "A128GCM"},
"request_object_signing_alg_values_supported": []string{"none", "RS256", "ES256"},
"display_values_supported": []string{"page", "popup"},
"claim_types_supported": []string{"normal", "distributed"},
"claims_supported": []string{
"sub", "iss", "auth_time", "acr",
"name", "given_name", "family_name", "nickname",
"profile", "picture", "website",
"email", "email_verified", "locale", "zoneinfo",
h.jwtService.EmailClaim(), h.jwtService.AdminClaim(),
},
"claims_parameter_supported": true,
"service_documentation": "http://auth0/",
"ui_locales_supported": []string{"en-US"},
"code_challenge_methods_supported": []string{"plain", "S256"},
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(config)
}
// JWKS returns the JSON Web Key Set
func (h *DiscoveryHandler) JWKS(w http.ResponseWriter, r *http.Request) {
jwks, err := h.jwtService.GetJWKS()
if err != nil {
http.Error(w, "Failed to get JWKS", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(jwks)
}
+154
View File
@@ -0,0 +1,154 @@
package handlers
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"gitlab.com/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",
})
}
+337
View File
@@ -0,0 +1,337 @@
package handlers
import (
"embed"
"encoding/json"
"fmt"
"html/template"
"log/slog"
"net/http"
"net/url"
"gitlab.com/unboundsoftware/auth0mock/auth"
"gitlab.com/unboundsoftware/auth0mock/store"
)
//go:embed templates/login.html
var templateFS embed.FS
// OAuthHandler handles OAuth/OIDC endpoints
type OAuthHandler struct {
jwtService *auth.JWTService
sessionStore *store.SessionStore
loginTemplate *template.Template
logger *slog.Logger
}
// NewOAuthHandler creates a new OAuth handler
func NewOAuthHandler(jwtService *auth.JWTService, sessionStore *store.SessionStore, logger *slog.Logger) (*OAuthHandler, error) {
tmpl, err := template.ParseFS(templateFS, "templates/login.html")
if err != nil {
return nil, fmt.Errorf("parse login template: %w", err)
}
return &OAuthHandler{
jwtService: jwtService,
sessionStore: sessionStore,
loginTemplate: tmpl,
logger: logger,
}, nil
}
// TokenRequest represents the token endpoint request body
type TokenRequest struct {
GrantType string `json:"grant_type"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
Code string `json:"code"`
CodeVerifier string `json:"code_verifier"`
RedirectURI string `json:"redirect_uri"`
}
// TokenResponse represents the token endpoint response
type TokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
Scope string `json:"scope"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
// Token handles the POST /oauth/token endpoint
func (h *OAuthHandler) Token(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
grantType := r.FormValue("grant_type")
clientID := r.FormValue("client_id")
code := r.FormValue("code")
codeVerifier := r.FormValue("code_verifier")
w.Header().Set("Content-Type", "application/json")
switch grantType {
case "client_credentials":
h.handleClientCredentials(w, clientID)
case "authorization_code", "":
if code != "" {
h.handleAuthorizationCode(w, code, codeVerifier, clientID)
} else {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Missing code"})
}
default:
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": "Unsupported grant type"})
}
}
func (h *OAuthHandler) handleClientCredentials(w http.ResponseWriter, clientID string) {
if clientID == "" {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Missing client_id"})
return
}
adminClaim := map[string]interface{}{
h.jwtService.AdminClaim(): true,
}
accessToken, err := h.jwtService.SignAccessToken(
"auth0|management",
clientID,
"management@example.org",
[]map[string]interface{}{adminClaim},
)
if err != nil {
h.logger.Error("failed to sign access token", "error", err)
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
idToken, err := h.jwtService.SignIDToken(
"auth0|management",
clientID,
"",
"management@example.org",
"Management API",
"Management",
"API",
"",
[]map[string]interface{}{adminClaim},
)
if err != nil {
h.logger.Error("failed to sign ID token", "error", err)
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
h.logger.Info("signed token for management API")
json.NewEncoder(w).Encode(TokenResponse{
AccessToken: accessToken,
IDToken: idToken,
Scope: "openid%20profile%20email",
ExpiresIn: 7200,
TokenType: "Bearer",
})
}
func (h *OAuthHandler) handleAuthorizationCode(w http.ResponseWriter, code, codeVerifier, clientID string) {
session, ok := h.sessionStore.Get(code)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid code"})
return
}
// Verify PKCE if code_verifier is provided
if codeVerifier != "" && session.CodeChallenge != "" {
// Determine method - default to S256 if challenge looks like a hash
method := auth.PKCEMethodS256
if len(session.CodeChallenge) < 43 {
method = auth.PKCEMethodPlain
}
if !auth.VerifyPKCE(codeVerifier, session.CodeChallenge, method) {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid code_verifier"})
return
}
}
accessToken, err := h.jwtService.SignAccessToken(
"auth0|"+session.Email,
session.ClientID,
session.Email,
session.CustomClaims,
)
if err != nil {
h.logger.Error("failed to sign access token", "error", err)
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
idToken, err := h.jwtService.SignIDToken(
"auth0|"+session.Email,
session.ClientID,
session.Nonce,
session.Email,
"Example Person",
"Example",
"Person",
"https://cdn.playbuzz.com/cdn/5458360f-32ea-460e-a707-1a2d26760558/70bda687-cb84-4756-8a44-8cf735ed87b3.jpg",
session.CustomClaims,
)
if err != nil {
h.logger.Error("failed to sign ID token", "error", err)
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
return
}
h.logger.Info("signed token", "email", session.Email)
// Clean up session after successful token exchange
h.sessionStore.Delete(code)
json.NewEncoder(w).Encode(TokenResponse{
AccessToken: accessToken,
IDToken: idToken,
Scope: "openid%20profile%20email",
ExpiresIn: 7200,
TokenType: "Bearer",
})
}
// Code handles the POST /code endpoint (form submission from login page)
func (h *OAuthHandler) Code(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
email := r.FormValue("email")
password := r.FormValue("password")
codeChallenge := r.FormValue("codeChallenge")
redirect := r.FormValue("redirect")
state := r.FormValue("state")
nonce := r.FormValue("nonce")
clientID := r.FormValue("clientId")
admin := r.FormValue("admin") == "true"
if email == "" || password == "" || codeChallenge == "" {
h.logger.Debug("invalid code request", "email", email, "hasPassword", password != "", "hasChallenge", codeChallenge != "")
http.Error(w, "Email, password, or code challenge is missing", http.StatusBadRequest)
return
}
adminClaim := map[string]interface{}{
h.jwtService.AdminClaim(): admin,
}
session := &store.Session{
Email: email,
Password: password,
State: state,
Nonce: nonce,
ClientID: clientID,
CodeChallenge: codeChallenge,
CustomClaims: []map[string]interface{}{adminClaim},
}
h.sessionStore.Create(codeChallenge, session)
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirect, codeChallenge, url.QueryEscape(state))
http.Redirect(w, r, redirectURL, http.StatusFound)
}
// Authorize handles the GET /authorize endpoint
func (h *OAuthHandler) Authorize(w http.ResponseWriter, r *http.Request) {
redirect := r.URL.Query().Get("redirect_uri")
state := r.URL.Query().Get("state")
nonce := r.URL.Query().Get("nonce")
clientID := r.URL.Query().Get("client_id")
codeChallenge := r.URL.Query().Get("code_challenge")
prompt := r.URL.Query().Get("prompt")
responseMode := r.URL.Query().Get("response_mode")
// Try to get existing session from cookie
cookie, err := r.Cookie("auth0")
var existingCode string
if err == nil {
existingCode = cookie.Value
}
// Handle response_mode=query with existing session
if responseMode == "query" && existingCode != "" {
if h.sessionStore.Update(existingCode, codeChallenge, func(s *store.Session) {
s.Nonce = nonce
s.State = state
s.CodeChallenge = codeChallenge
}) {
redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirect, codeChallenge, state)
http.Redirect(w, r, redirectURL, http.StatusFound)
return
}
}
// Handle prompt=none with response_mode=web_message (silent auth)
if prompt == "none" && responseMode == "web_message" && existingCode != "" {
session, ok := h.sessionStore.Get(existingCode)
if ok {
h.sessionStore.Update(existingCode, existingCode, func(s *store.Session) {
s.Nonce = nonce
s.State = state
s.CodeChallenge = codeChallenge
})
// Send postMessage response
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<body>
<script type="text/javascript">
(() => {
const msg = {
type: 'authorization_response',
response: {
code: '%s',
state: '%s'
}
}
parent.postMessage(msg, "*")
})()
</script>
</body>
</html>`, existingCode, session.State)
return
}
}
// Set cookie for session tracking
http.SetCookie(w, &http.Cookie{
Name: "auth0",
Value: codeChallenge,
Path: "/",
SameSite: http.SameSiteNoneMode,
Secure: true,
HttpOnly: true,
})
// Render login form
w.Header().Set("Content-Type", "text/html")
data := map[string]string{
"Redirect": redirect,
"State": state,
"Nonce": nonce,
"ClientID": clientID,
"CodeChallenge": codeChallenge,
}
if err := h.loginTemplate.Execute(w, data); err != nil {
h.logger.Error("failed to render login template", "error", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
+97
View File
@@ -0,0 +1,97 @@
package handlers
import (
"encoding/json"
"log/slog"
"net/http"
"gitlab.com/unboundsoftware/auth0mock/auth"
"gitlab.com/unboundsoftware/auth0mock/store"
)
// SessionHandler handles session-related endpoints
type SessionHandler struct {
jwtService *auth.JWTService
sessionStore *store.SessionStore
logger *slog.Logger
}
// NewSessionHandler creates a new session handler
func NewSessionHandler(jwtService *auth.JWTService, sessionStore *store.SessionStore, logger *slog.Logger) *SessionHandler {
return &SessionHandler{
jwtService: jwtService,
sessionStore: sessionStore,
logger: logger,
}
}
// UserInfo handles GET /userinfo
func (h *SessionHandler) UserInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"picture": "https://cdn.playbuzz.com/cdn/5458360f-32ea-460e-a707-1a2d26760558/70bda687-cb84-4756-8a44-8cf735ed87b3.jpg",
})
}
// TokenInfo handles POST /tokeninfo
func (h *SessionHandler) TokenInfo(w http.ResponseWriter, r *http.Request) {
var req struct {
IDToken string `json:"id_token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.IDToken == "" {
h.logger.Debug("no token given in body")
http.Error(w, "missing id_token", http.StatusUnauthorized)
return
}
claims, err := h.jwtService.DecodeToken(req.IDToken)
if err != nil {
h.logger.Debug("failed to decode token", "error", err)
http.Error(w, "invalid id_token", http.StatusUnauthorized)
return
}
if userID, ok := claims["sub"].(string); ok {
h.logger.Debug("returning token data", "user_id", userID)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(claims)
}
// Logout handles GET /v2/logout
func (h *SessionHandler) Logout(w http.ResponseWriter, r *http.Request) {
returnTo := r.URL.Query().Get("returnTo")
// Try to get session from cookie
cookie, err := r.Cookie("auth0")
if err == nil && cookie.Value != "" {
h.sessionStore.Delete(cookie.Value)
h.logger.Debug("deleted session", "code", cookie.Value)
}
// Clear the cookie
http.SetCookie(w, &http.Cookie{
Name: "auth0",
Value: "",
Path: "/",
MaxAge: -1,
SameSite: http.SameSiteNoneMode,
Secure: true,
HttpOnly: true,
})
if returnTo != "" {
http.Redirect(w, r, returnTo, http.StatusFound)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("Logged out"))
}
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Auth</title>
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
</head>
<body>
<div class="container">
<form method="post" action="/code">
<div class="card" style="width: 18rem; margin-top: 2rem;">
<div class="card-body">
<h5 class="card-title">Login</h5>
<div class="form-group">
<label for="email">Email</label>
<input type="text" name="email" id="email" class="form-control">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" name="password" id="password" class="form-control">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="admin" value="true" id="admin">
<label class="form-check-label" for="admin">
Admin
</label>
</div>
<button type="submit" class="btn btn-primary mt-3">Login</button>
<input type="hidden" value="{{.Redirect}}" name="redirect">
<input type="hidden" value="{{.State}}" name="state">
<input type="hidden" value="{{.Nonce}}" name="nonce">
<input type="hidden" value="{{.ClientID}}" name="clientId">
<input type="hidden" value="{{.CodeChallenge}}" name="codeChallenge">
</div>
</div>
</form>
</div>
</body>
</html>