Add downscoping to ouath2 credentials (#309)


diff --git a/docs/reference/google.auth.crypt.rst b/docs/reference/google.auth.crypt.rst
index 26b8b4a..a3e2b12 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 bc6740b..53ab699 100644
--- a/docs/reference/google.auth.rst
+++ b/docs/reference/google.auth.rst
@@ -12,6 +12,7 @@
 .. toctree::
 
     google.auth.compute_engine
+    google.auth.crypt
     google.auth.transport
 
 Submodules
@@ -21,7 +22,6 @@
 
    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 dc35be2..5121a32 100644
--- a/google/oauth2/_client.py
+++ b/google/oauth2/_client.py
@@ -201,7 +201,8 @@
     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 @@
             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 @@
         '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 4cb909c..b56e314 100644
--- a/google/oauth2/credentials.py
+++ b/google/oauth2/credentials.py
@@ -67,10 +67,13 @@
             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 @@
         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 3ec7fc6..5a4a567 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 @@
     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 922c3bb..3231509 100644
--- a/tests/oauth2/test_credentials.py
+++ b/tests/oauth2/test_credentials.py
@@ -86,7 +86,7 @@
         # 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 @@
 
         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()