Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,14 @@ If you do not require email confirmation, you may set this to `true`. Defaults t

Controls the duration an email link or OTP is valid for.

`MAILER_TEST_OTP` - `string`

Comma-separated `<email>:<otp>` pairs, for example `qa@example.com:123456`. Requests for a listed email use the fixed OTP and no email is sent, which is useful for app store review accounts and end-to-end tests. Emails are matched case-insensitively. Only the magic link / email OTP sign-in and signup confirmation flows honor test OTPs; password recovery, invites, reauthentication and email change always send a real email. Do not set this in production.

`MAILER_TEST_OTP_VALID_UNTIL` - `string`

ISO 8601 datetime (for example `2026-12-31T23:59:59Z`) after which `MAILER_TEST_OTP` is ignored. Defaults to no expiry.

`MAILER_URLPATHS_INVITE` - `string`

URL path to use in the user invite email. Defaults to `/verify`.
Expand Down
4 changes: 4 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ GOTRUE_HOOK_CUSTOM_SMS_PROVIDER_SECRET=""
# Test OTP Config
GOTRUE_SMS_TEST_OTP="<phone-1>:<otp-1>, <phone-2>:<otp-2>..."
GOTRUE_SMS_TEST_OTP_VALID_UNTIL="<ISO date time>" # (e.g. 2023-09-29T08:14:06Z)
# Emails are matched case-insensitively. Only magic link / email OTP sign-in and
# signup confirmation honor test OTPs; no email is sent for these addresses.
GOTRUE_MAILER_TEST_OTP="<email-1>:<otp-1>, <email-2>:<otp-2>..."
GOTRUE_MAILER_TEST_OTP_VALID_UNTIL="<ISO date time>" # (e.g. 2023-09-29T08:14:06Z)

GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED="false"
GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED="false"
Expand Down
1 change: 1 addition & 0 deletions hack/test.env
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ GOTRUE_SAML_ENABLED="true"
GOTRUE_SAML_PRIVATE_KEY="MIIEowIBAAKCAQEAszrVveMQcSsa0Y+zN1ZFb19cRS0jn4UgIHTprW2tVBmO2PABzjY3XFCfx6vPirMAPWBYpsKmXrvm1tr0A6DZYmA8YmJd937VUQ67fa6DMyppBYTjNgGEkEhmKuszvF3MARsIKCGtZqUrmS7UG4404wYxVppnr2EYm3RGtHlkYsXu20MBqSDXP47bQP+PkJqC3BuNGk3xt5UHl2FSFpTHelkI6lBynw16B+lUT1F96SERNDaMqi/TRsZdGe5mB/29ngC/QBMpEbRBLNRir5iUevKS7Pn4aph9Qjaxx/97siktK210FJT23KjHpgcUfjoQ6BgPBTLtEeQdRyDuc/CgfwIDAQABAoIBAGYDWOEpupQPSsZ4mjMnAYJwrp4ZISuMpEqVAORbhspVeb70bLKonT4IDcmiexCg7cQBcLQKGpPVM4CbQ0RFazXZPMVq470ZDeWDEyhoCfk3bGtdxc1Zc9CDxNMs6FeQs6r1beEZug6weG5J/yRn/qYxQife3qEuDMl+lzfl2EN3HYVOSnBmdt50dxRuX26iW3nqqbMRqYn9OHuJ1LvRRfYeyVKqgC5vgt/6Tf7DAJwGe0dD7q08byHV8DBZ0pnMVU0bYpf1GTgMibgjnLjK//EVWafFHtN+RXcjzGmyJrk3+7ZyPUpzpDjO21kpzUQLrpEkkBRnmg6bwHnSrBr8avECgYEA3pq1PTCAOuLQoIm1CWR9/dhkbJQiKTJevlWV8slXQLR50P0WvI2RdFuSxlWmA4xZej8s4e7iD3MYye6SBsQHygOVGc4efvvEZV8/XTlDdyj7iLVGhnEmu2r7AFKzy8cOvXx0QcLg+zNd7vxZv/8D3Qj9Jje2LjLHKM5n/dZ3RzUCgYEAzh5Lo2anc4WN8faLGt7rPkGQF+7/18ImQE11joHWa3LzAEy7FbeOGpE/vhOv5umq5M/KlWFIRahMEQv4RusieHWI19ZLIP+JwQFxWxS+cPp3xOiGcquSAZnlyVSxZ//dlVgaZq2o2MfrxECcovRlaknl2csyf+HjFFwKlNxHm2MCgYAr//R3BdEy0oZeVRndo2lr9YvUEmu2LOihQpWDCd0fQw0ZDA2kc28eysL2RROte95r1XTvq6IvX5a0w11FzRWlDpQ4J4/LlcQ6LVt+98SoFwew+/PWuyLmxLycUbyMOOpm9eSc4wJJZNvaUzMCSkvfMtmm5jgyZYMMQ9A2Ul/9SQKBgB9mfh9mhBwVPIqgBJETZMMXOdxrjI5SBYHGSyJqpT+5Q0vIZLfqPrvNZOiQFzwWXPJ+tV4Mc/YorW3rZOdo6tdvEGnRO6DLTTEaByrY/io3/gcBZXoSqSuVRmxleqFdWWRnB56c1hwwWLqNHU+1671FhL6pNghFYVK4suP6qu4BAoGBAMk+VipXcIlD67mfGrET/xDqiWWBZtgTzTMjTpODhDY1GZck1eb4CQMP5j5V3gFJ4cSgWDJvnWg8rcz0unz/q4aeMGl1rah5WNDWj1QKWMS6vJhMHM/rqN1WHWR0ZnV83svYgtg0zDnQKlLujqW4JmGXLMU7ur6a+e6lpa1fvLsP"
GOTRUE_MAX_VERIFIED_FACTORS=10
GOTRUE_SMS_TEST_OTP_VALID_UNTIL=""
GOTRUE_MAILER_TEST_OTP_VALID_UNTIL=""
GOTRUE_INDEX_WORKER_MAX_USERS_THRESHOLD=1000000
GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPT=true
GOTRUE_SECURITY_DB_ENCRYPTION_ENCRYPTION_KEY_ID=abc
Expand Down
276 changes: 276 additions & 0 deletions internal/api/email_test_otp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
package api

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/supabase/auth/internal/api/apierrors"
"github.com/supabase/auth/internal/conf"
"github.com/supabase/auth/internal/crypto"
"github.com/supabase/auth/internal/mailer/mockclient"
"github.com/supabase/auth/internal/models"
)

