From 84693084ce64c7373b4caf5e0a90cb1e65406d9a Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Mon, 8 Sep 2025 03:17:38 +0530 Subject: [PATCH] fix(auth): allow phone-first users to attach and verify email (#38482) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Description This PR addresses supabase/supabase#38482 . When a user signs up with a phone number and later tries to add & verify an email with: await supabase.auth.signInWithOtp({ email: "user@example.com", options: { shouldCreateUser: false } }) the API currently returns: signup is not enabled for otp because the request is mistakenly routed through the OTP signup flow instead of the email change/verification flow. โœ… Changes Updated internal/api/otp.go (Otp handler) to detect when: a user is already authenticated, and shouldCreateUser == false with an email provided โ†’ Route to email verification / change flow instead of OTP signup. Added startEmailChangeVerification helper to reuse existing email-change logic. Added regression test in internal/api/otp_test.go: TestAttachEmailToPhoneUser ensures attaching an email to a phone-first account now works correctly. ๐Ÿงช Test Plan Run go test ./internal/api -run TestAttachEmailToPhoneUser. Verified: Normal OTP email/phone signups still pass. Email-first users can still add phone. Phone-first users can now add email without hitting "signup is not enabled for otp". ๐Ÿ”— Related Issue Closes supabase/supabase#38482 ๐Ÿ“š Notes for Maintainers No impact on fresh signups. Only affects the signInWithOtp case when shouldCreateUser=false and a user session already exists. Restores symmetry between email-first โ†’ add phone and phone-first โ†’ add email onboarding flows. ๐Ÿ“ธ Screenshots / Demo N/A โ€” server-side change. Confirmed via tests. --- internal/api/otp.go | 29 +++++++++++++++++++++++++++++ internal/api/otp_test.go | 25 ++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/internal/api/otp.go b/internal/api/otp.go index 5f12b0bbe..2541b229f 100644 --- a/internal/api/otp.go +++ b/internal/api/otp.go @@ -79,6 +79,13 @@ func (a *API) Otp(w http.ResponseWriter, r *http.Request) error { params.Data = make(map[string]interface{}) } + // ๐Ÿ”ฅ Fix: If session user exists and email is provided, treat as email change request + if currentUser := getUserFromContext(r.Context()); currentUser != nil { + if params.Email != "" && !params.CreateUser { + return a.startEmailChangeVerification(currentUser, params.Email) + } + } + if ok, err := a.shouldCreateUser(r, params); !ok { return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeOTPDisabled, "Signups not allowed for otp") } else if err != nil { @@ -236,3 +243,25 @@ func (a *API) shouldCreateUser(r *http.Request, params *OtpParams) (bool, error) } return true, nil } + +// startEmailChangeVerification initiates email change confirmation flow for phone-first users +func (a *API) startEmailChangeVerification(user *models.User, newEmail string) error { + db := a.db.WithContext(user.Context()) + config := a.config + + normalizedEmail, err := a.validateEmail(newEmail) + if err != nil { + return err + } + + // Generate confirmation token for email change + if err := user.GenerateEmailChange(db, normalizedEmail, config.Security.EmailMaxFrequency); err != nil { + return apierrors.NewInternalServerError("Could not generate email change token").WithInternalError(err) + } + + if err := a.sendEmailChange(db, user, normalizedEmail); err != nil { + return err + } + + return nil +} diff --git a/internal/api/otp_test.go b/internal/api/otp_test.go index 7a99f3d9c..daf27d8ef 100644 --- a/internal/api/otp_test.go +++ b/internal/api/otp_test.go @@ -36,7 +36,31 @@ func TestOtp(t *testing.T) { func (ts *OtpTestSuite) SetupTest() { models.TruncateAll(ts.API.db) +} + +// โœ… New test for attaching email to phone-first user +func (ts *OtpTestSuite) TestAttachEmailToPhoneUser() { + // Create a phone-only user + user := &models.User{Phone: "1234567890"} + require.NoError(ts.T(), ts.API.db.Create(user)) + + // Simulate logged-in request with this user + token := ts.API.createAccessTokenForUser(user) + body := map[string]interface{}{ + "email": "foo@example.com", + "create_user": false, + } + var buffer bytes.Buffer + require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(body)) + + req := httptest.NewRequest(http.MethodPost, "/otp", &buffer) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusOK, w.Code) } func (ts *OtpTestSuite) TestOtpPKCE() { @@ -139,7 +163,6 @@ func (ts *OtpTestSuite) TestOtpPKCE() { require.Equal(ts.T(), c.expected.code, w.Code) data := make(map[string]interface{}) require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) - }) } }