Skip to content

Commit dff2c95

Browse files
committed
feat(auth): Add support for VERIFY_AND_CHANGE_EMAIL out-of-band links
1 parent f564d77 commit dff2c95

8 files changed

Lines changed: 157 additions & 6 deletions

File tree

firebase_admin/_auth_client.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,31 @@ def generate_sign_in_with_email_link(self, email, action_code_settings):
500500
return self._user_manager.generate_email_action_link(
501501
'EMAIL_SIGNIN', email, action_code_settings=action_code_settings)
502502

503+
def generate_verify_and_change_email_link(self, email, new_email, action_code_settings=None):
504+
"""Generates the out-of-band email action link for email verification and change flows for
505+
the specified email address.
506+
507+
Args:
508+
email: The current email of the user.
509+
new_email: The new email address of the user to be verified.
510+
action_code_settings: ``ActionCodeSettings`` instance (optional). Defines whether
511+
the link is to be handled by a mobile app and the additional state information to
512+
be passed in the deep link.
513+
514+
Returns:
515+
str: The email verification and change link created by the API
516+
517+
Raises:
518+
ValueError: If the provided arguments are invalid
519+
FirebaseError: If an error occurs while generating the link
520+
"""
521+
return self._user_manager.generate_email_action_link(
522+
"VERIFY_AND_CHANGE_EMAIL",
523+
email,
524+
action_code_settings=action_code_settings,
525+
new_email=new_email,
526+
)
527+
503528
def get_oidc_provider_config(self, provider_id):
504529
"""Returns the ``OIDCProviderConfig`` with the given ID.
505530

firebase_admin/_auth_utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,9 @@
2929
'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', 'exp', 'iat',
3030
'iss', 'jti', 'nbf', 'nonce', 'sub', 'firebase',
3131
])
32-
VALID_EMAIL_ACTION_TYPES = set(['VERIFY_EMAIL', 'EMAIL_SIGNIN', 'PASSWORD_RESET'])
32+
VALID_EMAIL_ACTION_TYPES = set(
33+
["VERIFY_EMAIL", "EMAIL_SIGNIN", "PASSWORD_RESET", "VERIFY_AND_CHANGE_EMAIL"]
34+
)
3335

3436

3537
class PageIterator:

firebase_admin/_user_mgt.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -827,29 +827,42 @@ def import_users(self, users, hash_alg=None):
827827
'Failed to import users.', http_response=http_resp)
828828
return body
829829

830-
def generate_email_action_link(self, action_type, email, action_code_settings=None):
830+
def generate_email_action_link(
831+
self, action_type, email, action_code_settings=None, new_email=None
832+
):
831833
"""Fetches the email action links for types
832834
833835
Args:
834-
action_type: String. Valid values ['VERIFY_EMAIL', 'EMAIL_SIGNIN', 'PASSWORD_RESET']
836+
action_type: String. Valid values ['VERIFY_EMAIL', 'EMAIL_SIGNIN', 'PASSWORD_RESET',
837+
'VERIFY_AND_CHANGE_EMAIL']
835838
email: Email of the user for which the action is performed
836839
action_code_settings: ``ActionCodeSettings`` object or dict (optional). Defines whether
837840
the link is to be handled by a mobile app and the additional state information to be
838841
passed in the deep link, etc.
842+
new_email: The new email address of the user. This is required if ``action_type``
843+
is 'VERIFY_AND_CHANGE_EMAIL'.
839844
Returns:
840-
link_url: action url to be emailed to the user
845+
str: Action URL to be emailed to the user
841846
842847
Raises:
843848
UnexpectedResponseError: If the backend server responds with an unexpected message
844849
FirebaseError: If an error occurs while generating the link
845850
ValueError: If the provided arguments are invalid
846851
"""
852+
if action_type == 'VERIFY_AND_CHANGE_EMAIL' and not new_email:
853+
raise ValueError(
854+
'new_email must be provided when action_type is VERIFY_AND_CHANGE_EMAIL.'
855+
)
856+
847857
payload = {
848858
'requestType': _auth_utils.validate_action_type(action_type),
849-
'email': _auth_utils.validate_email(email),
859+
'email': _auth_utils.validate_email(email, required=True),
850860
'returnOobLink': True
851861
}
852862

863+
if new_email:
864+
payload['newEmail'] = _auth_utils.validate_email(new_email, required=True)
865+
853866
if action_code_settings:
854867
payload.update(encode_action_code_settings(action_code_settings))
855868

firebase_admin/auth.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
'generate_email_verification_link',
9393
'generate_password_reset_link',
9494
'generate_sign_in_with_email_link',
95+
'generate_verify_and_change_email_link',
9596
'get_oidc_provider_config',
9697
'get_saml_provider_config',
9798
'get_user',
@@ -647,6 +648,30 @@ def generate_sign_in_with_email_link(email, action_code_settings, app=None):
647648
email, action_code_settings=action_code_settings)
648649

649650

651+
def generate_verify_and_change_email_link(email, new_email, action_code_settings=None, app=None):
652+
"""Generates the out-of-band email action link for email verification and change flows for the
653+
specified email address.
654+
655+
Args:
656+
email: The current email of the user.
657+
new_email: The new email address of the user to be verified.
658+
action_code_settings: ``ActionCodeSettings`` instance (optional). Defines whether
659+
the link is to be handled by a mobile app and the additional state information to be
660+
passed in the deep link.
661+
app: An App instance (optional).
662+
663+
Returns:
664+
str: The email verification and change link created by the API
665+
666+
Raises:
667+
ValueError: If the provided arguments are invalid
668+
FirebaseError: If an error occurs while generating the link
669+
"""
670+
client = _get_client(app)
671+
return client.generate_verify_and_change_email_link(
672+
email, new_email, action_code_settings=action_code_settings)
673+
674+
650675
def get_oidc_provider_config(provider_id, app=None):
651676
"""Returns the ``OIDCProviderConfig`` with the given ID.
652677

