diff --git a/docs/reference/google.auth.crypt.rst b/docs/reference/google.auth.crypt.rst index 26b8b4ac4..a3e2b1206 100644 --- a/docs/reference/google.auth.crypt.rst +++ b/docs/reference/google.auth.crypt.rst @@ -1,7 +1,16 @@ -google.auth.crypt module -======================== +google.auth.crypt package +========================= .. automodule:: google.auth.crypt :members: :inherited-members: :show-inheritance: + +Submodules +---------- + +.. toctree:: + + google.auth.crypt.base + google.auth.crypt.rsa + diff --git a/docs/reference/google.auth.rst b/docs/reference/google.auth.rst index bc6740b09..53ab699c6 100644 --- a/docs/reference/google.auth.rst +++ b/docs/reference/google.auth.rst @@ -12,6 +12,7 @@ Subpackages .. toctree:: google.auth.compute_engine + google.auth.crypt google.auth.transport Submodules @@ -21,7 +22,6 @@ Submodules google.auth.app_engine google.auth.credentials - google.auth.crypt google.auth.environment_vars google.auth.exceptions google.auth.iam diff --git a/google/oauth2/_client.py b/google/oauth2/_client.py index dc35be271..5121a3274 100644 --- a/google/oauth2/_client.py +++ b/google/oauth2/_client.py @@ -201,7 +201,8 @@ def id_token_jwt_grant(request, token_uri, assertion): return id_token, expiry, response_data -def refresh_grant(request, token_uri, refresh_token, client_id, client_secret): +def refresh_grant(request, token_uri, refresh_token, client_id, client_secret, + scopes=None): """Implements the OAuth 2.0 refresh token grant. For more details, see `rfc678 section 6`_. @@ -215,6 +216,10 @@ def refresh_grant(request, token_uri, refresh_token, client_id, client_secret): token. client_id (str): The OAuth 2.0 application's client ID. client_secret (str): The Oauth 2.0 appliaction's client secret. + scopes (Optional(Sequence[str])): Scopes to request. If present, all + scopes must be authorized for the refresh token. Useful if refresh + token has a wild card scope (e.g. + 'https://www.googleapis.com/auth/any-api'). Returns: Tuple[str, Optional[str], Optional[datetime], Mapping[str, str]]: The @@ -233,6 +238,8 @@ def refresh_grant(request, token_uri, refresh_token, client_id, client_secret): 'client_secret': client_secret, 'refresh_token': refresh_token, } + if scopes: + body['scope'] = ' '.join(scopes) response_data = _token_endpoint_request(request, token_uri, body) diff --git a/google/oauth2/credentials.py b/google/oauth2/credentials.py index 4cb909cb6..b56e31426 100644 --- a/google/oauth2/credentials.py +++ b/google/oauth2/credentials.py @@ -67,10 +67,13 @@ def __init__(self, token, refresh_token=None, id_token=None, client_secret(str): The OAuth 2.0 client secret. Must be specified for refresh, can be left as None if the token can not be refreshed. - scopes (Sequence[str]): The scopes that were originally used - to obtain authorization. This is a purely informative parameter - that can be used by :meth:`has_scopes`. OAuth 2.0 credentials - can not request additional scopes after authorization. + scopes (Sequence[str]): The scopes used to obtain authorization. + This parameter is used by :meth:`has_scopes`. OAuth 2.0 + credentials can not request additional scopes after + authorization. The scopes must be derivable from the refresh + token if refresh information is provided (e.g. The refresh + token scopes are a superset of this or contain a wild card + scope like 'https://www.googleapis.com/auth/any-api'). """ super(Credentials, self).__init__() self.token = token @@ -133,13 +136,24 @@ def refresh(self, request): access_token, refresh_token, expiry, grant_response = ( _client.refresh_grant( request, self._token_uri, self._refresh_token, self._client_id, - self._client_secret)) + self._client_secret, self._scopes)) self.token = access_token self.expiry = expiry self._refresh_token = refresh_token self._id_token = grant_response.get('id_token') + if self._scopes and 'scopes' in grant_response: + requested_scopes = frozenset(self._scopes) + granted_scopes = frozenset(grant_response['scopes'].split()) + scopes_requested_but_not_granted = ( + requested_scopes - granted_scopes) + if scopes_requested_but_not_granted: + raise exceptions.RefreshError( + 'Not all requested scopes were granted by the ' + 'authorization server, missing scopes {}.'.format( + ', '.join(scopes_requested_but_not_granted))) + @classmethod def from_authorized_user_info(cls, info, scopes=None): """Creates a Credentials instance from parsed authorized user info. diff --git a/tests/oauth2/test__client.py b/tests/oauth2/test__client.py index 3ec7fc62a..5a4a56745 100644 --- a/tests/oauth2/test__client.py +++ b/tests/oauth2/test__client.py @@ -37,6 +37,11 @@ SIGNER = crypt.RSASigner.from_string(PRIVATE_KEY_BYTES, '1') +SCOPES_AS_LIST = ['https://www.googleapis.com/auth/pubsub', + 'https://www.googleapis.com/auth/logging.write'] +SCOPES_AS_STRING = ('https://www.googleapis.com/auth/pubsub' + ' https://www.googleapis.com/auth/logging.write') + def test__handle_error_response(): response_data = json.dumps({ @@ -204,6 +209,35 @@ def test_refresh_grant(unused_utcnow): assert extra_data['extra'] == 'data' +@mock.patch('google.auth._helpers.utcnow', return_value=datetime.datetime.min) +def test_refresh_grant_with_scopes(unused_utcnow): + request = make_request({ + 'access_token': 'token', + 'refresh_token': 'new_refresh_token', + 'expires_in': 500, + 'extra': 'data', + 'scope': SCOPES_AS_STRING}) + + token, refresh_token, expiry, extra_data = _client.refresh_grant( + request, 'http://example.com', 'refresh_token', 'client_id', + 'client_secret', SCOPES_AS_LIST) + + # Check request call. + verify_request_params(request, { + 'grant_type': _client._REFRESH_GRANT_TYPE, + 'refresh_token': 'refresh_token', + 'client_id': 'client_id', + 'client_secret': 'client_secret', + 'scope': SCOPES_AS_STRING + }) + + # Check result. + assert token == 'token' + assert refresh_token == 'new_refresh_token' + assert expiry == datetime.datetime.min + datetime.timedelta(seconds=500) + assert extra_data['extra'] == 'data' + + def test_refresh_grant_no_access_token(): request = make_request({ # No access token. diff --git a/tests/oauth2/test_credentials.py b/tests/oauth2/test_credentials.py index 922c3bbf7..32315096a 100644 --- a/tests/oauth2/test_credentials.py +++ b/tests/oauth2/test_credentials.py @@ -86,7 +86,7 @@ def test_refresh_success(self, unused_utcnow, refresh_grant): # Check jwt grant call. refresh_grant.assert_called_with( request, self.TOKEN_URI, self.REFRESH_TOKEN, self.CLIENT_ID, - self.CLIENT_SECRET) + self.CLIENT_SECRET, None) # Check that the credentials have the token and expiry assert credentials.token == token @@ -107,6 +107,143 @@ def test_refresh_no_refresh_token(self): request.assert_not_called() + @mock.patch('google.oauth2._client.refresh_grant', autospec=True) + @mock.patch( + 'google.auth._helpers.utcnow', + return_value=datetime.datetime.min + _helpers.CLOCK_SKEW) + def test_credentials_with_scopes_requested_refresh_success( + self, unused_utcnow, refresh_grant): + scopes = ['email', 'profile'] + token = 'token' + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {'id_token': mock.sentinel.id_token} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response) + + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, scopes=scopes) + + # Refresh credentials + creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, self.TOKEN_URI, self.REFRESH_TOKEN, self.CLIENT_ID, + self.CLIENT_SECRET, scopes) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + @mock.patch('google.oauth2._client.refresh_grant', autospec=True) + @mock.patch( + 'google.auth._helpers.utcnow', + return_value=datetime.datetime.min + _helpers.CLOCK_SKEW) + def test_credentials_with_scopes_returned_refresh_success( + self, unused_utcnow, refresh_grant): + scopes = ['email', 'profile'] + token = 'token' + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {'id_token': mock.sentinel.id_token, + 'scopes': ' '.join(scopes)} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response) + + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, scopes=scopes) + + # Refresh credentials + creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, self.TOKEN_URI, self.REFRESH_TOKEN, self.CLIENT_ID, + self.CLIENT_SECRET, scopes) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + + @mock.patch('google.oauth2._client.refresh_grant', autospec=True) + @mock.patch( + 'google.auth._helpers.utcnow', + return_value=datetime.datetime.min + _helpers.CLOCK_SKEW) + def test_credentials_with_scopes_refresh_failure_raises_refresh_error( + self, unused_utcnow, refresh_grant): + scopes = ['email', 'profile'] + scopes_returned = ['email'] + token = 'token' + expiry = _helpers.utcnow() + datetime.timedelta(seconds=500) + grant_response = {'id_token': mock.sentinel.id_token, + 'scopes': ' '.join(scopes_returned)} + refresh_grant.return_value = ( + # Access token + token, + # New refresh token + None, + # Expiry, + expiry, + # Extra data + grant_response) + + request = mock.create_autospec(transport.Request) + creds = credentials.Credentials( + token=None, refresh_token=self.REFRESH_TOKEN, + token_uri=self.TOKEN_URI, client_id=self.CLIENT_ID, + client_secret=self.CLIENT_SECRET, scopes=scopes) + + # Refresh credentials + with pytest.raises(exceptions.RefreshError, + match='Not all requested scopes were granted'): + creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, self.TOKEN_URI, self.REFRESH_TOKEN, self.CLIENT_ID, + self.CLIENT_SECRET, scopes) + + # Check that the credentials have the token and expiry + assert creds.token == token + assert creds.expiry == expiry + assert creds.id_token == mock.sentinel.id_token + assert creds.has_scopes(scopes) + + # Check that the credentials are valid (have a token and are not + # expired.) + assert creds.valid + def test_from_authorized_user_info(self): info = AUTH_USER_INFO.copy()