const (
testOTPEmail = "test@example.com"
testOTPCode = "123456"
)

type EmailTestOTPTestSuite struct {
suite.Suite
API *API
Config *conf.GlobalConfiguration
Mailer *mockclient.MockMailer
}

func TestEmailTestOTP(t *testing.T) {
mockMailer := &mockclient.MockMailer{}
api, config, err := setupAPIForTest(WithMailer(mockMailer))
require.NoError(t, err)

ts := &EmailTestOTPTestSuite{
API: api,
Config: config,
Mailer: mockMailer,
}
defer api.db.Close()

suite.Run(t, ts)
}

func (ts *EmailTestOTPTestSuite) SetupTest() {
models.TruncateAll(ts.API.db)
ts.Mailer.Reset()

// allow repeated sends to the same user within a test
ts.Config.SMTP.MaxFrequency = 0
ts.Config.Mailer.Autoconfirm = false
ts.Config.Mailer.TestOTP = nil
ts.Config.Mailer.TestOTPValidUntil = conf.Time{}
ts.Config.External.Email.AuthorizedAddresses = nil

u, err := models.NewUser("", testOTPEmail, "password", ts.Config.JWT.Aud, nil)
require.NoError(ts.T(), err, "Error creating test user model")
require.NoError(ts.T(), ts.API.db.Create(u), "Error saving new test user")
}

func (ts *EmailTestOTPTestSuite) TearDownTest() {
ts.Config.Mailer.TestOTP = nil
ts.Config.Mailer.TestOTPValidUntil = conf.Time{}
ts.Config.External.Email.AuthorizedAddresses = nil
}

func (ts *EmailTestOTPTestSuite) findUser() *models.User {
u, err := models.FindUserByEmailAndAudience(ts.API.db, testOTPEmail, ts.Config.JWT.Aud)
require.NoError(ts.T(), err)
return u
}

