From fdfaac8a35cb4cccf4eeae213643631f704fd7a7 Mon Sep 17 00:00:00 2001
From: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com>
Date: Thu, 16 Jul 2026 06:44:59 -0700
Subject: [PATCH] feat: restrict signup invite links to a specific email domain
---
backend/internal/common/errors.go | 10 +++
backend/internal/dto/validations.go | 25 ++++++
backend/internal/dto/validations_test.go | 36 +++++++++
backend/internal/service/e2etest_service.go | 10 +++
backend/internal/usersignup/dto.go | 21 +++--
backend/internal/usersignup/handler.go | 24 +++++-
backend/internal/usersignup/models.go | 33 ++++++--
backend/internal/usersignup/models_test.go | 39 ++++++++++
backend/internal/usersignup/module.go | 1 +
backend/internal/usersignup/service.go | 43 +++++++++--
...7000000_signup_token_email_domain.down.sql | 1 +
...727000000_signup_token_email_domain.up.sql | 1 +
...7000000_signup_token_email_domain.down.sql | 5 ++
...727000000_signup_token_email_domain.up.sql | 5 ++
frontend/messages/en.json | 5 ++
.../lib/components/signup/signup-form.svelte | 24 +++++-
.../signup/signup-token-list-modal.svelte | 5 ++
.../signup/signup-token-modal.svelte | 35 ++++++++-
frontend/src/lib/services/user-service.ts | 17 +++-
frontend/src/lib/types/signup-token.type.ts | 5 ++
frontend/src/routes/signup/+page.svelte | 6 +-
frontend/src/routes/signup/+page.ts | 19 ++++-
tests/data.ts | 9 +++
tests/resources/export/database.json | 21 ++++-
tests/specs/user-signup.spec.ts | 77 +++++++++++++++++++
25 files changed, 442 insertions(+), 35 deletions(-)
create mode 100644 backend/internal/usersignup/models_test.go
create mode 100644 backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.down.sql
create mode 100644 backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.up.sql
create mode 100644 backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.down.sql
create mode 100644 backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.up.sql
diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go
index 14536541..e913733e 100644
--- a/backend/internal/common/errors.go
+++ b/backend/internal/common/errors.go
@@ -277,3 +277,13 @@ type InvalidEmailVerificationTokenError struct{}
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest }
+
+type EmailDomainNotAllowedError struct {
+ Domain string
+}
+
+func (e EmailDomainNotAllowedError) Error() string {
+ return fmt.Sprintf("The email address must use the domain @%s", e.Domain)
+}
+
+func (e EmailDomainNotAllowedError) HttpStatusCode() int { return http.StatusBadRequest }
diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go
index 0f4ab9f6..ece81a03 100644
--- a/backend/internal/dto/validations.go
+++ b/backend/internal/dto/validations.go
@@ -21,6 +21,9 @@ var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
+// emailDomainRegex validates the domain part of an email address (e.g. "example.com" or "mail.example.co.uk")
+var emailDomainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
+
func init() {
engine := binding.Validator.Engine().(*validator.Validate)
@@ -34,6 +37,9 @@ func init() {
"client_id": func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String())
},
+ "email_domain": func(fl validator.FieldLevel) bool {
+ return ValidateEmailDomain(fl.Field().String())
+ },
"ttl": func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
@@ -106,3 +112,22 @@ func ValidateCallbackURL(str string) bool {
func ValidateCallbackURLPattern(raw string) bool {
return utils.ValidateCallbackURLPattern(raw) == nil
}
+
+// ValidateEmailDomain validates an optional email domain.
+// It accepts inputs with or without a leading "@"
+func ValidateEmailDomain(domain string) bool {
+ if domain == "" {
+ return false
+ }
+
+ domain = strings.TrimPrefix(strings.ToLower(domain), "@")
+ if domain == "" {
+ return false
+ }
+
+ if !emailDomainRegex.MatchString(domain) {
+ return false
+ }
+
+ return true
+}
diff --git a/backend/internal/dto/validations_test.go b/backend/internal/dto/validations_test.go
index c752743e..291fc982 100644
--- a/backend/internal/dto/validations_test.go
+++ b/backend/internal/dto/validations_test.go
@@ -112,6 +112,42 @@ func TestValidateCallbackURL(t *testing.T) {
}
}
+func TestValidateEmailDomain(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected bool
+ }{
+ {"valid simple", "example.com", true},
+ {"valid with leading at", "@example.com", true},
+ {"valid subdomain", "mail.example.co.uk", true},
+ {"valid uppercase", "EXAMPLE.COM", true},
+ {"valid with at and uppercase", "@Example.Com", true},
+ {"valid with hyphen", "my-domain.com", true},
+ {"valid with digits", "example123.com", true},
+ {"valid single char label", "a.com", true},
+ {"empty", "", false},
+ {"only at", "@", false},
+ {"no tld", "example", false},
+ {"tld too short", "example.c", false},
+ {"trailing dot", "example.com.", false},
+ {"leading dot", ".example.com", false},
+ {"double dot", "example..com", false},
+ {"label starts with hyphen", "-example.com", false},
+ {"label ends with hyphen", "example-.com", false},
+ {"numeric tld", "example.123", false},
+ {"contains space", "exa mple.com", false},
+ {"double at", "@@example.com", false},
+ {"trailing space", "example.com ", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.expected, ValidateEmailDomain(tt.input))
+ })
+ }
+}
+
func TestValidateCallbackURLPattern(t *testing.T) {
tests := []struct {
name string
diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go
index bd32bc38..7c0937e0 100644
--- a/backend/internal/service/e2etest_service.go
+++ b/backend/internal/service/e2etest_service.go
@@ -486,6 +486,16 @@ func (s *TestService) SeedDatabase(baseURL string) error {
UsageLimit: 1,
UsageCount: 1, // Usage limit reached
},
+ {
+ Base: model.Base{
+ ID: "b2c3d4e5-f6a7-4890-bcde-f01234567891",
+ },
+ Token: "DOMAIN1234567890",
+ ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
+ UsageLimit: 5,
+ UsageCount: 0,
+ EmailDomain: new("example.com"),
+ },
}
for _, token := range signupTokens {
if err := tx.Create(&token).Error; err != nil {
diff --git a/backend/internal/usersignup/dto.go b/backend/internal/usersignup/dto.go
index b7a4aad4..772f7c88 100644
--- a/backend/internal/usersignup/dto.go
+++ b/backend/internal/usersignup/dto.go
@@ -18,14 +18,21 @@ type signupTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
UserGroupIDs []string `json:"userGroupIds"`
+ EmailDomain *string `json:"emailDomain"`
}
type signupTokenDto struct {
- ID string `json:"id"`
- Token string `json:"token"`
- ExpiresAt datatype.DateTime `json:"expiresAt"`
- UsageLimit int `json:"usageLimit"`
- UsageCount int `json:"usageCount"`
- UserGroups []dto.UserGroupMinimalDto `json:"userGroups"`
- CreatedAt datatype.DateTime `json:"createdAt"`
+ ID string `json:"id"`
+ Token string `json:"token"`
+ ExpiresAt datatype.DateTime `json:"expiresAt"`
+ UsageLimit int `json:"usageLimit"`
+ UsageCount int `json:"usageCount"`
+ EmailDomain *string `json:"emailDomain" binding:"omitempty,email_domain"`
+ UserGroups []dto.UserGroupMinimalDto `json:"userGroups"`
+ CreatedAt datatype.DateTime `json:"createdAt"`
+}
+
+// signupTokenInfoDto exposes the limited, publicly readable metadata of a signup token
+type signupTokenInfoDto struct {
+ EmailDomain *string `json:"emailDomain"`
}
diff --git a/backend/internal/usersignup/handler.go b/backend/internal/usersignup/handler.go
index 5abdbcd7..848789ee 100644
--- a/backend/internal/usersignup/handler.go
+++ b/backend/internal/usersignup/handler.go
@@ -93,7 +93,7 @@ func (h *handler) createSignupToken(c *gin.Context) {
ttl = defaultSignupTokenDuration
}
- signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
+ signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs, input.EmailDomain)
if err != nil {
_ = c.Error(err)
return
@@ -159,6 +159,28 @@ func (h *handler) deleteSignupToken(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+// signupTokenInfoHandler godoc
+// @Summary Get signup token info
+// @Description Get the public metadata (such as the required email domain) of a signup token
+// @Tags Users
+// @Produce json
+// @Param token path string true "Signup token"
+// @Success 200 {object} signupTokenInfoDto
+// @Router /api/signup/token/{token} [get]
+func (h *handler) signupTokenInfo(c *gin.Context) {
+ token := c.Param("token")
+
+ signupToken, err := h.service.GetSignupTokenInfo(c.Request.Context(), token)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusOK, signupTokenInfoDto{
+ EmailDomain: signupToken.EmailDomain,
+ })
+}
+
// signupHandler godoc
// @Summary Sign up
// @Description Create a new user account
diff --git a/backend/internal/usersignup/models.go b/backend/internal/usersignup/models.go
index ddbf5b3e..5dd708f0 100644
--- a/backend/internal/usersignup/models.go
+++ b/backend/internal/usersignup/models.go
@@ -1,6 +1,7 @@
package usersignup
import (
+ "strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -11,11 +12,12 @@ import (
type SignupToken struct {
model.Base
- Token string `json:"token"`
- ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
- UsageLimit int `json:"usageLimit" sortable:"true"`
- UsageCount int `json:"usageCount" sortable:"true"`
- UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
+ Token string `json:"token"`
+ ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
+ UsageLimit int `json:"usageLimit" sortable:"true"`
+ UsageCount int `json:"usageCount" sortable:"true"`
+ EmailDomain *string `json:"emailDomain"`
+ UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
}
func (st *SignupToken) IsExpired() bool {
@@ -29,3 +31,24 @@ func (st *SignupToken) IsUsageLimitReached() bool {
func (st *SignupToken) IsValid() bool {
return !st.IsExpired() && !st.IsUsageLimitReached()
}
+
+// HasEmailDomainRestriction reports whether the token limits sign-ups to a specific email domain
+func (st *SignupToken) HasEmailDomainRestriction() bool {
+ return st.EmailDomain != nil && *st.EmailDomain != ""
+}
+
+// EmailMatchesDomain reports whether the given email address is allowed by the token's domain restriction
+// It returns true when the token has no restriction
+// The comparison is case-insensitive
+func (st *SignupToken) EmailMatchesDomain(email string) bool {
+ if !st.HasEmailDomainRestriction() {
+ return true
+ }
+
+ at := strings.LastIndexByte(email, '@')
+ if at < 0 {
+ return false
+ }
+
+ return strings.EqualFold(email[at+1:], *st.EmailDomain)
+}
diff --git a/backend/internal/usersignup/models_test.go b/backend/internal/usersignup/models_test.go
new file mode 100644
index 00000000..ee866efb
--- /dev/null
+++ b/backend/internal/usersignup/models_test.go
@@ -0,0 +1,39 @@
+package usersignup
+
+import (
+ "testing"
+)
+
+func strPtr(s string) *string {
+ return &s
+}
+
+func TestSignupTokenEmailMatchesDomain(t *testing.T) {
+ tests := []struct {
+ name string
+ emailDomain *string
+ email string
+ want bool
+ }{
+ {name: "no restriction allows any email", emailDomain: nil, email: "user@anything.com", want: true},
+ {name: "empty restriction allows any email", emailDomain: strPtr(""), email: "user@anything.com", want: true},
+ {name: "matching domain", emailDomain: strPtr("example.com"), email: "user@example.com", want: true},
+ {name: "matching domain case-insensitive", emailDomain: strPtr("example.com"), email: "User@Example.COM", want: true},
+ {name: "non-matching domain", emailDomain: strPtr("example.com"), email: "user@other.com", want: false},
+ {name: "subdomain does not match", emailDomain: strPtr("example.com"), email: "user@mail.example.com", want: false},
+ {name: "domain suffix does not match", emailDomain: strPtr("example.com"), email: "user@notexample.com", want: false},
+ {name: "missing @ with restriction", emailDomain: strPtr("example.com"), email: "userexample.com", want: false},
+ {name: "empty email with restriction", emailDomain: strPtr("example.com"), email: "", want: false},
+ {name: "plus addressing still matches", emailDomain: strPtr("example.com"), email: "user+tag@example.com", want: true},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ st := &SignupToken{EmailDomain: tc.emailDomain}
+ got := st.EmailMatchesDomain(tc.email)
+ if got != tc.want {
+ t.Errorf("EmailMatchesDomain(%q) with domain %v = %v, want %v", tc.email, tc.emailDomain, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/backend/internal/usersignup/module.go b/backend/internal/usersignup/module.go
index 54f4b0b8..d2941868 100644
--- a/backend/internal/usersignup/module.go
+++ b/backend/internal/usersignup/module.go
@@ -55,6 +55,7 @@ func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRate
apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens)
apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken)
apiGroup.POST("/signup", signupRateLimit, m.handler.signup)
+ apiGroup.GET("/signup/token/:token", signupRateLimit, m.handler.signupTokenInfo)
apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable)
apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin)
}
diff --git a/backend/internal/usersignup/service.go b/backend/internal/usersignup/service.go
index 0ec3a76d..0b2fbabc 100644
--- a/backend/internal/usersignup/service.go
+++ b/backend/internal/usersignup/service.go
@@ -72,6 +72,16 @@ func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, u
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
+ if signupToken.HasEmailDomainRestriction() {
+ email := ""
+ if signupData.Email != nil {
+ email = *signupData.Email
+ }
+ if !signupToken.EmailMatchesDomain(email) {
+ return model.User{}, "", &common.EmailDomainNotAllowedError{Domain: *signupToken.EmailDomain}
+ }
+ }
+
for _, group := range signupToken.UserGroups {
userGroupIDs = append(userGroupIDs, group.ID)
}
@@ -190,8 +200,26 @@ func (s *Service) DeleteSignupToken(ctx context.Context, tokenID string) error {
return s.db.WithContext(ctx).Delete(&SignupToken{}, "id = ?", tokenID).Error
}
-func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (SignupToken, error) {
- signupToken, err := newSignupToken(ttl, usageLimit)
+// GetSignupTokenInfo returns a signup token by its token string.
+// It's used to expose the limited, public metadata (such as the required email domain) needed to render the signup form.
+func (s *Service) GetSignupTokenInfo(ctx context.Context, token string) (SignupToken, error) {
+ var signupToken SignupToken
+ err := s.db.
+ WithContext(ctx).
+ Where("token = ?", token).
+ First(&signupToken).
+ Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return SignupToken{}, &common.TokenInvalidOrExpiredError{}
+ } else if err != nil {
+ return SignupToken{}, err
+ }
+
+ return signupToken, nil
+}
+
+func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string, emailDomain *string) (SignupToken, error) {
+ signupToken, err := newSignupToken(ttl, usageLimit, emailDomain)
if err != nil {
return SignupToken{}, err
}
@@ -214,7 +242,7 @@ func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usag
return *signupToken, nil
}
-func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
+func newSignupToken(ttl time.Duration, usageLimit int, emailDomain *string) (*SignupToken, error) {
// Generate a random token
randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
@@ -223,10 +251,11 @@ func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
now := time.Now().Round(time.Second)
token := &SignupToken{
- Token: randomString,
- ExpiresAt: datatype.DateTime(now.Add(ttl)),
- UsageLimit: usageLimit,
- UsageCount: 0,
+ Token: randomString,
+ ExpiresAt: datatype.DateTime(now.Add(ttl)),
+ UsageLimit: usageLimit,
+ UsageCount: 0,
+ EmailDomain: emailDomain,
}
return token, nil
diff --git a/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.down.sql b/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.down.sql
new file mode 100644
index 00000000..63fec39f
--- /dev/null
+++ b/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.down.sql
@@ -0,0 +1 @@
+ALTER TABLE signup_tokens DROP COLUMN email_domain;
diff --git a/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.up.sql b/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.up.sql
new file mode 100644
index 00000000..7a76b28c
--- /dev/null
+++ b/backend/resources/migrations/postgres/20260727000000_signup_token_email_domain.up.sql
@@ -0,0 +1 @@
+ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT;
diff --git a/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.down.sql b/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.down.sql
new file mode 100644
index 00000000..ee1b7fa8
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.down.sql
@@ -0,0 +1,5 @@
+PRAGMA foreign_keys=OFF;
+BEGIN;
+ALTER TABLE signup_tokens DROP COLUMN email_domain;
+COMMIT;
+PRAGMA foreign_keys=ON;
diff --git a/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.up.sql b/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.up.sql
new file mode 100644
index 00000000..58e63871
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20260727000000_signup_token_email_domain.up.sql
@@ -0,0 +1,5 @@
+PRAGMA foreign_keys=OFF;
+BEGIN;
+ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT;
+COMMIT;
+PRAGMA foreign_keys=ON;
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 92c7515e..9f3a78ef 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -406,6 +406,11 @@
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
"usage_limit": "Usage Limit",
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
+ "email_domain": "Email Domain",
+ "signup_token_email_domain_description": "Optionally restrict signups with this token to email addresses of a specific domain (e.g. example.com). Leave empty to allow any domain.",
+ "invalid_email_domain": "Enter a valid domain, e.g. example.com",
+ "email_must_use_domain": "The email address must use the domain {domain}",
+ "email_domain_required_hint": "Must be an email address with the domain {domain}",
"expires": "Expires",
"signup": "Sign Up",
"user_creation": "User Creation",
diff --git a/frontend/src/lib/components/signup/signup-form.svelte b/frontend/src/lib/components/signup/signup-form.svelte
index ea57f85c..10d62feb 100644
--- a/frontend/src/lib/components/signup/signup-form.svelte
+++ b/frontend/src/lib/components/signup/signup-form.svelte
@@ -12,10 +12,12 @@
let {
callback,
- isLoading
+ isLoading,
+ requiredEmailDomain
}: {
callback: (user: UserSignUp) => Promise