Skip to content

Commit c2adbab

Browse files
authored
Implemented EmailVerificationLink() API (#243)
1 parent 37edd08 commit c2adbab

File tree

5 files changed

+361
-9
lines changed

5 files changed

+361
-9
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Unreleased
22

3+
- [added] Implemented `auth.EmailVerificationLink()` function for
4+
generating email verification action links.
5+
36
# v3.7.0
47

58
- [added] Implemented `auth.SessionCookie()` function for creating

auth/email_action_links.go

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright 2019 Google Inc. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package auth
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"errors"
21+
"fmt"
22+
"net/http"
23+
"net/url"
24+
)
25+
26+
// ActionCodeSettings specifies the required continue/state URL with optional Android and iOS settings. Used when
27+
// invoking the email action link generation APIs.
28+
type ActionCodeSettings struct {
29+
URL string `json:"continueUrl"`
30+
HandleCodeInApp bool `json:"canHandleCodeInApp"`
31+
IOSBundleID string `json:"iOSBundleId,omitempty"`
32+
AndroidPackageName string `json:"androidPackageName,omitempty"`
33+
AndroidMinimumVersion string `json:"androidMinimumVersion,omitempty"`
34+
AndroidInstallApp bool `json:"androidInstallApp,omitempty"`
35+
DynamicLinkDomain string `json:"dynamicLinkDomain,omitempty"`
36+
}
37+
38+
func (settings *ActionCodeSettings) toMap() (map[string]interface{}, error) {
39+
if settings.URL == "" {
40+
return nil, errors.New("URL must not be empty")
41+
}
42+
43+
url, err := url.Parse(settings.URL)
44+
if err != nil || url.Scheme == "" || url.Host == "" {
45+
return nil, fmt.Errorf("malformed url string: %q", settings.URL)
46+
}
47+
48+
if settings.AndroidMinimumVersion != "" || settings.AndroidInstallApp {
49+
if settings.AndroidPackageName == "" {
50+
return nil, errors.New("Android package name is required when specifying other Android settings")
51+
}
52+
}
53+
54+
b, err := json.Marshal(settings)
55+
if err != nil {
56+
return nil, err
57+
}
58+
var result map[string]interface{}
59+
if err := json.Unmarshal(b, &result); err != nil {
60+
return nil, err
61+
}
62+
return result, nil
63+
}
64+
65+
type linkType string
66+
67+
const (
68+
emailLinkSignIn linkType = "EMAIL_SIGNIN"
69+
emailVerification linkType = "VERIFY_EMAIL"
70+
passwordReset linkType = "PASSWORD_RESET"
71+
)
72+
73+
// EmailVerificationLink generates the out-of-band email action link for email verification flows for the specified
74+
// email address.
75+
func (c *userManagementClient) EmailVerificationLink(ctx context.Context, email string) (string, error) {
76+
return c.EmailVerificationLinkWithSettings(ctx, email, nil)
77+
}
78+
79+
// EmailVerificationLinkWithSettings generates the out-of-band email action link for email verification flows for the
80+
// specified email address, using the action code settings provided.
81+
func (c *userManagementClient) EmailVerificationLinkWithSettings(
82+
ctx context.Context, email string, settings *ActionCodeSettings) (string, error) {
83+
return c.generateEmailActionLink(ctx, emailVerification, email, settings)
84+
}
85+
86+
func (c *userManagementClient) generateEmailActionLink(
87+
ctx context.Context, linkType linkType, email string, settings *ActionCodeSettings) (string, error) {
88+
89+
if email == "" {
90+
return "", errors.New("email must not be empty")
91+
}
92+
93+
payload := map[string]interface{}{
94+
"requestType": linkType,
95+
"email": email,
96+
"returnOobLink": true,
97+
}
98+
if settings != nil {
99+
settingsMap, err := settings.toMap()
100+
if err != nil {
101+
return "", err
102+
}
103+
for k, v := range settingsMap {
104+
payload[k] = v
105+
}
106+
}
107+
108+
resp, err := c.post(ctx, "/accounts:sendOobCode", payload)
109+
if err != nil {
110+
return "", err
111+
}
112+
113+
if resp.Status != http.StatusOK {
114+
return "", handleHTTPError(resp)
115+
}
116+
117+
var result struct {
118+
OOBLink string `json:"oobLink"`
119+
}
120+
err = json.Unmarshal(resp.Body, &result)
121+
return result.OOBLink, err
122+
}