// doTestSendEmailOtp mirrors doTestSendPhoneConfirmation in phone_test.go: it
// calls the send functions directly and checks the persisted token state and
// whether a mail was handed to the mailer.
func (ts *EmailTestOTPTestSuite) doTestSendEmailOtp(useTestOTP bool, flowType models.FlowType) {
if useTestOTP {
ts.Config.Mailer.TestOTP = map[string]string{testOTPEmail: testOTPCode}
} else {
ts.Config.Mailer.TestOTP = nil
}

req, err := http.NewRequest(http.MethodPost, "http://localhost:9998/otp", nil)
require.NoError(ts.T(), err)

expectedHash := addFlowPrefixToToken(crypto.GenerateTokenHash(testOTPEmail, testOTPCode), flowType)

ts.Run("magic link", func() {
ts.Mailer.Reset()
u := ts.findUser()

require.NoError(ts.T(), ts.API.sendMagicLink(req, ts.API.db, u, flowType))

u = ts.findUser()
require.NotEmpty(ts.T(), u.RecoveryToken)
require.NotNil(ts.T(), u.RecoverySentAt)

if useTestOTP {
require.Empty(ts.T(), ts.Mailer.MagicLinkMailCalls)
require.Equal(ts.T(), expectedHash, u.RecoveryToken)
} else {
require.Len(ts.T(), ts.Mailer.MagicLinkMailCalls, 1)
require.NotEqual(ts.T(), expectedHash, u.RecoveryToken)
}

_, err := models.FindOneTimeToken(ts.API.db, u.RecoveryToken, models.RecoveryToken)
require.NoError(ts.T(), err)
})

ts.Run("signup confirmation", func() {
ts.Mailer.Reset()
u := ts.findUser()

require.NoError(ts.T(), ts.API.sendConfirmation(req, ts.API.db, u, flowType))

u = ts.findUser()
require.NotEmpty(ts.T(), u.ConfirmationToken)
require.NotNil(ts.T(), u.ConfirmationSentAt)

if useTestOTP {
require.Empty(ts.T(), ts.Mailer.ConfirmationMailCalls)
require.Equal(ts.T(), expectedHash, u.ConfirmationToken)
} else {
require.Len(ts.T(), ts.Mailer.ConfirmationMailCalls, 1)
require.NotEqual(ts.T(), expectedHash, u.ConfirmationToken)
}

_, err := models.FindOneTimeToken(ts.API.db, u.ConfirmationToken, models.ConfirmationToken)
require.NoError(ts.T(), err)
})
}

func (ts *EmailTestOTPTestSuite) TestSendEmailOtp() {
ts.doTestSendEmailOtp(false, models.ImplicitFlow)
}

func (ts *EmailTestOTPTestSuite) TestSendEmailOtpWithTestOTP() {
ts.doTestSendEmailOtp(true, models.ImplicitFlow)
}

func (ts *EmailTestOTPTestSuite) TestSendEmailOtpWithTestOTPPKCE() {
ts.doTestSendEmailOtp(true, models.PKCEFlow)

u := ts.findUser()
require.True(ts.T(), strings.HasPrefix(u.RecoveryToken, "pkce_"))
require.True(ts.T(), strings.HasPrefix(u.ConfirmationToken, "pkce_"))
}

func (ts *EmailTestOTPTestSuite) TestTestOTPEmailIsCaseInsensitive() {
ts.Config.Mailer.TestOTP = map[string]string{testOTPEmail: testOTPCode}

otp, isTestOTP := ts.API.generateEmailOtp(" Test@Example.COM ")
require.True(ts.T(), isTestOTP)
require.Equal(ts.T(), testOTPCode, otp)

otp, isTestOTP = ts.API.generateEmailOtp("other@example.com")
require.False(ts.T(), isTestOTP)
require.Len(ts.T(), otp, ts.Config.Mailer.OtpLength)
}

