From 439e2e59788235e41d47c51ae63ad71b104e8518 Mon Sep 17 00:00:00 2001 From: Google Earth Engine Authors Date: Tue, 9 Sep 2025 10:50:44 -0700 Subject: [PATCH] oauth: Reauthenticate when requested scopes differ from stored credentials. PiperOrigin-RevId: 804977480 --- python/ee/oauth.py | 32 ++++++++++++--- python/ee/tests/oauth_test.py | 73 +++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 5 deletions(-) diff --git a/python/ee/oauth.py b/python/ee/oauth.py index 0346ddd05..cb3a51b5d 100644 --- a/python/ee/oauth.py +++ b/python/ee/oauth.py @@ -96,7 +96,8 @@ def get_credentials_arguments() -> dict[str, Any]: args['refresh_token'] = stored.get('refresh_token') args['client_id'] = stored.get('client_id', CLIENT_ID) args['client_secret'] = stored.get('client_secret', CLIENT_SECRET) - args['scopes'] = stored.get('scopes', SCOPES) + if 'scopes' in stored: + args['scopes'] = stored.get('scopes') args['quota_project_id'] = stored.get('project') return args @@ -126,10 +127,22 @@ def get_appdefault_project() -> Optional[str]: return None -def _valid_credentials_exist() -> bool: +def _valid_credentials_exist( + scopes: Optional[Sequence[str]] = None, +) -> bool: + """Checks if valid credentials exist and match the requested scopes.""" try: creds = ee_data.get_persistent_credentials() - return is_valid_credentials(creds) + if not is_valid_credentials(creds): + return False + if scopes is not None: + try: + stored_args = get_credentials_arguments() + if set(stored_args.get('scopes', SCOPES)) != set(scopes): + return False + except FileNotFoundError: + return False + return True except ee_exception.EEException: return False @@ -387,10 +400,11 @@ def _load_gcloud_credentials( run_gcloud_legacy: bool = False, ) -> None: """Initializes credentials by running gcloud flows.""" + scopes = scopes or SCOPES client_id_file = None command = GCLOUD_COMMAND.split() command[0] = shutil.which(command[0]) or command[0] # Windows fix - command += ['--scopes=%s' % (','.join(scopes or SCOPES))] + command += ['--scopes=%s' % (','.join(scopes))] if run_gcloud_legacy: client_id_json = dict( client_id=CLIENT_ID, @@ -425,6 +439,7 @@ def _load_gcloud_credentials( with open(adc_path) as adc_json: adc = json.load(adc_json) adc = {k: adc[k] for k in ['client_id', 'client_secret', 'refresh_token']} + adc['scopes'] = scopes write_private_json(get_credentials_path(), adc) print('\nSuccessfully saved authorization token.') @@ -514,11 +529,18 @@ def authenticate( Exception: on invalid arguments. """ + if auth_mode == 'colab' and scopes is not None and set(scopes) != set(SCOPES): + raise ee_exception.EEException( + 'Scopes cannot be customized when auth_mode is "colab". Please see' + ' https://developers.google.com/earth-engine/guides/auth#quick_reference_guide_and_table' + ' for more information.' + ) + if cli_authorization_code: _obtain_and_write_token(cli_authorization_code, cli_code_verifier, scopes) return - if not force and _valid_credentials_exist(): + if not force and _valid_credentials_exist(scopes): return True if not auth_mode: diff --git a/python/ee/tests/oauth_test.py b/python/ee/tests/oauth_test.py index 99b18cb29..24e4d8476 100644 --- a/python/ee/tests/oauth_test.py +++ b/python/ee/tests/oauth_test.py @@ -8,6 +8,7 @@ import urllib.parse import unittest +from ee import ee_exception from ee import oauth @@ -77,5 +78,77 @@ def test_is_sdk_credentials(self): ) ) + def testAuthenticate_colabAuthModeWithNonstandardScopes_raisesException(self): + with self.assertRaisesRegex( + ee_exception.EEException, + 'Scopes cannot be customized when auth_mode is "colab".' + ): + oauth.authenticate( + auth_mode='colab', + scopes=['https://www.googleapis.com/auth/earthengine.readonly'] + ) + + def testAuthenticate_colabAuthModeWithStandardScopes_succeeds(self): + # Should not raise an exception if the scopes are not narrowed. + with mock.patch.dict(sys.modules, {'google.colab': mock.MagicMock()}): + try: + oauth.authenticate(auth_mode='colab', scopes=oauth.SCOPES) + except ee_exception.EEException: + self.fail('authenticate raised an exception unexpectedly.') + + @mock.patch.object(oauth, '_obtain_and_write_token') + @mock.patch.object(oauth, 'is_valid_credentials') + @mock.patch.object(oauth, 'get_credentials_arguments') + @mock.patch.object(oauth.ee_data, 'get_persistent_credentials') + def testAuthenticate_differentScopes_reauthenticates( + self, + mock_get_persistent_credentials, + mock_get_credentials_arguments, + mock_is_valid_credentials, + mock_obtain_and_write_token, + ): + # Mock valid credentials to exist initially. + mock_is_valid_credentials.return_value = True + mock_get_persistent_credentials.return_value = mock.MagicMock() + + # First call with default scopes. + mock_get_credentials_arguments.return_value = {'scopes': oauth.SCOPES} + oauth.authenticate(auth_mode='notebook', scopes=oauth.SCOPES) + mock_obtain_and_write_token.assert_not_called() + + # Second call with different scopes. + new_scopes = ['https://www.googleapis.com/auth/earthengine.readonly'] + oauth.authenticate(auth_mode='notebook', scopes=new_scopes) + mock_obtain_and_write_token.assert_called_once() + + # Third call with the new scopes again. + mock_obtain_and_write_token.reset_mock() + mock_get_credentials_arguments.return_value = {'scopes': new_scopes} + oauth.authenticate(auth_mode='notebook', scopes=new_scopes) + mock_obtain_and_write_token.assert_not_called() + + @mock.patch.object(oauth, '_obtain_and_write_token') + @mock.patch.object(oauth, 'is_valid_credentials') + @mock.patch.object(oauth, 'get_credentials_arguments') + @mock.patch.object(oauth.ee_data, 'get_persistent_credentials') + def testAuthenticate_oldCreds_defaultScopes_succeeds( + self, + mock_get_persistent_credentials, + mock_get_credentials_arguments, + mock_is_valid_credentials, + mock_obtain_and_write_token, + ): + # Mock valid credentials to exist initially. + mock_is_valid_credentials.return_value = True + mock_get_persistent_credentials.return_value = mock.MagicMock() + + # If get_credentials_arguments returns no scopes (i.e., file saved by an + # older client), and we authenticate with default scopes, we should not + # trigger re-auth. This is to minimize disruption for the majority of users + # updating to the latest client. + mock_get_credentials_arguments.return_value = {} # No 'scopes' key + oauth.authenticate(auth_mode='notebook', scopes=oauth.SCOPES) + mock_obtain_and_write_token.assert_not_called() + if __name__ == '__main__': unittest.main()