From 9b2b53c00e8ef2ee0c8dadf4d9953086b8b03b67 Mon Sep 17 00:00:00 2001 From: Hardik Shah Date: Mon, 4 Feb 2019 23:56:11 +0530 Subject: [PATCH 1/5] Migrating Auth API to the new Identity Toolkit endpoint --- CHANGELOG.md | 2 ++ firebase_admin/_user_mgt.py | 12 ++++++------ firebase_admin/auth.py | 10 ++++++++-- tests/test_token_gen.py | 24 ++++++++---------------- tests/test_user_mgt.py | 8 ++++++++ 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d45c6e..e58f2487 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +- [added] Migrated the `FirebaseAuth` user management API to the + new Identity Toolkit endpoint. - [fixed] Extending HTTP retries to more HTTP methods like POST and PATCH. # v2.15.1 diff --git a/firebase_admin/_user_mgt.py b/firebase_admin/_user_mgt.py index 7036a0bd..a7bebb8c 100644 --- a/firebase_admin/_user_mgt.py +++ b/firebase_admin/_user_mgt.py @@ -394,7 +394,7 @@ def get_user(self, **kwargs): raise TypeError('Unsupported keyword arguments: {0}.'.format(kwargs)) try: - response = self._client.body('post', 'getAccountInfo', json=payload) + response = self._client.body('post', '/accounts:lookup', json=payload) except requests.exceptions.RequestException as error: msg = 'Failed to get user by {0}: {1}.'.format(key_type, key) self._handle_http_error(INTERNAL_ERROR, msg, error) @@ -421,7 +421,7 @@ def list_users(self, page_token=None, max_results=MAX_LIST_USERS_RESULTS): if page_token: payload['nextPageToken'] = page_token try: - return self._client.body('post', 'downloadAccount', json=payload) + return self._client.body('get', '/accounts:batchGet', json=payload) except requests.exceptions.RequestException as error: self._handle_http_error(USER_DOWNLOAD_ERROR, 'Failed to download user accounts.', error) @@ -440,7 +440,7 @@ def create_user(self, uid=None, display_name=None, email=None, phone_number=None } payload = {k: v for k, v in payload.items() if v is not None} try: - response = self._client.body('post', 'signupNewUser', json=payload) + response = self._client.body('post', '/accounts', json=payload) except requests.exceptions.RequestException as error: self._handle_http_error(USER_CREATE_ERROR, 'Failed to create new user.', error) else: @@ -490,7 +490,7 @@ def update_user(self, uid, display_name=_UNSPECIFIED, email=None, phone_number=_ payload = {k: v for k, v in payload.items() if v is not None} try: - response = self._client.body('post', 'setAccountInfo', json=payload) + response = self._client.body('post', '/accounts:update', json=payload) except requests.exceptions.RequestException as error: self._handle_http_error( USER_UPDATE_ERROR, 'Failed to update user: {0}.'.format(uid), error) @@ -503,7 +503,7 @@ def delete_user(self, uid): """Deletes the user identified by the specified user ID.""" _auth_utils.validate_uid(uid, required=True) try: - response = self._client.body('post', 'deleteAccount', json={'localId' : uid}) + response = self._client.body('post', '/accounts:delete', json={'localId' : uid}) except requests.exceptions.RequestException as error: self._handle_http_error( USER_DELETE_ERROR, 'Failed to delete user: {0}.'.format(uid), error) @@ -529,7 +529,7 @@ def import_users(self, users, hash_alg=None): raise ValueError('A UserImportHash is required to import users with passwords.') payload.update(hash_alg.to_dict()) try: - response = self._client.body('post', 'uploadAccount', json=payload) + response = self._client.body('post', '/accounts:batchCreate', json=payload) except requests.exceptions.RequestException as error: self._handle_http_error(USER_IMPORT_ERROR, 'Failed to import users.', error) else: diff --git a/firebase_admin/auth.py b/firebase_admin/auth.py index aa020b3c..f46769ea 100644 --- a/firebase_admin/auth.py +++ b/firebase_admin/auth.py @@ -466,13 +466,19 @@ def __init__(self, code, message, error=None): class _AuthService(object): """Firebase Authentication service.""" - ID_TOOLKIT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/' + ID_TOOLKIT_URL = 'https://identitytoolkit.googleapis.com/v1/projects/' def __init__(self, app): credential = app.credential.get_credential() version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__) + + if not app.project_id: + raise ValueError("Project ID is required to access the auth service. Use a service account credential or " + + "set the project ID explicitly via FirebaseOptions. Alternatively you can also " + + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable.") + client = _http_client.JsonHttpClient( - credential=credential, base_url=self.ID_TOOLKIT_URL, + credential=credential, base_url=self.ID_TOOLKIT_URL + app.project_id, headers={'X-Client-Version': version_header}) self._token_generator = _token_gen.TokenGenerator(app, client) self._token_verifier = _token_gen.TokenVerifier(app) diff --git a/tests/test_token_gen.py b/tests/test_token_gen.py index c5b686c8..52ccd172 100644 --- a/tests/test_token_gen.py +++ b/tests/test_token_gen.py @@ -199,7 +199,7 @@ def test_noncert_credential(self, user_mgt_app): auth.create_custom_token(MOCK_UID, app=user_mgt_app) def test_sign_with_iam(self): - options = {'serviceAccountId': 'test-service-account'} + options = {'serviceAccountId': 'test-service-account', 'projectId': 'mock-project-id'} app = firebase_admin.initialize_app( testutils.MockCredential(), name='iam-signer-app', options=options) try: @@ -213,7 +213,7 @@ def test_sign_with_iam(self): firebase_admin.delete_app(app) def test_sign_with_iam_error(self): - options = {'serviceAccountId': 'test-service-account'} + options = {'serviceAccountId': 'test-service-account', 'projectId': 'mock-project-id'} app = firebase_admin.initialize_app( testutils.MockCredential(), name='iam-signer-app', options=options) try: @@ -228,7 +228,9 @@ def test_sign_with_iam_error(self): def test_sign_with_discovered_service_account(self): request = testutils.MockRequest(200, 'discovered-service-account') - app = firebase_admin.initialize_app(testutils.MockCredential(), name='iam-signer-app') + options = {'projectId': 'mock-project-id'} + app = firebase_admin.initialize_app(testutils.MockCredential(), name='iam-signer-app', + options=options) try: _overwrite_iam_request(app, request) # Force initialization of the signing provider. This will invoke the Metadata service. @@ -248,7 +250,9 @@ def test_sign_with_discovered_service_account(self): def test_sign_with_discovery_failure(self): request = testutils.MockFailedRequest(Exception('test error')) - app = firebase_admin.initialize_app(testutils.MockCredential(), name='iam-signer-app') + options = {'projectId': 'mock-project-id'} + app = firebase_admin.initialize_app(testutils.MockCredential(), name='iam-signer-app', + options=options) try: _overwrite_iam_request(app, request) with pytest.raises(ValueError) as excinfo: @@ -409,12 +413,6 @@ def test_project_id_env_var(self, env_var_app): claims = auth.verify_id_token(TEST_ID_TOKEN, env_var_app) assert claims['admin'] is True - @pytest.mark.parametrize('env_var_app', [{}], indirect=True) - def test_no_project_id(self, env_var_app): - _overwrite_cert_request(env_var_app, MOCK_REQUEST) - with pytest.raises(ValueError): - auth.verify_id_token(TEST_ID_TOKEN, env_var_app) - def test_custom_token(self, auth_app): id_token = auth.create_custom_token(MOCK_UID, app=auth_app) _overwrite_cert_request(auth_app, MOCK_REQUEST) @@ -515,12 +513,6 @@ def test_project_id_env_var(self, env_var_app): claims = auth.verify_session_cookie(TEST_SESSION_COOKIE, app=env_var_app) assert claims['admin'] is True - @pytest.mark.parametrize('env_var_app', [{}], indirect=True) - def test_no_project_id(self, env_var_app): - _overwrite_cert_request(env_var_app, MOCK_REQUEST) - with pytest.raises(ValueError): - auth.verify_session_cookie(TEST_SESSION_COOKIE, app=env_var_app) - def test_custom_token(self, auth_app): custom_token = auth.create_custom_token(MOCK_UID, app=auth_app) _overwrite_cert_request(auth_app, MOCK_REQUEST) diff --git a/tests/test_user_mgt.py b/tests/test_user_mgt.py index 186748db..f49cd3af 100644 --- a/tests/test_user_mgt.py +++ b/tests/test_user_mgt.py @@ -87,6 +87,14 @@ def _check_user_record(user, expected_uid='testuser'): assert provider.provider_id == 'phone' +class TestAuthServiceInitialization(object): + + def test_fail_on_no_project_id(self): + app = firebase_admin.initialize_app(testutils.MockCredential(), name='userMgt2') + with pytest.raises(ValueError): + auth._get_auth_service(app) + firebase_admin.delete_app(app) + class TestUserRecord(object): # Input dict must be non-empty, and must not contain unsupported keys. From 8a685bc9e894f16721b9a2aad65c0f17c7ca8814 Mon Sep 17 00:00:00 2001 From: Hardik Shah Date: Tue, 5 Feb 2019 00:30:28 +0530 Subject: [PATCH 2/5] fix lint errors --- firebase_admin/auth.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/firebase_admin/auth.py b/firebase_admin/auth.py index f46769ea..a6658135 100644 --- a/firebase_admin/auth.py +++ b/firebase_admin/auth.py @@ -473,9 +473,10 @@ def __init__(self, app): version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__) if not app.project_id: - raise ValueError("Project ID is required to access the auth service. Use a service account credential or " - + "set the project ID explicitly via FirebaseOptions. Alternatively you can also " - + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable.") + raise ValueError("""Project ID is required to access the auth service. + 1. Use a service account credential, or + 2. set the project ID explicitly via FirebaseOptions, or + 3. set the project ID via the GOOGLE_CLOUD_PROJECT environment variable.""") client = _http_client.JsonHttpClient( credential=credential, base_url=self.ID_TOOLKIT_URL + app.project_id, From 16b773378cc12575f734ca565ec0ae09760f03b7 Mon Sep 17 00:00:00 2001 From: Hardik Shah Date: Tue, 5 Feb 2019 02:23:47 +0530 Subject: [PATCH 3/5] fixed integration tests --- firebase_admin/_token_gen.py | 2 +- firebase_admin/_user_mgt.py | 2 +- integration/test_auth.py | 1 + tests/test_user_mgt.py | 6 ++++-- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/firebase_admin/_token_gen.py b/firebase_admin/_token_gen.py index 1ff9bc7a..e2eaa571 100644 --- a/firebase_admin/_token_gen.py +++ b/firebase_admin/_token_gen.py @@ -206,7 +206,7 @@ def create_session_cookie(self, id_token, expires_in): 'validDuration': expires_in, } try: - response = self.client.body('post', 'createSessionCookie', json=payload) + response = self.client.body('post', ':createSessionCookie', json=payload) except requests.exceptions.RequestException as error: self._handle_http_error(COOKIE_CREATE_ERROR, 'Failed to create session cookie', error) else: diff --git a/firebase_admin/_user_mgt.py b/firebase_admin/_user_mgt.py index a7bebb8c..227e1315 100644 --- a/firebase_admin/_user_mgt.py +++ b/firebase_admin/_user_mgt.py @@ -421,7 +421,7 @@ def list_users(self, page_token=None, max_results=MAX_LIST_USERS_RESULTS): if page_token: payload['nextPageToken'] = page_token try: - return self._client.body('get', '/accounts:batchGet', json=payload) + return self._client.body('get', '/accounts:batchGet', params=payload) except requests.exceptions.RequestException as error: self._handle_http_error(USER_DOWNLOAD_ERROR, 'Failed to download user accounts.', error) diff --git a/integration/test_auth.py b/integration/test_auth.py index 07da9cf2..8604761c 100644 --- a/integration/test_auth.py +++ b/integration/test_auth.py @@ -65,6 +65,7 @@ def test_custom_token_without_service_account(api_key): cred = CredentialWrapper.from_existing_credential(google_cred) custom_app = firebase_admin.initialize_app(cred, { 'serviceAccountId': google_cred.service_account_email, + 'projectId': firebase_admin.get_app().project_id }, 'temp-app') try: custom_token = auth.create_custom_token('user1', app=custom_app) diff --git a/tests/test_user_mgt.py b/tests/test_user_mgt.py index f49cd3af..15cd8601 100644 --- a/tests/test_user_mgt.py +++ b/tests/test_user_mgt.py @@ -25,6 +25,8 @@ from firebase_admin import _user_import from firebase_admin import _user_mgt from tests import testutils +from urllib3.util import parse_url +from requests.compat import urlencode INVALID_STRINGS = [None, '', 0, 1, True, False, list(), tuple(), dict()] @@ -628,8 +630,8 @@ def _check_rpc_calls(self, recorder, expected=None): if expected is None: expected = {'maxResults' : 1000} assert len(recorder) == 1 - request = json.loads(recorder[0].body.decode()) - assert request == expected + request = parse_url(recorder[0].url).query + assert request == urlencode(query=expected) class TestUserProvider(object): From 73872c1140c82ab906b36fe9ecf282f05ffeeb01 Mon Sep 17 00:00:00 2001 From: Hardik Shah Date: Tue, 5 Feb 2019 15:15:24 +0530 Subject: [PATCH 4/5] fix to support py3 test verification --- tests/test_user_mgt.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/test_user_mgt.py b/tests/test_user_mgt.py index 15cd8601..af48f0ae 100644 --- a/tests/test_user_mgt.py +++ b/tests/test_user_mgt.py @@ -25,8 +25,11 @@ from firebase_admin import _user_import from firebase_admin import _user_mgt from tests import testutils -from urllib3.util import parse_url -from requests.compat import urlencode + +try: + from urllib.parse import urlsplit, parse_qsl +except ImportError: + from urlparse import urlsplit, parse_qsl INVALID_STRINGS = [None, '', 0, 1, True, False, list(), tuple(), dict()] @@ -521,7 +524,7 @@ def test_list_multiple_pages(self, user_mgt_app): assert page.next_page_token == '' assert page.has_next_page is False assert page.get_next_page() is None - self._check_rpc_calls(recorder, {'maxResults': 1000, 'nextPageToken': 'token'}) + self._check_rpc_calls(recorder, {'maxResults': '1000', 'nextPageToken': 'token'}) def test_list_users_paged_iteration(self, user_mgt_app): # Page 1 @@ -547,7 +550,7 @@ def test_list_users_paged_iteration(self, user_mgt_app): assert user.uid == 'user4' with pytest.raises(StopIteration): next(iterator) - self._check_rpc_calls(recorder, {'maxResults': 1000, 'nextPageToken': 'token'}) + self._check_rpc_calls(recorder, {'maxResults': '1000', 'nextPageToken': 'token'}) def test_list_users_iterator_state(self, user_mgt_app): response = { @@ -600,13 +603,13 @@ def test_list_users_with_max_results(self, user_mgt_app): _, recorder = _instrument_user_manager(user_mgt_app, 200, MOCK_LIST_USERS_RESPONSE) page = auth.list_users(max_results=500, app=user_mgt_app) self._check_page(page) - self._check_rpc_calls(recorder, {'maxResults' : 500}) + self._check_rpc_calls(recorder, {'maxResults' : '500'}) def test_list_users_with_all_args(self, user_mgt_app): _, recorder = _instrument_user_manager(user_mgt_app, 200, MOCK_LIST_USERS_RESPONSE) page = auth.list_users(page_token='foo', max_results=500, app=user_mgt_app) self._check_page(page) - self._check_rpc_calls(recorder, {'nextPageToken' : 'foo', 'maxResults' : 500}) + self._check_rpc_calls(recorder, {'nextPageToken' : 'foo', 'maxResults' : '500'}) def test_list_users_error(self, user_mgt_app): _instrument_user_manager(user_mgt_app, 500, '{"error":"test"}') @@ -628,10 +631,10 @@ def _check_page(self, page): def _check_rpc_calls(self, recorder, expected=None): if expected is None: - expected = {'maxResults' : 1000} + expected = {'maxResults' : '1000'} assert len(recorder) == 1 - request = parse_url(recorder[0].url).query - assert request == urlencode(query=expected) + request = dict(parse_qsl(urlsplit(recorder[0].url).query)) + assert request == expected class TestUserProvider(object): From bf4b83326ea87613eebf66920d9a79d702613168 Mon Sep 17 00:00:00 2001 From: Hardik Shah Date: Thu, 7 Feb 2019 10:21:23 +0530 Subject: [PATCH 5/5] fix review comments --- CHANGELOG.md | 2 +- firebase_admin/auth.py | 2 +- tests/test_user_mgt.py | 7 ++----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e58f2487..b7b12de9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Unreleased -- [added] Migrated the `FirebaseAuth` user management API to the +- [added] Migrated the `auth` user management API to the new Identity Toolkit endpoint. - [fixed] Extending HTTP retries to more HTTP methods like POST and PATCH. diff --git a/firebase_admin/auth.py b/firebase_admin/auth.py index a6658135..4c793d34 100644 --- a/firebase_admin/auth.py +++ b/firebase_admin/auth.py @@ -475,7 +475,7 @@ def __init__(self, app): if not app.project_id: raise ValueError("""Project ID is required to access the auth service. 1. Use a service account credential, or - 2. set the project ID explicitly via FirebaseOptions, or + 2. set the project ID explicitly via Firebase App options, or 3. set the project ID via the GOOGLE_CLOUD_PROJECT environment variable.""") client = _http_client.JsonHttpClient( diff --git a/tests/test_user_mgt.py b/tests/test_user_mgt.py index af48f0ae..f20a4e71 100644 --- a/tests/test_user_mgt.py +++ b/tests/test_user_mgt.py @@ -26,10 +26,7 @@ from firebase_admin import _user_mgt from tests import testutils -try: - from urllib.parse import urlsplit, parse_qsl -except ImportError: - from urlparse import urlsplit, parse_qsl +from six.moves import urllib INVALID_STRINGS = [None, '', 0, 1, True, False, list(), tuple(), dict()] @@ -633,7 +630,7 @@ def _check_rpc_calls(self, recorder, expected=None): if expected is None: expected = {'maxResults' : '1000'} assert len(recorder) == 1 - request = dict(parse_qsl(urlsplit(recorder[0].url).query)) + request = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(recorder[0].url).query)) assert request == expected