package auth import ( "encoding/json" "net/http" "strings" "time" ) // LoginRequest is the POST /api/auth/login body. type LoginRequest struct { Email string `json:"email"` Password string `json:"password"` } type TokenResponse struct { CSRFToken string `json:"csrf_token"` User *User `json:"user"` } // HandleLogin creates an HTTP handler for POST /api/auth/login. func HandleLogin(store *UserStore, secret []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req LoginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeAuthError(w, http.StatusBadRequest, "invalid JSON body") return } // Support both "email" and legacy "username" field email := req.Email if email == "" { // Try legacy format var legacy struct{ Username string `json:"username"` } email = legacy.Username } user, err := store.Authenticate(email, req.Password) if err != nil { if err == ErrEmailNotVerified { writeAuthError(w, http.StatusForbidden, "email not verified — check your inbox for the verification code") return } writeAuthError(w, http.StatusUnauthorized, "invalid credentials") return } accessToken, err := Sign(Claims{ Sub: user.Email, Role: user.Role, TenantID: user.TenantID, TokenType: "access", Exp: time.Now().Add(15 * time.Minute).Unix(), }, secret) if err != nil { writeAuthError(w, http.StatusInternalServerError, "token generation failed") return } refreshToken, err := Sign(Claims{ Sub: user.Email, Role: user.Role, TenantID: user.TenantID, TokenType: "refresh", Exp: time.Now().Add(7 * 24 * time.Hour).Unix(), }, secret) if err != nil { writeAuthError(w, http.StatusInternalServerError, "token generation failed") return } // SEC: H1 - Use httpOnly Cookies instead of localStorage http.SetCookie(w, &http.Cookie{ Name: "syntrex_token", Value: accessToken, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 900, }) http.SetCookie(w, &http.Cookie{ Name: "syntrex_refresh", Value: refreshToken, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 7 * 24 * 3600, }) // SEC: M2 - Generate stateless CSRF token csrfToken := hmacSign([]byte(accessToken), secret)[:32] resp := TokenResponse{ CSRFToken: csrfToken, User: user, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } } // HandleRefresh creates an HTTP handler for POST /api/auth/refresh. func HandleRefresh(secret []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Extract refresh token from cookie cookie, err := r.Cookie("syntrex_refresh") if err != nil { writeAuthError(w, http.StatusUnauthorized, "missing refresh token cookie") return } claims, err := Verify(cookie.Value, secret) if err != nil { writeAuthError(w, http.StatusUnauthorized, "invalid or expired refresh token") return } // SEC-C5: Only accept refresh tokens for token renewal if claims.TokenType != "refresh" { writeAuthError(w, http.StatusUnauthorized, "invalid token type — refresh token required") return } accessToken, err := NewAccessToken(claims.Sub, claims.Role, secret, 0) if err != nil { writeAuthError(w, http.StatusInternalServerError, "token generation failed") return } // SEC: H1 - Set new httpOnly token http.SetCookie(w, &http.Cookie{ Name: "syntrex_token", Value: accessToken, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 900, }) csrfToken := hmacSign([]byte(accessToken), secret)[:32] resp := TokenResponse{ CSRFToken: csrfToken, User: &User{Email: claims.Sub, Role: claims.Role}, // Mock user to provide payload } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } } // HandleLogout clears the auth cookies. // POST /api/auth/logout func HandleLogout() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ Name: "syntrex_token", Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) http.SetCookie(w, &http.Cookie{ Name: "syntrex_refresh", Value: "", Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: -1, }) w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]bool{"success": true}) } } // HandleMe returns the current authenticated user profile. // GET /api/auth/me func HandleMe(store *UserStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r.Context()) if claims == nil { writeAuthError(w, http.StatusUnauthorized, "not authenticated") return } user, err := store.GetByEmail(claims.Sub) if err != nil { writeAuthError(w, http.StatusNotFound, "user not found") return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(user) } } // HandleListUsers returns users scoped to the caller's tenant (admin only). // GET /api/auth/users func HandleListUsers(store *UserStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r.Context()) if claims == nil || claims.Role != "admin" { writeAuthError(w, http.StatusForbidden, "admin role required") return } // SEC: Filter users by tenant_id to prevent cross-tenant data leak allUsers := store.ListUsers() var filtered []*User for _, u := range allUsers { if u.TenantID == claims.TenantID { filtered = append(filtered, u) } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "users": filtered, "total": len(filtered), }) } } // HandleCreateUser creates a new user (admin only). // POST /api/auth/users func HandleCreateUser(store *UserStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var req struct { Email string `json:"email"` DisplayName string `json:"display_name"` Password string `json:"password"` Role string `json:"role"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeAuthError(w, http.StatusBadRequest, "invalid JSON") return } if req.Email == "" || req.Password == "" { writeAuthError(w, http.StatusBadRequest, "email and password required") return } if req.Role == "" { req.Role = "viewer" } // Validate role validRoles := map[string]bool{"admin": true, "analyst": true, "viewer": true} if !validRoles[req.Role] { writeAuthError(w, http.StatusBadRequest, "invalid role (valid: admin, analyst, viewer)") return } user, err := store.CreateUser(req.Email, req.DisplayName, req.Password, req.Role) if err != nil { if err == ErrUserExists { writeAuthError(w, http.StatusConflict, "user already exists") } else { writeAuthError(w, http.StatusInternalServerError, err.Error()) } return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(user) } } // HandleUpdateUser updates a user's profile (admin only). // PUT /api/auth/users/{id} func HandleUpdateUser(store *UserStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { writeAuthError(w, http.StatusBadRequest, "user id required") return } var req struct { DisplayName string `json:"display_name"` Role string `json:"role"` Active *bool `json:"active"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeAuthError(w, http.StatusBadRequest, "invalid JSON") return } active := true if req.Active != nil { active = *req.Active } if err := store.UpdateUser(id, req.DisplayName, req.Role, active); err != nil { writeAuthError(w, http.StatusNotFound, err.Error()) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "updated"}) } } // HandleDeleteUser deletes a user (admin only). // DELETE /api/auth/users/{id} func HandleDeleteUser(store *UserStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") if id == "" { writeAuthError(w, http.StatusBadRequest, "user id required") return } if err := store.DeleteUser(id); err != nil { writeAuthError(w, http.StatusNotFound, err.Error()) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "deleted"}) } } // HandleCreateAPIKey generates a new API key for the authenticated user. // POST /api/auth/keys func HandleCreateAPIKey(store *UserStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r.Context()) if claims == nil { writeAuthError(w, http.StatusUnauthorized, "not authenticated") return } user, err := store.GetByEmail(claims.Sub) if err != nil { writeAuthError(w, http.StatusNotFound, "user not found") return } var req struct { Name string `json:"name"` Role string `json:"role"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeAuthError(w, http.StatusBadRequest, "invalid JSON") return } if req.Name == "" { req.Name = "default" } if req.Role == "" { req.Role = user.Role } fullKey, ak, err := store.CreateAPIKey(user.ID, req.Name, req.Role) if err != nil { writeAuthError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]any{ "key": fullKey, // shown only once "details": ak, }) } } // HandleListAPIKeys returns API keys for the authenticated user. // GET /api/auth/keys func HandleListAPIKeys(store *UserStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r.Context()) if claims == nil { writeAuthError(w, http.StatusUnauthorized, "not authenticated") return } user, err := store.GetByEmail(claims.Sub) if err != nil { writeAuthError(w, http.StatusNotFound, "user not found") return } keys, err := store.ListAPIKeys(user.ID) if err != nil { writeAuthError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"keys": keys}) } } // HandleDeleteAPIKey revokes an API key. // DELETE /api/auth/keys/{id} func HandleDeleteAPIKey(store *UserStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { claims := GetClaims(r.Context()) if claims == nil { writeAuthError(w, http.StatusUnauthorized, "not authenticated") return } user, err := store.GetByEmail(claims.Sub) if err != nil { writeAuthError(w, http.StatusNotFound, "user not found") return } keyID := r.PathValue("id") if err := store.DeleteAPIKey(keyID, user.ID); err != nil { writeAuthError(w, http.StatusInternalServerError, err.Error()) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "revoked"}) } } // APIKeyMiddleware checks for API key authentication alongside JWT. // If Authorization header starts with "stx_", validate as API key. func APIKeyMiddleware(store *UserStore, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { authHeader := r.Header.Get("Authorization") if strings.HasPrefix(authHeader, "Bearer stx_") { key := strings.TrimPrefix(authHeader, "Bearer ") _, role, err := store.ValidateAPIKey(key) if err != nil { writeAuthError(w, http.StatusUnauthorized, "invalid API key") return } // Inject synthetic claims for RBAC compatibility claims := &Claims{Sub: "api-key", Role: role} ctx := SetClaimsContext(r.Context(), claims) next.ServeHTTP(w, r.WithContext(ctx)) return } next.ServeHTTP(w, r) }) }