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 all 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
24 changes: 19 additions & 5 deletions google/oauth2/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
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
139 changes: 138 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 @@ -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()

Expand Down