Skip to content

Commit

Permalink
feat: add authorized email address support (supabase#1757)
Browse files Browse the repository at this point in the history
Adds support for authorized email addresses, useful when needing to
restrict email sending to a handful of email addresses. In the Supabase
platform use case, this can be used to allow sending of emails on new
projects only to the project's owners or developers, reducing email
abuse.

To enable it, specify `GOTRUE_EXTERNAL_EMAIL_AUTHORIZED_ADDRESSES` as a
comma-delimited string of email addresses. The addresses should be
lowercased and without labels.

Labels are supported so emails will be sent to `someone+test1@gmail.com`
and `someone+test2@gmail.com` if and only if the `someone@gmail.com`
address is added in the authorized list.

Not a substitute for blocklists!
  • Loading branch information
hf authored Sep 2, 2024
1 parent 2ad0737 commit f3a28d1
Show file tree
Hide file tree
Showing 14 changed files with 85 additions and 27 deletions.
4 changes: 2 additions & 2 deletions internal/api/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error {
}

if params.Email != "" {
params.Email, err = validateEmail(params.Email)
params.Email, err = a.validateEmail(params.Email)
if err != nil {
return err
}
Expand Down Expand Up @@ -343,7 +343,7 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error {

var providers []string
if params.Email != "" {
params.Email, err = validateEmail(params.Email)
params.Email, err = a.validateEmail(params.Email)
if err != nil {
return err
}
Expand Down
3 changes: 2 additions & 1 deletion internal/api/errorcodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,5 +83,6 @@ const (
ErrorCodeMFATOTPVerifyDisabled ErrorCode = "mfa_totp_verify_not_enabled"
ErrorCodeMFAVerifiedFactorExists ErrorCode = "mfa_verified_factor_exists"
//#nosec G101 -- Not a secret value.
ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials"
ErrorCodeInvalidCredentials ErrorCode = "invalid_credentials"
ErrorCodeEmailAddressNotAuthorized ErrorCode = "email_address_not_authorized"
)
2 changes: 1 addition & 1 deletion internal/api/invite.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func (a *API) Invite(w http.ResponseWriter, r *http.Request) error {
}

var err error
params.Email, err = validateEmail(params.Email)
params.Email, err = a.validateEmail(params.Email)
if err != nil {
return err
}
Expand Down
6 changes: 3 additions & 3 deletions internal/api/magic_link.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ type MagicLinkParams struct {
CodeChallenge string `json:"code_challenge"`
}

func (p *MagicLinkParams) Validate() error {
func (p *MagicLinkParams) Validate(a *API) error {
if p.Email == "" {
return unprocessableEntityError(ErrorCodeValidationFailed, "Password recovery requires an email")
}
var err error
p.Email, err = validateEmail(p.Email)
p.Email, err = a.validateEmail(p.Email)
if err != nil {
return err
}
Expand Down Expand Up @@ -57,7 +57,7 @@ func (a *API) MagicLink(w http.ResponseWriter, r *http.Request) error {
return badRequestError(ErrorCodeBadJSON, "Could not read verification params: %v", err).WithInternalError(err)
}

if err := params.Validate(); err != nil {
if err := params.Validate(a); err != nil {
return err
}

Expand Down
28 changes: 24 additions & 4 deletions internal/api/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"net/http"
"regexp"
"strings"
"time"

Expand All @@ -24,6 +25,7 @@ import (

var (
EmailRateLimitExceeded error = errors.New("email rate limit exceeded")
AddressNotAuthorized error = errors.New("Destination email address not authorized")
)

type GenerateLinkParams struct {
Expand Down Expand Up @@ -56,7 +58,7 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error {
}

var err error
params.Email, err = validateEmail(params.Email)
params.Email, err = a.validateEmail(params.Email)
if err != nil {
return err
}
Expand Down Expand Up @@ -230,7 +232,7 @@ func (a *API) adminGenerateLink(w http.ResponseWriter, r *http.Request) error {
if !config.Mailer.SecureEmailChangeEnabled && params.Type == "email_change_current" {
return badRequestError(ErrorCodeValidationFailed, "Enable secure email change to generate link for current email")
}
params.NewEmail, terr = validateEmail(params.NewEmail)
params.NewEmail, terr = a.validateEmail(params.NewEmail)
if terr != nil {
return terr
}
Expand Down Expand Up @@ -548,7 +550,9 @@ func (a *API) sendEmailChange(r *http.Request, tx *storage.Connection, u *models
return nil
}

func validateEmail(email string) (string, error) {
var emailLabelPattern = regexp.MustCompile("[+][^@]+@")

func (a *API) validateEmail(email string) (string, error) {
if email == "" {
return "", badRequestError(ErrorCodeValidationFailed, "An email address is required")
}
Expand All @@ -558,7 +562,23 @@ func validateEmail(email string) (string, error) {
if err := checkmail.ValidateFormat(email); err != nil {
return "", badRequestError(ErrorCodeValidationFailed, "Unable to validate email address: "+err.Error())
}
return strings.ToLower(email), nil

email = strings.ToLower(email)

if len(a.config.External.Email.AuthorizedAddresses) > 0 {
// allow labelled emails when authorization rules are in place
normalized := emailLabelPattern.ReplaceAllString(email, "@")

for _, authorizedAddress := range a.config.External.Email.AuthorizedAddresses {
if normalized == authorizedAddress {
return email, nil
}
}

return "", badRequestError(ErrorCodeEmailAddressNotAuthorized, "Email address %q cannot be used as it is not authorized", email)
}

return email, nil
}

func validateSentWithinFrequencyLimit(sentAt *time.Time, frequency time.Duration) error {
Expand Down
35 changes: 35 additions & 0 deletions internal/api/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,41 @@ func (ts *MailTestSuite) SetupTest() {
require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new user")
}

func (ts *MailTestSuite) TestValidateEmailAuthorizedAddresses() {
ts.Config.External.Email.AuthorizedAddresses = []string{"someone-a@example.com", "someone-b@example.com"}
defer func() {
ts.Config.External.Email.AuthorizedAddresses = nil
}()

positiveExamples := []string{
"someone-a@example.com",
"someone-b@example.com",
"someone-a+test-1@example.com",
"someone-b+test-2@example.com",
"someone-A@example.com",
"someone-B@example.com",
"someone-a@Example.com",
"someone-b@Example.com",
}

negativeExamples := []string{
"someone@example.com",
"s.omeone@example.com",
"someone-a+@example.com",
"someone+a@example.com",
}

for _, example := range positiveExamples {
_, err := ts.API.validateEmail(example)
require.NoError(ts.T(), err)
}

for _, example := range negativeExamples {
_, err := ts.API.validateEmail(example)
require.Error(ts.T(), err)
}
}

func (ts *MailTestSuite) TestGenerateLink() {
// create admin jwt
claims := &AccessTokenClaims{
Expand Down
2 changes: 1 addition & 1 deletion internal/api/otp.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) (bool, error)
aud := a.requestAud(ctx, r)
var err error
if params.Email != "" {
params.Email, err = validateEmail(params.Email)
params.Email, err = a.validateEmail(params.Email)
if err != nil {
return false, err
}
Expand Down
6 changes: 3 additions & 3 deletions internal/api/recover.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ type RecoverParams struct {
CodeChallengeMethod string `json:"code_challenge_method"`
}

func (p *RecoverParams) Validate() error {
func (p *RecoverParams) Validate(a *API) error {
if p.Email == "" {
return badRequestError(ErrorCodeValidationFailed, "Password recovery requires an email")
}
var err error
if p.Email, err = validateEmail(p.Email); err != nil {
if p.Email, err = a.validateEmail(p.Email); err != nil {
return err
}
if err := validatePKCEParams(p.CodeChallengeMethod, p.CodeChallenge); err != nil {
Expand All @@ -38,7 +38,7 @@ func (a *API) Recover(w http.ResponseWriter, r *http.Request) error {
}

flowType := getFlowFromChallenge(params.CodeChallenge)
if err := params.Validate(); err != nil {
if err := params.Validate(a); err != nil {
return err
}

Expand Down
10 changes: 5 additions & 5 deletions internal/api/resend.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"net/http"

"github.com/supabase/auth/internal/api/sms_provider"
"github.com/supabase/auth/internal/conf"
mail "github.com/supabase/auth/internal/mailer"
"github.com/supabase/auth/internal/models"
"github.com/supabase/auth/internal/storage"
Expand All @@ -17,7 +16,9 @@ type ResendConfirmationParams struct {
Phone string `json:"phone"`
}

func (p *ResendConfirmationParams) Validate(config *conf.GlobalConfiguration) error {
func (p *ResendConfirmationParams) Validate(a *API) error {
config := a.config

switch p.Type {
case mail.SignupVerification, mail.EmailChangeVerification, smsVerification, phoneChangeVerification:
break
Expand All @@ -40,7 +41,7 @@ func (p *ResendConfirmationParams) Validate(config *conf.GlobalConfiguration) er
if !config.External.Email.Enabled {
return badRequestError(ErrorCodeEmailProviderDisabled, "Email logins are disabled")
}
p.Email, err = validateEmail(p.Email)
p.Email, err = a.validateEmail(p.Email)
if err != nil {
return err
}
Expand All @@ -63,13 +64,12 @@ func (p *ResendConfirmationParams) Validate(config *conf.GlobalConfiguration) er
func (a *API) Resend(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
db := a.db.WithContext(ctx)
config := a.config
params := &ResendConfirmationParams{}
if err := retrieveRequestParams(r, params); err != nil {
return err
}

if err := params.Validate(config); err != nil {
if err := params.Validate(a); err != nil {
return err
}

Expand Down
2 changes: 1 addition & 1 deletion internal/api/signup.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ func (a *API) Signup(w http.ResponseWriter, r *http.Request) error {
if !config.External.Email.Enabled {
return badRequestError(ErrorCodeEmailProviderDisabled, "Email signups are disabled")
}
params.Email, err = validateEmail(params.Email)
params.Email, err = a.validateEmail(params.Email)
if err != nil {
return err
}
Expand Down
2 changes: 1 addition & 1 deletion internal/api/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func (a *API) validateUserUpdateParams(ctx context.Context, p *UserUpdateParams)

var err error
if p.Email != "" {
p.Email, err = validateEmail(p.Email)
p.Email, err = a.validateEmail(p.Email)
if err != nil {
return err
}
Expand Down
8 changes: 4 additions & 4 deletions internal/api/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ type VerifyParams struct {
RedirectTo string `json:"redirect_to"`
}

func (p *VerifyParams) Validate(r *http.Request) error {
func (p *VerifyParams) Validate(r *http.Request, a *API) error {
var err error
if p.Type == "" {
return badRequestError(ErrorCodeValidationFailed, "Verify requires a verification type")
Expand All @@ -69,7 +69,7 @@ func (p *VerifyParams) Validate(r *http.Request) error {
}
p.TokenHash = crypto.GenerateTokenHash(p.Phone, p.Token)
} else if isEmailOtpVerification(p) {
p.Email, err = validateEmail(p.Email)
p.Email, err = a.validateEmail(p.Email)
if err != nil {
return unprocessableEntityError(ErrorCodeValidationFailed, "Invalid email format").WithInternalError(err)
}
Expand All @@ -96,15 +96,15 @@ func (a *API) Verify(w http.ResponseWriter, r *http.Request) error {
params.Token = r.FormValue("token")
params.Type = r.FormValue("type")
params.RedirectTo = utilities.GetReferrer(r, a.config)
if err := params.Validate(r); err != nil {
if err := params.Validate(r, a); err != nil {
return err
}
return a.verifyGet(w, r, params)
case http.MethodPost:
if err := retrieveRequestParams(r, params); err != nil {
return err
}
if err := params.Validate(r); err != nil {
if err := params.Validate(r, a); err != nil {
return err
}
return a.verifyPost(w, r, params)
Expand Down
2 changes: 1 addition & 1 deletion internal/api/verify_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1248,7 +1248,7 @@ func (ts *VerifyTestSuite) TestVerifyValidateParams() {
for _, c := range cases {
ts.Run(c.desc, func() {
req := httptest.NewRequest(c.method, "http://localhost", nil)
err := c.params.Validate(req)
err := c.params.Validate(req, ts.API)
require.Equal(ts.T(), c.expected, err)
})
}
Expand Down
2 changes: 2 additions & 0 deletions internal/conf/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ type AnonymousProviderConfiguration struct {
type EmailProviderConfiguration struct {
Enabled bool `json:"enabled" default:"true"`

AuthorizedAddresses []string `json:"authorized_addresses" split_words:"true"`

MagicLinkEnabled bool `json:"magic_link_enabled" default:"true" split_words:"true"`
}

Expand Down

0 comments on commit f3a28d1

Please sign in to comment.