package api import ( "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" "github.com/gofrs/uuid" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/supabase/auth/internal/api/provider" "github.com/supabase/auth/internal/conf" "github.com/supabase/auth/internal/models" ) type IdentityTestSuite struct { suite.Suite API *API Config *conf.GlobalConfiguration } func TestIdentity(t *testing.T) { api, config, err := setupAPIForTest() require.NoError(t, err) ts := &IdentityTestSuite{ API: api, Config: config, } defer api.db.Close() suite.Run(t, ts) } func (ts *IdentityTestSuite) SetupTest() { models.TruncateAll(ts.API.db) // Create user u, err := models.NewUser("", "one@example.com", "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") require.NoError(ts.T(), u.Confirm(ts.API.db)) // Create identity i, err := models.NewIdentity(u, "email", map[string]interface{}{ "sub": u.ID.String(), "email": u.GetEmail(), }) require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(i)) // Create user with 2 identities u, err = models.NewUser("123456789", "two@example.com", "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") require.NoError(ts.T(), u.Confirm(ts.API.db)) require.NoError(ts.T(), u.ConfirmPhone(ts.API.db)) i, err = models.NewIdentity(u, "email", map[string]interface{}{ "sub": u.ID.String(), "email": u.GetEmail(), }) require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(i)) i2, err := models.NewIdentity(u, "phone", map[string]interface{}{ "sub": u.ID.String(), "phone": u.GetPhone(), }) require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(i2)) } func (ts *IdentityTestSuite) TestLinkIdentityToUser() { u, err := models.FindUserByEmailAndAudience(ts.API.db, "one@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) ctx := withTargetUser(context.Background(), u) // link a valid identity testValidUserData := &provider.UserProvidedData{ Metadata: &provider.Claims{ Subject: "test_subject", }, } // request is just used as a placeholder in the function r := httptest.NewRequest(http.MethodGet, "/identities", nil) u, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testValidUserData, "test") require.NoError(ts.T(), err) // load associated identities for the user ts.API.db.Load(u, "Identities") require.Len(ts.T(), u.Identities, 2) require.Equal(ts.T(), u.AppMetaData["provider"], "email") require.Equal(ts.T(), u.AppMetaData["providers"], []string{"email", "test"}) // link an already existing identity testExistingUserData := &provider.UserProvidedData{ Metadata: &provider.Claims{ Subject: u.ID.String(), }, } u, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testExistingUserData, "email") require.ErrorIs(ts.T(), err, unprocessableEntityError(ErrorCodeIdentityAlreadyExists, "Identity is already linked")) require.Nil(ts.T(), u) } func (ts *IdentityTestSuite) TestUnlinkIdentityError() { ts.Config.Security.ManualLinkingEnabled = true userWithOneIdentity, err := models.FindUserByEmailAndAudience(ts.API.db, "one@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) userWithTwoIdentities, err := models.FindUserByEmailAndAudience(ts.API.db, "two@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) cases := []struct { desc string user *models.User identityId uuid.UUID expectedError *HTTPError }{ { desc: "User must have at least 1 identity after unlinking", user: userWithOneIdentity, identityId: userWithOneIdentity.Identities[0].ID, expectedError: unprocessableEntityError(ErrorCodeSingleIdentityNotDeletable, "User must have at least 1 identity after unlinking"), }, { desc: "Identity doesn't exist", user: userWithTwoIdentities, identityId: uuid.Must(uuid.NewV4()), expectedError: unprocessableEntityError(ErrorCodeIdentityNotFound, "Identity doesn't exist"), }, } for _, c := range cases { ts.Run(c.desc, func() { token := ts.generateAccessTokenAndSession(c.user) req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/user/identities/%s", c.identityId), nil) require.NoError(ts.T(), err) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) require.Equal(ts.T(), c.expectedError.HTTPStatus, w.Code) var data HTTPError require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&data)) require.Equal(ts.T(), c.expectedError.Message, data.Message) }) } } func (ts *IdentityTestSuite) TestUnlinkIdentity() { ts.Config.Security.ManualLinkingEnabled = true // we want to test 2 cases here: unlinking a phone identity and email identity from a user cases := []struct { desc string // the provider to be unlinked provider string // the remaining provider that should be linked to the user providerRemaining string }{ { desc: "Unlink phone identity successfully", provider: "phone", providerRemaining: "email", }, { desc: "Unlink email identity successfully", provider: "email", providerRemaining: "phone", }, } for _, c := range cases { ts.Run(c.desc, func() { // teardown and reset the state of the db to prevent running into errors ts.SetupTest() u, err := models.FindUserByEmailAndAudience(ts.API.db, "two@example.com", ts.Config.JWT.Aud) require.NoError(ts.T(), err) identity, err := models.FindIdentityByIdAndProvider(ts.API.db, u.ID.String(), c.provider) require.NoError(ts.T(), err) token := ts.generateAccessTokenAndSession(u) req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/user/identities/%s", identity.ID), nil) require.NoError(ts.T(), err) 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) // sanity checks u, err = models.FindUserByID(ts.API.db, u.ID) require.NoError(ts.T(), err) require.Len(ts.T(), u.Identities, 1) require.Equal(ts.T(), u.Identities[0].Provider, c.providerRemaining) // conditional checks depending on the provider that was unlinked switch c.provider { case "phone": require.Equal(ts.T(), "", u.GetPhone()) require.Nil(ts.T(), u.PhoneConfirmedAt) case "email": require.Equal(ts.T(), "", u.GetEmail()) require.Nil(ts.T(), u.EmailConfirmedAt) } // user still has a phone / email identity linked so it should not be unconfirmed require.NotNil(ts.T(), u.ConfirmedAt) }) } } func (ts *IdentityTestSuite) generateAccessTokenAndSession(u *models.User) string { s, err := models.NewSession(u.ID, nil) require.NoError(ts.T(), err) require.NoError(ts.T(), ts.API.db.Create(s)) req := httptest.NewRequest(http.MethodPost, "/token?grant_type=password", nil) token, _, err := ts.API.generateAccessToken(req, ts.API.db, u, &s.ID, models.PasswordGrant) require.NoError(ts.T(), err) return token }