From fd4e6df12ae690ee1868d0fad72781483707ba34 Mon Sep 17 00:00:00 2001 From: Jonny Tan Date: Fri, 20 Mar 2020 15:00:13 -0700 Subject: [PATCH 1/2] feat: Asyncio and aiohttp support for google.auth and google.oauth2 Adds asyncio copies and conversions of the oauth2 libraries for credentials and service accounts. Most code/tests have been copy-pasted over. Code was reused wherever possible. The new functional code is the aio transport abstract base class and the aiohttp transport implementation. Asyncio requests can be created with either the aiohttp basic API or by passing in a ClientSession, and tokens are now refreshed asynchronously. Asyncio requires Python >=3.5. This change adds dependencies on aiohttp and pytest-asyncio. --- google/auth/aio/__init__.py | 0 google/auth/aio/credentials.py | 66 ++++++ google/auth/transport/aio/__init__.py | 77 +++++++ google/auth/transport/aio/aiohttp.py | 83 ++++++++ google/oauth2/aio/__init__.py | 0 google/oauth2/aio/_client.py | 227 ++++++++++++++++++++ google/oauth2/aio/credentials.py | 65 ++++++ google/oauth2/aio/service_account.py | 45 ++++ noxfile.py | 21 ++ tests/aio/test_aio_credentials.py | 47 +++++ tests/oauth2/aio/test_aio_client.py | 240 ++++++++++++++++++++++ tests/oauth2/aio/test_credentials.py | 92 +++++++++ tests/oauth2/aio/test_service_account.py | 70 +++++++ tests/transport/aio/test_aio_transport.py | 28 +++ tests/transport/aio/test_aiohttp.py | 65 ++++++ 15 files changed, 1126 insertions(+) create mode 100644 google/auth/aio/__init__.py create mode 100644 google/auth/aio/credentials.py create mode 100644 google/auth/transport/aio/__init__.py create mode 100644 google/auth/transport/aio/aiohttp.py create mode 100644 google/oauth2/aio/__init__.py create mode 100644 google/oauth2/aio/_client.py create mode 100644 google/oauth2/aio/credentials.py create mode 100644 google/oauth2/aio/service_account.py create mode 100644 tests/aio/test_aio_credentials.py create mode 100644 tests/oauth2/aio/test_aio_client.py create mode 100644 tests/oauth2/aio/test_credentials.py create mode 100644 tests/oauth2/aio/test_service_account.py create mode 100644 tests/transport/aio/test_aio_transport.py create mode 100644 tests/transport/aio/test_aiohttp.py 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 d75361f73..058dcb111 100644 --- a/noxfile.py +++ b/noxfile.py @@ -28,6 +28,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"] @@ -67,6 +68,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" ) @@ -75,6 +93,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", @@ -91,6 +110,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") @@ -114,6 +134,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") From 4fe899c16458370667da2cbb7619fb408f162ac3 Mon Sep 17 00:00:00 2001 From: Jonny Tan Date: Fri, 20 Mar 2020 15:25:11 -0700 Subject: [PATCH 2/2] feat: Adding aiohttp to user-guide and docs Included an example of how the aiohttp transport can be used to generate auth headers. --- .../reference/google.auth.aio.credentials.rst | 7 +++++++ docs/reference/google.auth.aio.rst | 14 ++++++++++++++ docs/reference/google.auth.rst | 1 + .../google.auth.transport.aio.aiohttp.rst | 7 +++++++ docs/reference/google.auth.transport.aio.rst | 14 ++++++++++++++ docs/reference/google.auth.transport.rst | 7 +++++++ .../google.oauth2.aio.credentials.rst | 7 +++++++ docs/reference/google.oauth2.aio.rst | 15 +++++++++++++++ .../google.oauth2.aio.service_account.rst | 7 +++++++ docs/reference/google.oauth2.rst | 7 +++++++ docs/requirements-docs.txt | 1 + docs/user-guide.rst | 19 +++++++++++++++++++ 12 files changed, 106 insertions(+) create mode 100644 docs/reference/google.auth.aio.credentials.rst create mode 100644 docs/reference/google.auth.aio.rst create mode 100644 docs/reference/google.auth.transport.aio.aiohttp.rst create mode 100644 docs/reference/google.auth.transport.aio.rst create mode 100644 docs/reference/google.oauth2.aio.credentials.rst create mode 100644 docs/reference/google.oauth2.aio.rst create mode 100644 docs/reference/google.oauth2.aio.service_account.rst 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: + ...