Skip to content

Commit 7d46936

Browse files
authored
feat: identity linked/unlinked notifications (#2185)
1 parent 21f3070 commit 7d46936

File tree

11 files changed

+313
-1
lines changed

11 files changed

+313
-1
lines changed

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -740,6 +740,47 @@ Default Content (if template is unavailable):
740740

741741
Whether to send a notification email when a user's phone number is changed. Defaults to `false`.
742742

743+
`GOTRUE_MAILER_TEMPLATES_IDENTITY_LINKED_NOTIFICATION` - `string`
744+
745+
URL path to an email template to use when notifying a user that a new identity has been linked to their account. (e.g. `https://www.example.com/path-to-email-template.html`)
746+
`Email` and `Provider` variables are available.
747+
748+
Default Content (if template is unavailable):
749+
750+
```html
751+
<h2>A new identity has been linked</h2>
752+
753+
<p>
754+
A new identity ({{ .Provider }}) has been linked to your account {{ .Email }}.
755+
</p>
756+
<p>If you did not make this change, please contact support immediately.</p>
757+
```
758+
759+
`GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_LINKED_ENABLED` - `bool`
760+
761+
Whether to send a notification email when a new identity is linked to a user's account. Defaults to `false`.
762+
763+
`GOTRUE_MAILER_TEMPLATES_IDENTITY_UNLINKED_NOTIFICATION` - `string`
764+
765+
URL path to an email template to use when notifying a user that an identity has been unlinked from their account. (e.g. `https://www.example.com/path-to-email-template.html`)
766+
`Email` and `Provider` variables are available.
767+
768+
Default Content (if template is unavailable):
769+
770+
```html
771+
<h2>An identity has been unlinked</h2>
772+
773+
<p>
774+
An identity ({{ .Provider }}) has been unlinked from your account {{ .Email
775+
}}.
776+
</p>
777+
<p>If you did not make this change, please contact support immediately.</p>
778+
```
779+
780+
`GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_UNLINKED_ENABLED` - `bool`
781+
782+
Whether to send a notification email when an identity is unlinked from a user's account. Defaults to `false`.
783+
743784
`GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION` - `string`
744785

745786
URL path to an email template to use when notifying a user that they have enrolled in a new MFA factor. (e.g. `https://www.example.com/path-to-email-template.html`)

example.env

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ GOTRUE_MAILER_SUBJECTS_INVITE="You have been invited"
3838
GOTRUE_MAILER_SUBJECTS_PASSWORD_CHANGED_NOTIFICATION="Your password has been changed"
3939
GOTRUE_MAILER_SUBJECTS_EMAIL_CHANGED_NOTIFICATION="Your email address has been changed"
4040
GOTRUE_MAILER_SUBJECTS_PHONE_CHANGED_NOTIFICATION="Your phone number has been changed"
41+
GOTRUE_MAILER_SUBJECTS_IDENTITY_LINKED_NOTIFICATION="A new identity has been linked"
42+
GOTRUE_MAILER_SUBJECTS_IDENTITY_UNLINKED_NOTIFICATION="An identity has been unlinked"
4143
GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_ENROLLED_NOTIFICATION="MFA factor enrolled"
4244
GOTRUE_MAILER_SUBJECTS_MFA_FACTOR_UNENROLLED_NOTIFICATION="MFA factor unenrolled"
4345
GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED="true"
@@ -51,13 +53,17 @@ GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGE=""
5153
GOTRUE_MAILER_TEMPLATES_PASSWORD_CHANGED_NOTIFICATION=""
5254
GOTRUE_MAILER_TEMPLATES_EMAIL_CHANGED_NOTIFICATION=""
5355
GOTRUE_MAILER_TEMPLATES_PHONE_CHANGED_NOTIFICATION=""
56+
GOTRUE_MAILER_TEMPLATES_IDENTITY_LINKED_NOTIFICATION=""
57+
GOTRUE_MAILER_TEMPLATES_IDENTITY_UNLINKED_NOTIFICATION=""
5458
GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_ENROLLED_NOTIFICATION=""
5559
GOTRUE_MAILER_TEMPLATES_MFA_FACTOR_UNENROLLED_NOTIFICATION=""
5660

5761
# Account changes notifications configuration
5862
GOTRUE_MAILER_NOTIFICATIONS_PASSWORD_CHANGED_ENABLED="false"
5963
GOTRUE_MAILER_NOTIFICATIONS_EMAIL_CHANGED_ENABLED="false"
6064
GOTRUE_MAILER_NOTIFICATIONS_PHONE_CHANGED_ENABLED="false"
65+
GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_LINKED_ENABLED="false"
66+
GOTRUE_MAILER_NOTIFICATIONS_IDENTITY_UNLINKED_ENABLED="false"
6167
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_ENROLLED_ENABLED="false"
6268
GOTRUE_MAILER_NOTIFICATIONS_MFA_FACTOR_UNENROLLED_ENABLED="false"
6369

internal/api/identity.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/fatih/structs"
88
"github.com/go-chi/chi/v5"
99
"github.com/gofrs/uuid"
10+
"github.com/sirupsen/logrus"
1011
"github.com/supabase/auth/internal/api/apierrors"
1112
"github.com/supabase/auth/internal/api/provider"
1213
"github.com/supabase/auth/internal/models"
@@ -50,6 +51,7 @@ func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error {
5051
return apierrors.NewUnprocessableEntityError(apierrors.ErrorCodeIdentityNotFound, "Identity doesn't exist")
5152
}
5253

54+
provider := identityToBeDeleted.Provider
5355
err = db.Transaction(func(tx *storage.Connection) error {
5456
if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.IdentityUnlinkAction, "", map[string]interface{}{
5557
"identity_id": identityToBeDeleted.ID,
@@ -88,6 +90,14 @@ func (a *API) DeleteIdentity(w http.ResponseWriter, r *http.Request) error {
8890
return err
8991
}
9092

93+
// Send identity unlinked notification email if enabled and user has an email
94+
if config.Mailer.Notifications.IdentityUnlinkedEnabled && user.GetEmail() != "" {
95+
if err := a.sendIdentityUnlinkedNotification(r, db, user, provider); err != nil {
96+
// Log the error but don't fail the unlinking
97+
logrus.WithError(err).Warn("Unable to send identity unlinked notification email")
98+
}
99+
}
100+
91101
return sendJSON(w, http.StatusOK, map[string]interface{}{})
92102
}
93103

@@ -154,5 +164,14 @@ func (a *API) linkIdentityToUser(r *http.Request, ctx context.Context, tx *stora
154164
if terr := targetUser.UpdateAppMetaDataProviders(tx); terr != nil {
155165
return nil, terr
156166
}
167+
168+
// Send identity linked notification email if enabled and user has an email
169+
if a.config.Mailer.Notifications.IdentityLinkedEnabled && targetUser.GetEmail() != "" {
170+
if terr := a.sendIdentityLinkedNotification(r, tx, targetUser, providerType); terr != nil {
171+
// Log the error but don't fail the linking
172+
logrus.WithError(terr).Warn("Unable to send identity linked notification email")
173+
}
174+
}
175+
157176
return targetUser, nil
158177
}

internal/api/identity_test.go

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,26 @@ import (
1414
"github.com/supabase/auth/internal/api/apierrors"
1515
"github.com/supabase/auth/internal/api/provider"
1616
"github.com/supabase/auth/internal/conf"
17+
mail "github.com/supabase/auth/internal/mailer"
18+
"github.com/supabase/auth/internal/mailer/mockclient"
1719
"github.com/supabase/auth/internal/models"
1820
)
1921

2022
type IdentityTestSuite struct {
2123
suite.Suite
2224
API *API
2325
Config *conf.GlobalConfiguration
26+
Mailer mail.Mailer
2427
}
2528

2629
func TestIdentity(t *testing.T) {
27-
api, config, err := setupAPIForTest()
30+
mockMailer := &mockclient.MockMailer{}
31+
api, config, err := setupAPIForTest(WithMailer(mockMailer))
2832
require.NoError(t, err)
2933
ts := &IdentityTestSuite{
3034
API: api,
3135
Config: config,
36+
Mailer: mockMailer,
3237
}
3338
defer api.db.Close()
3439
suite.Run(t, ts)
@@ -226,3 +231,115 @@ func (ts *IdentityTestSuite) generateAccessTokenAndSession(u *models.User) strin
226231
return token
227232

228233
}
234+
235+
func (ts *IdentityTestSuite) TestLinkIdentitySendsNotificationEmailEnabled() {
236+
ts.Config.Mailer.Notifications.IdentityLinkedEnabled = true
237+
238+
u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
239+
require.NoError(ts.T(), err)
240+
ctx := withTargetUser(context.Background(), u)
241+
242+
// Get the mock mailer and reset it
243+
mockMailer, ok := ts.Mailer.(*mockclient.MockMailer)
244+
require.True(ts.T(), ok, "Mailer is not of type *MockMailer")
245+
mockMailer.Reset()
246+
247+
// Link a new identity
248+
testValidUserData := &provider.UserProvidedData{
249+
Metadata: &provider.Claims{
250+
Subject: "test_subject",
251+
},
252+
}
253+
r := httptest.NewRequest(http.MethodGet, "/identities", nil)
254+
u, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testValidUserData, "google")
255+
require.NoError(ts.T(), err)
256+
257+
// Assert that identity linked notification email was sent
258+
require.Len(ts.T(), mockMailer.IdentityLinkedMailCalls, 1, "Expected 1 identity linked notification email(s) to be sent")
259+
require.Equal(ts.T(), u.ID, mockMailer.IdentityLinkedMailCalls[0].User.ID, "Email should be sent to the correct user")
260+
require.Equal(ts.T(), "google", mockMailer.IdentityLinkedMailCalls[0].Provider, "Provider should match")
261+
require.Equal(ts.T(), "[email protected]", mockMailer.IdentityLinkedMailCalls[0].User.GetEmail(), "Email should be sent to the correct email address")
262+
}
263+
264+
func (ts *IdentityTestSuite) TestLinkIdentitySendsNotificationEmailDisabled() {
265+
ts.Config.Mailer.Notifications.IdentityLinkedEnabled = false
266+
267+
u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
268+
require.NoError(ts.T(), err)
269+
ctx := withTargetUser(context.Background(), u)
270+
271+
// Get the mock mailer and reset it
272+
mockMailer, ok := ts.Mailer.(*mockclient.MockMailer)
273+
require.True(ts.T(), ok, "Mailer is not of type *MockMailer")
274+
mockMailer.Reset()
275+
276+
// Link a new identity
277+
testValidUserData := &provider.UserProvidedData{
278+
Metadata: &provider.Claims{
279+
Subject: "test_subject_disabled",
280+
},
281+
}
282+
r := httptest.NewRequest(http.MethodGet, "/identities", nil)
283+
_, err = ts.API.linkIdentityToUser(r, ctx, ts.API.db, testValidUserData, "facebook")
284+
require.NoError(ts.T(), err)
285+
286+
// Assert that identity linked notification email was not sent
287+
require.Len(ts.T(), mockMailer.IdentityLinkedMailCalls, 0, "Expected 0 identity linked notification email(s) to be sent")
288+
}
289+
290+
func (ts *IdentityTestSuite) TestUnlinkIdentitySendsNotificationEmailEnabled() {
291+
ts.Config.Mailer.Notifications.IdentityUnlinkedEnabled = true
292+
ts.Config.Security.ManualLinkingEnabled = true
293+
294+
u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
295+
require.NoError(ts.T(), err)
296+
297+
identity, err := models.FindIdentityByIdAndProvider(ts.API.db, u.ID.String(), "phone")
298+
require.NoError(ts.T(), err)
299+
300+
// Get the mock mailer and reset it
301+
mockMailer, ok := ts.Mailer.(*mockclient.MockMailer)
302+
require.True(ts.T(), ok, "Mailer is not of type *MockMailer")
303+
mockMailer.Reset()
304+
305+
token := ts.generateAccessTokenAndSession(u)
306+
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/user/identities/%s", identity.ID), nil)
307+
require.NoError(ts.T(), err)
308+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
309+
w := httptest.NewRecorder()
310+
ts.API.handler.ServeHTTP(w, req)
311+
require.Equal(ts.T(), http.StatusOK, w.Code)
312+
313+
// Assert that identity unlinked notification email was sent
314+
require.Len(ts.T(), mockMailer.IdentityUnlinkedMailCalls, 1, "Expected 1 identity unlinked notification email(s) to be sent")
315+
require.Equal(ts.T(), u.ID, mockMailer.IdentityUnlinkedMailCalls[0].User.ID, "Email should be sent to the correct user")
316+
require.Equal(ts.T(), "phone", mockMailer.IdentityUnlinkedMailCalls[0].Provider, "Provider should match")
317+
require.Equal(ts.T(), "[email protected]", mockMailer.IdentityUnlinkedMailCalls[0].User.GetEmail(), "Email should be sent to the correct email address")
318+
}
319+
320+
func (ts *IdentityTestSuite) TestUnlinkIdentitySendsNotificationEmailDisabled() {
321+
ts.Config.Mailer.Notifications.IdentityUnlinkedEnabled = false
322+
ts.Config.Security.ManualLinkingEnabled = true
323+
324+
u, err := models.FindUserByEmailAndAudience(ts.API.db, "[email protected]", ts.Config.JWT.Aud)
325+
require.NoError(ts.T(), err)
326+
327+
identity, err := models.FindIdentityByIdAndProvider(ts.API.db, u.ID.String(), "phone")
328+
require.NoError(ts.T(), err)
329+
330+
// Get the mock mailer and reset it
331+
mockMailer, ok := ts.Mailer.(*mockclient.MockMailer)
332+
require.True(ts.T(), ok, "Mailer is not of type *MockMailer")
333+
mockMailer.Reset()
334+
335+
token := ts.generateAccessTokenAndSession(u)
336+
req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/user/identities/%s", identity.ID), nil)
337+
require.NoError(ts.T(), err)
338+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
339+
w := httptest.NewRecorder()
340+
ts.API.handler.ServeHTTP(w, req)
341+
require.Equal(ts.T(), http.StatusOK, w.Code)
342+
343+
// Assert that identity unlinked notification email was not sent
344+
require.Len(ts.T(), mockMailer.IdentityUnlinkedMailCalls, 0, "Expected 0 identity unlinked notification email(s) to be sent")
345+
}

internal/api/mail.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,40 @@ func (a *API) sendPhoneChangedNotification(r *http.Request, tx *storage.Connecti
634634
return nil
635635
}
636636

637+
func (a *API) sendIdentityLinkedNotification(r *http.Request, tx *storage.Connection, u *models.User, provider string) error {
638+
err := a.sendEmail(r, tx, u, sendEmailParams{
639+
emailActionType: mail.IdentityLinkedNotification,
640+
provider: provider,
641+
})
642+
if err != nil {
643+
if errors.Is(err, EmailRateLimitExceeded) {
644+
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
645+
} else if herr, ok := err.(*HTTPError); ok {
646+
return herr
647+
}
648+
return apierrors.NewInternalServerError("Error sending identity linked notification email").WithInternalError(err)
649+
}
650+
651+
return nil
652+
}
653+
654+
func (a *API) sendIdentityUnlinkedNotification(r *http.Request, tx *storage.Connection, u *models.User, provider string) error {
655+
err := a.sendEmail(r, tx, u, sendEmailParams{
656+
emailActionType: mail.IdentityUnlinkedNotification,
657+
provider: provider,
658+
})
659+
if err != nil {
660+
if errors.Is(err, EmailRateLimitExceeded) {
661+
return apierrors.NewTooManyRequestsError(apierrors.ErrorCodeOverEmailSendRateLimit, EmailRateLimitExceeded.Error())
662+
} else if herr, ok := err.(*HTTPError); ok {
663+
return herr
664+
}
665+
return apierrors.NewInternalServerError("Error sending identity unlinked notification email").WithInternalError(err)
666+
}
667+
668+
return nil
669+
}
670+
637671
func (a *API) sendMFAFactorEnrolledNotification(r *http.Request, tx *storage.Connection, u *models.User, factorType string) error {
638672
err := a.sendEmail(r, tx, u, sendEmailParams{
639673
emailActionType: mail.MFAFactorEnrolledNotification,
@@ -715,6 +749,7 @@ type sendEmailParams struct {
715749
tokenHashWithPrefix string
716750
oldEmail string
717751
oldPhone string
752+
provider string
718753
factorType string
719754
}
720755

@@ -838,6 +873,10 @@ func (a *API) sendEmail(r *http.Request, tx *storage.Connection, u *models.User,
838873
err = mr.EmailChangedNotificationMail(r, u, params.oldEmail)
839874
case mail.PhoneChangedNotification:
840875
err = mr.PhoneChangedNotificationMail(r, u, params.oldPhone)
876+
case mail.IdentityLinkedNotification:
877+
err = mr.IdentityLinkedNotificationMail(r, u, params.provider)
878+
case mail.IdentityUnlinkedNotification:
879+
err = mr.IdentityUnlinkedNotificationMail(r, u, params.provider)
841880
case mail.MFAFactorEnrolledNotification:
842881
err = mr.MFAFactorEnrolledNotificationMail(r, u, params.factorType)
843882
case mail.MFAFactorUnenrolledNotification:

internal/api/verify.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.
403403
config := a.config
404404

405405
oldPhone := user.GetPhone()
406+
phoneIdentityWasCreated := false
406407
err := conn.Transaction(func(tx *storage.Connection) error {
407408

408409
if params.Type == smsVerification {
@@ -430,6 +431,7 @@ func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.
430431
})); terr != nil {
431432
return terr
432433
}
434+
phoneIdentityWasCreated = true
433435
} else {
434436
if terr := identity.UpdateIdentityData(tx, map[string]interface{}{
435437
"phone": params.Phone,
@@ -467,6 +469,14 @@ func (a *API) smsVerify(r *http.Request, conn *storage.Connection, user *models.
467469
}
468470
}
469471

472+
// Send identity linked notification email if a new phone identity was created
473+
if phoneIdentityWasCreated && config.Mailer.Notifications.IdentityLinkedEnabled && user.GetEmail() != "" {
474+
if err := a.sendIdentityLinkedNotification(r, conn, user, "phone"); err != nil {
475+
// Log the error but don't fail the verification
476+
logrus.WithError(err).Warn("Unable to send identity linked notification email")
477+
}
478+
}
479+
470480
return user, nil
471481
}
472482

internal/conf/configuration.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,8 @@ type EmailContentConfiguration struct {
394394
PasswordChangedNotification string `json:"password_changed_notification" split_words:"true"`
395395
EmailChangedNotification string `json:"email_changed_notification" split_words:"true"`
396396
PhoneChangedNotification string `json:"phone_changed_notification" split_words:"true"`
397+
IdentityLinkedNotification string `json:"identity_linked_notification" split_words:"true"`
398+
IdentityUnlinkedNotification string `json:"identity_unlinked_notification" split_words:"true"`
397399
MFAFactorEnrolledNotification string `json:"mfa_factor_enrolled_notification" split_words:"true"`
398400
MFAFactorUnenrolledNotification string `json:"mfa_factor_unenrolled_notification" split_words:"true"`
399401
}
@@ -403,6 +405,8 @@ type NotificationsConfiguration struct {
403405
PasswordChangedEnabled bool `json:"password_changed_enabled" split_words:"true" default:"false"`
404406
EmailChangedEnabled bool `json:"email_changed_enabled" split_words:"true" default:"false"`
405407
PhoneChangedEnabled bool `json:"phone_changed_enabled" split_words:"true" default:"false"`
408+
IdentityLinkedEnabled bool `json:"identity_linked_enabled" split_words:"true" default:"false"`
409+
IdentityUnlinkedEnabled bool `json:"identity_unlinked_enabled" split_words:"true" default:"false"`
406410
MFAFactorEnrolledEnabled bool `json:"mfa_factor_enrolled_enabled" split_words:"true" default:"false"`
407411
MFAFactorUnenrolledEnabled bool `json:"mfa_factor_unenrolled_enabled" split_words:"true" default:"false"`
408412
}

internal/mailer/mailer.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const (
2323
PasswordChangedNotification = "password_changed_notification"
2424
EmailChangedNotification = "email_changed_notification"
2525
PhoneChangedNotification = "phone_changed_notification"
26+
IdentityLinkedNotification = "identity_linked_notification"
27+
IdentityUnlinkedNotification = "identity_unlinked_notification"
2628
MFAFactorEnrolledNotification = "mfa_factor_enrolled_notification"
2729
MFAFactorUnenrolledNotification = "mfa_factor_unenrolled_notification"
2830
)
@@ -41,6 +43,8 @@ type Mailer interface {
4143
PasswordChangedNotificationMail(r *http.Request, user *models.User) error
4244
EmailChangedNotificationMail(r *http.Request, user *models.User, oldEmail string) error
4345
PhoneChangedNotificationMail(r *http.Request, user *models.User, oldPhone string) error
46+
IdentityLinkedNotificationMail(r *http.Request, user *models.User, provider string) error
47+
IdentityUnlinkedNotificationMail(r *http.Request, user *models.User, provider string) error
4448
MFAFactorEnrolledNotificationMail(r *http.Request, user *models.User, factorType string) error
4549
MFAFactorUnenrolledNotificationMail(r *http.Request, user *models.User, factorType string) error
4650
}

0 commit comments

Comments
 (0)