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
26 changes: 26 additions & 0 deletions internal/api/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ import (
"net/http"
"time"

"github.com/fatih/structs"
"github.com/gofrs/uuid"
"github.com/sirupsen/logrus"
"github.com/supabase/auth/internal/api/apierrors"
"github.com/supabase/auth/internal/api/provider"
"github.com/supabase/auth/internal/api/sms_provider"
"github.com/supabase/auth/internal/mailer"
"github.com/supabase/auth/internal/models"
Expand Down Expand Up @@ -216,6 +218,30 @@ func (a *API) UserUpdate(w http.ResponseWriter, r *http.Request) error {
return terr
}

// If the user has an email but no email identity yet (typically a
// user whose only identity is from an external OAuth provider),
// setting a password establishes email/password as a sign-in
// method. Create the email identity so it shows up in
// auth.identities and the email provider is reflected in
// app_metadata.providers. See #2085.
if user.GetEmail() != "" {
if _, terr := models.FindIdentityByIdAndProvider(tx, user.ID.String(), "email"); terr != nil {
if !models.IsNotFoundError(terr) {
return terr
}
if _, terr := a.createNewIdentity(tx, user, "email", structs.Map(provider.Claims{
Subject: user.ID.String(),
Email: user.GetEmail(),
EmailVerified: user.IsConfirmed(),
})); terr != nil {
return terr
}
if terr := user.UpdateAppMetaDataProviders(tx); terr != nil {
return terr
}
}
}

// send a Password Changed email notification to the user to inform them that their password has been changed
if config.Mailer.Notifications.PasswordChangedEnabled && user.GetEmail() != "" {
if err := a.sendPasswordChangedNotification(r, tx, user); err != nil {
Expand Down
71 changes: 71 additions & 0 deletions internal/api/user_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -764,3 +764,74 @@ func (ts *UserTestSuite) TestUserUpdatePasswordSendsNotificationEmail() {
})
}
}

// TestUserUpdatePasswordCreatesEmailIdentity covers issue #2085: when a user
// who only has an external (e.g. OAuth) identity sets a password via
// PUT /user, an `email` identity row should be created so the email/password
// sign-in method becomes addressable.
func (ts *UserTestSuite) TestUserUpdatePasswordCreatesEmailIdentity() {
// Create a user with an email but only an external (OAuth-style) identity.
u, err := models.NewUser("", "[email protected]", "", ts.Config.JWT.Aud, nil)
require.NoError(ts.T(), err)
require.NoError(ts.T(), ts.API.db.Create(u))

now := time.Now()
u.EmailConfirmedAt = &now
require.NoError(ts.T(), ts.API.db.UpdateOnly(u, "email_confirmed_at"))

externalIdentity, err := models.NewIdentity(u, "google", map[string]interface{}{
"sub": "ext-provider-sub-123",
"email": u.GetEmail(),
"email_verified": true,
})
require.NoError(ts.T(), err)
require.NoError(ts.T(), ts.API.db.Create(externalIdentity))

// Sanity check: no email identity yet.
_, err = models.FindIdentityByIdAndProvider(ts.API.db, u.ID.String(), "email")
require.True(ts.T(), models.IsNotFoundError(err), "expected email identity to be absent before password set")

token := ts.generateAccessTokenAndSession(u)
var buffer bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer).Encode(map[string]interface{}{
"password": "newpassword12345",
}))

req := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))

w := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w, req)
require.Equal(ts.T(), http.StatusOK, w.Code)

emailIdentity, err := models.FindIdentityByIdAndProvider(ts.API.db, u.ID.String(), "email")
require.NoError(ts.T(), err, "email identity should be created after PUT /user with password")
require.Equal(ts.T(), u.GetEmail(), emailIdentity.Email.String())
require.Equal(ts.T(), u.ID.String(), emailIdentity.ProviderID)

// Calling PUT /user with a password again should not duplicate the
// email identity. Supply the current password since the user now has
// one set.
var buffer2 bytes.Buffer
require.NoError(ts.T(), json.NewEncoder(&buffer2).Encode(map[string]interface{}{
"password": "anothernewpassword12345",
"current_password": "newpassword12345",
}))
req2 := httptest.NewRequest(http.MethodPut, "http://localhost/user", &buffer2)
req2.Header.Set("Content-Type", "application/json")
req2.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
w2 := httptest.NewRecorder()
ts.API.handler.ServeHTTP(w2, req2)
require.Equal(ts.T(), http.StatusOK, w2.Code)

identities, err := models.FindIdentitiesByUserID(ts.API.db, u.ID)
require.NoError(ts.T(), err)
emailCount := 0
for _, id := range identities {
if id.Provider == "email" {
emailCount++
}
}
require.Equal(ts.T(), 1, emailCount, "expected exactly one email identity, got %d", emailCount)
}