Skip to content

Commit b5bf37e

Browse files
hardiknshiranya911
authored andcommitted
Firebase auth Email Action Links API (#258)
* added email action link generation to auth * added change log entry * Integration tests added * fixed review comments * fixes for review comments * minor tidy up - review comments * fixed cosmetic comments in IT
1 parent 32d7dcd commit b5bf37e

File tree

6 files changed

+455
-0
lines changed

6 files changed

+455
-0
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# Unreleased
22

3+
- [added] Added `generate_password_reset_link()`,
4+
`generate_email_verification_link()` and `generate_sign_in_with_email_link()`
5+
methods to the `auth` API.
36
- [added] Migrated the `auth` user management API to the
47
new Identity Toolkit endpoint.
58
- [fixed] Extending HTTP retries to more HTTP methods like POST and PATCH.

firebase_admin/_auth_utils.py

+7
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', 'exp', 'iat',
2727
'iss', 'jti', 'nbf', 'nonce', 'sub', 'firebase',
2828
])
29+
VALID_EMAIL_ACTION_TYPES = set(['VERIFY_EMAIL', 'EMAIL_SIGNIN', 'PASSWORD_RESET'])
2930

3031

3132
def validate_uid(uid, required=False):
@@ -181,3 +182,9 @@ def validate_custom_claims(custom_claims, required=False):
181182
raise ValueError(
182183
'Claim "{0}" is reserved, and must not be set.'.format(invalid_claims.pop()))
183184
return claims_str
185+
186+
def validate_action_type(action_type):
187+
if action_type not in VALID_EMAIL_ACTION_TYPES:
188+
raise ValueError('Invalid action type provided action_type: {0}. \
189+
Valid values are {1}'.format(action_type, ', '.join(VALID_EMAIL_ACTION_TYPES)))
190+
return action_type

firebase_admin/_user_mgt.py

+118
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import requests
2020
import six
21+
from six.moves import urllib
2122

2223
from firebase_admin import _auth_utils
2324
from firebase_admin import _user_import
@@ -30,6 +31,7 @@
3031
USER_DELETE_ERROR = 'USER_DELETE_ERROR'
3132
USER_IMPORT_ERROR = 'USER_IMPORT_ERROR'
3233
USER_DOWNLOAD_ERROR = 'LIST_USERS_ERROR'
34+
GENERATE_EMAIL_ACTION_LINK_ERROR = 'GENERATE_EMAIL_ACTION_LINK_ERROR'
3335

3436
MAX_LIST_USERS_RESULTS = 1000
3537
MAX_IMPORT_USERS_SIZE = 1000
@@ -372,6 +374,87 @@ def photo_url(self):
372374
def provider_id(self):
373375
return self._data.get('providerId')
374376

377+
class ActionCodeSettings(object):
378+
"""Contains required continue/state URL with optional Android and iOS settings.
379+
Used when invoking the email action link generation APIs.
380+
"""
381+
382+
def __init__(self, url, handle_code_in_app=None, dynamic_link_domain=None, ios_bundle_id=None,
383+
android_package_name=None, android_install_app=None, android_minimum_version=None):
384+
self.url = url
385+
self.handle_code_in_app = handle_code_in_app
386+
self.dynamic_link_domain = dynamic_link_domain
387+
self.ios_bundle_id = ios_bundle_id
388+
self.android_package_name = android_package_name
389+
self.android_install_app = android_install_app
390+
self.android_minimum_version = android_minimum_version
391+
392+
def encode_action_code_settings(settings):
393+
""" Validates the provided action code settings for email link generation and
394+
populates the REST api parameters.
395+
396+
settings - ``ActionCodeSettings`` object provided to be encoded
397+
returns - dict of parameters to be passed for link gereration.
398+
"""
399+
400+
parameters = {}
401+
# url
402+
if not settings.url:
403+
raise ValueError("Dynamic action links url is mandatory")
404+
405+
try:
406+
parsed = urllib.parse.urlparse(settings.url)
407+
if not parsed.netloc:
408+
raise ValueError('Malformed dynamic action links url: "{0}".'.format(settings.url))
409+
parameters['continueUrl'] = settings.url
410+
except Exception:
411+
raise ValueError('Malformed dynamic action links url: "{0}".'.format(settings.url))
412+
413+
# handle_code_in_app
414+
if settings.handle_code_in_app is not None:
415+
if not isinstance(settings.handle_code_in_app, bool):
416+
raise ValueError('Invalid value provided for handle_code_in_app: {0}'
417+
.format(settings.handle_code_in_app))
418+
parameters['canHandleCodeInApp'] = settings.handle_code_in_app
419+
420+
# dynamic_link_domain
421+
if settings.dynamic_link_domain is not None:
422+
if not isinstance(settings.dynamic_link_domain, six.string_types):
423+
raise ValueError('Invalid value provided for dynamic_link_domain: {0}'
424+
.format(settings.dynamic_link_domain))
425+
parameters['dynamicLinkDomain'] = settings.dynamic_link_domain
426+
427+
# ios_bundle_id
428+
if settings.ios_bundle_id is not None:
429+
if not isinstance(settings.ios_bundle_id, six.string_types):
430+
raise ValueError('Invalid value provided for ios_bundle_id: {0}'
431+
.format(settings.ios_bundle_id))
432+
parameters['iosBundleId'] = settings.ios_bundle_id
433+
434+
# android_* attributes
435+
if (settings.android_minimum_version or settings.android_install_app) \
436+
and not settings.android_package_name:
437+
raise ValueError("Android package name is required when specifying other Android settings")
438+
439+
if settings.android_package_name is not None:
440+
if not isinstance(settings.android_package_name, six.string_types):
441+
raise ValueError('Invalid value provided for android_package_name: {0}'
442+
.format(settings.android_package_name))
443+
parameters['androidPackageName'] = settings.android_package_name
444+
445+
if settings.android_minimum_version is not None:
446+
if not isinstance(settings.android_minimum_version, six.string_types):
447+
raise ValueError('Invalid value provided for android_minimum_version: {0}'
448+
.format(settings.android_minimum_version))
449+
parameters['androidMinimumVersion'] = settings.android_minimum_version
450+
451+
if settings.android_install_app is not None:
452+
if not isinstance(settings.android_install_app, bool):
453+
raise ValueError('Invalid value provided for android_install_app: {0}'
454+
.format(settings.android_install_app))
455+
parameters['androidInstallApp'] = settings.android_install_app
456+
457+
return parameters
375458

376459
class UserManager(object):
377460
"""Provides methods for interacting with the Google Identity Toolkit."""
@@ -537,6 +620,41 @@ def import_users(self, users, hash_alg=None):
537620
raise ApiCallError(USER_IMPORT_ERROR, 'Failed to import users.')
538621
return response
539622

623+
def generate_email_action_link(self, action_type, email, action_code_settings=None):
624+
"""Fetches the email action links for types
625+
626+
Args:
627+
action_type: String. Valid values ['VERIFY_EMAIL', 'EMAIL_SIGNIN', 'PASSWORD_RESET']
628+
email: Email of the user for which the action is performed
629+
action_code_settings: ``ActionCodeSettings`` object or dict (optional). Defines whether
630+
the link is to be handled by a mobile app and the additional state information to be
631+
passed in the deep link, etc.
632+
Returns:
633+
link_url: action url to be emailed to the user
634+
635+
Raises:
636+
ApiCallError: If an error occurs while generating the link
637+
ValueError: If the provided arguments are invalid
638+
"""
639+
payload = {
640+
'requestType': _auth_utils.validate_action_type(action_type),
641+
'email': _auth_utils.validate_email(email),
642+
'returnOobLink': True
643+
}
644+
645+
if action_code_settings:
646+
payload.update(encode_action_code_settings(action_code_settings))
647+
648+
try:
649+
response = self._client.body('post', '/accounts:sendOobCode', json=payload)
650+
except requests.exceptions.RequestException as error:
651+
self._handle_http_error(GENERATE_EMAIL_ACTION_LINK_ERROR, 'Failed to generate link.',
652+
error)
653+
else:
654+
if not response or not response.get('oobLink'):
655+
raise ApiCallError(GENERATE_EMAIL_ACTION_LINK_ERROR, 'Failed to generate link.')
656+
return response.get('oobLink')
657+
540658
def _handle_http_error(self, code, msg, error):
541659
if error.response is not None:
542660
msg += '\nServer response: {0}'.format(error.response.content.decode())

firebase_admin/auth.py

+77
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636

3737
__all__ = [
38+
'ActionCodeSettings',
3839
'AuthError',
3940
'ErrorInfo',
4041
'ExportedUserRecord',
@@ -51,6 +52,9 @@
5152
'create_session_cookie',
5253
'create_user',
5354
'delete_user',
55+
'generate_password_reset_link',
56+
'generate_email_verification_link',
57+
'generate_sign_in_with_email_link',
5458
'get_user',
5559
'get_user_by_email',
5660
'get_user_by_phone_number',
@@ -63,6 +67,7 @@
6367
'verify_session_cookie',
6468
]
6569

70+
ActionCodeSettings = _user_mgt.ActionCodeSettings
6671
ErrorInfo = _user_import.ErrorInfo
6772
ExportedUserRecord = _user_mgt.ExportedUserRecord
6873
ListUsersPage = _user_mgt.ListUsersPage
@@ -448,6 +453,78 @@ def import_users(users, hash_alg=None, app=None):
448453
except _user_mgt.ApiCallError as error:
449454
raise AuthError(error.code, str(error), error.detail)
450455

456+
def generate_password_reset_link(email, action_code_settings=None, app=None):
457+
"""Generates the out-of-band email action link for password reset flows for the specified email
458+
address.
459+
460+
Args:
461+
email: The email of the user whose password is to be reset.
462+
action_code_settings: ``ActionCodeSettings`` instance (optional). Defines whether
463+
the link is to be handled by a mobile app and the additional state information to be
464+
passed in the deep link.
465+
app: An App instance (optional).
466+
Returns:
467+
link: The password reset link created by API
468+
469+
Raises:
470+
ValueError: If the provided arguments are invalid
471+
AuthError: If an error occurs while generating the link
472+
"""
473+
user_manager = _get_auth_service(app).user_manager
474+
try:
475+
return user_manager.generate_email_action_link('PASSWORD_RESET', email,
476+
action_code_settings=action_code_settings)
477+
except _user_mgt.ApiCallError as error:
478+
raise AuthError(error.code, str(error), error.detail)
479+
480+
def generate_email_verification_link(email, action_code_settings=None, app=None):
481+
"""Generates the out-of-band email action link for email verification flows for the specified
482+
email address.
483+
484+
Args:
485+
email: The email of the user to be verified.
486+
action_code_settings: ``ActionCodeSettings`` instance (optional). Defines whether
487+
the link is to be handled by a mobile app and the additional state information to be
488+
passed in the deep link.
489+
app: An App instance (optional).
490+
Returns:
491+
link: The email verification link created by API
492+
493+
Raises:
494+
ValueError: If the provided arguments are invalid
495+
AuthError: If an error occurs while generating the link
496+
"""
497+
user_manager = _get_auth_service(app).user_manager
498+
try:
499+
return user_manager.generate_email_action_link('VERIFY_EMAIL', email,
500+
action_code_settings=action_code_settings)
501+
except _user_mgt.ApiCallError as error:
502+
raise AuthError(error.code, str(error), error.detail)
503+
504+
def generate_sign_in_with_email_link(email, action_code_settings, app=None):
505+
"""Generates the out-of-band email action link for email link sign-in flows, using the action
506+
code settings provided.
507+
508+
Args:
509+
email: The email of the user signing in.
510+
action_code_settings: ``ActionCodeSettings`` instance. Defines whether
511+
the link is to be handled by a mobile app and the additional state information to be
512+
passed in the deep link.
513+
app: An App instance (optional).
514+
Returns:
515+
link: The email sign in link created by API
516+
517+
Raises:
518+
ValueError: If the provided arguments are invalid
519+
AuthError: If an error occurs while generating the link
520+
"""
521+
user_manager = _get_auth_service(app).user_manager
522+
try:
523+
return user_manager.generate_email_action_link('EMAIL_SIGNIN', email,
524+
action_code_settings=action_code_settings)
525+
except _user_mgt.ApiCallError as error:
526+
raise AuthError(error.code, str(error), error.detail)
527+
451528
def _check_jwt_revoked(verified_claims, error_code, label, app):
452529
user = get_user(verified_claims.get('uid'), app=app)
453530
if verified_claims.get('iat') * 1000 < user.tokens_valid_after_timestamp:

0 commit comments

Comments
 (0)