Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f28b086
Add user invite link feature for embedded IdP
braginini Jan 22, 2026
c4cf97c
Add OpenAPI definitions for new endpoints
braginini Jan 22, 2026
00306ea
Add best-effort IdP user rollback
braginini Jan 22, 2026
d02cc3c
Fix OpenAI definition
braginini Jan 22, 2026
114c362
Add invited by to invite info and account invites endpoint
braginini Jan 23, 2026
9c5d62d
Regenerate invite by ID
braginini Jan 23, 2026
8e56427
Add invite delete
braginini Jan 24, 2026
7ed5887
Add missing activities
braginini Jan 24, 2026
2229f40
The bypass path for invite acceptance is now more restrictive
braginini Jan 24, 2026
7e41d68
Don't swallow JSON malformed requests in invites
braginini Jan 24, 2026
39698e7
Don't ignore store errors when looking up invites
braginini Jan 24, 2026
32a8f5f
Add password constraints
braginini Jan 24, 2026
05a895f
Fix checksum padding to avoid spaces in tokens.
braginini Jan 24, 2026
ea83e54
Add password validation
braginini Jan 24, 2026
3806c33
Add invites handler tests
braginini Jan 24, 2026
26d369a
Add invites manager test
braginini Jan 24, 2026
59de054
Update invite instead of creating a new one when regenerating
braginini Jan 24, 2026
54ae2a4
Fix lint
braginini Jan 24, 2026
2de4c1a
Add rate limiter
braginini Jan 24, 2026
a5dd162
Rate limiter only considers remote addr
braginini Jan 25, 2026
97b9010
Add rate limiter test
braginini Jan 25, 2026
e6ec1c0
Add Istance Version Endpoints (#5179)
braginini Jan 25, 2026
ab4d1e4
Remove unnecessary HTTP methods check
braginini Jan 26, 2026
b525382
Unify response handling
braginini Jan 26, 2026
f28561f
Rename invite_link to invite_token
braginini Jan 26, 2026
abb5363
Fix tests
braginini Jan 26, 2026
c6608bd
add store tests for invites
braginini Jan 26, 2026
d6606d5
Remove unnecessary transaction when regenerating invite
braginini Jan 26, 2026
607ad52
Add user invite test
braginini Jan 26, 2026
d43f8af
Add minimum invite expiration
braginini Jan 26, 2026
a664640
Add DELETE invite endpoint definition
braginini Jan 26, 2026
0af74f4
Make error handling consistent
braginini Jan 26, 2026
e44693a
Remove unused DELETE method check
braginini Jan 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions management/server/account/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ type Manager interface {
autoGroups []string, usageLimit int, userID string, ephemeral bool, allowExtraDNSLabels bool) (*types.SetupKey, error)
SaveSetupKey(ctx context.Context, accountID string, key *types.SetupKey, userID string) (*types.SetupKey, error)
CreateUser(ctx context.Context, accountID, initiatorUserID string, key *types.UserInfo) (*types.UserInfo, error)
CreateUserInvite(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInviteResponse, error)
AcceptUserInvite(ctx context.Context, token, password string) error
RegenerateUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string, expiresIn int) (*types.UserInviteResponse, error)
GetUserInviteInfo(ctx context.Context, token string) (*types.UserInviteInfo, error)
ListUserInvites(ctx context.Context, accountID, initiatorUserID string) ([]*types.UserInvite, error)
DeleteUserInvite(ctx context.Context, accountID, initiatorUserID, inviteID string) error
DeleteUser(ctx context.Context, accountID, initiatorUserID string, targetUserID string) error
DeleteRegularUsers(ctx context.Context, accountID, initiatorUserID string, targetUserIDs []string, userInfos map[string]*types.UserInfo) error
UpdateUserPassword(ctx context.Context, accountID, currentUserID, targetUserID string, oldPassword, newPassword string) error
Expand Down
10 changes: 10 additions & 0 deletions management/server/activity/codes.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,11 @@ const (

UserPasswordChanged Activity = 103

UserInviteLinkCreated Activity = 104
UserInviteLinkAccepted Activity = 105
UserInviteLinkRegenerated Activity = 106
UserInviteLinkDeleted Activity = 107

AccountDeleted Activity = 99999
)