integration/test_auth.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,17 @@ def test_email_verification(new_user_email_unverified, api_key):
689689
assert new_user_email_unverified.email == user_email
690690
assert auth.get_user(new_user_email_unverified.uid).email_verified
691691

692+
def test_verify_and_change_email(new_user_email_unverified, api_key):
693+
_, new_email = _random_id()
694+
link = auth.generate_verify_and_change_email_link(new_user_email_unverified.email, new_email)
695+
assert isinstance(link, str)
696+
query_dict = _extract_link_params(link)
697+
user_email = _verify_email(query_dict['oobCode'], api_key)
698+
assert new_email == user_email
699+
user = auth.get_user(new_user_email_unverified.uid)
700+
assert user.email == new_email
701+
assert user.email_verified
702+
692703
def test_password_reset_with_settings(new_user_email_unverified, api_key):
693704
action_code_settings = auth.ActionCodeSettings(ACTION_LINK_CONTINUE_URL)
694705
link = auth.generate_password_reset_link(new_user_email_unverified.email,
@@ -712,6 +723,20 @@ def test_email_verification_with_settings(new_user_email_unverified, api_key):
712723
assert new_user_email_unverified.email == user_email
713724
assert auth.get_user(new_user_email_unverified.uid).email_verified
714725

726+
def test_verify_and_change_email_with_settings(new_user_email_unverified, api_key):
727+
_, new_email = _random_id()
728+
action_code_settings = auth.ActionCodeSettings(ACTION_LINK_CONTINUE_URL)
729+
link = auth.generate_verify_and_change_email_link(new_user_email_unverified.email, new_email,
730+
action_code_settings=action_code_settings)
731+
assert isinstance(link, str)
732+
query_dict = _extract_link_params(link)
733+
assert query_dict['continueUrl'] == ACTION_LINK_CONTINUE_URL
734+
user_email = _verify_email(query_dict['oobCode'], api_key)
735+
assert new_email == user_email
736+
user = auth.get_user(new_user_email_unverified.uid)
737+
assert user.email == new_email
738+
assert user.email_verified
739+
715740
def test_email_sign_in_with_settings(new_user_email_unverified, api_key):
716741
action_code_settings = auth.ActionCodeSettings(ACTION_LINK_CONTINUE_URL)
717742
link = auth.generate_sign_in_with_email_link(new_user_email_unverified.email,

integration/test_tenant_mgt.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,14 @@ def test_sign_in_with_email_link(sample_tenant, tenant_user):
202202
assert _tenant_id_from_link(link) == sample_tenant.tenant_id
203203

204204

205+
def test_verify_and_change_email_link(sample_tenant, tenant_user):
206+
client = tenant_mgt.auth_for_tenant(sample_tenant.tenant_id)
207+
new_email = _random_email()
208+
link = client.generate_verify_and_change_email_link(
209+
tenant_user.email, new_email, ACTION_CODE_SETTINGS
210+
)
211+
assert _tenant_id_from_link(link) == sample_tenant.tenant_id
212+
205213
def test_import_users(sample_tenant):
206214
client = tenant_mgt.auth_for_tenant(sample_tenant.tenant_id)
207215
user = auth.ImportUserRecord(

tests/test_tenant_mgt.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,24 @@ def test_generate_sign_in_with_email_link(self, tenant_mgt_app):
754754
'continueUrl': 'http://localhost',
755755
})
756756

757+
def test_generate_verify_and_change_email_link(self, tenant_mgt_app):
758+
client = tenant_mgt.auth_for_tenant('tenant-id', app=tenant_mgt_app)
759+
recorder = _instrument_user_mgt(client, 200, '{"oobLink":"https://testlink"}')
760+
settings = auth.ActionCodeSettings(url='http://localhost')
761+
762+
link = client.generate_verify_and_change_email_link(
763+
"test@test.com", "new@test.com", settings
764+
)
765+
766+
assert link == 'https://testlink'
767+
self._assert_request(recorder, '/accounts:sendOobCode', {
768+
'email': 'test@test.com',
769+
'newEmail': 'new@test.com',
770+
'requestType': 'VERIFY_AND_CHANGE_EMAIL',
771+
'returnOobLink': True,
772+
'continueUrl': 'http://localhost',
773+
})
774+
757775
def test_get_oidc_provider_config(self, tenant_mgt_app):
758776
client = tenant_mgt.auth_for_tenant('tenant-id', app=tenant_mgt_app)
759777
recorder = _instrument_provider_mgt(client, 200, OIDC_PROVIDER_CONFIG_RESPONSE)

tests/test_user_mgt.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1425,6 +1425,16 @@ def test_password_reset_no_settings(self, user_mgt_app):
14251425
assert request['requestType'] == 'PASSWORD_RESET'
14261426
self._validate_request(request)
14271427

1428+
def test_verify_and_change_email_no_settings(self, user_mgt_app):
1429+
_, recorder = _instrument_user_manager(user_mgt_app, 200, '{"oobLink":"https://testlink"}')
1430+
link = auth._get_client(user_mgt_app)._user_manager.generate_email_action_link(
1431+
'VERIFY_AND_CHANGE_EMAIL', 'test@test.com', new_email='new@test.com')
1432+
request = json.loads(recorder[0].body.decode())
1433+
1434+
assert link == 'https://testlink'
1435+
assert request['requestType'] == 'VERIFY_AND_CHANGE_EMAIL'
1436+
self._validate_request(request, new_email='new@test.com')
1437+
14281438
def test_email_signin_with_settings(self, user_mgt_app):
14291439
_, recorder = _instrument_user_manager(user_mgt_app, 200, '{"oobLink":"https://testlink"}')
14301440
link = auth.generate_sign_in_with_email_link('test@test.com',
@@ -1458,6 +1468,20 @@ def test_password_reset_with_settings(self, user_mgt_app):
14581468
assert request['requestType'] == 'PASSWORD_RESET'
14591469
self._validate_request(request, MOCK_ACTION_CODE_SETTINGS)
14601470

1471+
def test_verify_and_change_email_with_settings(self, user_mgt_app):
1472+
_, recorder = _instrument_user_manager(user_mgt_app, 200, '{"oobLink":"https://testlink"}')
1473+
link = auth._get_client(user_mgt_app)._user_manager.generate_email_action_link(
1474+
"VERIFY_AND_CHANGE_EMAIL",
1475+
"test@test.com",
1476+
action_code_settings=MOCK_ACTION_CODE_SETTINGS,
1477+
new_email="new@test.com",
1478+
)
1479+
request = json.loads(recorder[0].body.decode())
1480+
1481+
assert link == 'https://testlink'
1482+
assert request['requestType'] == 'VERIFY_AND_CHANGE_EMAIL'
1483+
self._validate_request(request, MOCK_ACTION_CODE_SETTINGS, new_email='new@test.com')
1484+
14611485
@pytest.mark.parametrize('func', [
14621486
auth.generate_sign_in_with_email_link,
14631487
auth.generate_email_verification_link,
@@ -1547,9 +1571,20 @@ def test_bad_action_type(self, user_mgt_app):
15471571
.generate_email_action_link('BAD_TYPE', 'test@test.com',
15481572
action_code_settings=MOCK_ACTION_CODE_SETTINGS)
15491573

1550-
def _validate_request(self, request, settings=None):
1574+
def test_verify_and_change_email_missing_new_email(self, user_mgt_app):
1575+
with pytest.raises(ValueError) as excinfo:
1576+
auth._get_client(user_mgt_app)._user_manager.generate_email_action_link(
1577+
'VERIFY_AND_CHANGE_EMAIL', 'test@test.com')
1578+
assert (
1579+
str(excinfo.value)
1580+
== "new_email must be provided when action_type is VERIFY_AND_CHANGE_EMAIL."
1581+
)
1582+
1583+
def _validate_request(self, request, settings=None, new_email=None):
15511584
assert request['email'] == 'test@test.com'
15521585
assert request['returnOobLink']
1586+
if new_email:
1587+
assert request['newEmail'] == new_email
15531588
if settings:
15541589
assert request['continueUrl'] == settings.url
15551590
assert request['canHandleCodeInApp'] == settings.handle_code_in_app

0 commit comments

Comments
 (0)