Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add downscoping to ouath2 credentials #309

Merged
merged 16 commits into from
May 22, 2019
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 11 additions & 2 deletions docs/reference/google.auth.crypt.rst
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
google.auth.crypt module
========================
google.auth.crypt package
=========================

This comment was marked as spam.

This comment was marked as spam.


.. automodule:: google.auth.crypt
:members:
:inherited-members:
:show-inheritance:

Submodules
----------

.. toctree::

google.auth.crypt.base
google.auth.crypt.rsa

2 changes: 1 addition & 1 deletion docs/reference/google.auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Subpackages
.. toctree::

google.auth.compute_engine
google.auth.crypt
google.auth.transport

Submodules
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion google/oauth2/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`_.
Expand All @@ -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
Expand All @@ -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)

Expand Down
27 changes: 21 additions & 6 deletions google/oauth2/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ class Credentials(credentials.ReadOnlyScoped, credentials.Credentials):

def __init__(self, token, refresh_token=None, id_token=None,
token_uri=None, client_id=None, client_secret=None,
scopes=None):
scopes=None, downscope=False):
"""
Args:
token (Optional(str)): The OAuth 2.0 access token. Can be None
Expand All @@ -71,6 +71,10 @@ def __init__(self, token, refresh_token=None, id_token=None,
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.
downscope (bool): Whether to reduce the requested scopes from those

This comment was marked as spam.

This comment was marked as spam.

of the refresh token to those listed in scopes. Useful if
refresh token has a wild card scope (e.g.
'https://www.googleapis.com/auth/any-api').

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.

"""
super(Credentials, self).__init__()
self.token = token
Expand All @@ -80,6 +84,7 @@ def __init__(self, token, refresh_token=None, id_token=None,
self._token_uri = token_uri
self._client_id = client_id
self._client_secret = client_secret
self._downscope = downscope

@property
def refresh_token(self):
Expand Down Expand Up @@ -130,25 +135,30 @@ def refresh(self, request):
'refresh the access token. You must specify refresh_token, '
'token_uri, client_id, and client_secret.')

scopes = self._scopes if self._downscope else None
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, scopes))

self.token = access_token
self.expiry = expiry
self._refresh_token = refresh_token
self._id_token = grant_response.get('id_token')

@classmethod
def from_authorized_user_info(cls, info, scopes=None):
def from_authorized_user_info(cls, info, scopes=None, downscope=False):
"""Creates a Credentials instance from parsed authorized user info.

Args:
info (Mapping[str, str]): The authorized user info in Google
format.
scopes (Sequence[str]): Optional list of scopes to include in the
credentials.
downscope (bool): Whether to reduce the requested scopes from those
of the refresh token to those listed in scopes. Useful if
refresh token has a wild card scope (e.g.
'https://www.googleapis.com/auth/any-api').

Returns:
google.oauth2.credentials.Credentials: The constructed
Expand All @@ -171,16 +181,21 @@ def from_authorized_user_info(cls, info, scopes=None):
token_uri=_GOOGLE_OAUTH2_TOKEN_ENDPOINT,
scopes=scopes,
client_id=info['client_id'],
client_secret=info['client_secret'])
client_secret=info['client_secret'],
downscope=downscope)

@classmethod
def from_authorized_user_file(cls, filename, scopes=None):
def from_authorized_user_file(cls, filename, scopes=None, downscope=False):
"""Creates a Credentials instance from an authorized user json file.

Args:
filename (str): The path to the authorized user json file.
scopes (Sequence[str]): Optional list of scopes to include in the
credentials.
downscope (bool): Whether to reduce the requested scopes from those
of the refresh token to those listed in scopes. Useful if
refresh token has a wild card scope (e.g.
'https://www.googleapis.com/auth/any-api').

Returns:
google.oauth2.credentials.Credentials: The constructed
Expand All @@ -191,4 +206,4 @@ def from_authorized_user_file(cls, filename, scopes=None):
"""
with io.open(filename, 'r', encoding='utf-8') as json_file:
data = json.load(json_file)
return cls.from_authorized_user_info(data, scopes)
return cls.from_authorized_user_info(data, scopes, downscope)
34 changes: 34 additions & 0 deletions tests/oauth2/test__client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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.
Expand Down
47 changes: 46 additions & 1 deletion tests/oauth2/test_credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -97,6 +97,51 @@ def test_refresh_success(self, unused_utcnow, refresh_grant):
# expired)
assert credentials.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_refresh_success_with_downscoping(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,
downscope=True)

# 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

def test_refresh_no_refresh_token(self):
request = mock.create_autospec(transport.Request)
credentials_ = credentials.Credentials(
Expand Down