Expand Down Expand Up @@ -327,6 +332,11 @@ var activityMap = map[Activity]Code{
JobCreatedByUser: {"Create Job for peer", "peer.job.create"},

UserPasswordChanged: {"User password changed", "user.password.change"},

UserInviteLinkCreated: {"User invite link created", "user.invite.link.create"},
UserInviteLinkAccepted: {"User invite link accepted", "user.invite.link.accept"},
UserInviteLinkRegenerated: {"User invite link regenerated", "user.invite.link.regenerate"},
UserInviteLinkDeleted: {"User invite link deleted", "user.invite.link.delete"},
}

// StringCode returns a string code of the activity
Expand Down
9 changes: 9 additions & 0 deletions management/server/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
if err := bypass.AddBypassPath("/api/setup"); err != nil {
return nil, fmt.Errorf("failed to add bypass path: %w", err)
}
// Public invite endpoints (tokens start with nbi_)
if err := bypass.AddBypassPath("/api/users/invites/nbi_*"); err != nil {
return nil, fmt.Errorf("failed to add bypass path: %w", err)
}
if err := bypass.AddBypassPath("/api/users/invites/nbi_*/accept"); err != nil {
return nil, fmt.Errorf("failed to add bypass path: %w", err)
}

var rateLimitingConfig *middleware.RateLimiterConfig
if os.Getenv(rateLimitingEnabledKey) == "true" {
Expand Down Expand Up @@ -132,6 +139,8 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
accounts.AddEndpoints(accountManager, settingsManager, embeddedIdpEnabled, router)
peers.AddEndpoints(accountManager, router, networkMapController)
users.AddEndpoints(accountManager, router)
users.AddInvitesEndpoints(accountManager, router)
users.AddPublicInvitesEndpoints(accountManager, router)
setup_keys.AddEndpoints(accountManager, router)
policies.AddEndpoints(accountManager, LocationManager, router)
policies.AddPostureCheckEndpoints(accountManager, LocationManager, router)
Expand Down
333 changes: 333 additions & 0 deletions management/server/http/handlers/users/invites_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
package users

import (
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"time"
"unicode"

"github.com/gorilla/mux"

"github.com/netbirdio/netbird/management/server/account"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/management/server/http/middleware"
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/http/util"
"github.com/netbirdio/netbird/shared/management/status"
)

const (
minPasswordLength = 8
)

// inviteAcceptRateLimiter limits accept invite requests by IP address to prevent brute-force attacks
var inviteAcceptRateLimiter = middleware.NewAPIRateLimiter(&middleware.RateLimiterConfig{

Check failure on line 28 in management/server/http/handlers/users/invites_handler.go

View workflow job for this annotation

GitHub Actions / Darwin

var inviteAcceptRateLimiter is unused (unused)

Check failure on line 28 in management/server/http/handlers/users/invites_handler.go

View workflow job for this annotation

GitHub Actions / Linux

var inviteAcceptRateLimiter is unused (unused)

Check failure on line 28 in management/server/http/handlers/users/invites_handler.go

View workflow job for this annotation

GitHub Actions / Windows

var inviteAcceptRateLimiter is unused (unused)
RequestsPerMinute: 10, // 10 attempts per minute per IP
Burst: 5, // Allow burst of 5 requests
CleanupInterval: 10 * time.Minute,
LimiterTTL: 30 * time.Minute,
})

// invitesHandler handles user invite operations
type invitesHandler struct {
accountManager account.Manager
}

// validatePassword checks password strength requirements:
// - Minimum 8 characters
// - At least 1 digit
// - At least 1 uppercase letter
// - At least 1 special character
func validatePassword(password string) error {
if len(password) < minPasswordLength {
return errors.New("password must be at least 8 characters long")
}

var hasDigit, hasUpper, hasSpecial bool
for _, c := range password {
switch {
case unicode.IsDigit(c):
hasDigit = true
case unicode.IsUpper(c):
hasUpper = true
case !unicode.IsLetter(c) && !unicode.IsDigit(c):
hasSpecial = true
}
}

var missing []string
if !hasDigit {
missing = append(missing, "one digit")
}
if !hasUpper {
missing = append(missing, "one uppercase letter")
}
if !hasSpecial {
missing = append(missing, "one special character")
}

if len(missing) > 0 {
return errors.New("password must contain at least " + strings.Join(missing, ", "))
}

return nil
}

// AddInvitesEndpoints registers invite-related endpoints
func AddInvitesEndpoints(accountManager account.Manager, router *mux.Router) {
h := &invitesHandler{accountManager: accountManager}

// Authenticated endpoints (require admin)
router.HandleFunc("/users/invites", h.listInvites).Methods("GET", "OPTIONS")
router.HandleFunc("/users/invites", h.createInvite).Methods("POST", "OPTIONS")
router.HandleFunc("/users/invites/{inviteId}", h.deleteInvite).Methods("DELETE", "OPTIONS")
router.HandleFunc("/users/invites/{inviteId}/regenerate", h.regenerateInvite).Methods("POST", "OPTIONS")
}

// AddPublicInvitesEndpoints registers public (unauthenticated) invite endpoints
func AddPublicInvitesEndpoints(accountManager account.Manager, router *mux.Router) {
h := &invitesHandler{accountManager: accountManager}

// Public endpoints (no auth required, protected by token)
router.HandleFunc("/users/invites/{token}", h.getInviteInfo).Methods("GET", "OPTIONS")
router.HandleFunc("/users/invites/{token}/accept", h.acceptInvite).Methods("POST", "OPTIONS")
}

// listInvites handles GET /api/users/invites
func (h *invitesHandler) listInvites(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
return
}

userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}