func (ts *EmailTestOTPTestSuite) TestTestOTPStillRequiresAuthorizedAddress() {
ts.Config.Mailer.TestOTP = map[string]string{testOTPEmail: testOTPCode}
ts.Config.External.Email.AuthorizedAddresses = []string{"someone-else@example.com"}

req, err := http.NewRequest(http.MethodPost, "http://localhost:9998/otp", nil)
require.NoError(ts.T(), err)

u := ts.findUser()
err = ts.API.sendMagicLink(req, ts.API.db, u, models.ImplicitFlow)
require.Error(ts.T(), err)

herr, ok := err.(*apierrors.HTTPError)
require.True(ts.T(), ok)
require.Equal(ts.T(), string(apierrors.ErrorCodeEmailAddressNotAuthorized), herr.ErrorCode)
require.Empty(ts.T(), ts.Mailer.MagicLinkMailCalls)
}

func (ts *EmailTestOTPTestSuite) post(path string, body map[string]interface{}) *httptest.ResponseRecorder {
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(body))

req := httptest.NewRequest(http.MethodPost, path, &buffer)
req.Header.Set("Content-Type", "application/json")

w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
return w
}

func (ts *EmailTestOTPTestSuite) decode(w *httptest.ResponseRecorder) map[string]interface{} {
data := make(map[string]interface{})
require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data))
return data
}

