diff --git a/auth/email_action_links.go b/auth/email_action_links.go index bc08d401..c2b8a70c 100644 --- a/auth/email_action_links.go +++ b/auth/email_action_links.go @@ -66,9 +66,10 @@ func (settings *ActionCodeSettings) toMap() (map[string]interface{}, error) { type linkType string const ( - emailLinkSignIn linkType = "EMAIL_SIGNIN" - emailVerification linkType = "VERIFY_EMAIL" - passwordReset linkType = "PASSWORD_RESET" + emailLinkSignIn linkType = "EMAIL_SIGNIN" + emailVerification linkType = "VERIFY_EMAIL" + passwordReset linkType = "PASSWORD_RESET" + verifyAndChangeEmail linkType = "VERIFY_AND_CHANGE_EMAIL" ) // EmailVerificationLink generates the out-of-band email action link for email verification flows for the specified @@ -77,6 +78,17 @@ func (c *baseClient) EmailVerificationLink(ctx context.Context, email string) (s return c.EmailVerificationLinkWithSettings(ctx, email, nil) } +// emailActionLinkOption modifies the request payload sent to the email action link generation API. +type emailActionLinkOption func(payload map[string]interface{}) + +// withNewEmail adds the newEmail field to the request payload. Used when generating links for +// verify-and-change-email flows. +func withNewEmail(newEmail string) emailActionLinkOption { + return func(payload map[string]interface{}) { + payload["newEmail"] = newEmail + } +} + // EmailVerificationLinkWithSettings generates the out-of-band email action link for email verification flows for the // specified email address, using the action code settings provided. func (c *baseClient) EmailVerificationLinkWithSettings( @@ -104,8 +116,25 @@ func (c *baseClient) EmailSignInLink( return c.generateEmailActionLink(ctx, emailLinkSignIn, email, settings) } +// VerifyAndChangeEmailLink generates the out-of-band email action link for email verification and change flows for the +// specified current email address and new email address. +func (c *baseClient) VerifyAndChangeEmailLink(ctx context.Context, email string, newEmail string) (string, error) { + return c.VerifyAndChangeEmailLinkWithSettings(ctx, email, newEmail, nil) +} + +// VerifyAndChangeEmailLinkWithSettings generates the out-of-band email action link for email verification and change +// flows for the specified current email address and new email address, using the action code settings provided. +func (c *baseClient) VerifyAndChangeEmailLinkWithSettings( + ctx context.Context, email string, newEmail string, settings *ActionCodeSettings) (string, error) { + if newEmail == "" { + return "", errors.New("newEmail must not be empty") + } + return c.generateEmailActionLink(ctx, verifyAndChangeEmail, email, settings, withNewEmail(newEmail)) +} + func (c *baseClient) generateEmailActionLink( - ctx context.Context, linkType linkType, email string, settings *ActionCodeSettings) (string, error) { + ctx context.Context, linkType linkType, email string, settings *ActionCodeSettings, + opts ...emailActionLinkOption) (string, error) { if email == "" { return "", errors.New("email must not be empty") @@ -120,6 +149,11 @@ func (c *baseClient) generateEmailActionLink( "email": email, "returnOobLink": true, } + + for _, opt := range opts { + opt(payload) + } + if settings != nil { settingsMap, err := settings.toMap() if err != nil { diff --git a/auth/email_action_links_test.go b/auth/email_action_links_test.go index 8ed55e66..4e5a2646 100644 --- a/auth/email_action_links_test.go +++ b/auth/email_action_links_test.go @@ -29,6 +29,7 @@ const ( testActionLink = "https://test.link" testActionLinkFormat = `{"oobLink": %q}` testEmail = "user@domain.com" + testNewEmail = "user-new@domain.com" ) var testActionLinkResponse = []byte(fmt.Sprintf(testActionLinkFormat, testActionLink)) @@ -243,6 +244,21 @@ func TestEmailActionLinkNoEmail(t *testing.T) { if _, err := client.EmailSignInLink(context.Background(), "", testActionCodeSettings); err == nil { t.Errorf("EmailSignInLink('') = nil; want error") } + + if _, err := client.VerifyAndChangeEmailLink(context.Background(), "", testNewEmail); err == nil { + t.Errorf("VerifyAndChangeEmailLink('') = nil; want error") + } +} + +func TestVerifyAndChangeEmailLinkNoNewEmail(t *testing.T) { + client := &Client{ + baseClient: &baseClient{}, + } + + want := "newEmail must not be empty" + if _, err := client.VerifyAndChangeEmailLink(context.Background(), testEmail, ""); err == nil || err.Error() != want { + t.Errorf("VerifyAndChangeEmailLink('') = %v; want = %q", err, want) + } } func TestEmailVerificationLinkInvalidSettings(t *testing.T) { @@ -312,6 +328,55 @@ func TestEmailVerificationLinkError(t *testing.T) { } } +func TestVerifyAndChangeEmailLink(t *testing.T) { + s := echoServer(testActionLinkResponse, t) + defer s.Close() + + link, err := s.Client.VerifyAndChangeEmailLink(context.Background(), testEmail, testNewEmail) + if err != nil { + t.Fatal(err) + } + if link != testActionLink { + t.Errorf("TestVerifyAndChangeEmailLink() = %q; want = %q", link, testActionLink) + } + + want := map[string]interface{}{ + "requestType": "VERIFY_AND_CHANGE_EMAIL", + "email": testEmail, + "returnOobLink": true, + "newEmail": testNewEmail, + } + if err := checkActionLinkRequest(want, s); err != nil { + t.Fatalf("TestVerifyAndChangeEmailLink() %v", err) + } +} + +func TestVerifyAndChangeEmailLinkWithSettings(t *testing.T) { + s := echoServer(testActionLinkResponse, t) + defer s.Close() + + link, err := s.Client.VerifyAndChangeEmailLinkWithSettings(context.Background(), testEmail, testNewEmail, testActionCodeSettings) + if err != nil { + t.Fatal(err) + } + if link != testActionLink { + t.Errorf("VerifyAndChangeEmailLinkWithSettings() = %q; want = %q", link, testActionLink) + } + + want := map[string]interface{}{ + "requestType": "VERIFY_AND_CHANGE_EMAIL", + "email": testEmail, + "returnOobLink": true, + "newEmail": testNewEmail, + } + for k, v := range testActionCodeSettingsMap { + want[k] = v + } + if err := checkActionLinkRequest(want, s); err != nil { + t.Fatalf("checkActionLinkRequest() = %v", err) + } +} + func checkActionLinkRequest(want map[string]interface{}, s *mockAuthServer) error { wantURL := "/projects/mock-project-id/accounts:sendOobCode" return checkActionLinkRequestWithURL(want, wantURL, s) diff --git a/auth/tenant_mgt_test.go b/auth/tenant_mgt_test.go index 539d24e5..aaf65b52 100644 --- a/auth/tenant_mgt_test.go +++ b/auth/tenant_mgt_test.go @@ -598,6 +598,34 @@ func TestTenantEmailSignInLink(t *testing.T) { } } +func TestTenantVerifyAndChangeEmail(t *testing.T) { + s := echoServer(testActionLinkResponse, t) + defer s.Close() + + client, err := s.Client.TenantManager.AuthForTenant("tenantID") + if err != nil { + t.Fatalf("AuthForTenant() = %v", err) + } + + link, err := client.VerifyAndChangeEmailLink(context.Background(), testEmail, testNewEmail) + if err != nil { + t.Fatal(err) + } + if link != testActionLink { + t.Errorf("VerifyAndChangeEmailLink() = %q; want = %q", link, testActionLink) + } + + want := map[string]interface{}{ + "requestType": "VERIFY_AND_CHANGE_EMAIL", + "email": testEmail, + "returnOobLink": true, + "newEmail": testNewEmail, + } + if err := checkActionLinkRequestWithURL(want, wantEmailActionURL, s); err != nil { + t.Fatalf("checkActionLinkRequestWithURL() = %v", err) + } +} + func TestTenantOIDCProviderConfig(t *testing.T) { s := echoServer([]byte(oidcConfigResponse), t) defer s.Close() diff --git a/integration/auth/tenant_mgt_test.go b/integration/auth/tenant_mgt_test.go index c5ab6de2..8ece70f3 100644 --- a/integration/auth/tenant_mgt_test.go +++ b/integration/auth/tenant_mgt_test.go @@ -372,6 +372,23 @@ func testTenantAwareUserManagement(t *testing.T, id string) { } }) + t.Run("VerifyAndChangeEmailLink()", func(t *testing.T) { + newEmail := "new-" + want.Email + link, err := tenantClient.VerifyAndChangeEmailLink(context.Background(), want.Email, newEmail) + if err != nil { + t.Fatalf("VerifyAndChangeEmailLink() = %v", err) + } + + tenant, err := extractTenantID(link) + if err != nil { + t.Fatalf("extractTenantID(%s) = %v", link, err) + } + + if id != tenant { + t.Fatalf("VerifyAndChangeEmailLink() TenantID = %q; want = %q", tenant, id) + } + }) + t.Run("RevokeRefreshTokens()", func(t *testing.T) { validSinceMillis := time.Now().Unix() * 1000 time.Sleep(1 * time.Second)