invites, err := h.accountManager.ListUserInvites(r.Context(), userAuth.AccountId, userAuth.UserId)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}

resp := make([]api.UserInviteListItem, 0, len(invites))
for _, invite := range invites {
autoGroups := invite.AutoGroups
if autoGroups == nil {
autoGroups = []string{}
}
resp = append(resp, api.UserInviteListItem{
Id: invite.ID,
Email: invite.Email,
Name: invite.Name,
Role: invite.Role,
AutoGroups: autoGroups,
ExpiresAt: invite.ExpiresAt.UTC(),
CreatedAt: invite.CreatedAt.UTC(),
Expired: invite.IsExpired(),
})
}

util.WriteJSONObject(r.Context(), w, resp)
}

// createInvite handles POST /api/users/invites
func (h *invitesHandler) createInvite(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Comment thread
braginini marked this conversation as resolved.
Outdated
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
return
}

userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}

var req api.UserInviteCreateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
return
}

invite := &types.UserInfo{
Email: req.Email,
Name: req.Name,
Role: req.Role,
AutoGroups: req.AutoGroups,
}

expiresIn := 0
if req.ExpiresIn != nil {
expiresIn = *req.ExpiresIn
}

result, err := h.accountManager.CreateUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, invite, expiresIn)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}

autoGroups := result.UserInfo.AutoGroups
if autoGroups == nil {
autoGroups = []string{}
}

expiresAt := result.InviteExpiresAt.UTC()
util.WriteJSONObject(r.Context(), w, &api.UserInviteCreateResponse{
Comment thread
braginini marked this conversation as resolved.
Outdated
Id: result.UserInfo.ID,
Email: result.UserInfo.Email,
Name: result.UserInfo.Name,
Role: result.UserInfo.Role,
AutoGroups: autoGroups,
Status: result.UserInfo.Status,
InviteLink: result.InviteLink,
InviteExpiresAt: expiresAt,
})
}

// getInviteInfo handles GET /api/users/invites/{token}
func (h *invitesHandler) getInviteInfo(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
return
}

