diff --git a/docs/reference/google.auth.aio.credentials.rst b/docs/reference/google.auth.aio.credentials.rst new file mode 100644 index 000000000..951716598 --- /dev/null +++ b/docs/reference/google.auth.aio.credentials.rst @@ -0,0 +1,7 @@ +google.auth.aio.credentials module +================================== + +.. automodule:: google.auth.aio.credentials + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.auth.aio.rst b/docs/reference/google.auth.aio.rst new file mode 100644 index 000000000..bda368915 --- /dev/null +++ b/docs/reference/google.auth.aio.rst @@ -0,0 +1,14 @@ +google.auth.aio package +======================= + +.. automodule:: google.auth.aio + :members: + :inherited-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + + google.auth.aio.credentials diff --git a/docs/reference/google.auth.rst b/docs/reference/google.auth.rst index f6ea073c5..dd9dde0cb 100644 --- a/docs/reference/google.auth.rst +++ b/docs/reference/google.auth.rst @@ -11,6 +11,7 @@ Subpackages .. toctree:: + google.auth.aio google.auth.compute_engine google.auth.crypt google.auth.transport diff --git a/docs/reference/google.auth.transport.aio.aiohttp.rst b/docs/reference/google.auth.transport.aio.aiohttp.rst new file mode 100644 index 000000000..587ce7fff --- /dev/null +++ b/docs/reference/google.auth.transport.aio.aiohttp.rst @@ -0,0 +1,7 @@ +google.auth.transport.aio.aiohttp module +======================================== + +.. automodule:: google.auth.transport.aio.aiohttp + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.auth.transport.aio.rst b/docs/reference/google.auth.transport.aio.rst new file mode 100644 index 000000000..13b1183bd --- /dev/null +++ b/docs/reference/google.auth.transport.aio.rst @@ -0,0 +1,14 @@ +google.auth.transport.aio package +================================= + +.. automodule:: google.auth.transport.aio + :members: + :inherited-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + + google.auth.transport.aio.aiohttp diff --git a/docs/reference/google.auth.transport.rst b/docs/reference/google.auth.transport.rst index 48e2e0551..76d156ccd 100644 --- a/docs/reference/google.auth.transport.rst +++ b/docs/reference/google.auth.transport.rst @@ -6,6 +6,13 @@ google.auth.transport package :inherited-members: :show-inheritance: +Subpackages +----------- + +.. toctree:: + + google.auth.transport.aio + Submodules ---------- diff --git a/docs/reference/google.oauth2.aio.credentials.rst b/docs/reference/google.oauth2.aio.credentials.rst new file mode 100644 index 000000000..dfa3dec74 --- /dev/null +++ b/docs/reference/google.oauth2.aio.credentials.rst @@ -0,0 +1,7 @@ +google.oauth2.aio.credentials module +==================================== + +.. automodule:: google.oauth2.aio.credentials + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.oauth2.aio.rst b/docs/reference/google.oauth2.aio.rst new file mode 100644 index 000000000..90f234d92 --- /dev/null +++ b/docs/reference/google.oauth2.aio.rst @@ -0,0 +1,15 @@ +google.oauth2.aio package +========================= + +.. automodule:: google.oauth2.aio + :members: + :inherited-members: + :show-inheritance: + +Submodules +---------- + +.. toctree:: + + google.oauth2.aio.credentials + google.oauth2.aio.service_account diff --git a/docs/reference/google.oauth2.aio.service_account.rst b/docs/reference/google.oauth2.aio.service_account.rst new file mode 100644 index 000000000..a031f9f40 --- /dev/null +++ b/docs/reference/google.oauth2.aio.service_account.rst @@ -0,0 +1,7 @@ +google.oauth2.aio.service\_account module +========================================= + +.. automodule:: google.oauth2.aio.service_account + :members: + :inherited-members: + :show-inheritance: diff --git a/docs/reference/google.oauth2.rst b/docs/reference/google.oauth2.rst index 4f1df071f..b05ea9d77 100644 --- a/docs/reference/google.oauth2.rst +++ b/docs/reference/google.oauth2.rst @@ -6,6 +6,13 @@ google.oauth2 package :inherited-members: :show-inheritance: +Subpackages +----------- + +.. toctree:: + + google.oauth2.aio + Submodules ---------- diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 8dabaf9d6..aff26559b 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -2,3 +2,4 @@ sphinx-docstring-typing urllib3 requests requests-oauthlib +aiohttp diff --git a/docs/user-guide.rst b/docs/user-guide.rst index 0abe160a3..ebc32b72b 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -437,3 +437,22 @@ to a gRPC service:: http://www.grpc.io/docs/guides/wire.html .. _Call Credentials: http://www.grpc.io/docs/guides/auth.html + +aiohttp ++++++++ + +:mod:`aiohttp` is an HTTP library for use with asyncio. +:mod:`google.auth.transport.aio.aiohttp` presents a coroutine interface for +authenticated request headers:: + + import aiohttp + from google.auth.transport.aio import aiohttp as aiohttp_transport + + session = aiohttp.ClientSession() + headers = {} + request = aiohttp_transport.Request(session) + await aio_credentials.before_request( + request, 'get', 'https//www.googleapis.com/storage/v1/b') + with session.get( + 'https//www.googleapis.com/storage/v1/b', headers=headers) as resp: + ... diff --git a/google/auth/aio/__init__.py b/google/auth/aio/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/google/auth/aio/credentials.py b/google/auth/aio/credentials.py new file mode 100644 index 000000000..6c8bb44b6 --- /dev/null +++ b/google/auth/aio/credentials.py @@ -0,0 +1,66 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Credentials supporting asynchronous transports. + +Overrides credentials base methods with asynchronous versions. +""" + +import abc +from typing import Any, Mapping, Text + +from google.auth import credentials +import google.auth.transport.aio + + +class Credentials(credentials.Credentials): + """Base credentials class for asynchronous applications.""" + + @abc.abstractmethod + async def refresh(self, request: google.auth.transport.aio.Request): + """Refreshes the access token. + + Args: + request: HTTP request client. + + Raises: + google.auth.exceptions.RefreshError: If credentials could not be + refreshed. + """ + + async def before_request( + self, + request: google.auth.transport.aio.Request, + method: Text, + url: Text, + headers: Mapping[Any, Any], + ): + """Performs credential-specific request pre-processing. + + Schedules the credentials to be refreshed if necessary, then calls + :meth:`apply` to apply the token to the authentication header. + + + Args: + request: The object used to make HTTP requests. + method: The request's HTTP method or the RPC method being invoked. + url: The request's URI or the RPC service's URI. + headers (Mapping): The request's headers. + """ + del method + del url + if not self.valid: + await self.refresh(request) + self.apply(headers) diff --git a/google/auth/transport/aio/__init__.py b/google/auth/transport/aio/__init__.py new file mode 100644 index 000000000..66fc76cec --- /dev/null +++ b/google/auth/transport/aio/__init__.py @@ -0,0 +1,77 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Async HTTP client library support. + +Interfaces for asynchronous HTTP libraries. This provides a request adapter for +libraries built on top of asyncio, where HTTP requests are written as +coroutines. +""" + +import abc +from typing import Any, Mapping, Text + +from google.auth import transport + + +class Response(metaclass=abc.ABCMeta): + """HTTP Response data.""" + + def __init__( + self, + status: int = None, + headers: Mapping[Text, Text] = None, + data: bytes = None, + ): + self.status = status + self.headers = headers + self.data = data + + +class Request(metaclass=abc.ABCMeta): + """Interface for a callable that makes HTTP requests. + + Specific transport implementations should provide an implementation of + this that adapts their specific request / response API. + """ + + @abc.abstractmethod + async def __call__( + self, + url: Text, + method: Text = "get", + body: Any = None, + headers: Mapping[Text, Text] = None, + **kwargs + ) -> transport.Response: + """Make an HTTP request. + + Same as google.auth.transport.Request, but without + a timeout parameter as asyncio.wait_for should be used instead. + + Args: + url: The URI to be requested. + method: The HTTP method to use for the request. Defaults to 'GET'. + body: The payload / body in HTTP request. + headers: Request headers. + **kwargs: Additionally arguments passed on to the transport's request + method. + + Returns: + Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ diff --git a/google/auth/transport/aio/aiohttp.py b/google/auth/transport/aio/aiohttp.py new file mode 100644 index 000000000..4ad809cf3 --- /dev/null +++ b/google/auth/transport/aio/aiohttp.py @@ -0,0 +1,83 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# +"""Aiohttp adapter transport adapter. + +Uses aiohttp as an http client for refreshing credentials. +""" + +from typing import Any, Mapping, Optional, Text + +import aiohttp + +from google.auth import exceptions +import google.auth.transport.aio as aio_transport + + +class Request(aio_transport.Request): + """Aiohttp transport request adapter.""" + + def __init__(self, session: Optional[aiohttp.ClientSession] = None): + """Aiohttp request constructor. + + Aiohttp recommends using application-wide sessions, so a ClientSession can + be optionally passed into the creation of these requests. If no session is + provided, the basic API will be used instead. + + Args: + session: ClientSession which will be used in requests if provided. + """ + self._session = session + + async def __call__( + self, + url: Text, + method: Text = "get", + body: Any = None, + headers: Mapping[Text, Text] = None, + **kwargs + ) -> aio_transport.Response: + """Make an HTTP request. + + Same as google.auth.transport.Request, but without + a timeout parameter as asyncio.wait_for should be used instead. + + Args: + url: The URI to be requested. + method: The HTTP method to use for the request. Defaults to 'GET'. + body: The payload / body in HTTP request. + headers: Request headers. + **kwargs: Additionally arguments passed on to the transport's request + method. + + Returns: + Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + request = self._session.request if self._session else aiohttp.request + try: + async with request( + method, url, data=body, headers=headers, **kwargs + ) as resp: + status = resp.status + headers = resp.headers + content = await resp.read() + return aio_transport.Response(status, headers, content) + except aiohttp.ClientError as caught_exc: + new_exc = exceptions.TransportError(caught_exc) + raise new_exc from caught_exc diff --git a/google/oauth2/aio/__init__.py b/google/oauth2/aio/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/google/oauth2/aio/_client.py b/google/oauth2/aio/_client.py new file mode 100644 index 000000000..46e887773 --- /dev/null +++ b/google/oauth2/aio/_client.py @@ -0,0 +1,227 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Async OAuth 2.0 client. + +This is a client for interacting with an OAuth 2.0 authorization server's +token endpoint. + +For more information about the token endpoint, see +`Section 3.1 of rfc6749`_ + +.. _Section 3.1 of rfc6749: https://tools.ietf.org/html/rfc6749#section-3.2 +""" +import datetime +import http.client +import json +from typing import Mapping, Optional, Sequence, Text, Tuple, Union +import urllib + +from google.auth import exceptions +from google.auth import jwt +import google.auth.transport.aio +from google.oauth2 import _client + +# pylint: disable=protected-access +_JWT_GRANT_TYPE = _client._JWT_GRANT_TYPE +_REFRESH_GRANT_TYPE = _client._REFRESH_GRANT_TYPE +_URLENCODED_CONTENT_TYPE = _client._URLENCODED_CONTENT_TYPE +_handle_error_response = _client._handle_error_response +_parse_expiry = _client._parse_expiry +# pylint: disable=protected-access + + +async def _token_endpoint_request( + request: google.auth.transport.aio.Request, + token_uri: Text, + body: Mapping[Text, Text], + retries: int = 2, +) -> Mapping[Text, Text]: + """Makes a request to the OAuth 2.0 authorization server's token endpoint. + + Args: + request: A coroutine used to make HTTP requests. + token_uri: The OAuth 2.0 authorizations server's token endpoint URI. + body: The parameters to send in the request body. + retries: Number of retries allotted if internal failure occurs on request. + + Returns: + The JSON-decoded response data. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + """ + body = urllib.parse.urlencode(body) + + headers = {"content-type": _URLENCODED_CONTENT_TYPE} + + # retry to fetch token if any internal failure occurs. + for _ in range(1 + retries): + response = await request( + method="POST", url=token_uri, headers=headers, body=body + ) + response_body = ( + response.data.decode("utf-8") + if hasattr(response.data, "decode") + else response.data + ) + response_data = json.loads(response_body) + + if response.status == http.client.OK: + break + else: + error_desc = response_data.get("error_description") or "" + error_code = response_data.get("error") or "" + if "internal_failure" not in (error_code, error_desc): + _handle_error_response(response_body) + else: + _handle_error_response(response_body) + + return response_data + + +async def jwt_grant( + request: google.auth.transport.aio.Request, + token_uri: Text, + assertion: Union[bytes, Text], +) -> Tuple[Text, Optional[datetime.datetime], Mapping[Text, Text]]: + """Implements the JWT Profile for OAuth 2.0 Authorization Grants. + + For more details, see `rfc7523 section 4`_. + + Args: + request: A coroutine used to make HTTP requests. + token_uri: The OAuth 2.0 authorizations server's token endpoint URI. + assertion: The OAuth 2.0 assertion. + + Returns: + The access token, expiration, and additional data returned by the token + endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned an + error. + + .. _rfc7523 section 4: https://tools.ietf.org/html/rfc7523#section-4 + """ + body = {"assertion": assertion, "grant_type": _JWT_GRANT_TYPE} + + response_data = await _token_endpoint_request(request, token_uri, body) + + try: + access_token = response_data["access_token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError("No access token in response.", response_data) + raise new_exc from caught_exc + + expiry = _parse_expiry(response_data) + + return access_token, expiry, response_data + + +async def id_token_jwt_grant( + request: google.auth.transport.aio.Request, token_uri: Text, assertion: Text +) -> Tuple[Text, Optional[datetime.datetime], Mapping[Text, Text]]: + """Implements JWT Profile for OAuth 2.0 with OpenID Connect Token. + + This is a variant on the standard JWT Profile that is currently unique + to Google. This was added for the benefit of authenticating to services + that require ID Tokens instead of access tokens or JWT bearer tokens. + + Args: + request: A coroutine used to make HTTP requests. + token_uri: The OAuth 2.0 authorization server's token endpoint URI. + assertion: JWT token signed by a service account. The token's payload must + include a ``target_audience`` claim. + + Returns: + The (encoded) Open ID Connect ID Token, expiration, and additional + data returned by the endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned an + error. + """ + # pylint: disable=protected-access + body = {"assertion": assertion, "grant_type": _client._JWT_GRANT_TYPE} + + response_data = await _token_endpoint_request(request, token_uri, body) + + try: + id_token = response_data["id_token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError("No ID token in response.", response_data) + raise new_exc from caught_exc + + payload = jwt.decode(id_token, verify=False) + expiry = datetime.datetime.utcfromtimestamp(payload["exp"]) + + return id_token, expiry, response_data + + +async def refresh_grant( + request: google.auth.transport.aio.Request, + token_uri: Text, + refresh_token: Text, + client_id: Text, + client_secret: Text, + scopes: Optional[Sequence[Text]] = None, +) -> Tuple[Text, Optional[Text], Optional[datetime.datetime], Mapping[Text, Text]]: + """Implements the OAuth 2.0 refresh token grant. + + For more details, see `rfc678 section 6`_. + + Args: + request: A callable used to make HTTP requests. + token_uri: The OAuth 2.0 authorizations server's token endpoint URI. + refresh_token: The refresh token to use to get a new access token. + client_id: The OAuth 2.0 application's client ID. + client_secret: The Oauth 2.0 appliaction's client secret. + scopes: 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: + The access token, new refresh token, expiration, and additional data + returned by the token endpoint. + + Raises: + google.auth.exceptions.RefreshError: If the token endpoint returned + an error. + + .. _rfc6748 section 6: https://tools.ietf.org/html/rfc6749#section-6 + """ + body = { + "grant_type": _REFRESH_GRANT_TYPE, + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": refresh_token, + } + if scopes: + body["scope"] = " ".join(scopes) + + response_data = await _token_endpoint_request(request, token_uri, body) + + try: + access_token = response_data["access_token"] + except KeyError as caught_exc: + new_exc = exceptions.RefreshError("No access token in response.", response_data) + raise new_exc from caught_exc + + refresh_token = response_data.get("refresh_token", refresh_token) + expiry = _client._parse_expiry(response_data) + + return access_token, refresh_token, expiry, response_data diff --git a/google/oauth2/aio/credentials.py b/google/oauth2/aio/credentials.py new file mode 100644 index 000000000..667944623 --- /dev/null +++ b/google/oauth2/aio/credentials.py @@ -0,0 +1,65 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Asynchronous OAuth 2.0 Credentials. + +This module extends google.oauth2.credentials with an interface for use with +async transports. +""" + +from google.auth import exceptions +import google.auth.aio.credentials as aio_credentials +import google.auth.transport.aio +from google.oauth2.aio import _client +import google.oauth2.credentials as oauth2_credentials + + +class Credentials(aio_credentials.Credentials, oauth2_credentials.Credentials): + """Credentials using OAuth 2.0 access and refresh tokens.""" + + async def refresh(self, request: google.auth.transport.aio.Request): + """Refreshes the access token. + + Args: + request: HTTP request client. + + Raises: + google.auth.exceptions.RefreshError: If credentials could not be + refreshed. + """ + if ( + self._refresh_token is None + or self._token_uri is None + or self._client_id is None + or self._client_secret is None + ): + raise exceptions.RefreshError( + "The credentials do not contain the necessary fields need to " + "refresh the access token. You must specify refresh_token, " + "token_uri, client_id, and client_secret." + ) + + access_token, refresh_token, expiry, grant_response = await _client.refresh_grant( + request, + self._token_uri, + self._refresh_token, + self._client_id, + self._client_secret, + ) + + self.token = access_token + self.expiry = expiry + self._refresh_token = refresh_token + self._id_token = grant_response.get("id_token") diff --git a/google/oauth2/aio/service_account.py b/google/oauth2/aio/service_account.py new file mode 100644 index 000000000..53fbb194b --- /dev/null +++ b/google/oauth2/aio/service_account.py @@ -0,0 +1,45 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Asynchronous service account interface. + +Service accounts that support asynchronous HTTP clients. +""" + +from google.auth.aio import credentials +import google.auth.transport.aio +from google.oauth2 import service_account +from google.oauth2.aio import _client + + +class Credentials(credentials.Credentials, service_account.Credentials): + """Service account credentials for asynchronous applications.""" + + async def refresh(self, request: google.auth.transport.aio.Request): + """Refreshes the access token. + + Args: + request: HTTP request client. + + Raises: + google.auth.exceptions.RefreshError: If credentials could not be + refreshed. + """ + assertion = self._make_authorization_grant_assertion() + access_token, expiry, _ = await _client.jwt_grant( + request, self._token_uri, assertion + ) + self.token = access_token + self.expiry = expiry diff --git a/noxfile.py b/noxfile.py index bcea1fbc8..2be6b1e76 100644 --- a/noxfile.py +++ b/noxfile.py @@ -29,6 +29,7 @@ "responses", "grpcio", ] +PY3_TEST_DEPENDENCIES = ["aiohttp", "pytest-asyncio"] BLACK_VERSION = "black==19.3b0" BLACK_PATHS = ["google", "tests", "noxfile.py", "setup.py", "docs/conf.py"] @@ -68,6 +69,23 @@ def blacken(session): def unit(session): session.install(*TEST_DEPENDENCIES) session.install(".") + session.run( + "pytest", + "--cov=google.auth", + "--cov=google.oauth2", + "--cov=tests", + "--ignore=tests/aio", + "--ignore=tests/transport/aio", + "--ignore=tests/oauth2/aio", + "tests", + ) + + +@nox.session(python=["3.5", "3.6", "3.7"]) +def unit_py3(session): + session.install(*TEST_DEPENDENCIES) + session.install(*PY3_TEST_DEPENDENCIES) + session.install(".") session.run( "pytest", "--cov=google.auth", "--cov=google.oauth2", "--cov=tests", "tests" ) @@ -76,6 +94,7 @@ def unit(session): @nox.session(python="3.7") def cover(session): session.install(*TEST_DEPENDENCIES) + session.install(*PY3_TEST_DEPENDENCIES) session.install(".") session.run( "pytest", @@ -92,6 +111,7 @@ def cover(session): def docgen(session): session.env["SPHINX_APIDOC_OPTIONS"] = "members,inherited-members,show-inheritance" session.install(*TEST_DEPENDENCIES) + session.install(*PY3_TEST_DEPENDENCIES) session.install("sphinx") session.install(".") session.run("rm", "-r", "docs/reference") @@ -115,6 +135,7 @@ def docs(session): @nox.session(python="pypy") def pypy(session): session.install(*TEST_DEPENDENCIES) + session.install(*PY3_TEST_DEPENDENCIES) session.install(".") session.run( "pytest", "--cov=google.auth", "--cov=google.oauth2", "--cov=tests", "tests" diff --git a/tests/aio/test_aio_credentials.py b/tests/aio/test_aio_credentials.py new file mode 100644 index 000000000..b79522c75 --- /dev/null +++ b/tests/aio/test_aio_credentials.py @@ -0,0 +1,47 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for google.auth.credentials.""" + +import pytest + +from google.auth.aio import credentials + + +class CredentialsImpl(credentials.Credentials): + async def refresh(self, request): + self.token = request + + +@pytest.mark.asyncio +async def test_before_request(): + credentials = CredentialsImpl() + request = "token" + headers = {} + + # First call should call refresh, setting the token. + await credentials.before_request(request, "http://example.com", "GET", headers) + assert credentials.valid + assert credentials.token == "token" + assert headers["authorization"] == "Bearer token" + + request = "token2" + headers = {} + + # Second call shouldn't call refresh. + await credentials.before_request(request, "http://example.com", "GET", headers) + assert credentials.valid + assert credentials.token == "token" + assert headers["authorization"] == "Bearer token" diff --git a/tests/oauth2/aio/test_aio_client.py b/tests/oauth2/aio/test_aio_client.py new file mode 100644 index 000000000..ab747b090 --- /dev/null +++ b/tests/oauth2/aio/test_aio_client.py @@ -0,0 +1,240 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for google3.third_party.py.google.oauth2.aio._client. + +Tests asynchronous oauth2 flow written with pytest to use the async plugin. +""" + +import datetime +import http +import json +from typing import Any, Mapping, Text + +import mock +import pytest + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import transport +from google.oauth2.aio import _client + +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 make_request( + response_data: Mapping[Text, Text], status: http.HTTPStatus = http.HTTPStatus.OK +): + """Request is now an awaitable.""" + response = mock.create_autospec(transport.Response, instance=True) + response.status = status + response.data = json.dumps(response_data).encode("utf-8") + + async def mock_response( + url: Text, + method: Text = "get", + body: Any = None, + headers: Mapping[Text, Text] = None, + **kwargs + ): + del url, method, body, headers, kwargs + return response + + return mock_response + + +@pytest.mark.asyncio +async def test__token_endpoint_request(): + request = make_request({"test": "response"}) + + result = await _client._token_endpoint_request( + request, "http://example.com", {"test": "params"} + ) + + # Check result + assert result == {"test": "response"} + + +@pytest.mark.asyncio +async def test__token_endpoint_request_error(): + request = make_request({}, status=http.HTTPStatus.BAD_REQUEST) + + with pytest.raises(exceptions.RefreshError): + await _client._token_endpoint_request(request, "http://example.com", {}) + + +@pytest.mark.asyncio +async def test__token_endpoint_request_internal_failure_error(): + request = make_request( + {"error_description": "internal_failure"}, status=http.HTTPStatus.BAD_REQUEST + ) + + with pytest.raises(exceptions.RefreshError): + await _client._token_endpoint_request( + request, "http://example.com", {"error_description": "internal_failure"} + ) + + request = make_request( + {"error": "internal_failure"}, status=http.HTTPStatus.BAD_REQUEST + ) + + with pytest.raises(exceptions.RefreshError): + await _client._token_endpoint_request( + request, "http://example.com", {"error": "internal_failure"} + ) + + +@pytest.mark.asyncio +async def test_jwt_grant(): + request = make_request( + {"access_token": "token", "expires_in": 500, "extra": "data"} + ) + with mock.patch( + "google.auth._helpers.utcnow", return_value=datetime.datetime.min + ) as utcnow: + token, expiry, extra_data = await _client.jwt_grant( + request, "http://example.com", "assertion_value" + ) + + # Check result + assert token == "token" + assert expiry == utcnow() + datetime.timedelta(seconds=500) + assert extra_data["extra"] == "data" + + +@pytest.mark.asyncio +async def test_jwt_grant_no_access_token(): + request = make_request( + { + # No access token. + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError): + await _client.jwt_grant(request, "http://example.com", "assertion_value") + + +@pytest.mark.asyncio +async def test_id_token_jwt_grant(): + now = _helpers.utcnow() + id_token_expiry = _helpers.datetime_to_secs(now) + id_token = "a_real_token" + request = make_request({"id_token": "a_real_token", "extra": "data"}) + + mock_payload = {"exp": id_token_expiry} + with mock.patch("google.auth.jwt.decode", return_value=mock_payload): + token, expiry, extra_data = await _client.id_token_jwt_grant( + request, "http://example.com", "assertion_value" + ) + + # Check result + assert token == id_token + # JWT does not store microseconds + now = now.replace(microsecond=0) + assert expiry == now + assert extra_data["extra"] == "data" + + +@pytest.mark.asyncio +async def test_id_token_jwt_grant_no_access_token(): + request = make_request( + { + # No access token. + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError): + await _client.id_token_jwt_grant( + request, "http://example.com", "assertion_value" + ) + + +@pytest.mark.asyncio +async def test_refresh_grant(): + request = make_request( + { + "access_token": "token", + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + } + ) + + with mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min): + token, refresh_token, expiry, extra_data = await _client.refresh_grant( + request, "http://example.com", "refresh_token", "client_id", "client_secret" + ) + + # 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" + + +@pytest.mark.asyncio +async def test_refresh_grant_with_scopes(): + request = make_request( + { + "access_token": "token", + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + "scope": SCOPES_AS_STRING, + } + ) + with mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min): + token, refresh_token, expiry, extra_data = await _client.refresh_grant( + request, + "http://example.com", + "refresh_token", + "client_id", + "client_secret", + SCOPES_AS_LIST, + ) + + # 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" + + +@pytest.mark.asyncio +async def test_refresh_grant_no_access_token(): + request = make_request( + { + # No access token. + "refresh_token": "new_refresh_token", + "expires_in": 500, + "extra": "data", + } + ) + + with pytest.raises(exceptions.RefreshError): + await _client.refresh_grant( + request, "http://example.com", "refresh_token", "client_id", "client_secret" + ) diff --git a/tests/oauth2/aio/test_credentials.py b/tests/oauth2/aio/test_credentials.py new file mode 100644 index 000000000..7d3e1b23c --- /dev/null +++ b/tests/oauth2/aio/test_credentials.py @@ -0,0 +1,92 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for google.oauth2.aio.credentials.""" + +import datetime + +import mock +import pytest + +from google.auth import _helpers +from google.auth import exceptions +from google.auth.transport import aio as aio_transport +from google.oauth2.aio import credentials + +ACCESS_TOKEN = "access_token" +TOKEN_URI = "https://example.com/oauth2/token" +REFRESH_TOKEN = "refresh_token" +CLIENT_ID = "client_id" +CLIENT_SECRET = "client_secret" + + +def make_credentials(): + return credentials.Credentials( + token=None, + refresh_token=REFRESH_TOKEN, + token_uri=TOKEN_URI, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + + +@pytest.mark.asyncio +async def test_refresh_success(): + with mock.patch( + "google.auth._helpers.utcnow", + return_value=datetime.datetime.min + _helpers.CLOCK_SKEW, + ) as utcnow: + token = "token" + expiry = utcnow() + datetime.timedelta(seconds=500) + grant_response = {"id_token": mock.sentinel.id_token} + + async def mock_refresh_grant(*args, **kwargs): + del args + del kwargs + return token, None, expiry, grant_response + + with mock.patch( + "google.oauth2.aio._client.refresh_grant", wraps=mock_refresh_grant + ) as refresh_grant: + request = mock.create_autospec(aio_transport.Request) + creds = make_credentials() + + # Refresh credentials + await creds.refresh(request) + + # Check jwt grant call. + refresh_grant.assert_called_with( + request, TOKEN_URI, REFRESH_TOKEN, CLIENT_ID, CLIENT_SECRET + ) + + # 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 + + # Check that the credentials are valid (have a token and are not + # expired) + assert creds.valid + + +@pytest.mark.asyncio +async def test_refresh_no_refresh_token(): + request = mock.create_autospec(aio_transport.Request) + creds = credentials.Credentials(token=None, refresh_token=None) + + with pytest.raises(exceptions.RefreshError, match="necessary fields"): + await creds.refresh(request) + + request.assert_not_called() diff --git a/tests/oauth2/aio/test_service_account.py b/tests/oauth2/aio/test_service_account.py new file mode 100644 index 000000000..b1976981f --- /dev/null +++ b/tests/oauth2/aio/test_service_account.py @@ -0,0 +1,70 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for google.oauth2.aio.service_account.""" + +import datetime + +import mock +import pytest + +from google.auth import _helpers +from google.auth.transport import aio as aio_transport +from google.oauth2.aio import service_account + +SIGNER = None +SERVICE_ACCOUNT_EMAIL = "service-account@example.com" +TOKEN_URI = "https://example.com/oauth2/token" + + +@pytest.fixture +def credentials(): + return service_account.Credentials(SIGNER, SERVICE_ACCOUNT_EMAIL, TOKEN_URI) + + +@pytest.mark.asyncio +# pylint: disable=redefined-outer-name +async def test_refresh_success(credentials): + token = "token" + + async def mock_jwt_grant(request, token_uri, assertion): + return token, _helpers.utcnow() + datetime.timedelta(seconds=500), {} + + with mock.patch( + "google.oauth2.aio._client.jwt_grant", wraps=mock_jwt_grant + ) as jwt_grant: + with mock.patch.object( + credentials, + "_make_authorization_grant_assertion", + return_value="totally_valid_assertion", + ): + request = mock.create_autospec(aio_transport.Request, instance=True) + + # Refresh credentials + await credentials.refresh(request) + + # Check jwt grant call. + assert jwt_grant.called + + called_request, token_uri, _ = jwt_grant.call_args[0] + assert called_request == request + assert token_uri == credentials._token_uri + + # Check that the credentials have the token. + assert credentials.token == token + + # Check that the credentials are valid (have a token and are not + # expired) + assert credentials.valid diff --git a/tests/transport/aio/test_aio_transport.py b/tests/transport/aio/test_aio_transport.py new file mode 100644 index 000000000..693565699 --- /dev/null +++ b/tests/transport/aio/test_aio_transport.py @@ -0,0 +1,28 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests google.auth.transport.aio""" + +import google.auth.transport.aio as aio_transport + + +def test_response_construction(): + test_status = 200 + test_headers = {"header": "header_value"} + test_data = b"test_data" + response = aio_transport.Response(test_status, test_headers, test_data) + assert response.status == test_status + assert response.headers == test_headers + assert response.data == test_data diff --git a/tests/transport/aio/test_aiohttp.py b/tests/transport/aio/test_aiohttp.py new file mode 100644 index 000000000..a6e72af91 --- /dev/null +++ b/tests/transport/aio/test_aiohttp.py @@ -0,0 +1,65 @@ +# Lint as: python3 +# Copyright 2016 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for google3.third_party.py.google.auth.transport.aio.aiohttp.""" + +import http +import json + +import aiohttp +import pytest + +from google.auth import exceptions +import google.auth.transport.aio.aiohttp as aiohttp_transport + +pytest_plugins = "aiohttp.pytest_plugin" + +TEST_RESPONSE = {"status": "OK"} + + +@pytest.fixture +def client(loop, aiohttp_client): + app = aiohttp.web.Application() + + async def handle_request(request: aiohttp.web.Request): + del request + return aiohttp.web.Response( + text=json.dumps(TEST_RESPONSE), content_type="application/json" + ) + + async def handle_request_poorly(request: aiohttp.web.Request): + del request + return + + app.router.add_get("/", handle_request) + app.router.add_get("/broken", handle_request_poorly) + return loop.run_until_complete(aiohttp_client(app)) + + +# pylint: disable=redefined-outer-name +async def test_request(client): + request = aiohttp_transport.Request(client) + response = await request("/", body="body") + response_body = response.data.decode("utf-8") + response_json = json.loads(response_body) + assert response.status == http.HTTPStatus.OK + assert response_json == TEST_RESPONSE + + +# pylint: disable=redefined-outer-name +async def test_request_unsuccessful(client): + request = aiohttp_transport.Request(client) + with pytest.raises(exceptions.TransportError): + await request("/broken", body="body")