Skip to content

Commit 436dff0

Browse files
authored
Added PasswordResetLink() and EmailSignInLink() APIs (#247)
* Implemented EmailVerificationLink() API * Implemented PasswordResetLink() and EmailSignInLink() APIs
1 parent c2adbab commit 436dff0

File tree

5 files changed

+271
-16
lines changed

5 files changed

+271
-16
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
- [added] Implemented `auth.EmailVerificationLink()` function for
44
generating email verification action links.
5+
- [added] Implemented `auth.PasswordResetLink()` function for
6+
generating password reset action links.
7+
- [added] Implemented `auth.EmailSignInLink()` function for generating
8+
email sign in action links.
59

610
# v3.7.0
711

auth/email_action_links.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,37 @@ func (c *userManagementClient) EmailVerificationLinkWithSettings(
8383
return c.generateEmailActionLink(ctx, emailVerification, email, settings)
8484
}
8585

86+
// PasswordResetLink generates the out-of-band email action link for password reset flows for the specified email
87+
// address.
88+
func (c *userManagementClient) PasswordResetLink(ctx context.Context, email string) (string, error) {
89+
return c.PasswordResetLinkWithSettings(ctx, email, nil)
90+
}
91+
92+
// PasswordResetLinkWithSettings generates the out-of-band email action link for password reset flows for the
93+
// specified email address, using the action code settings provided.
94+
func (c *userManagementClient) PasswordResetLinkWithSettings(
95+
ctx context.Context, email string, settings *ActionCodeSettings) (string, error) {
96+
return c.generateEmailActionLink(ctx, passwordReset, email, settings)
97+
}
98+
99+
// EmailSignInLink generates the out-of-band email action link for email link sign-in flows, using the action
100+
// code settings provided.
101+
func (c *userManagementClient) EmailSignInLink(
102+
ctx context.Context, email string, settings *ActionCodeSettings) (string, error) {
103+
return c.generateEmailActionLink(ctx, emailLinkSignIn, email, settings)
104+
}
105+
86106
func (c *userManagementClient) generateEmailActionLink(
87107
ctx context.Context, linkType linkType, email string, settings *ActionCodeSettings) (string, error) {
88108

89109
if email == "" {
90110
return "", errors.New("email must not be empty")
91111
}
92112

113+
if linkType == emailLinkSignIn && settings == nil {
114+
return "", errors.New("ActionCodeSettings must not be nil when generating sign-in links")
115+
}
116+
93117
payload := map[string]interface{}{
94118
"requestType": linkType,
95119
"email": email,

auth/email_action_links_test.go

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,94 @@ func TestEmailVerificationLinkWithSettings(t *testing.T) {
130130
}
131131
}
132132

133-
func TestEmailVerificationLinkNoEmail(t *testing.T) {
133+
func TestPasswordResetLink(t *testing.T) {
134+
s := echoServer(testActionLinkResponse, t)
135+
defer s.Close()
136+
137+
link, err := s.Client.PasswordResetLink(context.Background(), testEmail)
138+
if err != nil {
139+
t.Fatal(err)
140+
}
141+
if link != testActionLink {
142+
t.Errorf("PasswordResetLink() = %q; want = %q", link, testActionLink)
143+
}
144+
145+
want := map[string]interface{}{
146+
"requestType": "PASSWORD_RESET",
147+
"email": testEmail,
148+
"returnOobLink": true,
149+
}
150+
if err := checkActionLinkRequest(want, s); err != nil {
151+
t.Fatal(err)
152+
}
153+
}
154+
155+
func TestPasswordResetLinkWithSettings(t *testing.T) {
156+
s := echoServer(testActionLinkResponse, t)
157+
defer s.Close()
158+
159+
link, err := s.Client.PasswordResetLinkWithSettings(context.Background(), testEmail, testActionCodeSettings)
160+
if err != nil {
161+
t.Fatal(err)
162+
}
163+
if link != testActionLink {
164+
t.Errorf("PasswordResetLinkWithSettings() = %q; want = %q", link, testActionLink)
165+
}
166+
167+
want := map[string]interface{}{
168+
"requestType": "PASSWORD_RESET",
169+
"email": testEmail,
170+
"returnOobLink": true,
171+
}
172+
for k, v := range testActionCodeSettingsMap {
173+
want[k] = v
174+
}
175+
if err := checkActionLinkRequest(want, s); err != nil {
176+
t.Fatal(err)
177+
}
178+
}
179+
180+
func TestEmailSignInLink(t *testing.T) {
181+
s := echoServer(testActionLinkResponse, t)
182+
defer s.Close()
183+
184+
link, err := s.Client.EmailSignInLink(context.Background(), testEmail, testActionCodeSettings)
185+
if err != nil {
186+
t.Fatal(err)
187+
}
188+
if link != testActionLink {
189+
t.Errorf("EmailSignInLink() = %q; want = %q", link, testActionLink)
190+
}
191+
192+
want := map[string]interface{}{
193+
"requestType": "EMAIL_SIGNIN",
194+
"email": testEmail,
195+
"returnOobLink": true,
196+
}
197+
for k, v := range testActionCodeSettingsMap {
198+
want[k] = v
199+
}
200+
if err := checkActionLinkRequest(want, s); err != nil {
201+
t.Fatal(err)
202+
}
203+
}
204+
205+
func TestEmailActionLinkNoEmail(t *testing.T) {
134206
client := &Client{}
135207
_, err := client.EmailVerificationLink(context.Background(), "")
136208
if err == nil {
137209
t.Errorf("EmailVerificationLink('') = nil; want error")
138210
}
211+
212+
_, err = client.PasswordResetLink(context.Background(), "")
213+
if err == nil {
214+
t.Errorf("PasswordResetLink('') = nil; want error")
215+
}
216+
217+
_, err = client.EmailSignInLink(context.Background(), "", testActionCodeSettings)
218+
if err == nil {
219+
t.Errorf("EmailSignInLink('') = nil; want error")
220+
}
139221
}
140222

141223
func TestEmailVerificationLinkInvalidSettings(t *testing.T) {
@@ -148,6 +230,34 @@ func TestEmailVerificationLinkInvalidSettings(t *testing.T) {
148230
}
149231
}
150232

233+
func TestPasswordResetLinkInvalidSettings(t *testing.T) {
234+
client := &Client{}
235+
for _, tc := range invalidActionCodeSettings {
236+
_, err := client.PasswordResetLinkWithSettings(context.Background(), testEmail, tc.settings)
237+
if err == nil || err.Error() != tc.want {
238+
t.Errorf("PasswordResetLinkWithSettings(%q) = %v; want = %q", tc.name, err, tc.want)
239+
}
240+
}
241+
}
242+
243+
func TestEmailSignInLinkInvalidSettings(t *testing.T) {
244+
client := &Client{}
245+
for _, tc := range invalidActionCodeSettings {
246+
_, err := client.EmailSignInLink(context.Background(), testEmail, tc.settings)
247+
if err == nil || err.Error() != tc.want {
248+
t.Errorf("EmailSignInLink(%q) = %v; want = %q", tc.name, err, tc.want)
249+
}
250+
}
251+
}
252+
253+
func TestEmailSignInLinkNoSettings(t *testing.T) {
254+
client := &Client{}
255+
_, err := client.EmailSignInLink(context.Background(), testEmail, nil)
256+
if err == nil {
257+
t.Errorf("EmailSignInLink(nil) = %v; want = error", err)
258+
}
259+
}
260+
151261
func TestEmailVerificationLinkError(t *testing.T) {
152262
cases := map[string]func(error) bool{
153263
"UNAUTHORIZED_DOMAIN": IsUnauthorizedContinueURI,

integration/auth/auth_test.go

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const (
4242
)
4343

4444
var client *auth.Client
45+
var apiKey string
4546

4647
func TestMain(m *testing.M) {
4748
flag.Parse()
@@ -58,6 +59,10 @@ func TestMain(m *testing.M) {
5859
if err != nil {
5960
log.Fatalln(err)
6061
}
62+
apiKey, err = internal.APIKey()
63+
if err != nil {
64+
log.Fatalln(err)
65+
}
6166

6267
seed := time.Now().UTC().UnixNano()
6368
log.Printf("Using random seed: %d", seed)
@@ -207,10 +212,6 @@ func signInWithCustomToken(token string) (string, error) {
207212
return "", err
208213
}
209214

210-
apiKey, err := internal.APIKey()
211-
if err != nil {
212-
return "", err
213-
}
214215
resp, err := postRequest(fmt.Sprintf(verifyCustomTokenURL, apiKey), req)
215216
if err != nil {
216217
return "", err
@@ -233,10 +234,6 @@ func signInWithPassword(email, password string) (string, error) {
233234
return "", err
234235
}
235236

236-
apiKey, err := internal.APIKey()
237-
if err != nil {
238-
return "", err
239-
}
240237
resp, err := postRequest(fmt.Sprintf(verifyPasswordURL, apiKey), req)
241238
if err != nil {
242239
return "", err

integration/auth/user_mgt_test.go

Lines changed: 127 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package auth
1818
import (
1919
"context"
2020
"encoding/base64"
21+
"encoding/json"
2122
"fmt"
2223
"math/rand"
2324
"net/url"
@@ -31,7 +32,14 @@ import (
3132
"google.golang.org/api/iterator"
3233
)
3334

34-
const continueURL = "http://localhost/?a=1&b=2#c=3"
35+
const (
36+
continueURL = "http://localhost/?a=1&b=2#c=3"
37+
continueURLKey = "continueUrl"
38+
oobCodeKey = "oobCode"
39+
modeKey = "mode"
40+
resetPasswordURL = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/resetPassword?key=%s"
41+
emailLinkSignInURL = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/emailLinkSignin?key=%s"
42+
)
3543

3644
func TestGetUser(t *testing.T) {
3745
want := newUserWithParams(t)
@@ -466,20 +474,133 @@ func TestEmailVerificationLink(t *testing.T) {
466474
t.Fatal(err)
467475
}
468476

469-
const (
470-
continueURLKey = "continueUrl"
471-
modeKey = "mode"
472-
verifyEmail = "verifyEmail"
473-
)
474477
query := parsed.Query()
475478
if got := query.Get(continueURLKey); got != continueURL {
476479
t.Errorf("EmailVerificationLinkWithSettings() %s = %q; want = %q", continueURLKey, got, continueURL)
477480
}
481+
482+
const verifyEmail = "verifyEmail"
478483
if got := query.Get(modeKey); got != verifyEmail {
479484
t.Errorf("EmailVerificationLinkWithSettings() %s = %q; want = %q", modeKey, got, verifyEmail)
480485
}
481486
}
482487

488+
func TestPasswordResetLink(t *testing.T) {
489+
user := newUserWithParams(t)
490+
defer deleteUser(user.UID)
491+
link, err := client.PasswordResetLinkWithSettings(context.Background(), user.Email, &auth.ActionCodeSettings{
492+
URL: continueURL,
493+
HandleCodeInApp: false,
494+
})
495+
if err != nil {
496+
t.Fatal(err)
497+
}
498+
499+
parsed, err := url.ParseRequestURI(link)
500+
if err != nil {
501+
t.Fatal(err)
502+
}
503+
504+
query := parsed.Query()
505+
if got := query.Get(continueURLKey); got != continueURL {
506+
t.Errorf("PasswordResetLinkWithSettings() %s = %q; want = %q", continueURLKey, got, continueURL)
507+
}
508+
509+
oobCode := query.Get(oobCodeKey)
510+
if err := resetPassword(user.Email, "password", "newPassword", oobCode); err != nil {
511+
t.Fatalf("PasswordResetLinkWithSettings() reset = %v; want = nil", err)
512+
}
513+
514+
// Password reset also verifies the user's email
515+
user, err = client.GetUser(context.Background(), user.UID)
516+
if err != nil {
517+
t.Fatalf("GetUser() = %v; want = nil", err)
518+
}
519+
if !user.EmailVerified {
520+
t.Error("PasswordResetLinkWithSettings() EmailVerified = false; want = true")
521+
}
522+
}
523+
524+
func TestEmailSignInLink(t *testing.T) {
525+
user := newUserWithParams(t)
526+
defer deleteUser(user.UID)
527+
link, err := client.EmailSignInLink(context.Background(), user.Email, &auth.ActionCodeSettings{
528+
URL: continueURL,
529+
HandleCodeInApp: false,
530+
})
531+
if err != nil {
532+
t.Fatal(err)
533+
}
534+
535+
parsed, err := url.ParseRequestURI(link)
536+
if err != nil {
537+
t.Fatal(err)
538+
}
539+
540+
query := parsed.Query()
541+
if got := query.Get(continueURLKey); got != continueURL {
542+
t.Errorf("EmailSignInLink() %s = %q; want = %q", continueURLKey, got, continueURL)
543+
}
544+
545+
oobCode := query.Get(oobCodeKey)
546+
idToken, err := signInWithEmailLink(user.Email, oobCode)
547+
if err != nil {
548+
t.Fatalf("EmailSignInLink() signIn = %v; want = nil", err)
549+
}
550+
if idToken == "" {
551+
t.Errorf("ID Token = empty; want = non-empty")
552+
}
553+
554+
// Signing in with email link also verifies the user's email
555+
user, err = client.GetUser(context.Background(), user.UID)
556+
if err != nil {
557+
t.Fatalf("GetUser() = %v; want = nil", err)
558+
}
559+
if !user.EmailVerified {
560+
t.Error("EmailSignInLink() EmailVerified = false; want = true")
561+
}
562+
}
563+
564+
func resetPassword(email, oldPassword, newPassword, oobCode string) error {
565+
req := map[string]interface{}{
566+
"email": email,
567+
"oldPassword": oldPassword,
568+
"newPassword": newPassword,
569+
"oobCode": oobCode,
570+
}
571+
reqBytes, err := json.Marshal(req)
572+
if err != nil {
573+
return err
574+
}
575+
576+
_, err = postRequest(fmt.Sprintf(resetPasswordURL, apiKey), reqBytes)
577+
return err
578+
}
579+
580+
func signInWithEmailLink(email, oobCode string) (string, error) {
581+
req := map[string]interface{}{
582+
"email": email,
583+
"oobCode": oobCode,
584+
}
585+
reqBytes, err := json.Marshal(req)
586+
if err != nil {
587+
return "", err
588+
}
589+
590+
b, err := postRequest(fmt.Sprintf(emailLinkSignInURL, apiKey), reqBytes)
591+
if err != nil {
592+
return "", err
593+
}
594+
595+
var parsed struct {
596+
IDToken string `json:"idToken"`
597+
}
598+
if err := json.Unmarshal(b, &parsed); err != nil {
599+
return "", err
600+
}
601+
return parsed.IDToken, nil
602+
}
603+
483604
var seededRand = rand.New(rand.NewSource(time.Now().UnixNano()))
484605

485606
func randomUID() string {
@@ -514,7 +635,6 @@ func newUserWithParams(t *testing.T) *auth.UserRecord {
514635
PhoneNumber(phone).
515636
DisplayName("Random User").
516637
PhotoURL("https://example.com/photo.png").
517-
EmailVerified(true).
518638
Password("password")
519639
user, err := client.CreateUser(context.Background(), params)
520640
if err != nil {

0 commit comments

Comments
 (0)