// TestOtpAndVerifyWithTestOTP exercises the full HTTP flow from issue #901: a
// brand-new email requests an OTP, nothing is sent, and the configured code
// verifies into a session. It then repeats the flow for the now-confirmed user
// which goes through the magic link sender instead of signup confirmation.
func (ts *EmailTestOTPTestSuite) TestOtpAndVerifyWithTestOTP() {
models.TruncateAll(ts.API.db)
ts.Mailer.Reset()

newEmail := "new-user@example.com"
ts.Config.Mailer.TestOTP = map[string]string{newEmail: testOTPCode}

// 1. brand-new user: MagicLink routes through Signup -> sendConfirmation
w := ts.post("/otp", map[string]interface{}{
"email": newEmail,
"create_user": true,
})
require.Equal(ts.T(), http.StatusOK, w.Code)
require.Empty(ts.T(), ts.Mailer.ConfirmationMailCalls)
require.Empty(ts.T(), ts.Mailer.MagicLinkMailCalls)

u, err := models.FindUserByEmailAndAudience(ts.API.db, newEmail, ts.Config.JWT.Aud)
require.NoError(ts.T(), err)
require.False(ts.T(), u.IsConfirmed())
require.Equal(ts.T(), crypto.GenerateTokenHash(newEmail, testOTPCode), u.ConfirmationToken)

// 2. wrong code is rejected
w = ts.post("/verify", map[string]interface{}{
"type": "email",
"email": newEmail,
"token": "000000",
})
require.Equal(ts.T(), http.StatusForbidden, w.Code)
require.Equal(ts.T(), string(apierrors.ErrorCodeOTPExpired), ts.decode(w)["error_code"])

// 3. the configured code verifies and returns a session
w = ts.post("/verify", map[string]interface{}{
"type": "email",
"email": newEmail,
"token": testOTPCode,
})
require.Equal(ts.T(), http.StatusOK, w.Code)
require.NotEmpty(ts.T(), ts.decode(w)["access_token"])

u, err = models.FindUserByEmailAndAudience(ts.API.db, newEmail, ts.Config.JWT.Aud)
require.NoError(ts.T(), err)
require.True(ts.T(), u.IsConfirmed())

// 4. confirmed user: MagicLink goes through sendMagicLink
w = ts.post("/otp", map[string]interface{}{
"email": newEmail,
})
require.Equal(ts.T(), http.StatusOK, w.Code)
require.Empty(ts.T(), ts.Mailer.MagicLinkMailCalls)

w = ts.post("/verify", map[string]interface{}{
"type": "magiclink",
"email": newEmail,
"token": testOTPCode,
})
require.Equal(ts.T(), http.StatusOK, w.Code)
require.NotEmpty(ts.T(), ts.decode(w)["access_token"])

// 5. an expired test OTP configuration falls back to a real OTP and sends mail
ts.Config.Mailer.TestOTPValidUntil = conf.Time{Time: time.Now().Add(-time.Hour)}

w = ts.post("/otp", map[string]interface{}{
"email": newEmail,
})
require.Equal(ts.T(), http.StatusOK, w.Code)
require.Len(ts.T(), ts.Mailer.MagicLinkMailCalls, 1)

w = ts.post("/verify", map[string]interface{}{
"type": "magiclink",
"email": newEmail,
"token": testOTPCode,
})
require.Equal(ts.T(), http.StatusForbidden, w.Code)
}
27 changes: 23 additions & 4 deletions internal/api/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,13 +321,12 @@ func (a *API) sendConfirmation(r *http.Request, tx *storage.Connection, u *model

config := a.config
maxFrequency := config.SMTP.MaxFrequency
otpLength := config.Mailer.OtpLength

if err = validateSentWithinFrequencyLimit(u.ConfirmationSentAt, maxFrequency); err != nil {
return err
}
oldToken := u.ConfirmationToken
otp := crypto.GenerateOtp(otpLength)
otp, isTestOTP := a.generateEmailOtp(u.GetEmail())

token := crypto.GenerateTokenHash(u.GetEmail(), otp)
u.ConfirmationToken = addFlowPrefixToToken(token, flowType)
Expand All @@ -336,6 +335,7 @@ func (a *API) sendConfirmation(r *http.Request, tx *storage.Connection, u *model
emailActionType: mail.SignupVerification,
otp: otp,
tokenHashWithPrefix: u.ConfirmationToken,
isTestOTP: isTestOTP,
}); err != nil {
u.ConfirmationToken = oldToken
if errors.Is(err, EmailRateLimitExceeded) {
Expand Down Expand Up @@ -480,7 +480,6 @@ func (a *API) sendReauthenticationOtp(r *http.Request, tx *storage.Connection, u
func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.User, flowType models.FlowType) error {
var err error
config := a.config
otpLength := config.Mailer.OtpLength

// since Magic Link is just a recovery with a different template and behaviour
// around new users we will reuse the recovery db timer to prevent potential abuse
Expand All @@ -489,7 +488,7 @@ func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.U
}

oldToken := u.RecoveryToken
otp := crypto.GenerateOtp(otpLength)
otp, isTestOTP := a.generateEmailOtp(u.GetEmail())

token := crypto.GenerateTokenHash(u.GetEmail(), otp)
u.RecoveryToken = addFlowPrefixToToken(token, flowType)
Expand All @@ -499,6 +498,7 @@ func (a *API) sendMagicLink(r *http.Request, tx *storage.Connection, u *models.U
emailActionType: mail.MagicLinkVerification,
otp: otp,
tokenHashWithPrefix: u.RecoveryToken,
isTestOTP: isTestOTP,
}); err != nil {
u.RecoveryToken = oldToken
if errors.Is(err, EmailRateLimitExceeded) {
Expand Down Expand Up @@ -728,6 +728,16 @@ func validateSentWithinFrequencyLimit(sentAt *time.Time, frequency time.Duration
return nil
}

// generateEmailOtp returns the OTP to use for the given email address and
// whether it is a configured test OTP. Test OTPs are stored and verified
// like regular OTPs, but no email is sent for them.
func (a *API) generateEmailOtp(email string) (otp string, isTestOTP bool) {
if testOTP, ok := a.config.Mailer.GetTestOTP(email, time.Now()); ok {
return testOTP, true
}
return crypto.GenerateOtp(a.config.Mailer.OtpLength), false
}

var emailLabelPattern = regexp.MustCompile("[+][^@]+@")

func (a *API) checkEmailAddressAuthorization(email string) bool {
Expand Down Expand Up @@ -757,6 +767,8 @@ type sendEmailParams struct {
provider string
factorType string
recipientEmail string
// isTestOTP is set when otp came from Mailer.TestOTP; the email is not sent.
isTestOTP bool
}

func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User, params sendEmailParams) error {
Expand Down Expand Up @@ -790,6 +802,13 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
}
}

// test OTPs are never delivered, so skip rate limiting, hooks and the
// mailer. The caller still persists the token and sent_at fields, which
// keeps verification identical to a regular OTP.
if params.isTestOTP {
return nil
}

// if the number of events is set to zero, we immediately apply rate limits.
if config.RateLimitEmailSent.Events == 0 {
emailRateLimitCounter.Add(
Expand Down
Loading