From 98438644c51311538758b03a69b468372fbc339d Mon Sep 17 00:00:00 2001 From: Jay Lee Date: Sun, 8 Oct 2017 21:14:33 -0400 Subject: [PATCH] Move service accounts to google-auth, part of #505 --- src/gam.py | 24 +- src/google/__init__.py | 22 + src/google/auth/__init__.py | 28 + src/google/auth/_cloud_sdk.py | 139 ++++ src/google/auth/_default.py | 286 +++++++ src/google/auth/_helpers.py | 217 +++++ src/google/auth/_oauth2client.py | 166 ++++ src/google/auth/_service_account_info.py | 73 ++ src/google/auth/app_engine.py | 154 ++++ src/google/auth/compute_engine/__init__.py | 22 + src/google/auth/compute_engine/_metadata.py | 202 +++++ src/google/auth/compute_engine/credentials.py | 107 +++ src/google/auth/credentials.py | 280 +++++++ src/google/auth/crypt/__init__.py | 79 ++ src/google/auth/crypt/_python_rsa.py | 221 +++++ src/google/auth/crypt/base.py | 64 ++ src/google/auth/crypt/rsa.py | 20 + src/google/auth/environment_vars.py | 49 ++ src/google/auth/exceptions.py | 32 + src/google/auth/iam.py | 102 +++ src/google/auth/jwt.py | 755 ++++++++++++++++++ src/google/auth/transport/__init__.py | 96 +++ src/google/auth/transport/_http_client.py | 111 +++ src/google/auth/transport/grpc.py | 131 +++ src/google/auth/transport/requests.py | 202 +++++ src/google/auth/transport/urllib3.py | 259 ++++++ src/google/oauth2/__init__.py | 15 + src/google/oauth2/_client.py | 200 +++++ src/google/oauth2/credentials.py | 131 +++ src/google/oauth2/id_token.py | 115 +++ src/google/oauth2/service_account.py | 338 ++++++++ src/google_auth_httplib2.py | 235 ++++++ 32 files changed, 4865 insertions(+), 10 deletions(-) create mode 100644 src/google/__init__.py create mode 100644 src/google/auth/__init__.py create mode 100644 src/google/auth/_cloud_sdk.py create mode 100644 src/google/auth/_default.py create mode 100644 src/google/auth/_helpers.py create mode 100644 src/google/auth/_oauth2client.py create mode 100644 src/google/auth/_service_account_info.py create mode 100644 src/google/auth/app_engine.py create mode 100644 src/google/auth/compute_engine/__init__.py create mode 100644 src/google/auth/compute_engine/_metadata.py create mode 100644 src/google/auth/compute_engine/credentials.py create mode 100644 src/google/auth/credentials.py create mode 100644 src/google/auth/crypt/__init__.py create mode 100644 src/google/auth/crypt/_python_rsa.py create mode 100644 src/google/auth/crypt/base.py create mode 100644 src/google/auth/crypt/rsa.py create mode 100644 src/google/auth/environment_vars.py create mode 100644 src/google/auth/exceptions.py create mode 100644 src/google/auth/iam.py create mode 100644 src/google/auth/jwt.py create mode 100644 src/google/auth/transport/__init__.py create mode 100644 src/google/auth/transport/_http_client.py create mode 100644 src/google/auth/transport/grpc.py create mode 100644 src/google/auth/transport/requests.py create mode 100644 src/google/auth/transport/urllib3.py create mode 100644 src/google/oauth2/__init__.py create mode 100644 src/google/oauth2/_client.py create mode 100644 src/google/oauth2/credentials.py create mode 100644 src/google/oauth2/id_token.py create mode 100644 src/google/oauth2/service_account.py create mode 100644 src/google_auth_httplib2.py diff --git a/src/gam.py b/src/gam.py index 37efaaa7..9a25259f 100755 --- a/src/gam.py +++ b/src/gam.py @@ -47,7 +47,8 @@ import googleapiclient.errors import googleapiclient.http import httplib2 import oauth2client.client -import oauth2client.service_account +import google.oauth2.service_account +import google_auth_httplib2 import oauth2client.file import oauth2client.tools @@ -533,11 +534,11 @@ def getSvcAcctCredentials(scopes, act_as): printLine(MESSAGE_INSTRUCTIONS_OAUTH2SERVICE_JSON) systemErrorExit(6, None) GM_Globals[GM_OAUTH2SERVICE_JSON_DATA] = json.loads(json_string) - credentials = oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_dict(GM_Globals[GM_OAUTH2SERVICE_JSON_DATA], scopes) - credentials = credentials.create_delegated(act_as) - credentials.user_agent = GAM_INFO - serialization_data = credentials.serialization_data - GM_Globals[GM_OAUTH2SERVICE_ACCOUNT_CLIENT_ID] = serialization_data[u'client_id'] + credentials = google.oauth2.service_account.Credentials.from_service_account_info(GM_Globals[GM_OAUTH2SERVICE_JSON_DATA]) + credentials = credentials.with_scopes(scopes) + credentials = credentials.with_subject(act_as) + # TODO: figure out how to set user agent + GM_Globals[GM_OAUTH2SERVICE_ACCOUNT_CLIENT_ID] = credentials.project_id return credentials except (ValueError, KeyError): printLine(MESSAGE_INSTRUCTIONS_OAUTH2SERVICE_JSON) @@ -993,8 +994,10 @@ def buildGAPIServiceObject(api, act_as, use_scopes=None): GM_Globals[GM_CURRENT_API_USER] = act_as GM_Globals[GM_CURRENT_API_SCOPES] = use_scopes or API_SCOPE_MAPPING[api] credentials = getSvcAcctCredentials(GM_Globals[GM_CURRENT_API_SCOPES], act_as) + request = google_auth_httplib2.Request(http) + credentials.refresh(request) try: - service._http = credentials.authorize(http) + service._http = google_auth_httplib2.AuthorizedHttp(credentials, http=http) except httplib2.ServerNotFoundError as e: systemErrorExit(4, e) except oauth2client.client.AccessTokenRefreshError as e: @@ -1055,8 +1058,9 @@ def doCheckServiceAccount(users): print u'User: %s' % (user) for scope in all_scopes: try: - credentials = getSvcAcctCredentials(scope, user) - credentials.refresh(httplib2.Http(disable_ssl_certificate_validation=GC_Values[GC_NO_VERIFY_SSL])) + credentials = getSvcAcctCredentials([scope], user) + request = google_auth_httplib2.Request(httplib2.Http(disable_ssl_certificate_validation=GC_Values[GC_NO_VERIFY_SSL])) + credentials.refresh(request) result = u'PASS' except httplib2.ServerNotFoundError as e: systemErrorExit(4, e) @@ -1064,7 +1068,7 @@ def doCheckServiceAccount(users): result = u'FAIL' all_scopes_pass = False print u' Scope: {0:60} {1}'.format(scope, result) - service_account = credentials.serialization_data[u'client_id'] + service_account = credentials.project_id if all_scopes_pass: print u'\nAll scopes passed!\nService account %s is fully authorized.' % service_account else: diff --git a/src/google/__init__.py b/src/google/__init__.py new file mode 100644 index 00000000..a35569c3 --- /dev/null +++ b/src/google/__init__.py @@ -0,0 +1,22 @@ +# 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. + +"""Google namespace package.""" + +try: + import pkg_resources + pkg_resources.declare_namespace(__name__) +except ImportError: + import pkgutil + __path__ = pkgutil.extend_path(__path__, __name__) diff --git a/src/google/auth/__init__.py b/src/google/auth/__init__.py new file mode 100644 index 00000000..65e13951 --- /dev/null +++ b/src/google/auth/__init__.py @@ -0,0 +1,28 @@ +# 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. + +"""Google Auth Library for Python.""" + +import logging + +from google.auth._default import default + + +__all__ = [ + 'default', +] + + +# Set default logging handler to avoid "No handler found" warnings. +logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/src/google/auth/_cloud_sdk.py b/src/google/auth/_cloud_sdk.py new file mode 100644 index 00000000..898c6ec8 --- /dev/null +++ b/src/google/auth/_cloud_sdk.py @@ -0,0 +1,139 @@ +# Copyright 2015 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. + +"""Helpers for reading the Google Cloud SDK's configuration.""" + +import json +import os +import subprocess + +import six + +from google.auth import environment_vars +import google.oauth2.credentials + +# The Google OAuth 2.0 token endpoint. Used for authorized user credentials. +_GOOGLE_OAUTH2_TOKEN_ENDPOINT = 'https://accounts.google.com/o/oauth2/token' + +# The ~/.config subdirectory containing gcloud credentials. +_CONFIG_DIRECTORY = 'gcloud' +# Windows systems store config at %APPDATA%\gcloud +_WINDOWS_CONFIG_ROOT_ENV_VAR = 'APPDATA' +# The name of the file in the Cloud SDK config that contains default +# credentials. +_CREDENTIALS_FILENAME = 'application_default_credentials.json' +# The name of the Cloud SDK shell script +_CLOUD_SDK_POSIX_COMMAND = 'gcloud' +_CLOUD_SDK_WINDOWS_COMMAND = 'gcloud.cmd' +# The command to get the Cloud SDK configuration +_CLOUD_SDK_CONFIG_COMMAND = ('config', 'config-helper', '--format', 'json') + + +def get_config_path(): + """Returns the absolute path the the Cloud SDK's configuration directory. + + Returns: + str: The Cloud SDK config path. + """ + # If the path is explicitly set, return that. + try: + return os.environ[environment_vars.CLOUD_SDK_CONFIG_DIR] + except KeyError: + pass + + # Non-windows systems store this at ~/.config/gcloud + if os.name != 'nt': + return os.path.join( + os.path.expanduser('~'), '.config', _CONFIG_DIRECTORY) + # Windows systems store config at %APPDATA%\gcloud + else: + try: + return os.path.join( + os.environ[_WINDOWS_CONFIG_ROOT_ENV_VAR], + _CONFIG_DIRECTORY) + except KeyError: + # This should never happen unless someone is really + # messing with things, but we'll cover the case anyway. + drive = os.environ.get('SystemDrive', 'C:') + return os.path.join( + drive, '\\', _CONFIG_DIRECTORY) + + +def get_application_default_credentials_path(): + """Gets the path to the application default credentials file. + + The path may or may not exist. + + Returns: + str: The full path to application default credentials. + """ + config_path = get_config_path() + return os.path.join(config_path, _CREDENTIALS_FILENAME) + + +def load_authorized_user_credentials(info): + """Loads an authorized user credential. + + Args: + info (Mapping[str, str]): The loaded file's data. + + Returns: + google.oauth2.credentials.Credentials: The constructed credentials. + + Raises: + ValueError: if the info is in the wrong format or missing data. + """ + keys_needed = set(('refresh_token', 'client_id', 'client_secret')) + missing = keys_needed.difference(six.iterkeys(info)) + + if missing: + raise ValueError( + 'Authorized user info was not in the expected format, missing ' + 'fields {}.'.format(', '.join(missing))) + + return google.oauth2.credentials.Credentials( + None, # No access token, must be refreshed. + refresh_token=info['refresh_token'], + token_uri=_GOOGLE_OAUTH2_TOKEN_ENDPOINT, + client_id=info['client_id'], + client_secret=info['client_secret']) + + +def get_project_id(): + """Gets the project ID from the Cloud SDK. + + Returns: + Optional[str]: The project ID. + """ + if os.name == 'nt': + command = _CLOUD_SDK_WINDOWS_COMMAND + else: + command = _CLOUD_SDK_POSIX_COMMAND + + try: + output = subprocess.check_output( + (command,) + _CLOUD_SDK_CONFIG_COMMAND, + stderr=subprocess.STDOUT) + except (subprocess.CalledProcessError, OSError, IOError): + return None + + try: + configuration = json.loads(output.decode('utf-8')) + except ValueError: + return None + + try: + return configuration['configuration']['properties']['core']['project'] + except KeyError: + return None diff --git a/src/google/auth/_default.py b/src/google/auth/_default.py new file mode 100644 index 00000000..7dac642e --- /dev/null +++ b/src/google/auth/_default.py @@ -0,0 +1,286 @@ +# Copyright 2015 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. + +"""Application default credentials. + +Implements application default credentials and project ID detection. +""" + +import io +import json +import logging +import os + +from google.auth import environment_vars +from google.auth import exceptions +import google.auth.transport._http_client + +_LOGGER = logging.getLogger(__name__) + +# Valid types accepted for file-based credentials. +_AUTHORIZED_USER_TYPE = 'authorized_user' +_SERVICE_ACCOUNT_TYPE = 'service_account' +_VALID_TYPES = (_AUTHORIZED_USER_TYPE, _SERVICE_ACCOUNT_TYPE) + +# Help message when no credentials can be found. +_HELP_MESSAGE = """ +Could not automatically determine credentials. Please set {env} or +explicitly create credential and re-run the application. For more +information, please see +https://developers.google.com/accounts/docs/application-default-credentials. +""".format(env=environment_vars.CREDENTIALS).strip() + + +def _load_credentials_from_file(filename): + """Loads credentials from a file. + + The credentials file must be a service account key or stored authorized + user credentials. + + Args: + filename (str): The full path to the credentials file. + + Returns: + Tuple[google.auth.credentials.Credentials, Optional[str]]: Loaded + credentials and the project ID. Authorized user credentials do not + have the project ID information. + + Raises: + google.auth.exceptions.DefaultCredentialsError: if the file is in the + wrong format or is missing. + """ + if not os.path.exists(filename): + raise exceptions.DefaultCredentialsError( + 'File {} was not found.'.format(filename)) + + with io.open(filename, 'r') as file_obj: + try: + info = json.load(file_obj) + except ValueError as exc: + raise exceptions.DefaultCredentialsError( + 'File {} is not a valid json file.'.format(filename), exc) + + # The type key should indicate that the file is either a service account + # credentials file or an authorized user credentials file. + credential_type = info.get('type') + + if credential_type == _AUTHORIZED_USER_TYPE: + from google.auth import _cloud_sdk + + try: + credentials = _cloud_sdk.load_authorized_user_credentials(info) + except ValueError as exc: + raise exceptions.DefaultCredentialsError( + 'Failed to load authorized user credentials from {}'.format( + filename), exc) + # Authorized user credentials do not contain the project ID. + return credentials, None + + elif credential_type == _SERVICE_ACCOUNT_TYPE: + from google.oauth2 import service_account + + try: + credentials = ( + service_account.Credentials.from_service_account_info(info)) + except ValueError as exc: + raise exceptions.DefaultCredentialsError( + 'Failed to load service account credentials from {}'.format( + filename), exc) + return credentials, info.get('project_id') + + else: + raise exceptions.DefaultCredentialsError( + 'The file {file} does not have a valid type. ' + 'Type is {type}, expected one of {valid_types}.'.format( + file=filename, type=credential_type, valid_types=_VALID_TYPES)) + + +def _get_gcloud_sdk_credentials(): + """Gets the credentials and project ID from the Cloud SDK.""" + from google.auth import _cloud_sdk + + # Check if application default credentials exist. + credentials_filename = ( + _cloud_sdk.get_application_default_credentials_path()) + + if not os.path.isfile(credentials_filename): + return None, None + + credentials, project_id = _load_credentials_from_file( + credentials_filename) + + if not project_id: + project_id = _cloud_sdk.get_project_id() + + if not project_id: + _LOGGER.warning( + 'No project ID could be determined from the Cloud SDK ' + 'configuration. Consider running `gcloud config set project` or ' + 'setting the %s environment variable', environment_vars.PROJECT) + + return credentials, project_id + + +def _get_explicit_environ_credentials(): + """Gets credentials from the GOOGLE_APPLICATION_CREDENTIALS environment + variable.""" + explicit_file = os.environ.get(environment_vars.CREDENTIALS) + + if explicit_file is not None: + credentials, project_id = _load_credentials_from_file( + os.environ[environment_vars.CREDENTIALS]) + + if not project_id: + _LOGGER.warning( + 'No project ID could be determined from the credentials at %s ' + 'Consider setting the %s environment variable', + environment_vars.CREDENTIALS, environment_vars.PROJECT) + + return credentials, project_id + + else: + return None, None + + +def _get_gae_credentials(): + """Gets Google App Engine App Identity credentials and project ID.""" + from google.auth import app_engine + + try: + credentials = app_engine.Credentials() + project_id = app_engine.get_project_id() + return credentials, project_id + except EnvironmentError: + return None, None + + +def _get_gce_credentials(request=None): + """Gets credentials and project ID from the GCE Metadata Service.""" + # Ping requires a transport, but we want application default credentials + # to require no arguments. So, we'll use the _http_client transport which + # uses http.client. This is only acceptable because the metadata server + # doesn't do SSL and never requires proxies. + from google.auth import compute_engine + from google.auth.compute_engine import _metadata + + if request is None: + request = google.auth.transport._http_client.Request() + + if _metadata.ping(request=request): + # Get the project ID. + try: + project_id = _metadata.get_project_id(request=request) + except exceptions.TransportError: + _LOGGER.warning( + 'No project ID could be determined from the Compute Engine ' + 'metadata service. Consider setting the %s environment ' + 'variable.', environment_vars.PROJECT) + project_id = None + + return compute_engine.Credentials(), project_id + else: + return None, None + + +def default(scopes=None, request=None): + """Gets the default credentials for the current environment. + + `Application Default Credentials`_ provides an easy way to obtain + credentials to call Google APIs for server-to-server or local applications. + This function acquires credentials from the environment in the following + order: + + 1. If the environment variable ``GOOGLE_APPLICATION_CREDENTIALS`` is set + to the path of a valid service account JSON private key file, then it is + loaded and returned. The project ID returned is the project ID defined + in the service account file if available (some older files do not + contain project ID information). + 2. If the `Google Cloud SDK`_ is installed and has application default + credentials set they are loaded and returned. + + To enable application default credentials with the Cloud SDK run:: + + gcloud auth application-default login + + If the Cloud SDK has an active project, the project ID is returned. The + active project can be set using:: + + gcloud config set project + + 3. If the application is running in the `App Engine standard environment`_ + then the credentials and project ID from the `App Identity Service`_ + are used. + 4. If the application is running in `Compute Engine`_ or the + `App Engine flexible environment`_ then the credentials and project ID + are obtained from the `Metadata Service`_. + 5. If no credentials are found, + :class:`~google.auth.exceptions.DefaultCredentialsError` will be raised. + + .. _Application Default Credentials: https://developers.google.com\ + /identity/protocols/application-default-credentials + .. _Google Cloud SDK: https://cloud.google.com/sdk + .. _App Engine standard environment: https://cloud.google.com/appengine + .. _App Identity Service: https://cloud.google.com/appengine/docs/python\ + /appidentity/ + .. _Compute Engine: https://cloud.google.com/compute + .. _App Engine flexible environment: https://cloud.google.com\ + /appengine/flexible + .. _Metadata Service: https://cloud.google.com/compute/docs\ + /storing-retrieving-metadata + + Example:: + + import google.auth + + credentials, project_id = google.auth.default() + + Args: + scopes (Sequence[str]): The list of scopes for the credentials. If + specified, the credentials will automatically be scoped if + necessary. + request (google.auth.transport.Request): An object used to make + HTTP requests. This is used to detect whether the application + is running on Compute Engine. If not specified, then it will + use the standard library http client to make requests. + + Returns: + Tuple[~google.auth.credentials.Credentials, Optional[str]]: + the current environment's credentials and project ID. Project ID + may be None, which indicates that the Project ID could not be + ascertained from the environment. + + Raises: + ~google.auth.exceptions.DefaultCredentialsError: + If no credentials were found, or if the credentials found were + invalid. + """ + from google.auth.credentials import with_scopes_if_required + + explicit_project_id = os.environ.get( + environment_vars.PROJECT, + os.environ.get(environment_vars.LEGACY_PROJECT)) + + checkers = ( + _get_explicit_environ_credentials, + _get_gcloud_sdk_credentials, + _get_gae_credentials, + lambda: _get_gce_credentials(request)) + + for checker in checkers: + credentials, project_id = checker() + if credentials is not None: + credentials = with_scopes_if_required(credentials, scopes) + return credentials, explicit_project_id or project_id + + raise exceptions.DefaultCredentialsError(_HELP_MESSAGE) diff --git a/src/google/auth/_helpers.py b/src/google/auth/_helpers.py new file mode 100644 index 00000000..860b8271 --- /dev/null +++ b/src/google/auth/_helpers.py @@ -0,0 +1,217 @@ +# Copyright 2015 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. + +"""Helper functions for commonly used utilities.""" + +import base64 +import calendar +import datetime + +import six +from six.moves import urllib + + +CLOCK_SKEW_SECS = 300 # 5 minutes in seconds +CLOCK_SKEW = datetime.timedelta(seconds=CLOCK_SKEW_SECS) + + +def copy_docstring(source_class): + """Decorator that copies a method's docstring from another class. + + Args: + source_class (type): The class that has the documented method. + + Returns: + Callable: A decorator that will copy the docstring of the same + named method in the source class to the decorated method. + """ + def decorator(method): + """Decorator implementation. + + Args: + method (Callable): The method to copy the docstring to. + + Returns: + Callable: the same method passed in with an updated docstring. + + Raises: + ValueError: if the method already has a docstring. + """ + if method.__doc__: + raise ValueError('Method already has a docstring.') + + source_method = getattr(source_class, method.__name__) + method.__doc__ = source_method.__doc__ + + return method + return decorator + + +def utcnow(): + """Returns the current UTC datetime. + + Returns: + datetime: The current time in UTC. + """ + return datetime.datetime.utcnow() + + +def datetime_to_secs(value): + """Convert a datetime object to the number of seconds since the UNIX epoch. + + Args: + value (datetime): The datetime to convert. + + Returns: + int: The number of seconds since the UNIX epoch. + """ + return calendar.timegm(value.utctimetuple()) + + +def to_bytes(value, encoding='utf-8'): + """Converts a string value to bytes, if necessary. + + Unfortunately, ``six.b`` is insufficient for this task since in + Python 2 because it does not modify ``unicode`` objects. + + Args: + value (Union[str, bytes]): The value to be converted. + encoding (str): The encoding to use to convert unicode to bytes. + Defaults to "utf-8". + + Returns: + bytes: The original value converted to bytes (if unicode) or as + passed in if it started out as bytes. + + Raises: + ValueError: If the value could not be converted to bytes. + """ + result = (value.encode(encoding) + if isinstance(value, six.text_type) else value) + if isinstance(result, six.binary_type): + return result + else: + raise ValueError('{0!r} could not be converted to bytes'.format(value)) + + +def from_bytes(value): + """Converts bytes to a string value, if necessary. + + Args: + value (Union[str, bytes]): The value to be converted. + + Returns: + str: The original value converted to unicode (if bytes) or as passed in + if it started out as unicode. + + Raises: + ValueError: If the value could not be converted to unicode. + """ + result = (value.decode('utf-8') + if isinstance(value, six.binary_type) else value) + if isinstance(result, six.text_type): + return result + else: + raise ValueError( + '{0!r} could not be converted to unicode'.format(value)) + + +def update_query(url, params, remove=None): + """Updates a URL's query parameters. + + Replaces any current values if they are already present in the URL. + + Args: + url (str): The URL to update. + params (Mapping[str, str]): A mapping of query parameter + keys to values. + remove (Sequence[str]): Parameters to remove from the query string. + + Returns: + str: The URL with updated query parameters. + + Examples: + + >>> url = 'http://example.com?a=1' + >>> update_query(url, {'a': '2'}) + http://example.com?a=2 + >>> update_query(url, {'b': '3'}) + http://example.com?a=1&b=3 + >> update_query(url, {'b': '3'}, remove=['a']) + http://example.com?b=3 + + """ + if remove is None: + remove = [] + + # Split the URL into parts. + parts = urllib.parse.urlparse(url) + # Parse the query string. + query_params = urllib.parse.parse_qs(parts.query) + # Update the query parameters with the new parameters. + query_params.update(params) + # Remove any values specified in remove. + query_params = { + key: value for key, value + in six.iteritems(query_params) + if key not in remove} + # Re-encoded the query string. + new_query = urllib.parse.urlencode(query_params, doseq=True) + # Unsplit the url. + new_parts = parts._replace(query=new_query) + return urllib.parse.urlunparse(new_parts) + + +def scopes_to_string(scopes): + """Converts scope value to a string suitable for sending to OAuth 2.0 + authorization servers. + + Args: + scopes (Sequence[str]): The sequence of scopes to convert. + + Returns: + str: The scopes formatted as a single string. + """ + return ' '.join(scopes) + + +def string_to_scopes(scopes): + """Converts stringifed scopes value to a list. + + Args: + scopes (Union[Sequence, str]): The string of space-separated scopes + to convert. + Returns: + Sequence(str): The separated scopes. + """ + if not scopes: + return [] + + return scopes.split(' ') + + +def padded_urlsafe_b64decode(value): + """Decodes base64 strings lacking padding characters. + + Google infrastructure tends to omit the base64 padding characters. + + Args: + value (Union[str, bytes]): The encoded value. + + Returns: + bytes: The decoded value + """ + b64string = to_bytes(value) + padded = b64string + b'=' * (-len(b64string) % 4) + return base64.urlsafe_b64decode(padded) diff --git a/src/google/auth/_oauth2client.py b/src/google/auth/_oauth2client.py new file mode 100644 index 00000000..312326e1 --- /dev/null +++ b/src/google/auth/_oauth2client.py @@ -0,0 +1,166 @@ +# 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. + +"""Helpers for transitioning from oauth2client to google-auth. + +.. warning:: + This module is private as it is intended to assist first-party downstream + clients with the transition from oauth2client to google-auth. +""" + +from __future__ import absolute_import + +from google.auth import _helpers +import google.auth.app_engine +import google.oauth2.credentials +import google.oauth2.service_account + +try: + import oauth2client.client + import oauth2client.contrib.gce + import oauth2client.service_account +except ImportError: + raise ImportError('oauth2client is not installed.') + +try: + import oauth2client.contrib.appengine + _HAS_APPENGINE = True +except ImportError: + _HAS_APPENGINE = False + + +_CONVERT_ERROR_TMPL = ( + 'Unable to convert {} to a google-auth credentials class.') + + +def _convert_oauth2_credentials(credentials): + """Converts to :class:`google.oauth2.credentials.Credentials`. + + Args: + credentials (Union[oauth2client.client.OAuth2Credentials, + oauth2client.client.GoogleCredentials]): The credentials to + convert. + + Returns: + google.oauth2.credentials.Credentials: The converted credentials. + """ + new_credentials = google.oauth2.credentials.Credentials( + token=credentials.access_token, + refresh_token=credentials.refresh_token, + token_uri=credentials.token_uri, + client_id=credentials.client_id, + client_secret=credentials.client_secret, + scopes=credentials.scopes) + + new_credentials._expires = credentials.token_expiry + + return new_credentials + + +def _convert_service_account_credentials(credentials): + """Converts to :class:`google.oauth2.service_account.Credentials`. + + Args: + credentials (Union[ + oauth2client.service_account.ServiceAccountCredentials, + oauth2client.service_account._JWTAccessCredentials]): The + credentials to convert. + + Returns: + google.oauth2.service_account.Credentials: The converted credentials. + """ + info = credentials.serialization_data.copy() + info['token_uri'] = credentials.token_uri + return google.oauth2.service_account.Credentials.from_service_account_info( + info) + + +def _convert_gce_app_assertion_credentials(credentials): + """Converts to :class:`google.auth.compute_engine.Credentials`. + + Args: + credentials (oauth2client.contrib.gce.AppAssertionCredentials): The + credentials to convert. + + Returns: + google.oauth2.service_account.Credentials: The converted credentials. + """ + return google.auth.compute_engine.Credentials( + service_account_email=credentials.service_account_email) + + +def _convert_appengine_app_assertion_credentials(credentials): + """Converts to :class:`google.auth.app_engine.Credentials`. + + Args: + credentials (oauth2client.contrib.app_engine.AppAssertionCredentials): + The credentials to convert. + + Returns: + google.oauth2.service_account.Credentials: The converted credentials. + """ + # pylint: disable=invalid-name + return google.auth.app_engine.Credentials( + scopes=_helpers.string_to_scopes(credentials.scope), + service_account_id=credentials.service_account_id) + + +_CLASS_CONVERSION_MAP = { + oauth2client.client.OAuth2Credentials: _convert_oauth2_credentials, + oauth2client.client.GoogleCredentials: _convert_oauth2_credentials, + oauth2client.service_account.ServiceAccountCredentials: + _convert_service_account_credentials, + oauth2client.service_account._JWTAccessCredentials: + _convert_service_account_credentials, + oauth2client.contrib.gce.AppAssertionCredentials: + _convert_gce_app_assertion_credentials, +} + +if _HAS_APPENGINE: + _CLASS_CONVERSION_MAP[ + oauth2client.contrib.appengine.AppAssertionCredentials] = ( + _convert_appengine_app_assertion_credentials) + + +def convert(credentials): + """Convert oauth2client credentials to google-auth credentials. + + This class converts: + + - :class:`oauth2client.client.OAuth2Credentials` to + :class:`google.oauth2.credentials.Credentials`. + - :class:`oauth2client.client.GoogleCredentials` to + :class:`google.oauth2.credentials.Credentials`. + - :class:`oauth2client.service_account.ServiceAccountCredentials` to + :class:`google.oauth2.service_account.Credentials`. + - :class:`oauth2client.service_account._JWTAccessCredentials` to + :class:`google.oauth2.service_account.Credentials`. + - :class:`oauth2client.contrib.gce.AppAssertionCredentials` to + :class:`google.auth.compute_engine.Credentials`. + - :class:`oauth2client.contrib.appengine.AppAssertionCredentials` to + :class:`google.auth.app_engine.Credentials`. + + Returns: + google.auth.credentials.Credentials: The converted credentials. + + Raises: + ValueError: If the credentials could not be converted. + """ + + credentials_class = type(credentials) + + try: + return _CLASS_CONVERSION_MAP[credentials_class](credentials) + except KeyError: + raise ValueError(_CONVERT_ERROR_TMPL.format(credentials_class)) diff --git a/src/google/auth/_service_account_info.py b/src/google/auth/_service_account_info.py new file mode 100644 index 00000000..dd39ea7b --- /dev/null +++ b/src/google/auth/_service_account_info.py @@ -0,0 +1,73 @@ +# 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. + +"""Helper functions for loading data from a Google service account file.""" + +import io +import json + +import six + +from google.auth import crypt + + +def from_dict(data, require=None): + """Validates a dictionary containing Google service account data. + + Creates and returns a :class:`google.auth.crypt.Signer` instance from the + private key specified in the data. + + Args: + data (Mapping[str, str]): The service account data + require (Sequence[str]): List of keys required to be present in the + info. + + Returns: + google.auth.crypt.Signer: A signer created from the private key in the + service account file. + + Raises: + ValueError: if the data was in the wrong format, or if one of the + required keys is missing. + """ + keys_needed = set(require if require is not None else []) + + missing = keys_needed.difference(six.iterkeys(data)) + + if missing: + raise ValueError( + 'Service account info was not in the expected format, missing ' + 'fields {}.'.format(', '.join(missing))) + + # Create a signer. + signer = crypt.RSASigner.from_service_account_info(data) + + return signer + + +def from_filename(filename, require=None): + """Reads a Google service account JSON file and returns its parsed info. + + Args: + filename (str): The path to the service account .json file. + require (Sequence[str]): List of keys required to be present in the + info. + + Returns: + Tuple[ Mapping[str, str], google.auth.crypt.Signer ]: The verified + info and a signer instance. + """ + with io.open(filename, 'r', encoding='utf-8') as json_file: + data = json.load(json_file) + return data, from_dict(data, require=require) diff --git a/src/google/auth/app_engine.py b/src/google/auth/app_engine.py new file mode 100644 index 00000000..fa13f8ef --- /dev/null +++ b/src/google/auth/app_engine.py @@ -0,0 +1,154 @@ +# 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. + +"""Google App Engine standard environment support. + +This module provides authentication and signing for applications running on App +Engine in the standard environment using the `App Identity API`_. + + +.. _App Identity API: + https://cloud.google.com/appengine/docs/python/appidentity/ +""" + +import datetime + +from google.auth import _helpers +from google.auth import credentials +from google.auth import crypt + +try: + from google.appengine.api import app_identity +except ImportError: + app_identity = None + + +class Signer(crypt.Signer): + """Signs messages using the App Engine App Identity service. + + This can be used in place of :class:`google.auth.crypt.Signer` when + running in the App Engine standard environment. + """ + + @property + def key_id(self): + """Optional[str]: The key ID used to identify this private key. + + .. warning:: + This is always ``None``. The key ID used by App Engine can not + be reliably determined ahead of time. + """ + return None + + @_helpers.copy_docstring(crypt.Signer) + def sign(self, message): + message = _helpers.to_bytes(message) + _, signature = app_identity.sign_blob(message) + return signature + + +def get_project_id(): + """Gets the project ID for the current App Engine application. + + Returns: + str: The project ID + + Raises: + EnvironmentError: If the App Engine APIs are unavailable. + """ + # pylint: disable=missing-raises-doc + # Pylint rightfully thinks EnvironmentError is OSError, but doesn't + # realize it's a valid alias. + if app_identity is None: + raise EnvironmentError( + 'The App Engine APIs are not available.') + return app_identity.get_application_id() + + +class Credentials(credentials.Scoped, credentials.Signing, + credentials.Credentials): + """App Engine standard environment credentials. + + These credentials use the App Engine App Identity API to obtain access + tokens. + """ + + def __init__(self, scopes=None, service_account_id=None): + """ + Args: + scopes (Sequence[str]): Scopes to request from the App Identity + API. + service_account_id (str): The service account ID passed into + :func:`google.appengine.api.app_identity.get_access_token`. + If not specified, the default application service account + ID will be used. + + Raises: + EnvironmentError: If the App Engine APIs are unavailable. + """ + # pylint: disable=missing-raises-doc + # Pylint rightfully thinks EnvironmentError is OSError, but doesn't + # realize it's a valid alias. + if app_identity is None: + raise EnvironmentError( + 'The App Engine APIs are not available.') + + super(Credentials, self).__init__() + self._scopes = scopes + self._service_account_id = service_account_id + self._signer = Signer() + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + # pylint: disable=unused-argument + token, ttl = app_identity.get_access_token( + self._scopes, self._service_account_id) + expiry = datetime.datetime.utcfromtimestamp(ttl) + + self.token, self.expiry = token, expiry + + @property + def service_account_email(self): + """The service account email.""" + if self._service_account_id is None: + self._service_account_id = app_identity.get_service_account_name() + return self._service_account_id + + @property + def requires_scopes(self): + """Checks if the credentials requires scopes. + + Returns: + bool: True if there are no scopes set otherwise False. + """ + return not self._scopes + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes): + return Credentials( + scopes=scopes, service_account_id=self._service_account_id) + + @_helpers.copy_docstring(credentials.Signing) + def sign_bytes(self, message): + return self._signer.sign(message) + + @property + @_helpers.copy_docstring(credentials.Signing) + def signer_email(self): + return self.service_account_email + + @property + @_helpers.copy_docstring(credentials.Signing) + def signer(self): + return self._signer diff --git a/src/google/auth/compute_engine/__init__.py b/src/google/auth/compute_engine/__init__.py new file mode 100644 index 00000000..3794be2f --- /dev/null +++ b/src/google/auth/compute_engine/__init__.py @@ -0,0 +1,22 @@ +# 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. + +"""Google Compute Engine authentication.""" + +from google.auth.compute_engine.credentials import Credentials + + +__all__ = [ + 'Credentials' +] diff --git a/src/google/auth/compute_engine/_metadata.py b/src/google/auth/compute_engine/_metadata.py new file mode 100644 index 00000000..9a22d7a8 --- /dev/null +++ b/src/google/auth/compute_engine/_metadata.py @@ -0,0 +1,202 @@ +# 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. + +"""Provides helper methods for talking to the Compute Engine metadata server. + +See https://cloud.google.com/compute/docs/metadata for more details. +""" + +import datetime +import json +import logging +import os + +from six.moves import http_client +from six.moves.urllib import parse as urlparse + +from google.auth import _helpers +from google.auth import environment_vars +from google.auth import exceptions + +_LOGGER = logging.getLogger(__name__) + +_METADATA_ROOT = 'http://{}/computeMetadata/v1/'.format( + os.getenv(environment_vars.GCE_METADATA_ROOT, 'metadata.google.internal')) + +# This is used to ping the metadata server, it avoids the cost of a DNS +# lookup. +_METADATA_IP_ROOT = 'http://{}'.format( + os.getenv(environment_vars.GCE_METADATA_IP, '169.254.169.254')) +_METADATA_FLAVOR_HEADER = 'metadata-flavor' +_METADATA_FLAVOR_VALUE = 'Google' +_METADATA_HEADERS = {_METADATA_FLAVOR_HEADER: _METADATA_FLAVOR_VALUE} + +# Timeout in seconds to wait for the GCE metadata server when detecting the +# GCE environment. +try: + _METADATA_DEFAULT_TIMEOUT = int(os.getenv('GCE_METADATA_TIMEOUT', 3)) +except ValueError: # pragma: NO COVER + _METADATA_DEFAULT_TIMEOUT = 3 + + +def ping(request, timeout=_METADATA_DEFAULT_TIMEOUT): + """Checks to see if the metadata server is available. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + timeout (int): How long to wait for the metadata server to respond. + + Returns: + bool: True if the metadata server is reachable, False otherwise. + """ + # NOTE: The explicit ``timeout`` is a workaround. The underlying + # issue is that resolving an unknown host on some networks will take + # 20-30 seconds; making this timeout short fixes the issue, but + # could lead to false negatives in the event that we are on GCE, but + # the metadata resolution was particularly slow. The latter case is + # "unlikely". + try: + response = request( + url=_METADATA_IP_ROOT, method='GET', headers=_METADATA_HEADERS, + timeout=timeout) + + metadata_flavor = response.headers.get(_METADATA_FLAVOR_HEADER) + return (response.status == http_client.OK and + metadata_flavor == _METADATA_FLAVOR_VALUE) + + except exceptions.TransportError: + _LOGGER.info('Compute Engine Metadata server unavailable.') + return False + + +def get(request, path, root=_METADATA_ROOT, recursive=False): + """Fetch a resource from the metadata server. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + path (str): The resource to retrieve. For example, + ``'instance/service-accounts/default'``. + root (str): The full path to the metadata server root. + recursive (bool): Whether to do a recursive query of metadata. See + https://cloud.google.com/compute/docs/metadata#aggcontents for more + details. + + Returns: + Union[Mapping, str]: If the metadata server returns JSON, a mapping of + the decoded JSON is return. Otherwise, the response content is + returned as a string. + + Raises: + google.auth.exceptions.TransportError: if an error occurred while + retrieving metadata. + """ + base_url = urlparse.urljoin(root, path) + query_params = {} + + if recursive: + query_params['recursive'] = 'true' + + url = _helpers.update_query(base_url, query_params) + + response = request(url=url, method='GET', headers=_METADATA_HEADERS) + + if response.status == http_client.OK: + content = _helpers.from_bytes(response.data) + if response.headers['content-type'] == 'application/json': + try: + return json.loads(content) + except ValueError: + raise exceptions.TransportError( + 'Received invalid JSON from the Google Compute Engine' + 'metadata service: {:.20}'.format(content)) + else: + return content + else: + raise exceptions.TransportError( + 'Failed to retrieve {} from the Google Compute Engine' + 'metadata service. Status: {} Response:\n{}'.format( + url, response.status, response.data), response) + + +def get_project_id(request): + """Get the Google Cloud Project ID from the metadata server. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + + Returns: + str: The project ID + + Raises: + google.auth.exceptions.TransportError: if an error occurred while + retrieving metadata. + """ + return get(request, 'project/project-id') + + +def get_service_account_info(request, service_account='default'): + """Get information about a service account from the metadata server. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + service_account (str): The string 'default' or a service account email + address. The determines which service account for which to acquire + information. + + Returns: + Mapping: The service account's information, for example:: + + { + 'email': '...', + 'scopes': ['scope', ...], + 'aliases': ['default', '...'] + } + + Raises: + google.auth.exceptions.TransportError: if an error occurred while + retrieving metadata. + """ + return get( + request, + 'instance/service-accounts/{0}/'.format(service_account), + recursive=True) + + +def get_service_account_token(request, service_account='default'): + """Get the OAuth 2.0 access token for a service account. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + service_account (str): The string 'default' or a service account email + address. The determines which service account for which to acquire + an access token. + + Returns: + Union[str, datetime]: The access token and its expiration. + + Raises: + google.auth.exceptions.TransportError: if an error occurred while + retrieving metadata. + """ + token_json = get( + request, + 'instance/service-accounts/{0}/token'.format(service_account)) + token_expiry = _helpers.utcnow() + datetime.timedelta( + seconds=token_json['expires_in']) + return token_json['access_token'], token_expiry diff --git a/src/google/auth/compute_engine/credentials.py b/src/google/auth/compute_engine/credentials.py new file mode 100644 index 00000000..b8fe6f5f --- /dev/null +++ b/src/google/auth/compute_engine/credentials.py @@ -0,0 +1,107 @@ +# 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. + +"""Google Compute Engine credentials. + +This module provides authentication for application running on Google Compute +Engine using the Compute Engine metadata server. + +""" + +from google.auth import credentials +from google.auth import exceptions +from google.auth.compute_engine import _metadata + + +class Credentials(credentials.ReadOnlyScoped, credentials.Credentials): + """Compute Engine Credentials. + + These credentials use the Google Compute Engine metadata server to obtain + OAuth 2.0 access tokens associated with the instance's service account. + + For more information about Compute Engine authentication, including how + to configure scopes, see the `Compute Engine authentication + documentation`_. + + .. note:: Compute Engine instances can be created with scopes and therefore + these credentials are considered to be 'scoped'. However, you can + not use :meth:`~google.auth.credentials.ScopedCredentials.with_scopes` + because it is not possible to change the scopes that the instance + has. Also note that + :meth:`~google.auth.credentials.ScopedCredentials.has_scopes` will not + work until the credentials have been refreshed. + + .. _Compute Engine authentication documentation: + https://cloud.google.com/compute/docs/authentication#using + """ + + def __init__(self, service_account_email='default'): + """ + Args: + service_account_email (str): The service account email to use, or + 'default'. A Compute Engine instance may have multiple service + accounts. + """ + super(Credentials, self).__init__() + self._service_account_email = service_account_email + + def _retrieve_info(self, request): + """Retrieve information about the service account. + + Updates the scopes and retrieves the full service account email. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + """ + info = _metadata.get_service_account_info( + request, + service_account=self._service_account_email) + + self._service_account_email = info['email'] + self._scopes = info['scopes'] + + def refresh(self, request): + """Refresh the access token and scopes. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If the Compute Engine metadata + service can't be reached if if the instance has not + credentials. + """ + try: + self._retrieve_info(request) + self.token, self.expiry = _metadata.get_service_account_token( + request, + service_account=self._service_account_email) + except exceptions.TransportError as exc: + raise exceptions.RefreshError(exc) + + @property + def service_account_email(self): + """The service account email. + + .. note: This is not guaranteed to be set until :meth`refresh` has been + called. + """ + return self._service_account_email + + @property + def requires_scopes(self): + """False: Compute Engine credentials can not be scoped.""" + return False diff --git a/src/google/auth/credentials.py b/src/google/auth/credentials.py new file mode 100644 index 00000000..28f9c9f7 --- /dev/null +++ b/src/google/auth/credentials.py @@ -0,0 +1,280 @@ +# 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. + + +"""Interfaces for credentials.""" + +import abc + +import six + +from google.auth import _helpers + + +@six.add_metaclass(abc.ABCMeta) +class Credentials(object): + """Base class for all credentials. + + All credentials have a :attr:`token` that is used for authentication and + may also optionally set an :attr:`expiry` to indicate when the token will + no longer be valid. + + Most credentials will be :attr:`invalid` until :meth:`refresh` is called. + Credentials can do this automatically before the first HTTP request in + :meth:`before_request`. + + Although the token and expiration will change as the credentials are + :meth:`refreshed ` and used, credentials should be considered + immutable. Various credentials will accept configuration such as private + keys, scopes, and other options. These options are not changeable after + construction. Some classes will provide mechanisms to copy the credentials + with modifications such as :meth:`ScopedCredentials.with_scopes`. + """ + def __init__(self): + self.token = None + """str: The bearer token that can be used in HTTP headers to make + authenticated requests.""" + self.expiry = None + """Optional[datetime]: When the token expires and is no longer valid. + If this is None, the token is assumed to never expire.""" + + @property + def expired(self): + """Checks if the credentials are expired. + + Note that credentials can be invalid but not expired becaue Credentials + with :attr:`expiry` set to None is considered to never expire. + """ + if not self.expiry: + return False + + # Remove 5 minutes from expiry to err on the side of reporting + # expiration early so that we avoid the 401-refresh-retry loop. + skewed_expiry = self.expiry - _helpers.CLOCK_SKEW + return _helpers.utcnow() >= skewed_expiry + + @property + def valid(self): + """Checks the validity of the credentials. + + This is True if the credentials have a :attr:`token` and the token + is not :attr:`expired`. + """ + return self.token is not None and not self.expired + + @abc.abstractmethod + def refresh(self, request): + """Refreshes the access token. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.RefreshError: If the credentials could + not be refreshed. + """ + # pylint: disable=missing-raises-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError('Refresh must be implemented') + + def apply(self, headers, token=None): + """Apply the token to the authentication header. + + Args: + headers (Mapping): The HTTP request headers. + token (Optional[str]): If specified, overrides the current access + token. + """ + headers['authorization'] = 'Bearer {}'.format( + _helpers.from_bytes(token or self.token)) + + def before_request(self, request, method, url, headers): + """Performs credential-specific before request logic. + + Refreshes the credentials if necessary, then calls :meth:`apply` to + apply the token to the authentication header. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + method (str): The request's HTTP method or the RPC method being + invoked. + url (str): The request's URI or the RPC service's URI. + headers (Mapping): The request's headers. + """ + # pylint: disable=unused-argument + # (Subclasses may use these arguments to ascertain information about + # the http request.) + if not self.valid: + self.refresh(request) + self.apply(headers) + + +@six.add_metaclass(abc.ABCMeta) +class ReadOnlyScoped(object): + """Interface for credentials whose scopes can be queried. + + OAuth 2.0-based credentials allow limiting access using scopes as described + in `RFC6749 Section 3.3`_. + If a credential class implements this interface then the credentials either + use scopes in their implementation. + + Some credentials require scopes in order to obtain a token. You can check + if scoping is necessary with :attr:`requires_scopes`:: + + if credentials.requires_scopes: + # Scoping is required. + credentials = credentials.create_scoped(['one', 'two']) + + Credentials that require scopes must either be constructed with scopes:: + + credentials = SomeScopedCredentials(scopes=['one', 'two']) + + Or must copy an existing instance using :meth:`with_scopes`:: + + scoped_credentials = credentials.with_scopes(scopes=['one', 'two']) + + Some credentials have scopes but do not allow or require scopes to be set, + these credentials can be used as-is. + + .. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3 + """ + def __init__(self): + super(ReadOnlyScoped, self).__init__() + self._scopes = None + + @property + def scopes(self): + """Sequence[str]: the credentials' current set of scopes.""" + return self._scopes + + @abc.abstractproperty + def requires_scopes(self): + """True if these credentials require scopes to obtain an access token. + """ + return False + + def has_scopes(self, scopes): + """Checks if the credentials have the given scopes. + + .. warning: This method is not guaranteed to be accurate if the + credentials are :attr:`~Credentials.invalid`. + + Returns: + bool: True if the credentials have the given scopes. + """ + return set(scopes).issubset(set(self._scopes or [])) + + +class Scoped(ReadOnlyScoped): + """Interface for credentials whose scopes can be replaced while copying. + + OAuth 2.0-based credentials allow limiting access using scopes as described + in `RFC6749 Section 3.3`_. + If a credential class implements this interface then the credentials either + use scopes in their implementation. + + Some credentials require scopes in order to obtain a token. You can check + if scoping is necessary with :attr:`requires_scopes`:: + + if credentials.requires_scopes: + # Scoping is required. + credentials = credentials.create_scoped(['one', 'two']) + + Credentials that require scopes must either be constructed with scopes:: + + credentials = SomeScopedCredentials(scopes=['one', 'two']) + + Or must copy an existing instance using :meth:`with_scopes`:: + + scoped_credentials = credentials.with_scopes(scopes=['one', 'two']) + + Some credentials have scopes but do not allow or require scopes to be set, + these credentials can be used as-is. + + .. _RFC6749 Section 3.3: https://tools.ietf.org/html/rfc6749#section-3.3 + """ + @abc.abstractmethod + def with_scopes(self, scopes): + """Create a copy of these credentials with the specified scopes. + + Args: + scopes (Sequence[str]): The list of scopes to request. + + Raises: + NotImplementedError: If the credentials' scopes can not be changed. + This can be avoided by checking :attr:`requires_scopes` before + calling this method. + """ + raise NotImplementedError('This class does not require scoping.') + + +def with_scopes_if_required(credentials, scopes): + """Creates a copy of the credentials with scopes if scoping is required. + + This helper function is useful when you do not know (or care to know) the + specific type of credentials you are using (such as when you use + :func:`google.auth.default`). This function will call + :meth:`Scoped.with_scopes` if the credentials are scoped credentials and if + the credentials require scoping. Otherwise, it will return the credentials + as-is. + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + scope if necessary. + scopes (Sequence[str]): The list of scopes to use. + + Returns: + google.auth.credentials.Credentials: Either a new set of scoped + credentials, or the passed in credentials instance if no scoping + was required. + """ + if isinstance(credentials, Scoped) and credentials.requires_scopes: + return credentials.with_scopes(scopes) + else: + return credentials + + +@six.add_metaclass(abc.ABCMeta) +class Signing(object): + """Interface for credentials that can cryptographically sign messages.""" + + @abc.abstractmethod + def sign_bytes(self, message): + """Signs the given message. + + Args: + message (bytes): The message to sign. + + Returns: + bytes: The message's cryptographic signature. + """ + # pylint: disable=missing-raises-doc,redundant-returns-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError('Sign bytes must be implemented.') + + @abc.abstractproperty + def signer_email(self): + """Optional[str]: An email address that identifies the signer.""" + # pylint: disable=missing-raises-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError('Signer email must be implemented.') + + @abc.abstractproperty + def signer(self): + """google.auth.crypt.Signer: The signer used to sign bytes.""" + # pylint: disable=missing-raises-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError('Signer must be implemented.') diff --git a/src/google/auth/crypt/__init__.py b/src/google/auth/crypt/__init__.py new file mode 100644 index 00000000..fa59662e --- /dev/null +++ b/src/google/auth/crypt/__init__.py @@ -0,0 +1,79 @@ +# 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. + +"""Cryptography helpers for verifying and signing messages. + +The simplest way to verify signatures is using :func:`verify_signature`:: + + cert = open('certs.pem').read() + valid = crypt.verify_signature(message, signature, cert) + +If you're going to verify many messages with the same certificate, you can use +:class:`RSAVerifier`:: + + cert = open('certs.pem').read() + verifier = crypt.RSAVerifier.from_string(cert) + valid = verifier.verify(message, signature) + +To sign messages use :class:`RSASigner` with a private key:: + + private_key = open('private_key.pem').read() + signer = crypt.RSASigner(private_key) + signature = signer.sign(message) +""" + +import six + +from google.auth.crypt import base +from google.auth.crypt import rsa + + +__all__ = [ + 'RSASigner', + 'RSAVerifier', + 'Signer', + 'Verifier', +] + +# Aliases to maintain the v1.0.0 interface, as the crypt module was split +# into submodules. +Signer = base.Signer +Verifier = base.Verifier +RSASigner = rsa.RSASigner +RSAVerifier = rsa.RSAVerifier + + +def verify_signature(message, signature, certs): + """Verify an RSA cryptographic signature. + + Checks that the provided ``signature`` was generated from ``bytes`` using + the private key associated with the ``cert``. + + Args: + message (Union[str, bytes]): The plaintext message. + signature (Union[str, bytes]): The cryptographic signature to check. + certs (Union[Sequence, str, bytes]): The certificate or certificates + to use to check the signature. + + Returns: + bool: True if the signature is valid, otherwise False. + """ + if isinstance(certs, (six.text_type, six.binary_type)): + certs = [certs] + + for cert in certs: + verifier = rsa.RSAVerifier.from_string(cert) + if verifier.verify(message, signature): + return True + return False diff --git a/src/google/auth/crypt/_python_rsa.py b/src/google/auth/crypt/_python_rsa.py new file mode 100644 index 00000000..1f6384d2 --- /dev/null +++ b/src/google/auth/crypt/_python_rsa.py @@ -0,0 +1,221 @@ +# 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. + +"""Pure-Python RSA cryptography implementation. + +Uses the ``rsa``, ``pyasn1`` and ``pyasn1_modules`` packages +to parse PEM files storing PKCS#1 or PKCS#8 keys as well as +certificates. There is no support for p12 files. +""" + +from __future__ import absolute_import + +import io +import json + +from pyasn1.codec.der import decoder +from pyasn1_modules import pem +from pyasn1_modules.rfc2459 import Certificate +from pyasn1_modules.rfc5208 import PrivateKeyInfo +import rsa +import six + +from google.auth import _helpers +from google.auth.crypt import base + +_POW2 = (128, 64, 32, 16, 8, 4, 2, 1) +_CERTIFICATE_MARKER = b'-----BEGIN CERTIFICATE-----' +_PKCS1_MARKER = ('-----BEGIN RSA PRIVATE KEY-----', + '-----END RSA PRIVATE KEY-----') +_PKCS8_MARKER = ('-----BEGIN PRIVATE KEY-----', + '-----END PRIVATE KEY-----') +_PKCS8_SPEC = PrivateKeyInfo() +_JSON_FILE_PRIVATE_KEY = 'private_key' +_JSON_FILE_PRIVATE_KEY_ID = 'private_key_id' + + +def _bit_list_to_bytes(bit_list): + """Converts an iterable of 1s and 0s to bytes. + + Combines the list 8 at a time, treating each group of 8 bits + as a single byte. + + Args: + bit_list (Sequence): Sequence of 1s and 0s. + + Returns: + bytes: The decoded bytes. + """ + num_bits = len(bit_list) + byte_vals = bytearray() + for start in six.moves.xrange(0, num_bits, 8): + curr_bits = bit_list[start:start + 8] + char_val = sum( + val * digit for val, digit in six.moves.zip(_POW2, curr_bits)) + byte_vals.append(char_val) + return bytes(byte_vals) + + +class RSAVerifier(base.Verifier): + """Verifies RSA cryptographic signatures using public keys. + + Args: + public_key (rsa.key.PublicKey): The public key used to verify + signatures. + """ + + def __init__(self, public_key): + self._pubkey = public_key + + @_helpers.copy_docstring(base.Verifier) + def verify(self, message, signature): + message = _helpers.to_bytes(message) + try: + return rsa.pkcs1.verify(message, signature, self._pubkey) + except (ValueError, rsa.pkcs1.VerificationError): + return False + + @classmethod + def from_string(cls, public_key): + """Construct an Verifier instance from a public key or public + certificate string. + + Args: + public_key (Union[str, bytes]): The public key in PEM format or the + x509 public key certificate. + + Returns: + Verifier: The constructed verifier. + + Raises: + ValueError: If the public_key can't be parsed. + """ + public_key = _helpers.to_bytes(public_key) + is_x509_cert = _CERTIFICATE_MARKER in public_key + + # If this is a certificate, extract the public key info. + if is_x509_cert: + der = rsa.pem.load_pem(public_key, 'CERTIFICATE') + asn1_cert, remaining = decoder.decode(der, asn1Spec=Certificate()) + if remaining != b'': + raise ValueError('Unused bytes', remaining) + + cert_info = asn1_cert['tbsCertificate']['subjectPublicKeyInfo'] + key_bytes = _bit_list_to_bytes(cert_info['subjectPublicKey']) + pubkey = rsa.PublicKey.load_pkcs1(key_bytes, 'DER') + else: + pubkey = rsa.PublicKey.load_pkcs1(public_key, 'PEM') + return cls(pubkey) + + +class RSASigner(base.Signer): + """Signs messages with an RSA private key. + + Args: + private_key (rsa.key.PrivateKey): The private key to sign with. + key_id (str): Optional key ID used to identify this private key. This + can be useful to associate the private key with its associated + public key or certificate. + """ + + def __init__(self, private_key, key_id=None): + self._key = private_key + self._key_id = key_id + + @property + @_helpers.copy_docstring(base.Signer) + def key_id(self): + return self._key_id + + @_helpers.copy_docstring(base.Signer) + def sign(self, message): + message = _helpers.to_bytes(message) + return rsa.pkcs1.sign(message, self._key, 'SHA-256') + + @classmethod + def from_string(cls, key, key_id=None): + """Construct an Signer instance from a private key in PEM format. + + Args: + key (str): Private key in PEM format. + key_id (str): An optional key id used to identify the private key. + + Returns: + google.auth.crypt.Signer: The constructed signer. + + Raises: + ValueError: If the key cannot be parsed as PKCS#1 or PKCS#8 in + PEM format. + """ + key = _helpers.from_bytes(key) # PEM expects str in Python 3 + marker_id, key_bytes = pem.readPemBlocksFromFile( + six.StringIO(key), _PKCS1_MARKER, _PKCS8_MARKER) + + # Key is in pkcs1 format. + if marker_id == 0: + private_key = rsa.key.PrivateKey.load_pkcs1( + key_bytes, format='DER') + # Key is in pkcs8. + elif marker_id == 1: + key_info, remaining = decoder.decode( + key_bytes, asn1Spec=_PKCS8_SPEC) + if remaining != b'': + raise ValueError('Unused bytes', remaining) + private_key_info = key_info.getComponentByName('privateKey') + private_key = rsa.key.PrivateKey.load_pkcs1( + private_key_info.asOctets(), format='DER') + else: + raise ValueError('No key could be detected.') + + return cls(private_key, key_id=key_id) + + @classmethod + def from_service_account_info(cls, info): + """Creates a Signer instance instance from a dictionary containing + service account info in Google format. + + Args: + info (Mapping[str, str]): The service account info in Google + format. + + Returns: + google.auth.crypt.Signer: The constructed signer. + + Raises: + ValueError: If the info is not in the expected format. + """ + if _JSON_FILE_PRIVATE_KEY not in info: + raise ValueError( + 'The private_key field was not found in the service account ' + 'info.') + + return cls.from_string( + info[_JSON_FILE_PRIVATE_KEY], + info.get(_JSON_FILE_PRIVATE_KEY_ID)) + + @classmethod + def from_service_account_file(cls, filename): + """Creates a Signer instance from a service account .json file + in Google format. + + Args: + filename (str): The path to the service account .json file. + + Returns: + google.auth.crypt.Signer: The constructed signer. + """ + with io.open(filename, 'r', encoding='utf-8') as json_file: + data = json.load(json_file) + + return cls.from_service_account_info(data) diff --git a/src/google/auth/crypt/base.py b/src/google/auth/crypt/base.py new file mode 100644 index 00000000..05c5a2bf --- /dev/null +++ b/src/google/auth/crypt/base.py @@ -0,0 +1,64 @@ +# 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. + +"""Base classes for cryptographic signers and verifiers.""" + +import abc + +import six + + +@six.add_metaclass(abc.ABCMeta) +class Verifier(object): + """Abstract base class for crytographic signature verifiers.""" + + @abc.abstractmethod + def verify(self, message, signature): + """Verifies a message against a cryptographic signature. + + Args: + message (Union[str, bytes]): The message to verify. + signature (Union[str, bytes]): The cryptography signature to check. + + Returns: + bool: True if message was signed by the private key associated + with the public key that this object was constructed with. + """ + # pylint: disable=missing-raises-doc,redundant-returns-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError('Verify must be implemented') + + +@six.add_metaclass(abc.ABCMeta) +class Signer(object): + """Abstract base class for cryptographic signers.""" + + @abc.abstractproperty + def key_id(self): + """Optional[str]: The key ID used to identify this private key.""" + raise NotImplementedError('Key id must be implemented') + + @abc.abstractmethod + def sign(self, message): + """Signs a message. + + Args: + message (Union[str, bytes]): The message to be signed. + + Returns: + bytes: The signature of the message. + """ + # pylint: disable=missing-raises-doc,redundant-returns-doc + # (pylint doesn't recognize that this is abstract) + raise NotImplementedError('Sign must be implemented') diff --git a/src/google/auth/crypt/rsa.py b/src/google/auth/crypt/rsa.py new file mode 100644 index 00000000..d0bf2a0b --- /dev/null +++ b/src/google/auth/crypt/rsa.py @@ -0,0 +1,20 @@ +# Copyright 2017 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. + +"""RSA cryptography signer and verifier.""" + +from google.auth.crypt import _python_rsa + +RSASigner = _python_rsa.RSASigner +RSAVerifier = _python_rsa.RSAVerifier diff --git a/src/google/auth/environment_vars.py b/src/google/auth/environment_vars.py new file mode 100644 index 00000000..0110e6a3 --- /dev/null +++ b/src/google/auth/environment_vars.py @@ -0,0 +1,49 @@ +# 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. + +"""Environment variables used by :mod:`google.auth`.""" + + +PROJECT = 'GOOGLE_CLOUD_PROJECT' +"""Environment variable defining default project. + +This used by :func:`google.auth.default` to explicitly set a project ID. This +environment variable is also used by the Google Cloud Python Library. +""" + +LEGACY_PROJECT = 'GCLOUD_PROJECT' +"""Previously used environment variable defining the default project. + +This environment variable is used instead of the current one in some +situations (such as Google App Engine). +""" + +CREDENTIALS = 'GOOGLE_APPLICATION_CREDENTIALS' +"""Environment variable defining the location of Google application default +credentials.""" + +# The environment variable name which can replace ~/.config if set. +CLOUD_SDK_CONFIG_DIR = 'CLOUDSDK_CONFIG' +"""Environment variable defines the location of Google Cloud SDK's config +files.""" + +# These two variables allow for customization of the addresses used when +# contacting the GCE metadata service. +GCE_METADATA_ROOT = 'GCE_METADATA_ROOT' +"""Environment variable providing an alternate hostname or host:port to be +used for GCE metadata requests.""" + +GCE_METADATA_IP = 'GCE_METADATA_IP' +"""Environment variable providing an alternate ip:port to be used for ip-only +GCE metadata requests.""" diff --git a/src/google/auth/exceptions.py b/src/google/auth/exceptions.py new file mode 100644 index 00000000..2be9fd6d --- /dev/null +++ b/src/google/auth/exceptions.py @@ -0,0 +1,32 @@ +# 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. + +"""Exceptions used in the google.auth package.""" + + +class GoogleAuthError(Exception): + """Base class for all google.auth errors.""" + + +class TransportError(GoogleAuthError): + """Used to indicate an error occurred during an HTTP request.""" + + +class RefreshError(GoogleAuthError): + """Used to indicate that an refreshing the credentials' access token + failed.""" + + +class DefaultCredentialsError(GoogleAuthError): + """Used to indicate that acquiring default credentials failed.""" diff --git a/src/google/auth/iam.py b/src/google/auth/iam.py new file mode 100644 index 00000000..e091e47f --- /dev/null +++ b/src/google/auth/iam.py @@ -0,0 +1,102 @@ +# Copyright 2017 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. + +"""Tools for using the Google `Cloud Identity and Access Management (IAM) +API`_'s auth-related functionality. + +.. _Cloud Identity and Access Management (IAM) API: + https://cloud.google.com/iam/docs/ +""" + +import base64 +import json + +from six.moves import http_client + +from google.auth import _helpers +from google.auth import crypt +from google.auth import exceptions + +_IAM_API_ROOT_URI = 'https://iam.googleapis.com/v1' +_SIGN_BLOB_URI = ( + _IAM_API_ROOT_URI + '/projects/-/serviceAccounts/{}:signBlob?alt=json') + + +class Signer(crypt.Signer): + """Signs messages using the IAM `signBlob API`_. + + This is useful when you need to sign bytes but do not have access to the + credential's private key file. + + .. _signBlob API: + https://cloud.google.com/iam/reference/rest/v1/projects.serviceAccounts + /signBlob + """ + + def __init__(self, request, credentials, service_account_email): + """ + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + credentials (google.auth.credentials.Credentials): The credentials + that will be used to authenticate the request to the IAM API. + The credentials must have of one the following scopes: + + - https://www.googleapis.com/auth/iam + - https://www.googleapis.com/auth/cloud-platform + service_account_email (str): The service account email identifying + which service account to use to sign bytes. Often, this can + be the same as the service account email in the given + credentials. + """ + self._request = request + self._credentials = credentials + self._service_account_email = service_account_email + + def _make_signing_request(self, message): + """Makes a request to the API signBlob API.""" + message = _helpers.to_bytes(message) + + method = 'POST' + url = _SIGN_BLOB_URI.format(self._service_account_email) + headers = {} + body = json.dumps({ + 'bytesToSign': base64.b64encode(message).decode('utf-8'), + }) + + self._credentials.before_request(self._request, method, url, headers) + response = self._request( + url=url, method=method, body=body, headers=headers) + + if response.status != http_client.OK: + raise exceptions.TransportError( + 'Error calling the IAM signBytes API: {}'.format( + response.data)) + + return json.loads(response.data.decode('utf-8')) + + @property + def key_id(self): + """Optional[str]: The key ID used to identify this private key. + + .. warning:: + This is always ``None``. The key ID used by IAM can not + be reliably determined ahead of time. + """ + return None + + @_helpers.copy_docstring(crypt.Signer) + def sign(self, message): + response = self._make_signing_request(message) + return base64.b64decode(response['signature']) diff --git a/src/google/auth/jwt.py b/src/google/auth/jwt.py new file mode 100644 index 00000000..b1eb5fb9 --- /dev/null +++ b/src/google/auth/jwt.py @@ -0,0 +1,755 @@ +# 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. + +"""JSON Web Tokens + +Provides support for creating (encoding) and verifying (decoding) JWTs, +especially JWTs generated and consumed by Google infrastructure. + +See `rfc7519`_ for more details on JWTs. + +To encode a JWT use :func:`encode`:: + + from google.auth import crypto + from google.auth import jwt + + signer = crypt.Signer(private_key) + payload = {'some': 'payload'} + encoded = jwt.encode(signer, payload) + +To decode a JWT and verify claims use :func:`decode`:: + + claims = jwt.decode(encoded, certs=public_certs) + +You can also skip verification:: + + claims = jwt.decode(encoded, verify=False) + +.. _rfc7519: https://tools.ietf.org/html/rfc7519 + +""" + +import base64 +import collections +import copy +import datetime +import json + +import cachetools +from six.moves import urllib + +from google.auth import _helpers +from google.auth import _service_account_info +from google.auth import crypt +from google.auth import exceptions +import google.auth.credentials + +_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds +_DEFAULT_MAX_CACHE_SIZE = 10 + + +def encode(signer, payload, header=None, key_id=None): + """Make a signed JWT. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign the JWT. + payload (Mapping[str, str]): The JWT payload. + header (Mapping[str, str]): Additional JWT header payload. + key_id (str): The key id to add to the JWT header. If the + signer has a key id it will be used as the default. If this is + specified it will override the signer's key id. + + Returns: + bytes: The encoded JWT. + """ + if header is None: + header = {} + + if key_id is None: + key_id = signer.key_id + + header.update({'typ': 'JWT', 'alg': 'RS256'}) + + if key_id is not None: + header['kid'] = key_id + + segments = [ + base64.urlsafe_b64encode(json.dumps(header).encode('utf-8')), + base64.urlsafe_b64encode(json.dumps(payload).encode('utf-8')), + ] + + signing_input = b'.'.join(segments) + signature = signer.sign(signing_input) + segments.append(base64.urlsafe_b64encode(signature)) + + return b'.'.join(segments) + + +def _decode_jwt_segment(encoded_section): + """Decodes a single JWT segment.""" + section_bytes = _helpers.padded_urlsafe_b64decode(encoded_section) + try: + return json.loads(section_bytes.decode('utf-8')) + except ValueError: + raise ValueError('Can\'t parse segment: {0}'.format(section_bytes)) + + +def _unverified_decode(token): + """Decodes a token and does no verification. + + Args: + token (Union[str, bytes]): The encoded JWT. + + Returns: + Tuple[str, str, str, str]: header, payload, signed_section, and + signature. + + Raises: + ValueError: if there are an incorrect amount of segments in the token. + """ + token = _helpers.to_bytes(token) + + if token.count(b'.') != 2: + raise ValueError( + 'Wrong number of segments in token: {0}'.format(token)) + + encoded_header, encoded_payload, signature = token.split(b'.') + signed_section = encoded_header + b'.' + encoded_payload + signature = _helpers.padded_urlsafe_b64decode(signature) + + # Parse segments + header = _decode_jwt_segment(encoded_header) + payload = _decode_jwt_segment(encoded_payload) + + return header, payload, signed_section, signature + + +def decode_header(token): + """Return the decoded header of a token. + + No verification is done. This is useful to extract the key id from + the header in order to acquire the appropriate certificate to verify + the token. + + Args: + token (Union[str, bytes]): the encoded JWT. + + Returns: + Mapping: The decoded JWT header. + """ + header, _, _, _ = _unverified_decode(token) + return header + + +def _verify_iat_and_exp(payload): + """Verifies the ``iat`` (Issued At) and ``exp`` (Expires) claims in a token + payload. + + Args: + payload (Mapping[str, str]): The JWT payload. + + Raises: + ValueError: if any checks failed. + """ + now = _helpers.datetime_to_secs(_helpers.utcnow()) + + # Make sure the iat and exp claims are present. + for key in ('iat', 'exp'): + if key not in payload: + raise ValueError( + 'Token does not contain required claim {}'.format(key)) + + # Make sure the token wasn't issued in the future. + iat = payload['iat'] + # Err on the side of accepting a token that is slightly early to account + # for clock skew. + earliest = iat - _helpers.CLOCK_SKEW_SECS + if now < earliest: + raise ValueError('Token used too early, {} < {}'.format(now, iat)) + + # Make sure the token wasn't issued in the past. + exp = payload['exp'] + # Err on the side of accepting a token that is slightly out of date + # to account for clow skew. + latest = exp + _helpers.CLOCK_SKEW_SECS + if latest < now: + raise ValueError('Token expired, {} < {}'.format(latest, now)) + + +def decode(token, certs=None, verify=True, audience=None): + """Decode and verify a JWT. + + Args: + token (str): The encoded JWT. + certs (Union[str, bytes, Mapping[str, Union[str, bytes]]]): The + certificate used to validate the JWT signatyre. If bytes or string, + it must the the public key certificate in PEM format. If a mapping, + it must be a mapping of key IDs to public key certificates in PEM + format. The mapping must contain the same key ID that's specified + in the token's header. + verify (bool): Whether to perform signature and claim validation. + Verification is done by default. + audience (str): The audience claim, 'aud', that this JWT should + contain. If None then the JWT's 'aud' parameter is not verified. + + Returns: + Mapping[str, str]: The deserialized JSON payload in the JWT. + + Raises: + ValueError: if any verification checks failed. + """ + header, payload, signed_section, signature = _unverified_decode(token) + + if not verify: + return payload + + # If certs is specified as a dictionary of key IDs to certificates, then + # use the certificate identified by the key ID in the token header. + if isinstance(certs, collections.Mapping): + key_id = header.get('kid') + if key_id: + if key_id not in certs: + raise ValueError( + 'Certificate for key id {} not found.'.format(key_id)) + certs_to_check = [certs[key_id]] + # If there's no key id in the header, check against all of the certs. + else: + certs_to_check = certs.values() + else: + certs_to_check = certs + + # Verify that the signature matches the message. + if not crypt.verify_signature(signed_section, signature, certs_to_check): + raise ValueError('Could not verify token signature.') + + # Verify the issued at and created times in the payload. + _verify_iat_and_exp(payload) + + # Check audience. + if audience is not None: + claim_audience = payload.get('aud') + if audience != claim_audience: + raise ValueError( + 'Token has wrong audience {}, expected {}'.format( + claim_audience, audience)) + + return payload + + +class Credentials(google.auth.credentials.Signing, + google.auth.credentials.Credentials): + """Credentials that use a JWT as the bearer token. + + These credentials require an "audience" claim. This claim identifies the + intended recipient of the bearer token. + + The constructor arguments determine the claims for the JWT that is + sent with requests. Usually, you'll construct these credentials with + one of the helper constructors as shown in the next section. + + To create JWT credentials using a Google service account private key + JSON file:: + + audience = 'https://pubsub.googleapis.com/google.pubsub.v1.Publisher' + credentials = jwt.Credentials.from_service_account_file( + 'service-account.json', + audience=audience) + + If you already have the service account file loaded and parsed:: + + service_account_info = json.load(open('service_account.json')) + credentials = jwt.Credentials.from_service_account_info( + service_account_info, + audience=audience) + + Both helper methods pass on arguments to the constructor, so you can + specify the JWT claims:: + + credentials = jwt.Credentials.from_service_account_file( + 'service-account.json', + audience=audience, + additional_claims={'meta': 'data'}) + + You can also construct the credentials directly if you have a + :class:`~google.auth.crypt.Signer` instance:: + + credentials = jwt.Credentials( + signer, + issuer='your-issuer', + subject='your-subject', + audience=audience) + + The claims are considered immutable. If you want to modify the claims, + you can easily create another instance using :meth:`with_claims`:: + + new_audience = ( + 'https://pubsub.googleapis.com/google.pubsub.v1.Subscriber') + new_credentials = credentials.with_claims(audience=new_audience) + """ + + def __init__(self, signer, issuer, subject, audience, + additional_claims=None, + token_lifetime=_DEFAULT_TOKEN_LIFETIME_SECS): + """ + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + issuer (str): The `iss` claim. + subject (str): The `sub` claim. + audience (str): the `aud` claim. The intended audience for the + credentials. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. + token_lifetime (int): The amount of time in seconds for + which the token is valid. Defaults to 1 hour. + """ + super(Credentials, self).__init__() + self._signer = signer + self._issuer = issuer + self._subject = subject + self._audience = audience + self._token_lifetime = token_lifetime + + if additional_claims is None: + additional_claims = {} + + self._additional_claims = additional_claims + + @classmethod + def _from_signer_and_info(cls, signer, info, **kwargs): + """Creates a Credentials instance from a signer and service account + info. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + info (Mapping[str, str]): The service account info. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: The constructed credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + kwargs.setdefault('subject', info['client_email']) + kwargs.setdefault('issuer', info['client_email']) + return cls(signer, **kwargs) + + @classmethod + def from_service_account_info(cls, info, **kwargs): + """Creates an Credentials instance from a dictionary. + + Args: + info (Mapping[str, str]): The service account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: The constructed credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + signer = _service_account_info.from_dict( + info, require=['client_email']) + return cls._from_signer_and_info(signer, info, **kwargs) + + @classmethod + def from_service_account_file(cls, filename, **kwargs): + """Creates a Credentials instance from a service account .json file + in Google format. + + Args: + filename (str): The path to the service account .json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: The constructed credentials. + """ + info, signer = _service_account_info.from_filename( + filename, require=['client_email']) + return cls._from_signer_and_info(signer, info, **kwargs) + + @classmethod + def from_signing_credentials(cls, credentials, audience, **kwargs): + """Creates a new :class:`google.auth.jwt.Credentials` instance from an + existing :class:`google.auth.credentials.Signing` instance. + + The new instance will use the same signer as the existing instance and + will use the existing instance's signer email as the issuer and + subject by default. + + Example:: + + svc_creds = service_account.Credentials.from_service_account_file( + 'service_account.json') + audience = ( + 'https://pubsub.googleapis.com/google.pubsub.v1.Publisher') + jwt_creds = jwt.Credentials.from_signing_credentials( + svc_creds, audience=audience) + + Args: + credentials (google.auth.credentials.Signing): The credentials to + use to construct the new credentials. + audience (str): the `aud` claim. The intended audience for the + credentials. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: A new Credentials instance. + """ + kwargs.setdefault('issuer', credentials.signer_email) + kwargs.setdefault('subject', credentials.signer_email) + return cls( + credentials.signer, + audience=audience, + **kwargs) + + def with_claims(self, issuer=None, subject=None, audience=None, + additional_claims=None): + """Returns a copy of these credentials with modified claims. + + Args: + issuer (str): The `iss` claim. If unspecified the current issuer + claim will be used. + subject (str): The `sub` claim. If unspecified the current subject + claim will be used. + audience (str): the `aud` claim. If unspecified the current + audience claim will be used. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. This will be merged with the current + additional claims. + + Returns: + google.auth.jwt.Credentials: A new credentials instance. + """ + new_additional_claims = copy.deepcopy(self._additional_claims) + new_additional_claims.update(additional_claims or {}) + + return Credentials( + self._signer, + issuer=issuer if issuer is not None else self._issuer, + subject=subject if subject is not None else self._subject, + audience=audience if audience is not None else self._audience, + additional_claims=new_additional_claims) + + def _make_jwt(self): + """Make a signed JWT. + + Returns: + Tuple[bytes, datetime]: The encoded JWT and the expiration. + """ + now = _helpers.utcnow() + lifetime = datetime.timedelta(seconds=self._token_lifetime) + expiry = now + lifetime + + payload = { + 'iss': self._issuer, + 'sub': self._subject, + 'iat': _helpers.datetime_to_secs(now), + 'exp': _helpers.datetime_to_secs(expiry), + 'aud': self._audience, + } + + payload.update(self._additional_claims) + + jwt = encode(self._signer, payload) + + return jwt, expiry + + def refresh(self, request): + """Refreshes the access token. + + Args: + request (Any): Unused. + """ + # pylint: disable=unused-argument + # (pylint doesn't correctly recognize overridden methods.) + self.token, self.expiry = self._make_jwt() + + @_helpers.copy_docstring(google.auth.credentials.Signing) + def sign_bytes(self, message): + return self._signer.sign(message) + + @property + @_helpers.copy_docstring(google.auth.credentials.Signing) + def signer_email(self): + return self._issuer + + @property + @_helpers.copy_docstring(google.auth.credentials.Signing) + def signer(self): + return self._signer + + +class OnDemandCredentials( + google.auth.credentials.Signing, + google.auth.credentials.Credentials): + """On-demand JWT credentials. + + Like :class:`Credentials`, this class uses a JWT as the bearer token for + authentication. However, this class does not require the audience at + construction time. Instead, it will generate a new token on-demand for + each request using the request URI as the audience. It caches tokens + so that multiple requests to the same URI do not incur the overhead + of generating a new token every time. + + This behavior is especially useful for `gRPC`_ clients. A gRPC service may + have multiple audience and gRPC clients may not know all of the audiences + required for accessing a particular service. With these credentials, + no knowledge of the audiences is required ahead of time. + + .. _grpc: http://www.grpc.io/ + """ + + def __init__(self, signer, issuer, subject, + additional_claims=None, + token_lifetime=_DEFAULT_TOKEN_LIFETIME_SECS, + max_cache_size=_DEFAULT_MAX_CACHE_SIZE): + """ + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + issuer (str): The `iss` claim. + subject (str): The `sub` claim. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. + token_lifetime (int): The amount of time in seconds for + which the token is valid. Defaults to 1 hour. + max_cache_size (int): The maximum number of JWT tokens to keep in + cache. Tokens are cached using :class:`cachetools.LRUCache`. + """ + super(OnDemandCredentials, self).__init__() + self._signer = signer + self._issuer = issuer + self._subject = subject + self._token_lifetime = token_lifetime + + if additional_claims is None: + additional_claims = {} + + self._additional_claims = additional_claims + self._cache = cachetools.LRUCache(maxsize=max_cache_size) + + @classmethod + def _from_signer_and_info(cls, signer, info, **kwargs): + """Creates an OnDemandCredentials instance from a signer and service + account info. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + info (Mapping[str, str]): The service account info. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.OnDemandCredentials: The constructed credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + kwargs.setdefault('subject', info['client_email']) + kwargs.setdefault('issuer', info['client_email']) + return cls(signer, **kwargs) + + @classmethod + def from_service_account_info(cls, info, **kwargs): + """Creates an OnDemandCredentials instance from a dictionary. + + Args: + info (Mapping[str, str]): The service account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.OnDemandCredentials: The constructed credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + signer = _service_account_info.from_dict( + info, require=['client_email']) + return cls._from_signer_and_info(signer, info, **kwargs) + + @classmethod + def from_service_account_file(cls, filename, **kwargs): + """Creates an OnDemandCredentials instance from a service account .json + file in Google format. + + Args: + filename (str): The path to the service account .json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.OnDemandCredentials: The constructed credentials. + """ + info, signer = _service_account_info.from_filename( + filename, require=['client_email']) + return cls._from_signer_and_info(signer, info, **kwargs) + + @classmethod + def from_signing_credentials(cls, credentials, **kwargs): + """Creates a new :class:`google.auth.jwt.OnDemandCredentials` instance + from an existing :class:`google.auth.credentials.Signing` instance. + + The new instance will use the same signer as the existing instance and + will use the existing instance's signer email as the issuer and + subject by default. + + Example:: + + svc_creds = service_account.Credentials.from_service_account_file( + 'service_account.json') + jwt_creds = jwt.OnDemandCredentials.from_signing_credentials( + svc_creds) + + Args: + credentials (google.auth.credentials.Signing): The credentials to + use to construct the new credentials. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: A new Credentials instance. + """ + kwargs.setdefault('issuer', credentials.signer_email) + kwargs.setdefault('subject', credentials.signer_email) + return cls(credentials.signer, **kwargs) + + def with_claims(self, issuer=None, subject=None, additional_claims=None): + """Returns a copy of these credentials with modified claims. + + Args: + issuer (str): The `iss` claim. If unspecified the current issuer + claim will be used. + subject (str): The `sub` claim. If unspecified the current subject + claim will be used. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. This will be merged with the current + additional claims. + + Returns: + google.auth.jwt.OnDemandCredentials: A new credentials instance. + """ + new_additional_claims = copy.deepcopy(self._additional_claims) + new_additional_claims.update(additional_claims or {}) + + return OnDemandCredentials( + self._signer, + issuer=issuer if issuer is not None else self._issuer, + subject=subject if subject is not None else self._subject, + additional_claims=new_additional_claims, + max_cache_size=self._cache.maxsize) + + @property + def valid(self): + """Checks the validity of the credentials. + + These credentials are always valid because it generates tokens on + demand. + """ + return True + + def _make_jwt_for_audience(self, audience): + """Make a new JWT for the given audience. + + Args: + audience (str): The intended audience. + + Returns: + Tuple[bytes, datetime]: The encoded JWT and the expiration. + """ + now = _helpers.utcnow() + lifetime = datetime.timedelta(seconds=self._token_lifetime) + expiry = now + lifetime + + payload = { + 'iss': self._issuer, + 'sub': self._subject, + 'iat': _helpers.datetime_to_secs(now), + 'exp': _helpers.datetime_to_secs(expiry), + 'aud': audience, + } + + payload.update(self._additional_claims) + + jwt = encode(self._signer, payload) + + return jwt, expiry + + def _get_jwt_for_audience(self, audience): + """Get a JWT For a given audience. + + If there is already an existing, non-expired token in the cache for + the audience, that token is used. Otherwise, a new token will be + created. + + Args: + audience (str): The intended audience. + + Returns: + bytes: The encoded JWT. + """ + token, expiry = self._cache.get(audience, (None, None)) + + if token is None or expiry < _helpers.utcnow(): + token, expiry = self._make_jwt_for_audience(audience) + self._cache[audience] = token, expiry + + return token + + def refresh(self, request): + """Raises an exception, these credentials can not be directly + refreshed. + + Args: + request (Any): Unused. + + Raises: + google.auth.RefreshError + """ + # pylint: disable=unused-argument + # (pylint doesn't correctly recognize overridden methods.) + raise exceptions.RefreshError( + 'OnDemandCredentials can not be directly refreshed.') + + def before_request(self, request, method, url, headers): + """Performs credential-specific before request logic. + + Args: + request (Any): Unused. JWT credentials do not need to make an + HTTP request to refresh. + method (str): The request's HTTP method. + url (str): The request's URI. This is used as the audience claim + when generating the JWT. + headers (Mapping): The request's headers. + """ + # pylint: disable=unused-argument + # (pylint doesn't correctly recognize overridden methods.) + parts = urllib.parse.urlsplit(url) + # Strip query string and fragment + audience = urllib.parse.urlunsplit( + (parts.scheme, parts.netloc, parts.path, None, None)) + token = self._get_jwt_for_audience(audience) + self.apply(headers, token=token) + + @_helpers.copy_docstring(google.auth.credentials.Signing) + def sign_bytes(self, message): + return self._signer.sign(message) + + @property + @_helpers.copy_docstring(google.auth.credentials.Signing) + def signer_email(self): + return self._issuer + + @property + @_helpers.copy_docstring(google.auth.credentials.Signing) + def signer(self): + return self._signer diff --git a/src/google/auth/transport/__init__.py b/src/google/auth/transport/__init__.py new file mode 100644 index 00000000..d73c63cd --- /dev/null +++ b/src/google/auth/transport/__init__.py @@ -0,0 +1,96 @@ +# 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. + +"""Transport - HTTP client library support. + +:mod:`google.auth` is designed to work with various HTTP client libraries such +as urllib3 and requests. In order to work across these libraries with different +interfaces some abstraction is needed. + +This module provides two interfaces that are implemented by transport adapters +to support HTTP libraries. :class:`Request` defines the interface expected by +:mod:`google.auth` to make requests. :class:`Response` defines the interface +for the return value of :class:`Request`. +""" + +import abc + +import six +from six.moves import http_client + +DEFAULT_REFRESH_STATUS_CODES = (http_client.UNAUTHORIZED,) +"""Sequence[int]: Which HTTP status code indicate that credentials should be +refreshed and a request should be retried. +""" + +DEFAULT_MAX_REFRESH_ATTEMPTS = 2 +"""int: How many times to refresh the credentials and retry a request.""" + + +@six.add_metaclass(abc.ABCMeta) +class Response(object): + """HTTP Response data.""" + + @abc.abstractproperty + def status(self): + """int: The HTTP status code.""" + raise NotImplementedError('status must be implemented.') + + @abc.abstractproperty + def headers(self): + """Mapping[str, str]: The HTTP response headers.""" + raise NotImplementedError('headers must be implemented.') + + @abc.abstractproperty + def data(self): + """bytes: The response body.""" + raise NotImplementedError('data must be implemented.') + + +@six.add_metaclass(abc.ABCMeta) +class Request(object): + """Interface for a callable that makes HTTP requests. + + Specific transport implementations should provide an implementation of + this that adapts their specific request / response API. + + .. automethod:: __call__ + """ + + @abc.abstractmethod + def __call__(self, url, method='GET', body=None, headers=None, + timeout=None, **kwargs): + """Make an HTTP request. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload / body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (Optional[int]): The number of seconds to wait for a + response from the server. If not specified or if None, the + transport-specific default timeout will be used. + 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. + """ + # pylint: disable=redundant-returns-doc, missing-raises-doc + # (pylint doesn't play well with abstract docstrings.) + raise NotImplementedError('__call__ must be implemented.') diff --git a/src/google/auth/transport/_http_client.py b/src/google/auth/transport/_http_client.py new file mode 100644 index 00000000..4a100964 --- /dev/null +++ b/src/google/auth/transport/_http_client.py @@ -0,0 +1,111 @@ +# 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. + +"""Transport adapter for http.client, for internal use only.""" + +import logging +import socket + +from six.moves import http_client +from six.moves import urllib + +from google.auth import exceptions +from google.auth import transport + +_LOGGER = logging.getLogger(__name__) + + +class Response(transport.Response): + """http.client transport response adapter. + + Args: + response (http.client.HTTPResponse): The raw http client response. + """ + def __init__(self, response): + self._status = response.status + self._headers = { + key.lower(): value for key, value in response.getheaders()} + self._data = response.read() + + @property + def status(self): + return self._status + + @property + def headers(self): + return self._headers + + @property + def data(self): + return self._data + + +class Request(transport.Request): + """http.client transport request adapter.""" + + def __call__(self, url, method='GET', body=None, headers=None, + timeout=None, **kwargs): + """Make an HTTP request using http.client. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload / body in HTTP request. + headers (Mapping): Request headers. + timeout (Optional(int)): The number of seconds to wait for a + response from the server. If not specified or if None, the + socket global default timeout will be used. + kwargs: Additional arguments passed throught to the underlying + :meth:`~http.client.HTTPConnection.request` method. + + Returns: + Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + # socket._GLOBAL_DEFAULT_TIMEOUT is the default in http.client. + if timeout is None: + timeout = socket._GLOBAL_DEFAULT_TIMEOUT + + # http.client doesn't allow None as the headers argument. + if headers is None: + headers = {} + + # http.client needs the host and path parts specified separately. + parts = urllib.parse.urlsplit(url) + path = urllib.parse.urlunsplit( + ('', '', parts.path, parts.query, parts.fragment)) + + if parts.scheme != 'http': + raise exceptions.TransportError( + 'http.client transport only supports the http scheme, {}' + 'was specified'.format(parts.scheme)) + + connection = http_client.HTTPConnection(parts.netloc, timeout=timeout) + + try: + _LOGGER.debug('Making request: %s %s', method, url) + + connection.request( + method, path, body=body, headers=headers, **kwargs) + response = connection.getresponse() + return Response(response) + + except (http_client.HTTPException, socket.error) as exc: + raise exceptions.TransportError(exc) + + finally: + connection.close() diff --git a/src/google/auth/transport/grpc.py b/src/google/auth/transport/grpc.py new file mode 100644 index 00000000..8554ffa3 --- /dev/null +++ b/src/google/auth/transport/grpc.py @@ -0,0 +1,131 @@ +# 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. + +"""Authorization support for gRPC.""" + +from __future__ import absolute_import + +try: + import grpc +except ImportError: # pragma: NO COVER + raise ImportError( + 'gRPC is not installed, please install the grpcio package to use the ' + 'gRPC transport.') +import six + + +class AuthMetadataPlugin(grpc.AuthMetadataPlugin): + """A `gRPC AuthMetadataPlugin`_ that inserts the credentials into each + request. + + .. _gRPC AuthMetadataPlugin: + http://www.grpc.io/grpc/python/grpc.html#grpc.AuthMetadataPlugin + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + add to requests. + request (google.auth.transport.Request): A HTTP transport request + object used to refresh credentials as needed. + """ + def __init__(self, credentials, request): + # pylint: disable=no-value-for-parameter + # pylint doesn't realize that the super method takes no arguments + # because this class is the same name as the superclass. + super(AuthMetadataPlugin, self).__init__() + self._credentials = credentials + self._request = request + + def _get_authorization_headers(self, context): + """Gets the authorization headers for a request. + + Returns: + Sequence[Tuple[str, str]]: A list of request headers (key, value) + to add to the request. + """ + headers = {} + self._credentials.before_request( + self._request, + context.method_name, + context.service_url, + headers) + + return list(six.iteritems(headers)) + + def __call__(self, context, callback): + """Passes authorization metadata into the given callback. + + Args: + context (grpc.AuthMetadataContext): The RPC context. + callback (grpc.AuthMetadataPluginCallback): The callback that will + be invoked to pass in the authorization metadata. + """ + callback(self._get_authorization_headers(context), None) + + +def secure_authorized_channel( + credentials, request, target, ssl_credentials=None, **kwargs): + """Creates a secure authorized gRPC channel. + + This creates a channel with SSL and :class:`AuthMetadataPlugin`. This + channel can be used to create a stub that can make authorized requests. + + Example:: + + import google.auth + import google.auth.transport.grpc + import google.auth.transport.requests + from google.cloud.speech.v1 import cloud_speech_pb2 + + # Get credentials. + credentials, _ = google.auth.default() + + # Get an HTTP request function to refresh credentials. + request = google.auth.transport.requests.Request() + + # Create a channel. + channel = google.auth.transport.grpc.secure_authorized_channel( + credentials, 'speech.googleapis.com:443', request) + + # Use the channel to create a stub. + cloud_speech.create_Speech_stub(channel) + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + add to requests. + request (google.auth.transport.Request): A HTTP transport request + object used to refresh credentials as needed. Even though gRPC + is a separate transport, there's no way to refresh the credentials + without using a standard http transport. + target (str): The host and port of the service. + ssl_credentials (grpc.ChannelCredentials): Optional SSL channel + credentials. This can be used to specify different certificates. + kwargs: Additional arguments to pass to :func:`grpc.secure_channel`. + + Returns: + grpc.Channel: The created gRPC channel. + """ + # Create the metadata plugin for inserting the authorization header. + metadata_plugin = AuthMetadataPlugin(credentials, request) + + # Create a set of grpc.CallCredentials using the metadata plugin. + google_auth_credentials = grpc.metadata_call_credentials(metadata_plugin) + + if ssl_credentials is None: + ssl_credentials = grpc.ssl_channel_credentials() + + # Combine the ssl credentials and the authorization credentials. + composite_credentials = grpc.composite_channel_credentials( + ssl_credentials, google_auth_credentials) + + return grpc.secure_channel(target, composite_credentials, **kwargs) diff --git a/src/google/auth/transport/requests.py b/src/google/auth/transport/requests.py new file mode 100644 index 00000000..6fc395e2 --- /dev/null +++ b/src/google/auth/transport/requests.py @@ -0,0 +1,202 @@ +# 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. + +"""Transport adapter for Requests.""" + +from __future__ import absolute_import + +import logging + +try: + import requests +except ImportError: # pragma: NO COVER + raise ImportError( + 'The requests library is not installed, please install the requests ' + 'package to use the requests transport.') +import requests.exceptions + +from google.auth import exceptions +from google.auth import transport + +_LOGGER = logging.getLogger(__name__) + + +class _Response(transport.Response): + """Requests transport response adapter. + + Args: + response (requests.Response): The raw Requests response. + """ + def __init__(self, response): + self._response = response + + @property + def status(self): + return self._response.status_code + + @property + def headers(self): + return self._response.headers + + @property + def data(self): + return self._response.content + + +class Request(transport.Request): + """Requests request adapter. + + This class is used internally for making requests using various transports + in a consistent way. If you use :class:`AuthorizedSession` you do not need + to construct or use this class directly. + + This class can be useful if you want to manually refresh a + :class:`~google.auth.credentials.Credentials` instance:: + + import google.auth.transport.requests + import requests + + request = google.auth.transport.requests.Request() + + credentials.refresh(request) + + Args: + session (requests.Session): An instance :class:`requests.Session` used + to make HTTP requests. If not specified, a session will be created. + + .. automethod:: __call__ + """ + def __init__(self, session=None): + if not session: + session = requests.Session() + + self.session = session + + def __call__(self, url, method='GET', body=None, headers=None, + timeout=None, **kwargs): + """Make an HTTP request using requests. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload / body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (Optional[int]): The number of seconds to wait for a + response from the server. If not specified or if None, the + requests default timeout will be used. + kwargs: Additional arguments passed through to the underlying + requests :meth:`~requests.Session.request` method. + + Returns: + google.auth.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + try: + _LOGGER.debug('Making request: %s %s', method, url) + response = self.session.request( + method, url, data=body, headers=headers, timeout=timeout, + **kwargs) + return _Response(response) + except requests.exceptions.RequestException as exc: + raise exceptions.TransportError(exc) + + +class AuthorizedSession(requests.Session): + """A Requests Session class with credentials. + + This class is used to perform requests to API endpoints that require + authorization:: + + from google.auth.transport.requests import AuthorizedSession + + authed_session = AuthorizedSession(credentials) + + response = authed_session.request( + 'GET', 'https://www.googleapis.com/storage/v1/b') + + The underlying :meth:`request` implementation handles adding the + credentials' headers to the request and refreshing credentials as needed. + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + add to the request. + refresh_status_codes (Sequence[int]): Which HTTP status codes indicate + that credentials should be refreshed and the request should be + retried. + max_refresh_attempts (int): The maximum number of times to attempt to + refresh the credentials and retry the request. + kwargs: Additional arguments passed to the :class:`requests.Session` + constructor. + """ + def __init__(self, credentials, + refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES, + max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS, + **kwargs): + super(AuthorizedSession, self).__init__(**kwargs) + self.credentials = credentials + self._refresh_status_codes = refresh_status_codes + self._max_refresh_attempts = max_refresh_attempts + # Request instance used by internal methods (for example, + # credentials.refresh). + # Do not pass `self` as the session here, as it can lead to infinite + # recursion. + self._auth_request = Request() + + def request(self, method, url, data=None, headers=None, **kwargs): + """Implementation of Requests' request.""" + # pylint: disable=arguments-differ + # Requests has a ton of arguments to request, but only two + # (method, url) are required. We pass through all of the other + # arguments to super, so no need to exhaustively list them here. + + # Use a kwarg for this instead of an attribute to maintain + # thread-safety. + _credential_refresh_attempt = kwargs.pop( + '_credential_refresh_attempt', 0) + + # Make a copy of the headers. They will be modified by the credentials + # and we want to pass the original headers if we recurse. + request_headers = headers.copy() if headers is not None else {} + + self.credentials.before_request( + self._auth_request, method, url, request_headers) + + response = super(AuthorizedSession, self).request( + method, url, data=data, headers=request_headers, **kwargs) + + # If the response indicated that the credentials needed to be + # refreshed, then refresh the credentials and re-attempt the + # request. + # A stored token may expire between the time it is retrieved and + # the time the request is made, so we may need to try twice. + if (response.status_code in self._refresh_status_codes + and _credential_refresh_attempt < self._max_refresh_attempts): + + _LOGGER.info( + 'Refreshing credentials due to a %s response. Attempt %s/%s.', + response.status_code, _credential_refresh_attempt + 1, + self._max_refresh_attempts) + + self.credentials.refresh(self._auth_request) + + # Recurse. Pass in the original headers, not our modified set. + return self.request( + method, url, data=data, headers=headers, + _credential_refresh_attempt=_credential_refresh_attempt + 1, + **kwargs) + + return response diff --git a/src/google/auth/transport/urllib3.py b/src/google/auth/transport/urllib3.py new file mode 100644 index 00000000..0dfe9130 --- /dev/null +++ b/src/google/auth/transport/urllib3.py @@ -0,0 +1,259 @@ +# 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. + +"""Transport adapter for urllib3.""" + +from __future__ import absolute_import + +import logging + + +# Certifi is Mozilla's certificate bundle. Urllib3 needs a certificate bundle +# to verify HTTPS requests, and certifi is the recommended and most reliable +# way to get a root certificate bundle. See +# http://urllib3.readthedocs.io/en/latest/user-guide.html\ +# #certificate-verification +# For more details. +try: + import certifi +except ImportError: # pragma: NO COVER + certifi = None + +try: + import urllib3 +except ImportError: # pragma: NO COVER + raise ImportError( + 'The urllib3 library is not installed, please install the urllib3 ' + 'package to use the urllib3 transport.') +import urllib3.exceptions + +from google.auth import exceptions +from google.auth import transport + +_LOGGER = logging.getLogger(__name__) + + +class _Response(transport.Response): + """urllib3 transport response adapter. + + Args: + response (urllib3.response.HTTPResponse): The raw urllib3 response. + """ + def __init__(self, response): + self._response = response + + @property + def status(self): + return self._response.status + + @property + def headers(self): + return self._response.headers + + @property + def data(self): + return self._response.data + + +class Request(transport.Request): + """urllib3 request adapter. + + This class is used internally for making requests using various transports + in a consistent way. If you use :class:`AuthorizedHttp` you do not need + to construct or use this class directly. + + This class can be useful if you want to manually refresh a + :class:`~google.auth.credentials.Credentials` instance:: + + import google.auth.transport.urllib3 + import urllib3 + + http = urllib3.PoolManager() + request = google.auth.transport.urllib3.Request(http) + + credentials.refresh(request) + + Args: + http (urllib3.request.RequestMethods): An instance of any urllib3 + class that implements :class:`~urllib3.request.RequestMethods`, + usually :class:`urllib3.PoolManager`. + + .. automethod:: __call__ + """ + def __init__(self, http): + self.http = http + + def __call__(self, url, method='GET', body=None, headers=None, + timeout=None, **kwargs): + """Make an HTTP request using urllib3. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload / body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (Optional[int]): The number of seconds to wait for a + response from the server. If not specified or if None, the + urllib3 default timeout will be used. + kwargs: Additional arguments passed throught to the underlying + urllib3 :meth:`urlopen` method. + + Returns: + google.auth.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + # urllib3 uses a sentinel default value for timeout, so only set it if + # specified. + if timeout is not None: + kwargs['timeout'] = timeout + + try: + _LOGGER.debug('Making request: %s %s', method, url) + response = self.http.request( + method, url, body=body, headers=headers, **kwargs) + return _Response(response) + except urllib3.exceptions.HTTPError as exc: + raise exceptions.TransportError(exc) + + +def _make_default_http(): + if certifi is not None: + return urllib3.PoolManager( + cert_reqs='CERT_REQUIRED', + ca_certs=certifi.where()) + else: + return urllib3.PoolManager() + + +class AuthorizedHttp(urllib3.request.RequestMethods): + """A urllib3 HTTP class with credentials. + + This class is used to perform requests to API endpoints that require + authorization:: + + from google.auth.transport.urllib3 import AuthorizedHttp + + authed_http = AuthorizedHttp(credentials) + + response = authed_http.request( + 'GET', 'https://www.googleapis.com/storage/v1/b') + + This class implements :class:`urllib3.request.RequestMethods` and can be + used just like any other :class:`urllib3.PoolManager`. + + The underlying :meth:`urlopen` implementation handles adding the + credentials' headers to the request and refreshing credentials as needed. + + Args: + credentials (google.auth.credentials.Credentials): The credentials to + add to the request. + http (urllib3.PoolManager): The underlying HTTP object to + use to make requests. If not specified, a + :class:`urllib3.PoolManager` instance will be constructed with + sane defaults. + refresh_status_codes (Sequence[int]): Which HTTP status codes indicate + that credentials should be refreshed and the request should be + retried. + max_refresh_attempts (int): The maximum number of times to attempt to + refresh the credentials and retry the request. + """ + def __init__(self, credentials, http=None, + refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES, + max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS): + + if http is None: + http = _make_default_http() + + self.credentials = credentials + self.http = http + self._refresh_status_codes = refresh_status_codes + self._max_refresh_attempts = max_refresh_attempts + # Request instance used by internal methods (for example, + # credentials.refresh). + self._request = Request(self.http) + + super(AuthorizedHttp, self).__init__() + + def urlopen(self, method, url, body=None, headers=None, **kwargs): + """Implementation of urllib3's urlopen.""" + # pylint: disable=arguments-differ + # We use kwargs to collect additional args that we don't need to + # introspect here. However, we do explicitly collect the two + # positional arguments. + + # Use a kwarg for this instead of an attribute to maintain + # thread-safety. + _credential_refresh_attempt = kwargs.pop( + '_credential_refresh_attempt', 0) + + if headers is None: + headers = self.headers + + # Make a copy of the headers. They will be modified by the credentials + # and we want to pass the original headers if we recurse. + request_headers = headers.copy() + + self.credentials.before_request( + self._request, method, url, request_headers) + + response = self.http.urlopen( + method, url, body=body, headers=request_headers, **kwargs) + + # If the response indicated that the credentials needed to be + # refreshed, then refresh the credentials and re-attempt the + # request. + # A stored token may expire between the time it is retrieved and + # the time the request is made, so we may need to try twice. + # The reason urllib3's retries aren't used is because they + # don't allow you to modify the request headers. :/ + if (response.status in self._refresh_status_codes + and _credential_refresh_attempt < self._max_refresh_attempts): + + _LOGGER.info( + 'Refreshing credentials due to a %s response. Attempt %s/%s.', + response.status, _credential_refresh_attempt + 1, + self._max_refresh_attempts) + + self.credentials.refresh(self._request) + + # Recurse. Pass in the original headers, not our modified set. + return self.urlopen( + method, url, body=body, headers=headers, + _credential_refresh_attempt=_credential_refresh_attempt + 1, + **kwargs) + + return response + + # Proxy methods for compliance with the urllib3.PoolManager interface + + def __enter__(self): + """Proxy to ``self.http``.""" + return self.http.__enter__() + + def __exit__(self, exc_type, exc_val, exc_tb): + """Proxy to ``self.http``.""" + return self.http.__exit__(exc_type, exc_val, exc_tb) + + @property + def headers(self): + """Proxy to ``self.http``.""" + return self.http.headers + + @headers.setter + def headers(self, value): + """Proxy to ``self.http``.""" + self.http.headers = value diff --git a/src/google/oauth2/__init__.py b/src/google/oauth2/__init__.py new file mode 100644 index 00000000..6d3ee7f9 --- /dev/null +++ b/src/google/oauth2/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Google OAuth 2.0 Library for Python.""" diff --git a/src/google/oauth2/_client.py b/src/google/oauth2/_client.py new file mode 100644 index 00000000..468cb7e8 --- /dev/null +++ b/src/google/oauth2/_client.py @@ -0,0 +1,200 @@ +# 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. + +"""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 json + +from six.moves import http_client +from six.moves import urllib + +from google.auth import _helpers +from google.auth import exceptions + +_URLENCODED_CONTENT_TYPE = 'application/x-www-form-urlencoded' +_JWT_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer' +_REFRESH_GRANT_TYPE = 'refresh_token' + + +def _handle_error_response(response_body): + """"Translates an error response into an exception. + + Args: + response_body (str): The decoded response data. + + Raises: + google.auth.exceptions.RefreshError + """ + try: + error_data = json.loads(response_body) + error_details = '{}: {}'.format( + error_data['error'], + error_data.get('error_description')) + # If no details could be extracted, use the response data. + except (KeyError, ValueError): + error_details = response_body + + raise exceptions.RefreshError( + error_details, response_body) + + +def _parse_expiry(response_data): + """Parses the expiry field from a response into a datetime. + + Args: + response_data (Mapping): The JSON-parsed response data. + + Returns: + Optional[datetime]: The expiration or ``None`` if no expiration was + specified. + """ + expires_in = response_data.get('expires_in', None) + + if expires_in is not None: + return _helpers.utcnow() + datetime.timedelta( + seconds=expires_in) + else: + return None + + +def _token_endpoint_request(request, token_uri, body): + """Makes a request to the OAuth 2.0 authorization server's token endpoint. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + body (Mapping[str, str]): The parameters to send in the request body. + + Returns: + Mapping[str, str]: 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, + } + + response = request( + method='POST', url=token_uri, headers=headers, body=body) + + response_body = response.data.decode('utf-8') + + if response.status != http_client.OK: + _handle_error_response(response_body) + + response_data = json.loads(response_body) + + return response_data + + +def jwt_grant(request, token_uri, assertion): + """Implements the JWT Profile for OAuth 2.0 Authorization Grants. + + For more details, see `rfc7523 section 4`_. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + assertion (str): The OAuth 2.0 assertion. + + Returns: + Tuple[str, Optional[datetime], Mapping[str, str]]: 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 = _token_endpoint_request(request, token_uri, body) + + try: + access_token = response_data['access_token'] + except KeyError: + raise exceptions.RefreshError( + 'No access token in response.', response_data) + + expiry = _parse_expiry(response_data) + + return access_token, expiry, response_data + + +def refresh_grant(request, token_uri, refresh_token, client_id, client_secret): + """Implements the OAuth 2.0 refresh token grant. + + For more details, see `rfc678 section 6`_. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token_uri (str): The OAuth 2.0 authorizations server's token endpoint + URI. + refresh_token (str): The refresh token to use to get a new access + token. + client_id (str): The OAuth 2.0 application's client ID. + client_secret (str): The Oauth 2.0 appliaction's client secret. + + Returns: + Tuple[str, Optional[str], Optional[datetime], Mapping[str, str]]: 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, + } + + response_data = _token_endpoint_request(request, token_uri, body) + + try: + access_token = response_data['access_token'] + except KeyError: + raise exceptions.RefreshError( + 'No access token in response.', response_data) + + refresh_token = response_data.get('refresh_token', refresh_token) + expiry = _parse_expiry(response_data) + + return access_token, refresh_token, expiry, response_data diff --git a/src/google/oauth2/credentials.py b/src/google/oauth2/credentials.py new file mode 100644 index 00000000..6a635dda --- /dev/null +++ b/src/google/oauth2/credentials.py @@ -0,0 +1,131 @@ +# 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. + +"""OAuth 2.0 Credentials. + +This module provides credentials based on OAuth 2.0 access and refresh tokens. +These credentials usually access resources on behalf of a user (resource +owner). + +Specifically, this is intended to use access tokens acquired using the +`Authorization Code grant`_ and can refresh those tokens using a +optional `refresh token`_. + +Obtaining the initial access and refresh token is outside of the scope of this +module. Consult `rfc6749 section 4.1`_ for complete details on the +Authorization Code grant flow. + +.. _Authorization Code grant: https://tools.ietf.org/html/rfc6749#section-1.3.1 +.. _refresh token: https://tools.ietf.org/html/rfc6749#section-6 +.. _rfc6749 section 4.1: https://tools.ietf.org/html/rfc6749#section-4.1 +""" + +from google.auth import _helpers +from google.auth import credentials +from google.oauth2 import _client + + +class Credentials(credentials.Scoped, credentials.Credentials): + """Credentials using OAuth 2.0 access and refresh tokens.""" + + def __init__(self, token, refresh_token=None, id_token=None, + token_uri=None, client_id=None, client_secret=None, + scopes=None): + """ + Args: + token (Optional(str)): The OAuth 2.0 access token. Can be None + if refresh information is provided. + refresh_token (str): The OAuth 2.0 refresh token. If specified, + credentials can be refreshed. + id_token (str): The Open ID Connect ID Token. + token_uri (str): The OAuth 2.0 authorization server's token + endpoint URI. Must be specified for refresh, can be left as + None if the token can not be refreshed. + client_id (str): The OAuth 2.0 client ID. Must be specified for + refresh, can be left as None if the token can not be refreshed. + 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. + """ + super(Credentials, self).__init__() + self.token = token + self._refresh_token = refresh_token + self._id_token = id_token + self._scopes = scopes + self._token_uri = token_uri + self._client_id = client_id + self._client_secret = client_secret + + @property + def refresh_token(self): + """Optional[str]: The OAuth 2.0 refresh token.""" + return self._refresh_token + + @property + def token_uri(self): + """Optional[str]: The OAuth 2.0 authorization server's token endpoint + URI.""" + return self._token_uri + + @property + def id_token(self): + """Optional[str]: The Open ID Connect ID Token. + + Depending on the authorization server and the scopes requested, this + may be populated when credentials are obtained and updated when + :meth:`refresh` is called. This token is a JWT. It can be verified + and decoded using :func:`google.oauth2.id_token.verify_oauth2_token`. + """ + return self._id_token + + @property + def client_id(self): + """Optional[str]: The OAuth 2.0 client ID.""" + return self._client_id + + @property + def client_secret(self): + """Optional[str]: The OAuth 2.0 client secret.""" + return self._client_secret + + @property + def requires_scopes(self): + """False: OAuth 2.0 credentials have their scopes set when + the initial token is requested and can not be changed.""" + return False + + def with_scopes(self, scopes): + """Unavailable, OAuth 2.0 credentials can not be re-scoped. + + OAuth 2.0 credentials have their scopes set when the initial token is + requested and can not be changed. + """ + raise NotImplementedError( + 'OAuth 2.0 Credentials can not modify their scopes.') + + @_helpers.copy_docstring(credentials.Credentials) + 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.token = access_token + self.expiry = expiry + self._refresh_token = refresh_token + self._id_token = grant_response.get('id_token') diff --git a/src/google/oauth2/id_token.py b/src/google/oauth2/id_token.py new file mode 100644 index 00000000..fa96fc03 --- /dev/null +++ b/src/google/oauth2/id_token.py @@ -0,0 +1,115 @@ +# 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. + +"""Google ID Token helpers.""" + +import json + +from six.moves import http_client + +from google.auth import exceptions +from google.auth import jwt + +# The URL that provides public certificates for verifying ID tokens issued +# by Google's OAuth 2.0 authorization server. +_GOOGLE_OAUTH2_CERTS_URL = 'https://www.googleapis.com/oauth2/v1/certs' + +# The URL that provides public certificates for verifying ID tokens issued +# by Firebase and the Google APIs infrastructure +_GOOGLE_APIS_CERTS_URL = ( + 'https://www.googleapis.com/robot/v1/metadata/x509' + '/securetoken@system.gserviceaccount.com') + + +def _fetch_certs(request, certs_url): + """Fetches certificates. + + Google-style cerificate endpoints return JSON in the format of + ``{'key id': 'x509 certificate'}``. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + certs_url (str): The certificate endpoint URL. + + Returns: + Mapping[str, str]: A mapping of public key ID to x.509 certificate + data. + """ + response = request(certs_url, method='GET') + + if response.status != http_client.OK: + raise exceptions.TransportError( + 'Could not fetch certificates at {}'.format(certs_url)) + + return json.loads(response.data.decode('utf-8')) + + +def verify_token(id_token, request, audience=None, + certs_url=_GOOGLE_OAUTH2_CERTS_URL): + """Verifies an ID token and returns the decoded token. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. + audience (str): The audience that this token is intended for. If None + then the audience is not verified. + certs_url (str): The URL that specifies the certificates to use to + verify the token. This URL should return JSON in the format of + ``{'key id': 'x509 certificate'}``. + + Returns: + Mapping[str, Any]: The decoded token. + """ + certs = _fetch_certs(request, certs_url) + + return jwt.decode(id_token, certs=certs, audience=audience) + + +def verify_oauth2_token(id_token, request, audience=None): + """Verifies an ID Token issued by Google's OAuth 2.0 authorization server. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. + audience (str): The audience that this token is intended for. This is + typically your application's OAuth 2.0 client ID. If None then the + audience is not verified. + + Returns: + Mapping[str, Any]: The decoded token. + """ + return verify_token( + id_token, request, audience=audience, + certs_url=_GOOGLE_OAUTH2_CERTS_URL) + + +def verify_firebase_token(id_token, request, audience=None): + """Verifies an ID Token issued by Firebase Authentication. + + Args: + id_token (Union[str, bytes]): The encoded token. + request (google.auth.transport.Request): The object used to make + HTTP requests. + audience (str): The audience that this token is intended for. This is + typically your Firebase application ID. If None then the audience + is not verified. + + Returns: + Mapping[str, Any]: The decoded token. + """ + return verify_token( + id_token, request, audience=audience, certs_url=_GOOGLE_APIS_CERTS_URL) diff --git a/src/google/oauth2/service_account.py b/src/google/oauth2/service_account.py new file mode 100644 index 00000000..81b5dabe --- /dev/null +++ b/src/google/oauth2/service_account.py @@ -0,0 +1,338 @@ +# 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. + +"""Service Accounts: JSON Web Token (JWT) Profile for OAuth 2.0 + +This module implements the JWT Profile for OAuth 2.0 Authorization Grants +as defined by `RFC 7523`_ with particular support for how this RFC is +implemented in Google's infrastructure. Google refers to these credentials +as *Service Accounts*. + +Service accounts are used for server-to-server communication, such as +interactions between a web application server and a Google service. The +service account belongs to your application instead of to an individual end +user. In contrast to other OAuth 2.0 profiles, no users are involved and your +application "acts" as the service account. + +Typically an application uses a service account when the application uses +Google APIs to work with its own data rather than a user's data. For example, +an application that uses Google Cloud Datastore for data persistence would use +a service account to authenticate its calls to the Google Cloud Datastore API. +However, an application that needs to access a user's Drive documents would +use the normal OAuth 2.0 profile. + +Additionally, Google Apps domain administrators can grant service accounts +`domain-wide delegation`_ authority to access user data on behalf of users in +the domain. + +This profile uses a JWT to acquire an OAuth 2.0 access token. The JWT is used +in place of the usual authorization token returned during the standard +OAuth 2.0 Authorization Code grant. The JWT is only used for this purpose, as +the acquired access token is used as the bearer token when making requests +using these credentials. + +This profile differs from normal OAuth 2.0 profile because no user consent +step is required. The use of the private key allows this profile to assert +identity directly. + +This profile also differs from the :mod:`google.auth.jwt` authentication +because the JWT credentials use the JWT directly as the bearer token. This +profile instead only uses the JWT to obtain an OAuth 2.0 access token. The +obtained OAuth 2.0 access token is used as the bearer token. + +Domain-wide delegation +---------------------- + +Domain-wide delegation allows a service account to access user data on +behalf of any user in a Google Apps domain without consent from the user. +For example, an application that uses the Google Calendar API to add events to +the calendars of all users in a Google Apps domain would use a service account +to access the Google Calendar API on behalf of users. + +The Google Apps administrator must explicitly authorize the service account to +do this. This authorization step is referred to as "delegating domain-wide +authority" to a service account. + +You can use domain-wise delegation by creating a set of credentials with a +specific subject using :meth:`~Credentials.with_subject`. + +.. _RFC 7523: https://tools.ietf.org/html/rfc7523 +""" + +import copy +import datetime + +from google.auth import _helpers +from google.auth import _service_account_info +from google.auth import credentials +from google.auth import jwt +from google.oauth2 import _client + +_DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in sections + + +class Credentials(credentials.Signing, + credentials.Scoped, + credentials.Credentials): + """Service account credentials + + Usually, you'll create these credentials with one of the helper + constructors. To create credentials using a Google service account + private key JSON file:: + + credentials = service_account.Credentials.from_service_account_file( + 'service-account.json') + + Or if you already have the service account file loaded:: + + service_account_info = json.load(open('service_account.json')) + credentials = service_account.Credentials.from_service_account_info( + service_account_info) + + Both helper methods pass on arguments to the constructor, so you can + specify additional scopes and a subject if necessary:: + + credentials = service_account.Credentials.from_service_account_file( + 'service-account.json', + scopes=['email'], + subject='user@example.com') + + The credentials are considered immutable. If you want to modify the scopes + or the subject used for delegation, use :meth:`with_scopes` or + :meth:`with_subject`:: + + scoped_credentials = credentials.with_scopes(['email']) + delegated_credentials = credentials.with_subject(subject) + """ + + def __init__(self, signer, service_account_email, token_uri, scopes=None, + subject=None, project_id=None, additional_claims=None): + """ + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + service_account_email (str): The service account's email. + scopes (Sequence[str]): Scopes to request during the authorization + grant. + token_uri (str): The OAuth 2.0 Token URI. + subject (str): For domain-wide delegation, the email address of the + user to for which to request delegated access. + project_id (str): Project ID associated with the service account + credential. + additional_claims (Mapping[str, str]): Any additional claims for + the JWT assertion used in the authorization grant. + + .. note:: Typically one of the helper constructors + :meth:`from_service_account_file` or + :meth:`from_service_account_info` are used instead of calling the + constructor directly. + """ + super(Credentials, self).__init__() + + self._scopes = scopes + self._signer = signer + self._service_account_email = service_account_email + self._subject = subject + self._project_id = project_id + self._token_uri = token_uri + + if additional_claims is not None: + self._additional_claims = additional_claims + else: + self._additional_claims = {} + + @classmethod + def _from_signer_and_info(cls, signer, info, **kwargs): + """Creates a Credentials instance from a signer and service account + info. + + Args: + signer (google.auth.crypt.Signer): The signer used to sign JWTs. + info (Mapping[str, str]): The service account info. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.jwt.Credentials: The constructed credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + return cls( + signer, + service_account_email=info['client_email'], + token_uri=info['token_uri'], + project_id=info.get('project_id'), **kwargs) + + @classmethod + def from_service_account_info(cls, info, **kwargs): + """Creates a Credentials instance from parsed service account info. + + Args: + info (Mapping[str, str]): The service account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.service_account.Credentials: The constructed + credentials. + + Raises: + ValueError: If the info is not in the expected format. + """ + signer = _service_account_info.from_dict( + info, require=['client_email', 'token_uri']) + return cls._from_signer_and_info(signer, info, **kwargs) + + @classmethod + def from_service_account_file(cls, filename, **kwargs): + """Creates a Credentials instance from a service account json file. + + Args: + filename (str): The path to the service account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.service_account.Credentials: The constructed + credentials. + """ + info, signer = _service_account_info.from_filename( + filename, require=['client_email', 'token_uri']) + return cls._from_signer_and_info(signer, info, **kwargs) + + @property + def service_account_email(self): + """The service account email.""" + return self._service_account_email + + @property + def project_id(self): + """Project ID associated with this credential.""" + return self._project_id + + @property + def requires_scopes(self): + """Checks if the credentials requires scopes. + + Returns: + bool: True if there are no scopes set otherwise False. + """ + return True if not self._scopes else False + + @_helpers.copy_docstring(credentials.Scoped) + def with_scopes(self, scopes): + return Credentials( + self._signer, + service_account_email=self._service_account_email, + scopes=scopes, + token_uri=self._token_uri, + subject=self._subject, + project_id=self._project_id, + additional_claims=self._additional_claims.copy()) + + def with_subject(self, subject): + """Create a copy of these credentials with the specified subject. + + Args: + subject (str): The subject claim. + + Returns: + google.auth.service_account.Credentials: A new credentials + instance. + """ + return Credentials( + self._signer, + service_account_email=self._service_account_email, + scopes=self._scopes, + token_uri=self._token_uri, + subject=subject, + project_id=self._project_id, + additional_claims=self._additional_claims.copy()) + + def with_claims(self, additional_claims): + """Returns a copy of these credentials with modified claims. + + Args: + additional_claims (Mapping[str, str]): Any additional claims for + the JWT payload. This will be merged with the current + additional claims. + + Returns: + google.auth.service_account.Credentials: A new credentials + instance. + """ + new_additional_claims = copy.deepcopy(self._additional_claims) + new_additional_claims.update(additional_claims or {}) + + return Credentials( + self._signer, + service_account_email=self._service_account_email, + scopes=self._scopes, + token_uri=self._token_uri, + subject=self._subject, + project_id=self._project_id, + additional_claims=new_additional_claims) + + def _make_authorization_grant_assertion(self): + """Create the OAuth 2.0 assertion. + + This assertion is used during the OAuth 2.0 grant to acquire an + access token. + + Returns: + bytes: The authorization grant assertion. + """ + now = _helpers.utcnow() + lifetime = datetime.timedelta(seconds=_DEFAULT_TOKEN_LIFETIME_SECS) + expiry = now + lifetime + + payload = { + 'iat': _helpers.datetime_to_secs(now), + 'exp': _helpers.datetime_to_secs(expiry), + # The issuer must be the service account email. + 'iss': self._service_account_email, + # The audience must be the auth token endpoint's URI + 'aud': self._token_uri, + 'scope': _helpers.scopes_to_string(self._scopes or ()) + } + + payload.update(self._additional_claims) + + # The subject can be a user email for domain-wide delegation. + if self._subject: + payload.setdefault('sub', self._subject) + + token = jwt.encode(self._signer, payload) + + return token + + @_helpers.copy_docstring(credentials.Credentials) + def refresh(self, request): + assertion = self._make_authorization_grant_assertion() + access_token, expiry, _ = _client.jwt_grant( + request, self._token_uri, assertion) + self.token = access_token + self.expiry = expiry + + @_helpers.copy_docstring(credentials.Signing) + def sign_bytes(self, message): + return self._signer.sign(message) + + @property + @_helpers.copy_docstring(credentials.Signing) + def signer(self): + return self._signer + + @property + @_helpers.copy_docstring(credentials.Signing) + def signer_email(self): + return self._service_account_email diff --git a/src/google_auth_httplib2.py b/src/google_auth_httplib2.py new file mode 100644 index 00000000..98e64075 --- /dev/null +++ b/src/google_auth_httplib2.py @@ -0,0 +1,235 @@ +# 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. + +"""Transport adapter for httplib2.""" + +from __future__ import absolute_import + +import logging + +from google.auth import exceptions +from google.auth import transport +import httplib2 + + +_LOGGER = logging.getLogger(__name__) +# Properties present in file-like streams / buffers. +_STREAM_PROPERTIES = ('read', 'seek', 'tell') + + +class _Response(transport.Response): + """httplib2 transport response adapter. + + Args: + response (httplib2.Response): The raw httplib2 response. + data (bytes): The response body. + """ + def __init__(self, response, data): + self._response = response + self._data = data + + @property + def status(self): + """int: The HTTP status code.""" + return self._response.status + + @property + def headers(self): + """Mapping[str, str]: The HTTP response headers.""" + return dict(self._response) + + @property + def data(self): + """bytes: The response body.""" + return self._data + + +class Request(transport.Request): + """httplib2 request adapter. + + This class is used internally for making requests using various transports + in a consistent way. If you use :class:`AuthorizedHttp` you do not need + to construct or use this class directly. + + This class can be useful if you want to manually refresh a + :class:`~google.auth.credentials.Credentials` instance:: + + import google_auth_httplib2 + import httplib2 + + http = httplib2.Http() + request = google_auth_httplib2.Request(http) + + credentials.refresh(request) + + Args: + http (httplib2.Http): The underlying http object to use to make + requests. + + .. automethod:: __call__ + """ + def __init__(self, http): + self.http = http + + def __call__(self, url, method='GET', body=None, headers=None, + timeout=None, **kwargs): + """Make an HTTP request using httplib2. + + Args: + url (str): The URI to be requested. + method (str): The HTTP method to use for the request. Defaults + to 'GET'. + body (bytes): The payload / body in HTTP request. + headers (Mapping[str, str]): Request headers. + timeout (Optional[int]): The number of seconds to wait for a + response from the server. This is ignored by httplib2 and will + issue a warning. + kwargs: Additional arguments passed throught to the underlying + :meth:`httplib2.Http.request` method. + + Returns: + google.auth.transport.Response: The HTTP response. + + Raises: + google.auth.exceptions.TransportError: If any exception occurred. + """ + if timeout is not None: + _LOGGER.warning( + 'httplib2 transport does not support per-request timeout. ' + 'Set the timeout when constructing the httplib2.Http instance.' + ) + + try: + _LOGGER.debug('Making request: %s %s', method, url) + response, data = self.http.request( + url, method=method, body=body, headers=headers, **kwargs) + return _Response(response, data) + except httplib2.HttpLib2Error as exc: + raise exceptions.TransportError(exc) + + +def _make_default_http(): + """Returns a default httplib2.Http instance.""" + return httplib2.Http() + + +class AuthorizedHttp(object): + """A httplib2 HTTP class with credentials. + + This class is used to perform requests to API endpoints that require + authorization:: + + from google.auth.transport._httplib2 import AuthorizedHttp + + authed_http = AuthorizedHttp(credentials) + + response = authed_http.request( + 'https://www.googleapis.com/storage/v1/b') + + This class implements :meth:`request` in the same way as + :class:`httplib2.Http` and can usually be used just like any other + instance of :class:``httplib2.Http`. + + The underlying :meth:`request` implementation handles adding the + credentials' headers to the request and refreshing credentials as needed. + """ + def __init__(self, credentials, http=None, + refresh_status_codes=transport.DEFAULT_REFRESH_STATUS_CODES, + max_refresh_attempts=transport.DEFAULT_MAX_REFRESH_ATTEMPTS): + """ + Args: + credentials (google.auth.credentials.Credentials): The credentials + to add to the request. + http (httplib2.Http): The underlying HTTP object to + use to make requests. If not specified, a + :class:`httplib2.Http` instance will be constructed. + refresh_status_codes (Sequence[int]): Which HTTP status codes + indicate that credentials should be refreshed and the request + should be retried. + max_refresh_attempts (int): The maximum number of times to attempt + to refresh the credentials and retry the request. + """ + + if http is None: + http = _make_default_http() + + self.http = http + self.credentials = credentials + self._refresh_status_codes = refresh_status_codes + self._max_refresh_attempts = max_refresh_attempts + # Request instance used by internal methods (for example, + # credentials.refresh). + self._request = Request(self.http) + + def request(self, uri, method='GET', body=None, headers=None, + **kwargs): + """Implementation of httplib2's Http.request.""" + + _credential_refresh_attempt = kwargs.pop( + '_credential_refresh_attempt', 0) + + # Make a copy of the headers. They will be modified by the credentials + # and we want to pass the original headers if we recurse. + request_headers = headers.copy() if headers is not None else {} + + self.credentials.before_request( + self._request, method, uri, request_headers) + + # Check if the body is a file-like stream, and if so, save the body + # stream position so that it can be restored in case of refresh. + body_stream_position = None + if all(getattr(body, stream_prop, None) for stream_prop in + _STREAM_PROPERTIES): + body_stream_position = body.tell() + + # Make the request. + response, content = self.http.request( + uri, method, body=body, headers=request_headers, **kwargs) + + # If the response indicated that the credentials needed to be + # refreshed, then refresh the credentials and re-attempt the + # request. + # A stored token may expire between the time it is retrieved and + # the time the request is made, so we may need to try twice. + if (response.status in self._refresh_status_codes + and _credential_refresh_attempt < self._max_refresh_attempts): + + _LOGGER.info( + 'Refreshing credentials due to a %s response. Attempt %s/%s.', + response.status, _credential_refresh_attempt + 1, + self._max_refresh_attempts) + + self.credentials.refresh(self._request) + + # Restore the body's stream position if needed. + if body_stream_position is not None: + body.seek(body_stream_position) + + # Recurse. Pass in the original headers, not our modified set. + return self.request( + uri, method, body=body, headers=headers, + _credential_refresh_attempt=_credential_refresh_attempt + 1, + **kwargs) + + return response, content + + @property + def connections(self): + """Proxy to httplib2.Http.connections.""" + return self.http.connections + + @connections.setter + def connections(self, value): + """Proxy to httplib2.Http.connections.""" + self.http.connections = value