auth/email_action_links_test.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright 2019 Google Inc. All Rights Reserved.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package auth
16+
17+
import (
18+
"context"
19+
"encoding/json"
20+
"fmt"
21+
"net/http"
22+
"reflect"
23+
"testing"
24+
)
25+
26+
const (
27+
testActionLink = "https://test.link"
28+
testActionLinkFormat = `{"oobLink": %q}`
29+
testEmail = "user@domain.com"
30+
)
31+
32+
var testActionLinkResponse = []byte(fmt.Sprintf(testActionLinkFormat, testActionLink))
33+
var testActionCodeSettings = &ActionCodeSettings{
34+
URL: "https://example.dynamic.link",
35+
HandleCodeInApp: true,
36+
DynamicLinkDomain: "custom.page.link",
37+
IOSBundleID: "com.example.ios",
38+
AndroidPackageName: "com.example.android",
39+
AndroidInstallApp: true,
40+
AndroidMinimumVersion: "6",
41+
}
42+
var testActionCodeSettingsMap = map[string]interface{}{
43+
"continueUrl": "https://example.dynamic.link",
44+
"canHandleCodeInApp": true,
45+
"dynamicLinkDomain": "custom.page.link",
46+
"iOSBundleId": "com.example.ios",
47+
"androidPackageName": "com.example.android",
48+
"androidInstallApp": true,
49+
"androidMinimumVersion": "6",
50+
}
51+
var invalidActionCodeSettings = []struct {
52+
name string
53+
settings *ActionCodeSettings
54+
want string
55+
}{
56+
{
57+
"no-url",
58+
&ActionCodeSettings{},
59+
"URL must not be empty",
60+
},
61+
{
62+
"malformed-url",
63+
&ActionCodeSettings{
64+
URL: "not a url",
65+
},
66+
`malformed url string: "not a url"`,
67+
},
68+
{
69+
"no-android-package-1",
70+
&ActionCodeSettings{
71+
URL: "https://example.dynamic.link",
72+
AndroidInstallApp: true,
73+
},
74+
"Android package name is required when specifying other Android settings",
75+
},
76+
{
77+
"no-android-package-2",
78+
&ActionCodeSettings{
79+
URL: "https://example.dynamic.link",
80+
AndroidMinimumVersion: "6",
81+
},
82+
"Android package name is required when specifying other Android settings",
83+
},
84+
}
85+
86+
func TestEmailVerificationLink(t *testing.T) {
87+
s := echoServer(testActionLinkResponse, t)
88+
defer s.Close()
89+
90+
link, err := s.Client.EmailVerificationLink(context.Background(), testEmail)
91+
if err != nil {
92+
t.Fatal(err)
93+
}
94+
if link != testActionLink {
95+
t.Errorf("EmailVerificationLink() = %q; want = %q", link, testActionLink)
96+
}
97+
98+
want := map[string]interface{}{
99+
"requestType": "VERIFY_EMAIL",
100+
"email": testEmail,
101+
"returnOobLink": true,
102+
}
103+
if err := checkActionLinkRequest(want, s); err != nil {
104+
t.Fatal(err)
105+
}
106+
}
107+
108+
func TestEmailVerificationLinkWithSettings(t *testing.T) {
109+
s := echoServer(testActionLinkResponse, t)
110+
defer s.Close()
111+
112+
link, err := s.Client.EmailVerificationLinkWithSettings(context.Background(), testEmail, testActionCodeSettings)
113+
if err != nil {
114+
t.Fatal(err)
115+
}
116+
if link != testActionLink {
117+
t.Errorf("EmailVerificationLinkWithSettings() = %q; want = %q", link, testActionLink)
118+
}
119+
120+
want := map[string]interface{}{
121+
"requestType": "VERIFY_EMAIL",
122+
"email": testEmail,
123+
"returnOobLink": true,
124+
}
125+
for k, v := range testActionCodeSettingsMap {
126+
want[k] = v
127+
}
128+
if err := checkActionLinkRequest(want, s); err != nil {
129+
t.Fatal(err)
130+
}
131+
}
132+
133+
func TestEmailVerificationLinkNoEmail(t *testing.T) {
134+
client := &Client{}
135+
_, err := client.EmailVerificationLink(context.Background(), "")
136+
if err == nil {
137+
t.Errorf("EmailVerificationLink('') = nil; want error")
138+
}
139+
}
140+
141+
func TestEmailVerificationLinkInvalidSettings(t *testing.T) {
142+
client := &Client{}
143+
for _, tc := range invalidActionCodeSettings {
144+
_, err := client.EmailVerificationLinkWithSettings(context.Background(), testEmail, tc.settings)
145+
if err == nil || err.Error() != tc.want {
146+
t.Errorf("EmailVerificationLinkWithSettings(%q) = %v; want = %q", tc.name, err, tc.want)
147+
}
148+
}
149+
}
150+
151+
func TestEmailVerificationLinkError(t *testing.T) {
152+
cases := map[string]func(error) bool{
153+
"UNAUTHORIZED_DOMAIN": IsUnauthorizedContinueURI,
154+
"INVALID_DYNAMIC_LINK_DOMAIN": IsInvalidDynamicLinkDomain,
155+
}
156+
s := echoServer(testActionLinkResponse, t)
157+
defer s.Close()
158+
s.Client.httpClient.RetryConfig = nil
159+
s.Status = http.StatusInternalServerError
160+
161+
for code, check := range cases {
162+
resp := fmt.Sprintf(`{"error": {"message": %q}}`, code)
163+
s.Resp = []byte(resp)
164+
_, err := s.Client.EmailVerificationLink(context.Background(), testEmail)
165+
if err == nil || !check(err) {
166+
t.Errorf("EmailVerificationLink(%q) = %v; want = %q", code, err, serverError[code])
167+
}
168+
}
169+
}
170+
171+
func checkActionLinkRequest(want map[string]interface{}, s *mockAuthServer) error {
172+
var got map[string]interface{}
173+
if err := json.Unmarshal(s.Rbody, &got); err != nil {
174+
return err
175+
}
176+
if !reflect.DeepEqual(got, want) {
177+
return fmt.Errorf("EmailVerificationLink() request = %#v; want = %#v", got, want)
178+
}
179+
return nil
180+
}