vars := mux.Vars(r)
token := vars["token"]
if token == "" {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "token is required"), w)
return
}

info, err := h.accountManager.GetUserInviteInfo(r.Context(), token)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}

expiresAt := info.ExpiresAt.UTC()
util.WriteJSONObject(r.Context(), w, &api.UserInviteInfo{
Email: info.Email,
Name: info.Name,
ExpiresAt: expiresAt,
Valid: info.Valid,
InvitedBy: info.InvitedBy,
})
}

// acceptInvite handles POST /api/users/invites/{token}/accept
func (h *invitesHandler) acceptInvite(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
return
}

vars := mux.Vars(r)
token := vars["token"]
if token == "" {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "token is required"), w)
return
}

var req api.UserInviteAcceptRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
return
}

if err := validatePassword(req.Password); err != nil {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invalid password: %v", err), w)
return
}

err := h.accountManager.AcceptUserInvite(r.Context(), token, req.Password)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}
Comment thread
braginini marked this conversation as resolved.

util.WriteJSONObject(r.Context(), w, &api.UserInviteAcceptResponse{Success: true})
}

// regenerateInvite handles POST /api/users/invites/{inviteId}/regenerate
func (h *invitesHandler) regenerateInvite(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
return
}
Comment on lines +195 to +198

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

OPTIONS is registered but always rejected here.

This handler returns 405 for any non‑POST request, so registered OPTIONS requests will fail. Either handle OPTIONS explicitly or remove it from the route.

🐛 Suggested fix (handle OPTIONS explicitly)
-	if r.Method != http.MethodPost {
+	if r.Method == http.MethodOptions {
+		w.WriteHeader(http.StatusOK)
+		return
+	}
+	if r.Method != http.MethodPost {
 		util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
 		return
 	}
🤖 Prompt for AI Agents
In `@management/server/http/handlers/users/invites_handler.go` around lines 195 -
198, The handler currently rejects any non-POST method with
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w),
which causes registered OPTIONS preflight requests to receive 405; update the
method check to explicitly handle OPTIONS (check r.Method == http.MethodOptions)
by returning 200/204 and setting the appropriate Allow/CORS headers (e.g., set
"Allow: POST, OPTIONS" and any needed CORS headers) before returning, or remove
OPTIONS from the route if you don't want to support it; locate the method check
in invites_handler.go where r.Method is compared and modify it to handle
http.MethodOptions explicitly (and keep the existing util.WriteErrorResponse for
other non-POST methods).


userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}

vars := mux.Vars(r)
inviteID := vars["inviteId"]
if inviteID == "" {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invite ID is required"), w)
return
}

var req api.UserInviteRegenerateRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// Allow empty body (io.EOF) - expiresIn is optional
if !errors.Is(err, io.EOF) {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "couldn't parse JSON request: %v", err), w)
return
}
}

expiresIn := 0
if req.ExpiresIn != nil {
expiresIn = *req.ExpiresIn
}

result, err := h.accountManager.RegenerateUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, inviteID, expiresIn)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}

expiresAt := result.InviteExpiresAt.UTC()
util.WriteJSONObject(r.Context(), w, &api.UserInviteRegenerateResponse{
InviteLink: result.InviteLink,
InviteExpiresAt: expiresAt,
})
}

// deleteInvite handles DELETE /api/users/invites/{inviteId}
func (h *invitesHandler) deleteInvite(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
util.WriteErrorResponse("wrong HTTP method", http.StatusMethodNotAllowed, w)
return
}

userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
util.WriteError(r.Context(), err, w)
return
}

vars := mux.Vars(r)
inviteID := vars["inviteId"]
if inviteID == "" {
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "invite ID is required"), w)
return
}

err = h.accountManager.DeleteUserInvite(r.Context(), userAuth.AccountId, userAuth.UserId, inviteID)
if err != nil {
util.WriteError(r.Context(), err, w)
return
}

util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
}
Loading
Loading