auth/user_mgt.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -350,10 +350,12 @@ const (
350350
emailAlreadyExists = "email-already-exists"
351351
idTokenRevoked = "id-token-revoked"
352352
insufficientPermission = "insufficient-permission"
353+
invalidDynamicLinkDomain = "invalid-dynamic-link-domain"
353354
phoneNumberAlreadyExists = "phone-number-already-exists"
354355
projectNotFound = "project-not-found"
355356
sessionCookieRevoked = "session-cookie-revoked"
356357
uidAlreadyExists = "uid-already-exists"
358+
unauthorizedContinueURI = "unauthorized-continue-uri"
357359
unknown = "unknown-error"
358360
userNotFound = "user-not-found"
359361
)
@@ -373,6 +375,11 @@ func IsInsufficientPermission(err error) bool {
373375
return internal.HasErrorCode(err, insufficientPermission)
374376
}
375377

378+
// IsInvalidDynamicLinkDomain checks if the given error was due to an invalid dynamic link domain.
379+
func IsInvalidDynamicLinkDomain(err error) bool {
380+
return internal.HasErrorCode(err, invalidDynamicLinkDomain)
381+
}
382+
376383
// IsPhoneNumberAlreadyExists checks if the given error was due to a duplicate phone number.
377384
func IsPhoneNumberAlreadyExists(err error) bool {
378385
return internal.HasErrorCode(err, phoneNumberAlreadyExists)
@@ -393,6 +400,11 @@ func IsUIDAlreadyExists(err error) bool {
393400
return internal.HasErrorCode(err, uidAlreadyExists)
394401
}
395402

403+
// IsUnauthorizedContinueURI checks if the given error was due to an unauthorized continue URI domain.
404+
func IsUnauthorizedContinueURI(err error) bool {
405+
return internal.HasErrorCode(err, unauthorizedContinueURI)
406+
}
407+
396408
// IsUnknown checks if the given error was due to a unknown server error.
397409
func IsUnknown(err error) bool {
398410
return internal.HasErrorCode(err, unknown)
@@ -404,15 +416,17 @@ func IsUserNotFound(err error) bool {
404416
}
405417

406418
var serverError = map[string]string{
407-
"CONFIGURATION_NOT_FOUND": projectNotFound,
408-
"DUPLICATE_EMAIL": emailAlreadyExists,
409-
"DUPLICATE_LOCAL_ID": uidAlreadyExists,
410-
"EMAIL_EXISTS": emailAlreadyExists,
411-
"INSUFFICIENT_PERMISSION": insufficientPermission,
412-
"PERMISSION_DENIED": insufficientPermission,
413-
"PHONE_NUMBER_EXISTS": phoneNumberAlreadyExists,
414-
"PROJECT_NOT_FOUND": projectNotFound,
415-
"USER_NOT_FOUND": userNotFound,
419+
"CONFIGURATION_NOT_FOUND": projectNotFound,
420+
"DUPLICATE_EMAIL": emailAlreadyExists,
421+
"DUPLICATE_LOCAL_ID": uidAlreadyExists,
422+
"EMAIL_EXISTS": emailAlreadyExists,
423+
"INSUFFICIENT_PERMISSION": insufficientPermission,
424+
"INVALID_DYNAMIC_LINK_DOMAIN": invalidDynamicLinkDomain,
425+
"PERMISSION_DENIED": insufficientPermission,
426+
"PHONE_NUMBER_EXISTS": phoneNumberAlreadyExists,
427+
"PROJECT_NOT_FOUND": projectNotFound,
428+
"UNAUTHORIZED_DOMAIN": unauthorizedContinueURI,
429+
"USER_NOT_FOUND": userNotFound,
416430
}
417431

418432
func handleServerError(err error) error {

0 commit comments

Comments
 (0)