From 569f4e0d23a1aa0e5c15ce5dab872afe31006bb5 Mon Sep 17 00:00:00 2001 From: Orest Bolohan Date: Fri, 30 May 2014 11:05:24 -0700 Subject: [PATCH] Add support for Google Default Credentials. --- oauth2client/appengine.py | 7 + oauth2client/client.py | 330 +++++++++++++++- oauth2client/gce.py | 11 + oauth2client/service_account.py | 121 ++++++ samples/call_compute_service.py | 15 + samples/googleappengine/app.yaml | 10 + .../call_compute_service_from_gae.py | 21 + setup.py | 3 + tests/data/gcloud/credentials_default.json | 9 + .../credentials_default_authorized_user.json | 9 + .../credentials_default_malformed_1.json | 9 + .../credentials_default_malformed_2.json | 8 + .../credentials_default_malformed_3.json | 9 + tests/data/publickey_openssl.pem | 9 + tests/test_appengine.py | 27 ++ tests/test_gce.py | 45 ++- tests/test_oauth2client.py | 363 ++++++++++++++++++ tests/test_service_account.py | 120 ++++++ 18 files changed, 1120 insertions(+), 6 deletions(-) create mode 100644 oauth2client/service_account.py create mode 100644 samples/call_compute_service.py create mode 100644 samples/googleappengine/app.yaml create mode 100644 samples/googleappengine/call_compute_service_from_gae.py create mode 100644 tests/data/gcloud/credentials_default.json create mode 100644 tests/data/gcloud/credentials_default_authorized_user.json create mode 100644 tests/data/gcloud/credentials_default_malformed_1.json create mode 100644 tests/data/gcloud/credentials_default_malformed_2.json create mode 100644 tests/data/gcloud/credentials_default_malformed_3.json create mode 100644 tests/data/publickey_openssl.pem create mode 100644 tests/test_service_account.py diff --git a/oauth2client/appengine.py b/oauth2client/appengine.py index b463d4a..e90a4c6 100644 --- a/oauth2client/appengine.py +++ b/oauth2client/appengine.py @@ -164,6 +164,7 @@ class AppAssertionCredentials(AssertionCredentials): unspecified, the default service account for the app is used. """ self.scope = util.scopes_to_string(scope) + self._kwargs = kwargs self.service_account_id = kwargs.get('service_account_id', None) # Assertion type is no longer used, but still in the parent class signature. @@ -196,6 +197,12 @@ class AppAssertionCredentials(AssertionCredentials): raise AccessTokenRefreshError(str(e)) self.access_token = token + def create_scoped_required(self): + return not self.scope + + def create_scoped(self, scopes): + return AppAssertionCredentials(scopes, **self._kwargs) + class FlowProperty(db.Property): """App Engine datastore Property for Flow. diff --git a/oauth2client/client.py b/oauth2client/client.py index 99873e2..6279d7a 100644 --- a/oauth2client/client.py +++ b/oauth2client/client.py @@ -66,6 +66,14 @@ OOB_CALLBACK_URN = 'urn:ietf:wg:oauth:2.0:oob' # Google Data client libraries may need to set this to [401, 403]. REFRESH_STATUS_CODES = [401] +# The value representing user credentials. +AUTHORIZED_USER = 'authorized_user' + +# The value representing service account credentials. +SERVICE_ACCOUNT = 'service_account' + +# The environment variable pointing the file with local Default Credentials. +GOOGLE_CREDENTIALS_DEFAULT = 'GOOGLE_CREDENTIALS_DEFAULT' class Error(Exception): """Base error for this module.""" @@ -99,6 +107,10 @@ class NonAsciiHeaderError(Error): """Header names and values must be ASCII strings.""" +class DefaultCredentialsError(Error): + """Error retrieving the Default Credentials.""" + + def _abstract(): raise NotImplementedError('You need to override this function') @@ -126,7 +138,7 @@ class Credentials(object): an HTTP transport. Subclasses must also specify a classmethod named 'from_json' that takes a JSON - string as input and returns an instaniated Credentials object. + string as input and returns an instantiated Credentials object. """ NON_SERIALIZED_MEMBERS = ['store'] @@ -375,7 +387,7 @@ def _update_query_params(uri, params): The same URI but with the new query parameters added. """ parts = list(urlparse.urlparse(uri)) - query_params = dict(parse_qsl(parts[4])) # 4 is the index of the query part + query_params = dict(parse_qsl(parts[4])) # 4 is the index of the query part query_params.update(params) parts[4] = urllib.urlencode(query_params) return urlparse.urlunparse(parts) @@ -587,6 +599,20 @@ class OAuth2Credentials(Credentials): return True return False + def get_access_token(self, http=None): + """Return the access token. + + If the token does not exist, get one. + If the token expired, refresh it. + """ + if self.access_token and not self.access_token_expired: + return self.access_token + else: + if not http: + http = httplib2.Http() + self.refresh(http) + return self.access_token + def set_store(self, store): """Set the Storage for the credential. @@ -820,7 +846,303 @@ class AccessTokenCredentials(OAuth2Credentials): self._do_revoke(http_request, self.access_token) -class AssertionCredentials(OAuth2Credentials): +_env_name = None + + +def _get_environment(urllib2_urlopen=None): + """Detect the environment the code is being run on.""" + + global _env_name + + if _env_name: + return _env_name + + server_software = os.environ.get('SERVER_SOFTWARE', '') + if server_software.startswith('Google App Engine/'): + _env_name = 'GAE_PRODUCTION' + elif server_software.startswith('Development/'): + _env_name = 'GAE_LOCAL' + else: + import urllib2 + try: + if urllib2_urlopen is None: + urllib2_urlopen = urllib2.urlopen + response = urllib2_urlopen('http://metadata.google.internal') + if any('Metadata-Flavor: Google' in h for h in response.info().headers): + _env_name = 'GCE_PRODUCTION' + else: + _env_name = 'UNKNOWN' + except urllib2.URLError: + _env_name = 'UNKNOWN' + + return _env_name + + +class GoogleCredentials(OAuth2Credentials): + """Default credentials for use in calling Google APIs. + + The Default Credentials are being constructed as a function of the environment + where the code is being run. More details can be found on this page: + https://developers.google.com/accounts/docs/default-credentials + + Here is an example of how to use the Default Credentials for a service that + requires authentication: + + + from googleapiclient.discovery import build + from oauth2client.client import GoogleCredentials + + PROJECT = 'bamboo-machine-422' # replace this with one of your projects + ZONE = 'us-central1-a' # replace this with the zone you care about + + service = build('compute', 'v1', credentials=GoogleCredentials.get_default()) + + request = service.instances().list(project=PROJECT, zone=ZONE) + response = request.execute() + + print response + + + A service that does not require authentication does not need credentials + to be passed in: + + + from googleapiclient.discovery import build + + service = build('discovery', 'v1') + + request = service.apis().list() + response = request.execute() + + print response + + """ + + def __init__(self, access_token, client_id, client_secret, refresh_token, + token_expiry, token_uri, user_agent, + revoke_uri=GOOGLE_REVOKE_URI): + """Create an instance of GoogleCredentials. + + This constructor is not usually called by the user, instead + GoogleCredentials objects are instantiated by + GoogleCredentials.from_stream() or GoogleCredentials.get_default(). + + Args: + access_token: string, access token. + client_id: string, client identifier. + client_secret: string, client secret. + refresh_token: string, refresh token. + token_expiry: datetime, when the access_token expires. + token_uri: string, URI of token endpoint. + user_agent: string, The HTTP User-Agent to provide for this application. + revoke_uri: string, URI for revoke endpoint. + Defaults to GOOGLE_REVOKE_URI; a token can't be revoked if this is None. + """ + super(GoogleCredentials, self).__init__( + access_token, client_id, client_secret, refresh_token, token_expiry, + token_uri, user_agent, revoke_uri=revoke_uri) + + def create_scoped_required(self): + """Whether this Credentials object is scopeless. + + create_scoped(scopes) method needs to be called in order to create + a Credentials object for API calls. + """ + return False + + def create_scoped(self, scopes): + """Create a Credentials object for the given scopes. + + The Credentials type is preserved. + """ + return self + + @staticmethod + def get_default(): + """Get the Default Credentials for the current environment. + + Exceptions: + DefaultCredentialsError: raised when the credentials fail to be retrieved. + """ + + _env_name = _get_environment() + + if _env_name in ('GAE_PRODUCTION', 'GAE_LOCAL'): + # if we are running inside Google App Engine + # there is no need to look for credentials in local files + default_credential_filename = None + well_known_file = None + else: + default_credential_filename = _get_environment_variable_file() + well_known_file = _get_well_known_file() + + if default_credential_filename: + try: + return _get_default_credential_from_file(default_credential_filename) + except (DefaultCredentialsError, ValueError) as error: + extra_help = (' (pointed to by ' + GOOGLE_CREDENTIALS_DEFAULT + + ' environment variable)') + _raise_exception_for_reading_json(default_credential_filename, + extra_help, error) + elif well_known_file: + try: + return _get_default_credential_from_file(well_known_file) + except (DefaultCredentialsError, ValueError) as error: + extra_help = (' (produced automatically when running' + ' "gcloud auth login" command)') + _raise_exception_for_reading_json(well_known_file, extra_help, error) + elif _env_name in ('GAE_PRODUCTION', 'GAE_LOCAL'): + return _get_default_credential_GAE() + elif _env_name == 'GCE_PRODUCTION': + return _get_default_credential_GCE() + else: + raise DefaultCredentialsError( + "The Default Credentials are not available. They are available if " + "running in Google App Engine or Google Compute Engine. They are " + "also available if using the Google Cloud SDK and running 'gcloud " + "auth login'. Otherwise, the environment variable " + + GOOGLE_CREDENTIALS_DEFAULT + " must be defined pointing to a file " + "defining the credentials. " + "See https://developers.google.com/accounts/docs/default-credentials " + "for details.") + + @staticmethod + def from_stream(credential_filename): + """Create a Credentials object by reading the information from a given file. + + It returns an object of type GoogleCredentials. + + Args: + credential_filename: the path to the file from where the credentials + are to be read + + Exceptions: + DefaultCredentialsError: raised when the credentials fail to be retrieved. + """ + + if credential_filename and os.path.isfile(credential_filename): + try: + return _get_default_credential_from_file(credential_filename) + except (DefaultCredentialsError, ValueError) as error: + extra_help = ' (provided as parameter to the from_stream() method)' + _raise_exception_for_reading_json(credential_filename, + extra_help, + error) + else: + raise DefaultCredentialsError('The parameter passed to the from_stream()' + ' method should point to a file.') + + +def _get_environment_variable_file(): + default_credential_filename = os.environ.get(GOOGLE_CREDENTIALS_DEFAULT, + None) + + if default_credential_filename: + if os.path.isfile(default_credential_filename): + return default_credential_filename + else: + raise DefaultCredentialsError( + 'File ' + default_credential_filename + ' (pointed by ' + + GOOGLE_CREDENTIALS_DEFAULT + ' environment variable) does not exist!') + + +def _get_well_known_file(): + """Get the well known file produced by command 'gcloud auth login'.""" + # TODO(orestica): Revisit this method once gcloud provides a better way + # of pinpointing the exact location of the file. + + WELL_KNOWN_CREDENTIALS_FILE = 'credentials_default.json' + CLOUDSDK_CONFIG_DIRECTORY = 'gcloud' + + if os.name == 'nt': + try: + default_config_path = os.path.join(os.environ['APPDATA'], + CLOUDSDK_CONFIG_DIRECTORY) + except KeyError: + # This should never happen unless someone is really messing with things. + drive = os.environ.get('SystemDrive', 'C:') + default_config_path = os.path.join(drive, '\\', CLOUDSDK_CONFIG_DIRECTORY) + else: + default_config_path = os.path.join(os.path.expanduser('~'), + '.config', + CLOUDSDK_CONFIG_DIRECTORY) + + default_config_path = os.path.join(default_config_path, + WELL_KNOWN_CREDENTIALS_FILE) + + if os.path.isfile(default_config_path): + return default_config_path + + +def _get_default_credential_from_file(default_credential_filename): + """Build the Default Credentials from file.""" + + import service_account + + # read the credentials from the file + with open(default_credential_filename) as default_credential: + client_credentials = service_account.simplejson.load(default_credential) + + credentials_type = client_credentials.get('type') + if credentials_type == AUTHORIZED_USER: + required_fields = set(['client_id', 'client_secret', 'refresh_token']) + elif credentials_type == SERVICE_ACCOUNT: + required_fields = set(['client_id', 'client_email', 'private_key_id', + 'private_key']) + else: + raise DefaultCredentialsError("'type' field should be defined " + "(and have one of the '" + AUTHORIZED_USER + + "' or '" + SERVICE_ACCOUNT + "' values)") + + missing_fields = required_fields.difference(client_credentials.keys()) + + if missing_fields: + _raise_exception_for_missing_fields(missing_fields) + + if client_credentials['type'] == AUTHORIZED_USER: + return GoogleCredentials( + access_token=None, + client_id=client_credentials['client_id'], + client_secret=client_credentials['client_secret'], + refresh_token=client_credentials['refresh_token'], + token_expiry=None, + token_uri=GOOGLE_TOKEN_URI, + user_agent='Python client library') + else: # client_credentials['type'] == SERVICE_ACCOUNT + return service_account._ServiceAccountCredentials( + service_account_id=client_credentials['client_id'], + service_account_email=client_credentials['client_email'], + private_key_id=client_credentials['private_key_id'], + private_key_pkcs8_text=client_credentials['private_key'], + scopes=[]) + + +def _raise_exception_for_missing_fields(missing_fields): + raise DefaultCredentialsError('The following field(s): ' + + ', '.join(missing_fields) + ' must be defined.') + + +def _raise_exception_for_reading_json(credential_file, + extra_help, + error): + raise DefaultCredentialsError('An error was encountered while reading ' + 'json file: '+ credential_file + extra_help + + ': ' + str(error)) + + +def _get_default_credential_GAE(): + from oauth2client.appengine import AppAssertionCredentials + + return AppAssertionCredentials([]) + + +def _get_default_credential_GCE(): + from oauth2client.gce import AppAssertionCredentials + + return AppAssertionCredentials([]) + + +class AssertionCredentials(GoogleCredentials): """Abstract Credentials object used for OAuth 2.0 assertion grants. This credential does not require a flow to instantiate because it @@ -899,7 +1221,7 @@ if HAS_CRYPTO: later. For App Engine you may also consider using AppAssertionCredentials. """ - MAX_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds + MAX_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds @util.positional(4) def __init__(self, diff --git a/oauth2client/gce.py b/oauth2client/gce.py index c7fd7c1..c9ff8f4 100644 --- a/oauth2client/gce.py +++ b/oauth2client/gce.py @@ -57,6 +57,7 @@ class AppAssertionCredentials(AssertionCredentials): requested. """ self.scope = util.scopes_to_string(scope) + self.kwargs = kwargs # Assertion type is no longer used, but still in the parent class signature. super(AppAssertionCredentials, self).__init__(None) @@ -87,4 +88,14 @@ class AppAssertionCredentials(AssertionCredentials): raise AccessTokenRefreshError(str(e)) self.access_token = d['accessToken'] else: + if response.status == 404: + content = content + (' This can occur if a VM was created' + ' with no service account or scopes.') raise AccessTokenRefreshError(content) + + def create_scoped_required(self): + return not self.scope + + def create_scoped(self, scopes): + return AppAssertionCredentials(scopes, + **self.kwargs) diff --git a/oauth2client/service_account.py b/oauth2client/service_account.py new file mode 100644 index 0000000..de8c0a2 --- /dev/null +++ b/oauth2client/service_account.py @@ -0,0 +1,121 @@ +# Copyright (C) 2014 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. + +"""A service account credentials class. + +This credentials class is implemented on top of rsa library. +""" + +import base64 +import rsa +import time +import types + +from oauth2client import GOOGLE_REVOKE_URI +from oauth2client import GOOGLE_TOKEN_URI +from oauth2client import util +from oauth2client.anyjson import simplejson +from oauth2client.client import AssertionCredentials + +from pyasn1.codec.ber import decoder +from pyasn1_modules.rfc5208 import PrivateKeyInfo + + +class _ServiceAccountCredentials(AssertionCredentials): + """Class representing a service account (signed JWT) credential.""" + + MAX_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds + + def __init__(self, service_account_id, service_account_email, private_key_id, + private_key_pkcs8_text, scopes, user_agent=None, + token_uri=GOOGLE_TOKEN_URI, revoke_uri=GOOGLE_REVOKE_URI, **kwargs): + + super(_ServiceAccountCredentials, self).__init__( + None, user_agent=user_agent, token_uri=token_uri, revoke_uri=revoke_uri) + + self._service_account_id = service_account_id + self._service_account_email = service_account_email + self._private_key_id = private_key_id + self._private_key = _get_private_key(private_key_pkcs8_text) + self._private_key_pkcs8_text = private_key_pkcs8_text + self._scopes = util.scopes_to_string(scopes) + self._user_agent = user_agent + self._token_uri = token_uri + self._revoke_uri = revoke_uri + self._kwargs = kwargs + + def _generate_assertion(self): + """Generate the assertion that will be used in the request.""" + + header = { + 'alg': 'RS256', + 'typ': 'JWT', + 'kid': self._private_key_id + } + + now = long(time.time()) + payload = { + 'aud': self._token_uri, + 'scope': self._scopes, + 'iat': now, + 'exp': now + _ServiceAccountCredentials.MAX_TOKEN_LIFETIME_SECS, + 'iss': self._service_account_email + } + payload.update(self._kwargs) + + assertion_input = '%s.%s' % ( + _urlsafe_b64encode(header), + _urlsafe_b64encode(payload)) + + # Sign the assertion. + signature = base64.urlsafe_b64encode(rsa.pkcs1.sign( + assertion_input, self._private_key, 'SHA-256')).rstrip('=') + + return '%s.%s' % (assertion_input, signature) + + def sign_blob(self, blob): + return (self._private_key_id, + rsa.pkcs1.sign(blob, self._private_key, 'SHA-256')) + + @property + def service_account_email(self): + return self._service_account_email + + def create_scoped_required(self): + return not self._scopes + + def create_scoped(self, scopes): + return _ServiceAccountCredentials(self._service_account_id, + self._service_account_email, + self._private_key_id, + self._private_key_pkcs8_text, + scopes, + user_agent=self._user_agent, + token_uri=self._token_uri, + revoke_uri=self._revoke_uri, + **self._kwargs) + +def _urlsafe_b64encode(data): + return base64.urlsafe_b64encode( + simplejson.dumps(data, separators = (',', ':'))\ + .encode('UTF-8')).rstrip('=') + +def _get_private_key(private_key_pkcs8_text): + """Get an RSA private key object from a pkcs8 representation.""" + + der = rsa.pem.load_pem(private_key_pkcs8_text, 'PRIVATE KEY') + asn1_private_key, _ = decoder.decode(der, asn1Spec=PrivateKeyInfo()) + return rsa.PrivateKey.load_pkcs1( + asn1_private_key.getComponentByName('privateKey').asOctets(), + format='DER') diff --git a/samples/call_compute_service.py b/samples/call_compute_service.py new file mode 100644 index 0000000..aaa4f38 --- /dev/null +++ b/samples/call_compute_service.py @@ -0,0 +1,15 @@ +# To be used to test GoogleCredential.GetDefaultCredential() +# from local machine and GCE. + +from googleapiclient.discovery import build +from oauth2client.client import GoogleCredentials + +PROJECT = "bamboo-machine-422" # Provide your own GCE project here +ZONE = "us-central1-a" # Put here a zone which has some VMs + +service = build("compute", "v1", credentials=GoogleCredentials.get_default()) + +request = service.instances().list(project=PROJECT, zone=ZONE) +response = request.execute() + +print response diff --git a/samples/googleappengine/app.yaml b/samples/googleappengine/app.yaml new file mode 100644 index 0000000..a299030 --- /dev/null +++ b/samples/googleappengine/app.yaml @@ -0,0 +1,10 @@ +application: bamboo-machine-422 +version: 2 +runtime: python27 +api_version: 1 +threadsafe: true + +handlers: +- url: /.* + script: call_compute_service_from_gae.app + diff --git a/samples/googleappengine/call_compute_service_from_gae.py b/samples/googleappengine/call_compute_service_from_gae.py new file mode 100644 index 0000000..e2b01d2 --- /dev/null +++ b/samples/googleappengine/call_compute_service_from_gae.py @@ -0,0 +1,21 @@ +# To be used to test GoogleCredential.GetDefaultCredential() +# from devel GAE (ie, dev_appserver.py). + +import webapp2 +from googleapiclient.discovery import build +from oauth2client.client import GoogleCredentials + +PROJECT = "bamboo-machine-422" # Provide your own GCE project here +ZONE = "us-central1-a" # Put here a zone which has some VMs + +def get_instances(): + service = build("compute", "v1", credentials=GoogleCredentials.get_default()) + request = service.instances().list(project=PROJECT, zone=ZONE) + return request.execute() + +class MainPage(webapp2.RequestHandler): + + def get(self): + self.response.write(get_instances()) + +app = webapp2.WSGIApplication([('/', MainPage),], debug=True) diff --git a/setup.py b/setup.py index 552a50d..4e738fa 100644 --- a/setup.py +++ b/setup.py @@ -26,6 +26,9 @@ packages = [ install_requires = [ 'httplib2>=0.8', + 'pyasn1==0.1.7', + 'pyasn1_modules==0.0.5', + 'rsa==3.1.4', ] needs_json = False diff --git a/tests/data/gcloud/credentials_default.json b/tests/data/gcloud/credentials_default.json new file mode 100644 index 0000000..1d4bad1 --- /dev/null +++ b/tests/data/gcloud/credentials_default.json @@ -0,0 +1,9 @@ +{ + "type": "service_account", + "client_id": "123", + "client_secret": "secret", + "refresh_token": "alabalaportocala", + "client_email": "dummy@google.com", + "private_key_id": "ABCDEF", + "private_key": "Bag Attributes\n friendlyName: key\n localKeyID: 22 7E 04 FC 64 48 20 83 1E C1 BD E3 F5 2F 44 7D EA 99 A5 BC\nKey Attributes: \n-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6PSnttDsv+vi\ntUZTP1E3hVBah6PUGDWZhYgNiyW8quTWCmPvBmCR2YzuhUrY5+CtKP8UJOQico+p\noJHSAPsrzSr6YsGs3c9SQOslBmm9Fkh9/f/GZVTVZ6u5AsUmOcVvZ2q7Sz8Vj/aR\naIm0EJqRe9cQ5vvN9sg25rIv4xKwIZJ1VixKWJLmpCmDINqn7xvl+ldlUmSr3aGt\nw21uSDuEJhQlzO3yf2FwJMkJ9SkCm9oVDXyl77OnKXj5bOQ/rojbyGeIxDJSUDWE\nGKyRPuqKi6rSbwg6h2G/Z9qBJkqM5NNTbGRIFz/9/LdmmwvtaqCxlLtD7RVEryAp\n+qTGDk5hAgMBAAECggEBAMYYfNDEYpf4A2SdCLne/9zrrfZ0kphdUkL48MDPj5vN\nTzTRj6f9s5ixZ/+QKn3hdwbguCx13QbH5mocP0IjUhyqoFFHYAWxyyaZfpjM8tO4\nQoEYxby3BpjLe62UXESUzChQSytJZFwIDXKcdIPNO3zvVzufEJcfG5no2b9cIvsG\nDy6J1FNILWxCtDIqBM+G1B1is9DhZnUDgn0iKzINiZmh1I1l7k/4tMnozVIKAfwo\nf1kYjG/d2IzDM02mTeTElz3IKeNriaOIYTZgI26xLJxTkiFnBV4JOWFAZw15X+yR\n+DrjGSIkTfhzbLa20Vt3AFM+LFK0ZoXT2dRnjbYPjQECgYEA+9XJFGwLcEX6pl1p\nIwXAjXKJdju9DDn4lmHTW0Pbw25h1EXONwm/NPafwsWmPll9kW9IwsxUQVUyBC9a\nc3Q7rF1e8ai/qqVFRIZof275MI82ciV2Mw8Hz7FPAUyoju5CvnjAEH4+irt1VE/7\nSgdvQ1gDBQFegS69ijdz+cOhFxkCgYEA5aVoseMy/gIlsCvNPyw9+Jz/zBpKItX0\njGzdF7lhERRO2cursujKaoHntRckHcE3P/Z4K565bvVq+VaVG0T/BcBKPmPHrLmY\niuVXidltW7Jh9/RCVwb5+BvqlwlC470PEwhqoUatY/fPJ74srztrqJHvp1L29FT5\nsdmlJW8YwokCgYAUa3dMgp5C0knKp5RY1KSSU5E11w4zKZgwiWob4lq1dAPWtHpO\nGCo63yyBHImoUJVP75gUw4Cpc4EEudo5tlkIVuHV8nroGVKOhd9/Rb5K47Hke4kk\nBrn5a0Ues9qPDF65Fw1ryPDFSwHufjXAAO5SpZZJF51UGDgiNvDedbBgMQKBgHSk\nt7DjPhtW69234eCckD2fQS5ijBV1p2lMQmCygGM0dXiawvN02puOsCqDPoz+fxm2\nDwPY80cw0M0k9UeMnBxHt25JMDrDan/iTbxu++T/jlNrdebOXFlxlI5y3c7fULDS\nLZcNVzTXwhjlt7yp6d0NgzTyJw2ju9BiREfnTiRBAoGBAOPHrTOnPyjO+bVcCPTB\nWGLsbBd77mVPGIuL0XGrvbVYPE8yIcNbZcthd8VXL/38Ygy8SIZh2ZqsrU1b5WFa\nXUMLnGEODSS8x/GmW3i3KeirW5OxBNjfUzEF4XkJP8m41iTdsQEXQf9DdUY7X+CB\nVL5h7N0VstYhGgycuPpcIUQa\n-----END PRIVATE KEY-----\n" +} \ No newline at end of file diff --git a/tests/data/gcloud/credentials_default_authorized_user.json b/tests/data/gcloud/credentials_default_authorized_user.json new file mode 100644 index 0000000..76addd5 --- /dev/null +++ b/tests/data/gcloud/credentials_default_authorized_user.json @@ -0,0 +1,9 @@ +{ + "type": "authorized_user", + "client_id": "123", + "client_secret": "secret", + "refresh_token": "alabalaportocala", + "client_email": "dummy@google.com", + "private_key_id": "ABCDEF", + "private_key": "Bag Attributes\n friendlyName: key\n localKeyID: 22 7E 04 FC 64 48 20 83 1E C1 BD E3 F5 2F 44 7D EA 99 A5 BC\nKey Attributes: \n-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6PSnttDsv+vi\ntUZTP1E3hVBah6PUGDWZhYgNiyW8quTWCmPvBmCR2YzuhUrY5+CtKP8UJOQico+p\noJHSAPsrzSr6YsGs3c9SQOslBmm9Fkh9/f/GZVTVZ6u5AsUmOcVvZ2q7Sz8Vj/aR\naIm0EJqRe9cQ5vvN9sg25rIv4xKwIZJ1VixKWJLmpCmDINqn7xvl+ldlUmSr3aGt\nw21uSDuEJhQlzO3yf2FwJMkJ9SkCm9oVDXyl77OnKXj5bOQ/rojbyGeIxDJSUDWE\nGKyRPuqKi6rSbwg6h2G/Z9qBJkqM5NNTbGRIFz/9/LdmmwvtaqCxlLtD7RVEryAp\n+qTGDk5hAgMBAAECggEBAMYYfNDEYpf4A2SdCLne/9zrrfZ0kphdUkL48MDPj5vN\nTzTRj6f9s5ixZ/+QKn3hdwbguCx13QbH5mocP0IjUhyqoFFHYAWxyyaZfpjM8tO4\nQoEYxby3BpjLe62UXESUzChQSytJZFwIDXKcdIPNO3zvVzufEJcfG5no2b9cIvsG\nDy6J1FNILWxCtDIqBM+G1B1is9DhZnUDgn0iKzINiZmh1I1l7k/4tMnozVIKAfwo\nf1kYjG/d2IzDM02mTeTElz3IKeNriaOIYTZgI26xLJxTkiFnBV4JOWFAZw15X+yR\n+DrjGSIkTfhzbLa20Vt3AFM+LFK0ZoXT2dRnjbYPjQECgYEA+9XJFGwLcEX6pl1p\nIwXAjXKJdju9DDn4lmHTW0Pbw25h1EXONwm/NPafwsWmPll9kW9IwsxUQVUyBC9a\nc3Q7rF1e8ai/qqVFRIZof275MI82ciV2Mw8Hz7FPAUyoju5CvnjAEH4+irt1VE/7\nSgdvQ1gDBQFegS69ijdz+cOhFxkCgYEA5aVoseMy/gIlsCvNPyw9+Jz/zBpKItX0\njGzdF7lhERRO2cursujKaoHntRckHcE3P/Z4K565bvVq+VaVG0T/BcBKPmPHrLmY\niuVXidltW7Jh9/RCVwb5+BvqlwlC470PEwhqoUatY/fPJ74srztrqJHvp1L29FT5\nsdmlJW8YwokCgYAUa3dMgp5C0knKp5RY1KSSU5E11w4zKZgwiWob4lq1dAPWtHpO\nGCo63yyBHImoUJVP75gUw4Cpc4EEudo5tlkIVuHV8nroGVKOhd9/Rb5K47Hke4kk\nBrn5a0Ues9qPDF65Fw1ryPDFSwHufjXAAO5SpZZJF51UGDgiNvDedbBgMQKBgHSk\nt7DjPhtW69234eCckD2fQS5ijBV1p2lMQmCygGM0dXiawvN02puOsCqDPoz+fxm2\nDwPY80cw0M0k9UeMnBxHt25JMDrDan/iTbxu++T/jlNrdebOXFlxlI5y3c7fULDS\nLZcNVzTXwhjlt7yp6d0NgzTyJw2ju9BiREfnTiRBAoGBAOPHrTOnPyjO+bVcCPTB\nWGLsbBd77mVPGIuL0XGrvbVYPE8yIcNbZcthd8VXL/38Ygy8SIZh2ZqsrU1b5WFa\nXUMLnGEODSS8x/GmW3i3KeirW5OxBNjfUzEF4XkJP8m41iTdsQEXQf9DdUY7X+CB\nVL5h7N0VstYhGgycuPpcIUQa\n-----END PRIVATE KEY-----\n" +} \ No newline at end of file diff --git a/tests/data/gcloud/credentials_default_malformed_1.json b/tests/data/gcloud/credentials_default_malformed_1.json new file mode 100644 index 0000000..fe9fd1e --- /dev/null +++ b/tests/data/gcloud/credentials_default_malformed_1.json @@ -0,0 +1,9 @@ +{ + "type": "serviceaccount", + "client_id": "123", + "client_secret": "secret", + "refresh_token": "alabalaportocala", + "client_email": "dummy@google.com", + "private_key_id": "ABCDEF", + "private_key": "Bag Attributes\n friendlyName: key\n localKeyID: 22 7E 04 FC 64 48 20 83 1E C1 BD E3 F5 2F 44 7D EA 99 A5 BC\nKey Attributes: \n-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6PSnttDsv+vi\ntUZTP1E3hVBah6PUGDWZhYgNiyW8quTWCmPvBmCR2YzuhUrY5+CtKP8UJOQico+p\noJHSAPsrzSr6YsGs3c9SQOslBmm9Fkh9/f/GZVTVZ6u5AsUmOcVvZ2q7Sz8Vj/aR\naIm0EJqRe9cQ5vvN9sg25rIv4xKwIZJ1VixKWJLmpCmDINqn7xvl+ldlUmSr3aGt\nw21uSDuEJhQlzO3yf2FwJMkJ9SkCm9oVDXyl77OnKXj5bOQ/rojbyGeIxDJSUDWE\nGKyRPuqKi6rSbwg6h2G/Z9qBJkqM5NNTbGRIFz/9/LdmmwvtaqCxlLtD7RVEryAp\n+qTGDk5hAgMBAAECggEBAMYYfNDEYpf4A2SdCLne/9zrrfZ0kphdUkL48MDPj5vN\nTzTRj6f9s5ixZ/+QKn3hdwbguCx13QbH5mocP0IjUhyqoFFHYAWxyyaZfpjM8tO4\nQoEYxby3BpjLe62UXESUzChQSytJZFwIDXKcdIPNO3zvVzufEJcfG5no2b9cIvsG\nDy6J1FNILWxCtDIqBM+G1B1is9DhZnUDgn0iKzINiZmh1I1l7k/4tMnozVIKAfwo\nf1kYjG/d2IzDM02mTeTElz3IKeNriaOIYTZgI26xLJxTkiFnBV4JOWFAZw15X+yR\n+DrjGSIkTfhzbLa20Vt3AFM+LFK0ZoXT2dRnjbYPjQECgYEA+9XJFGwLcEX6pl1p\nIwXAjXKJdju9DDn4lmHTW0Pbw25h1EXONwm/NPafwsWmPll9kW9IwsxUQVUyBC9a\nc3Q7rF1e8ai/qqVFRIZof275MI82ciV2Mw8Hz7FPAUyoju5CvnjAEH4+irt1VE/7\nSgdvQ1gDBQFegS69ijdz+cOhFxkCgYEA5aVoseMy/gIlsCvNPyw9+Jz/zBpKItX0\njGzdF7lhERRO2cursujKaoHntRckHcE3P/Z4K565bvVq+VaVG0T/BcBKPmPHrLmY\niuVXidltW7Jh9/RCVwb5+BvqlwlC470PEwhqoUatY/fPJ74srztrqJHvp1L29FT5\nsdmlJW8YwokCgYAUa3dMgp5C0knKp5RY1KSSU5E11w4zKZgwiWob4lq1dAPWtHpO\nGCo63yyBHImoUJVP75gUw4Cpc4EEudo5tlkIVuHV8nroGVKOhd9/Rb5K47Hke4kk\nBrn5a0Ues9qPDF65Fw1ryPDFSwHufjXAAO5SpZZJF51UGDgiNvDedbBgMQKBgHSk\nt7DjPhtW69234eCckD2fQS5ijBV1p2lMQmCygGM0dXiawvN02puOsCqDPoz+fxm2\nDwPY80cw0M0k9UeMnBxHt25JMDrDan/iTbxu++T/jlNrdebOXFlxlI5y3c7fULDS\nLZcNVzTXwhjlt7yp6d0NgzTyJw2ju9BiREfnTiRBAoGBAOPHrTOnPyjO+bVcCPTB\nWGLsbBd77mVPGIuL0XGrvbVYPE8yIcNbZcthd8VXL/38Ygy8SIZh2ZqsrU1b5WFa\nXUMLnGEODSS8x/GmW3i3KeirW5OxBNjfUzEF4XkJP8m41iTdsQEXQf9DdUY7X+CB\nVL5h7N0VstYhGgycuPpcIUQa\n-----END PRIVATE KEY-----\n" +} \ No newline at end of file diff --git a/tests/data/gcloud/credentials_default_malformed_2.json b/tests/data/gcloud/credentials_default_malformed_2.json new file mode 100644 index 0000000..6f1ae52 --- /dev/null +++ b/tests/data/gcloud/credentials_default_malformed_2.json @@ -0,0 +1,8 @@ +{ + "type": "service_account", + "client_id": "123", + "client_secret": "secret", + "refresh_token": "alabalaportocala", + "client_email": "dummy@google.com", + "private_key": "Bag Attributes\n friendlyName: key\n localKeyID: 22 7E 04 FC 64 48 20 83 1E C1 BD E3 F5 2F 44 7D EA 99 A5 BC\nKey Attributes: \n-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6PSnttDsv+vi\ntUZTP1E3hVBah6PUGDWZhYgNiyW8quTWCmPvBmCR2YzuhUrY5+CtKP8UJOQico+p\noJHSAPsrzSr6YsGs3c9SQOslBmm9Fkh9/f/GZVTVZ6u5AsUmOcVvZ2q7Sz8Vj/aR\naIm0EJqRe9cQ5vvN9sg25rIv4xKwIZJ1VixKWJLmpCmDINqn7xvl+ldlUmSr3aGt\nw21uSDuEJhQlzO3yf2FwJMkJ9SkCm9oVDXyl77OnKXj5bOQ/rojbyGeIxDJSUDWE\nGKyRPuqKi6rSbwg6h2G/Z9qBJkqM5NNTbGRIFz/9/LdmmwvtaqCxlLtD7RVEryAp\n+qTGDk5hAgMBAAECggEBAMYYfNDEYpf4A2SdCLne/9zrrfZ0kphdUkL48MDPj5vN\nTzTRj6f9s5ixZ/+QKn3hdwbguCx13QbH5mocP0IjUhyqoFFHYAWxyyaZfpjM8tO4\nQoEYxby3BpjLe62UXESUzChQSytJZFwIDXKcdIPNO3zvVzufEJcfG5no2b9cIvsG\nDy6J1FNILWxCtDIqBM+G1B1is9DhZnUDgn0iKzINiZmh1I1l7k/4tMnozVIKAfwo\nf1kYjG/d2IzDM02mTeTElz3IKeNriaOIYTZgI26xLJxTkiFnBV4JOWFAZw15X+yR\n+DrjGSIkTfhzbLa20Vt3AFM+LFK0ZoXT2dRnjbYPjQECgYEA+9XJFGwLcEX6pl1p\nIwXAjXKJdju9DDn4lmHTW0Pbw25h1EXONwm/NPafwsWmPll9kW9IwsxUQVUyBC9a\nc3Q7rF1e8ai/qqVFRIZof275MI82ciV2Mw8Hz7FPAUyoju5CvnjAEH4+irt1VE/7\nSgdvQ1gDBQFegS69ijdz+cOhFxkCgYEA5aVoseMy/gIlsCvNPyw9+Jz/zBpKItX0\njGzdF7lhERRO2cursujKaoHntRckHcE3P/Z4K565bvVq+VaVG0T/BcBKPmPHrLmY\niuVXidltW7Jh9/RCVwb5+BvqlwlC470PEwhqoUatY/fPJ74srztrqJHvp1L29FT5\nsdmlJW8YwokCgYAUa3dMgp5C0knKp5RY1KSSU5E11w4zKZgwiWob4lq1dAPWtHpO\nGCo63yyBHImoUJVP75gUw4Cpc4EEudo5tlkIVuHV8nroGVKOhd9/Rb5K47Hke4kk\nBrn5a0Ues9qPDF65Fw1ryPDFSwHufjXAAO5SpZZJF51UGDgiNvDedbBgMQKBgHSk\nt7DjPhtW69234eCckD2fQS5ijBV1p2lMQmCygGM0dXiawvN02puOsCqDPoz+fxm2\nDwPY80cw0M0k9UeMnBxHt25JMDrDan/iTbxu++T/jlNrdebOXFlxlI5y3c7fULDS\nLZcNVzTXwhjlt7yp6d0NgzTyJw2ju9BiREfnTiRBAoGBAOPHrTOnPyjO+bVcCPTB\nWGLsbBd77mVPGIuL0XGrvbVYPE8yIcNbZcthd8VXL/38Ygy8SIZh2ZqsrU1b5WFa\nXUMLnGEODSS8x/GmW3i3KeirW5OxBNjfUzEF4XkJP8m41iTdsQEXQf9DdUY7X+CB\nVL5h7N0VstYhGgycuPpcIUQa\n-----END PRIVATE KEY-----\n" +} \ No newline at end of file diff --git a/tests/data/gcloud/credentials_default_malformed_3.json b/tests/data/gcloud/credentials_default_malformed_3.json new file mode 100644 index 0000000..efed137 --- /dev/null +++ b/tests/data/gcloud/credentials_default_malformed_3.json @@ -0,0 +1,9 @@ +{ + "type": "service_account" + "client_id": "123", + "client_secret": "secret", + "refresh_token": "alabalaportocala", + "client_email": "dummy@google.com", + "private_key_id": "ABCDEF", + "private_key": "Bag Attributes\n friendlyName: key\n localKeyID: 22 7E 04 FC 64 48 20 83 1E C1 BD E3 F5 2F 44 7D EA 99 A5 BC\nKey Attributes: \n-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6PSnttDsv+vi\ntUZTP1E3hVBah6PUGDWZhYgNiyW8quTWCmPvBmCR2YzuhUrY5+CtKP8UJOQico+p\noJHSAPsrzSr6YsGs3c9SQOslBmm9Fkh9/f/GZVTVZ6u5AsUmOcVvZ2q7Sz8Vj/aR\naIm0EJqRe9cQ5vvN9sg25rIv4xKwIZJ1VixKWJLmpCmDINqn7xvl+ldlUmSr3aGt\nw21uSDuEJhQlzO3yf2FwJMkJ9SkCm9oVDXyl77OnKXj5bOQ/rojbyGeIxDJSUDWE\nGKyRPuqKi6rSbwg6h2G/Z9qBJkqM5NNTbGRIFz/9/LdmmwvtaqCxlLtD7RVEryAp\n+qTGDk5hAgMBAAECggEBAMYYfNDEYpf4A2SdCLne/9zrrfZ0kphdUkL48MDPj5vN\nTzTRj6f9s5ixZ/+QKn3hdwbguCx13QbH5mocP0IjUhyqoFFHYAWxyyaZfpjM8tO4\nQoEYxby3BpjLe62UXESUzChQSytJZFwIDXKcdIPNO3zvVzufEJcfG5no2b9cIvsG\nDy6J1FNILWxCtDIqBM+G1B1is9DhZnUDgn0iKzINiZmh1I1l7k/4tMnozVIKAfwo\nf1kYjG/d2IzDM02mTeTElz3IKeNriaOIYTZgI26xLJxTkiFnBV4JOWFAZw15X+yR\n+DrjGSIkTfhzbLa20Vt3AFM+LFK0ZoXT2dRnjbYPjQECgYEA+9XJFGwLcEX6pl1p\nIwXAjXKJdju9DDn4lmHTW0Pbw25h1EXONwm/NPafwsWmPll9kW9IwsxUQVUyBC9a\nc3Q7rF1e8ai/qqVFRIZof275MI82ciV2Mw8Hz7FPAUyoju5CvnjAEH4+irt1VE/7\nSgdvQ1gDBQFegS69ijdz+cOhFxkCgYEA5aVoseMy/gIlsCvNPyw9+Jz/zBpKItX0\njGzdF7lhERRO2cursujKaoHntRckHcE3P/Z4K565bvVq+VaVG0T/BcBKPmPHrLmY\niuVXidltW7Jh9/RCVwb5+BvqlwlC470PEwhqoUatY/fPJ74srztrqJHvp1L29FT5\nsdmlJW8YwokCgYAUa3dMgp5C0knKp5RY1KSSU5E11w4zKZgwiWob4lq1dAPWtHpO\nGCo63yyBHImoUJVP75gUw4Cpc4EEudo5tlkIVuHV8nroGVKOhd9/Rb5K47Hke4kk\nBrn5a0Ues9qPDF65Fw1ryPDFSwHufjXAAO5SpZZJF51UGDgiNvDedbBgMQKBgHSk\nt7DjPhtW69234eCckD2fQS5ijBV1p2lMQmCygGM0dXiawvN02puOsCqDPoz+fxm2\nDwPY80cw0M0k9UeMnBxHt25JMDrDan/iTbxu++T/jlNrdebOXFlxlI5y3c7fULDS\nLZcNVzTXwhjlt7yp6d0NgzTyJw2ju9BiREfnTiRBAoGBAOPHrTOnPyjO+bVcCPTB\nWGLsbBd77mVPGIuL0XGrvbVYPE8yIcNbZcthd8VXL/38Ygy8SIZh2ZqsrU1b5WFa\nXUMLnGEODSS8x/GmW3i3KeirW5OxBNjfUzEF4XkJP8m41iTdsQEXQf9DdUY7X+CB\nVL5h7N0VstYhGgycuPpcIUQa\n-----END PRIVATE KEY-----\n" +} \ No newline at end of file diff --git a/tests/data/publickey_openssl.pem b/tests/data/publickey_openssl.pem new file mode 100644 index 0000000..893ee79 --- /dev/null +++ b/tests/data/publickey_openssl.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4ej0p7bQ7L/r4rVGUz9R +N4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7 +K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCa +kXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7 +hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7q +iouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5O +YQIDAQAB +-----END PUBLIC KEY----- diff --git a/tests/test_appengine.py b/tests/test_appengine.py index 8a3a9ee..e75ca88 100644 --- a/tests/test_appengine.py +++ b/tests/test_appengine.py @@ -220,6 +220,33 @@ class TestAppAssertionCredentials(unittest.TestCase): self.assertEqual('a_token_456', credentials.access_token) self.assertEqual(scope, credentials.scope) + def test_create_scoped_required_without_scopes(self): + credentials = AppAssertionCredentials([]) + self.assertTrue(credentials.create_scoped_required()) + + def test_create_scoped_required_with_scopes(self): + credentials = AppAssertionCredentials(['dummy_scope']) + self.assertFalse(credentials.create_scoped_required()) + + def test_create_scoped(self): + credentials = AppAssertionCredentials([]) + new_credentials = credentials.create_scoped(['dummy_scope']) + self.assertNotEqual(credentials, new_credentials) + self.assertTrue(isinstance(new_credentials, AppAssertionCredentials)) + self.assertEqual('dummy_scope', new_credentials.scope) + + def test_get_access_token(self): + app_identity_stub = self.AppIdentityStubImpl() + apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap() + apiproxy_stub_map.apiproxy.RegisterStub("app_identity_service", + app_identity_stub) + apiproxy_stub_map.apiproxy.RegisterStub( + 'memcache', memcache_stub.MemcacheServiceStub()) + + credentials = AppAssertionCredentials(['dummy_scope']) + token = credentials.get_access_token() + self.assertEqual('a_token_123', token) + class TestFlowModel(db.Model): flow = FlowProperty() diff --git a/tests/test_gce.py b/tests/test_gce.py index 15e45c4..ec87512 100644 --- a/tests/test_gce.py +++ b/tests/test_gce.py @@ -20,8 +20,9 @@ Unit tests for oauth2client.gce. __author__ = 'jcgregorio@google.com (Joe Gregorio)' -import unittest +import httplib2 import mox +import unittest from oauth2client.client import AccessTokenRefreshError from oauth2client.client import Credentials @@ -55,7 +56,6 @@ class AssertionCredentialsTests(unittest.TestCase): m.UnsetStubs() m.VerifyAll() - def test_fail_refresh(self): m = mox.Mox() @@ -90,3 +90,44 @@ class AssertionCredentialsTests(unittest.TestCase): c2 = Credentials.new_from_json(json) self.assertEqual(c.access_token, c2.access_token) + + def test_create_scoped_required_without_scopes(self): + credentials = AppAssertionCredentials([]) + self.assertTrue(credentials.create_scoped_required()) + + def test_create_scoped_required_with_scopes(self): + credentials = AppAssertionCredentials(['dummy_scope']) + self.assertFalse(credentials.create_scoped_required()) + + def test_create_scoped(self): + credentials = AppAssertionCredentials([]) + new_credentials = credentials.create_scoped(['dummy_scope']) + self.assertNotEqual(credentials, new_credentials) + self.assertTrue(isinstance(new_credentials, AppAssertionCredentials)) + self.assertEqual('dummy_scope', new_credentials.scope) + + def test_get_access_token(self): + m = mox.Mox() + + httplib2_response = m.CreateMock(object) + httplib2_response.status = 200 + + httplib2_request = m.CreateMock(object) + httplib2_request.__call__( + ('http://metadata.google.internal/0.1/meta-data/service-accounts/' + 'default/acquire?scope=dummy_scope' + )).AndReturn((httplib2_response, '{"accessToken": "this-is-a-token"}')) + + m.ReplayAll() + + credentials = AppAssertionCredentials(['dummy_scope']) + + http = httplib2.Http() + http.request = httplib2_request + + self.assertEquals('this-is-a-token', + credentials.get_access_token(http=http)) + + m.UnsetStubs() + m.VerifyAll() + diff --git a/tests/test_oauth2client.py b/tests/test_oauth2client.py index ab44e2a..4d9771d 100644 --- a/tests/test_oauth2client.py +++ b/tests/test_oauth2client.py @@ -24,7 +24,9 @@ __author__ = 'jcgregorio@google.com (Joe Gregorio)' import base64 import datetime +import mox import os +import time import unittest import urlparse @@ -37,23 +39,36 @@ from oauth2client.client import AccessTokenCredentials from oauth2client.client import AccessTokenCredentialsError from oauth2client.client import AccessTokenRefreshError from oauth2client.client import AssertionCredentials +from oauth2client.client import AUTHORIZED_USER from oauth2client.client import Credentials +from oauth2client.client import DefaultCredentialsError from oauth2client.client import FlowExchangeError +from oauth2client.client import GoogleCredentials +from oauth2client.client import GOOGLE_CREDENTIALS_DEFAULT from oauth2client.client import MemoryCache from oauth2client.client import NonAsciiHeaderError from oauth2client.client import OAuth2Credentials from oauth2client.client import OAuth2WebServerFlow from oauth2client.client import OOB_CALLBACK_URN from oauth2client.client import REFRESH_STATUS_CODES +from oauth2client.client import SERVICE_ACCOUNT from oauth2client.client import Storage from oauth2client.client import TokenRevokeError from oauth2client.client import VerifyJwtTokenError +from oauth2client.client import _env_name from oauth2client.client import _extract_id_token +from oauth2client.client import _get_default_credential_from_file +from oauth2client.client import _get_environment +from oauth2client.client import _get_environment_variable_file +from oauth2client.client import _get_well_known_file +from oauth2client.client import _raise_exception_for_missing_fields +from oauth2client.client import _raise_exception_for_reading_json from oauth2client.client import _update_query_params from oauth2client.client import credentials_from_clientsecrets_and_code from oauth2client.client import credentials_from_code from oauth2client.client import flow_from_clientsecrets from oauth2client.clientsecrets import _loadfile +from oauth2client.service_account import _ServiceAccountCredentials DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') @@ -107,6 +122,328 @@ class CredentialsTests(unittest.TestCase): restored = Credentials.new_from_json(json) +class MockResponse(object): + """Mock the response of urllib2.urlopen() call.""" + + def __init__(self, headers): + self._headers = headers + + def info(self): + class Info: + def __init__(self, headers): + self.headers = headers + + return Info(self._headers) + + +class GoogleCredentialsTests(unittest.TestCase): + + def setUp(self): + self.env_server_software = os.environ.get('SERVER_SOFTWARE', None) + self.env_google_credentials_default = ( + os.environ.get(GOOGLE_CREDENTIALS_DEFAULT, None)) + self.env_appdata = os.environ.get('APPDATA', None) + self.os_name = os.name + from oauth2client import client + setattr(client, '_env_name', None) + + def tearDown(self): + self.reset_env('SERVER_SOFTWARE', self.env_server_software) + self.reset_env(GOOGLE_CREDENTIALS_DEFAULT, + self.env_google_credentials_default) + self.reset_env('APPDATA', self.env_appdata) + os.name = self.os_name + + def reset_env(self, env, value): + """Set the environment variable 'env' to 'value'.""" + if value is not None: + os.environ[env] = value + else: + os.environ.pop(env, '') + + def validate_service_account_credentials(self, credentials): + self.assertTrue(isinstance(credentials, _ServiceAccountCredentials)) + self.assertEqual('123', credentials._service_account_id) + self.assertEqual('dummy@google.com', credentials._service_account_email) + self.assertEqual('ABCDEF', credentials._private_key_id) + self.assertEqual('', credentials._scopes) + + def validate_google_credentials(self, credentials): + self.assertTrue(isinstance(credentials, GoogleCredentials)) + self.assertEqual(None, credentials.access_token) + self.assertEqual('123', credentials.client_id) + self.assertEqual('secret', credentials.client_secret) + self.assertEqual('alabalaportocala', credentials.refresh_token) + self.assertEqual(None, credentials.token_expiry) + self.assertEqual(GOOGLE_TOKEN_URI, credentials.token_uri) + self.assertEqual('Python client library', credentials.user_agent) + + def get_a_google_credentials_object(self): + return GoogleCredentials(None, None, None, None, None, None, None, None) + + def test_create_scoped_required(self): + self.assertFalse( + self.get_a_google_credentials_object().create_scoped_required()) + + def test_create_scoped(self): + credentials = self.get_a_google_credentials_object() + self.assertEqual(credentials, credentials.create_scoped(None)) + self.assertEqual(credentials, + credentials.create_scoped(['dummy_scope'])) + + def test_get_environment_gae_production(self): + os.environ['SERVER_SOFTWARE'] = 'Google App Engine/XYZ' + self.assertEqual('GAE_PRODUCTION', _get_environment()) + + def test_get_environment_gae_local(self): + os.environ['SERVER_SOFTWARE'] = 'Development/XYZ' + self.assertEqual('GAE_LOCAL', _get_environment()) + + def test_get_environment_gce_production(self): + os.environ['SERVER_SOFTWARE'] = '' + mockResponse = MockResponse(['Metadata-Flavor: Google\r\n']) + + m = mox.Mox() + + urllib2_urlopen = m.CreateMock(object) + urllib2_urlopen.__call__(('http://metadata.google.internal' + )).AndReturn(mockResponse) + + m.ReplayAll() + + self.assertEqual('GCE_PRODUCTION', _get_environment(urllib2_urlopen)) + + m.UnsetStubs() + m.VerifyAll() + + def test_get_environment_unknown(self): + os.environ['SERVER_SOFTWARE'] = '' + mockResponse = MockResponse([]) + + m = mox.Mox() + + urllib2_urlopen = m.CreateMock(object) + urllib2_urlopen.__call__(('http://metadata.google.internal' + )).AndReturn(mockResponse) + + m.ReplayAll() + + self.assertEqual('UNKNOWN', _get_environment(urllib2_urlopen)) + + m.UnsetStubs() + m.VerifyAll() + + def test_get_environment_variable_file(self): + environment_variable_file = datafile( + os.path.join('gcloud', 'credentials_default.json')) + os.environ[GOOGLE_CREDENTIALS_DEFAULT] = environment_variable_file + self.assertEqual(environment_variable_file, + _get_environment_variable_file()) + + def test_get_environment_variable_file_error(self): + nonexistent_file = datafile('nonexistent') + os.environ[GOOGLE_CREDENTIALS_DEFAULT] = nonexistent_file + # we can't use self.assertRaisesRegexp() because it is only in Python 2.7+ + try: + _get_environment_variable_file() + self.fail(nonexistent_file + ' should not exist.') + except DefaultCredentialsError as error: + self.assertEqual('File ' + nonexistent_file + + ' (pointed by ' + GOOGLE_CREDENTIALS_DEFAULT + + ' environment variable) does not exist!', + str(error)) + + def test_get_well_known_file_on_windows(self): + well_known_file = datafile( + os.path.join('gcloud', 'credentials_default.json')) + os.name = 'nt' + os.environ['APPDATA'] = DATA_DIR + self.assertEqual(well_known_file, _get_well_known_file()) + + def test_get_well_known_file_on_windows_no_file(self): + os.name = 'nt' + os.environ['APPDATA'] = os.path.join(DATA_DIR, 'nonexistentpath') + self.assertEqual(None, _get_well_known_file()) + + def test_get_default_credential_from_file_service_account(self): + credentials_file = datafile( + os.path.join('gcloud', 'credentials_default.json')) + credentials = _get_default_credential_from_file(credentials_file) + self.validate_service_account_credentials(credentials) + + def test_get_default_credential_from_file_authorized_user(self): + credentials_file = datafile( + os.path.join('gcloud', 'credentials_default_authorized_user.json')) + credentials = _get_default_credential_from_file(credentials_file) + self.validate_google_credentials(credentials) + + def test_get_default_credential_from_malformed_file_1(self): + credentials_file = datafile( + os.path.join('gcloud', 'credentials_default_malformed_1.json')) + # we can't use self.assertRaisesRegexp() because it is only in Python 2.7+ + try: + _get_default_credential_from_file(credentials_file) + self.fail('An exception was expected!') + except DefaultCredentialsError as error: + self.assertEqual("'type' field should be defined " + "(and have one of the '" + AUTHORIZED_USER + + "' or '" + SERVICE_ACCOUNT + "' values)", + str(error)) + + def test_get_default_credential_from_malformed_file_2(self): + credentials_file = datafile( + os.path.join('gcloud', 'credentials_default_malformed_2.json')) + # we can't use self.assertRaisesRegexp() because it is only in Python 2.7+ + try: + _get_default_credential_from_file(credentials_file) + self.fail('An exception was expected!') + except DefaultCredentialsError as error: + self.assertEqual('The following field(s): ' + 'private_key_id must be defined.', + str(error)) + + def test_get_default_credential_from_malformed_file_3(self): + credentials_file = datafile( + os.path.join('gcloud', 'credentials_default_malformed_3.json')) + self.assertRaises(ValueError, _get_default_credential_from_file, + credentials_file) + + def test_raise_exception_for_missing_fields(self): + missing_fields = ['first', 'second', 'third'] + # we can't use self.assertRaisesRegexp() because it is only in Python 2.7+ + try: + _raise_exception_for_missing_fields(missing_fields) + self.fail('An exception was expected!') + except DefaultCredentialsError as error: + self.assertEqual('The following field(s): ' + + ', '.join(missing_fields) + ' must be defined.', + str(error)) + + def test_raise_exception_for_reading_json(self): + credential_file = 'any_file' + extra_help = ' be good' + error = DefaultCredentialsError('stuff happens') + # we can't use self.assertRaisesRegexp() because it is only in Python 2.7+ + try: + _raise_exception_for_reading_json(credential_file, extra_help, error) + self.fail('An exception was expected!') + except DefaultCredentialsError as ex: + self.assertEqual('An error was encountered while reading ' + 'json file: '+ credential_file + + extra_help + ': ' + str(error), + str(ex)) + + def test_get_default_from_environment_variable_service_account(self): + os.environ['SERVER_SOFTWARE'] = '' + environment_variable_file = datafile( + os.path.join('gcloud', 'credentials_default.json')) + os.environ[GOOGLE_CREDENTIALS_DEFAULT] = environment_variable_file + self.validate_service_account_credentials(GoogleCredentials.get_default()) + + def test_env_name(self): + from oauth2client import client + self.assertEqual(None, getattr(client, '_env_name')) + self.test_get_default_from_environment_variable_service_account() + self.assertEqual('UNKNOWN', getattr(client, '_env_name')) + + def test_get_default_from_environment_variable_authorized_user(self): + os.environ['SERVER_SOFTWARE'] = '' + environment_variable_file = datafile( + os.path.join('gcloud', 'credentials_default_authorized_user.json')) + os.environ[GOOGLE_CREDENTIALS_DEFAULT] = environment_variable_file + self.validate_google_credentials(GoogleCredentials.get_default()) + + def test_get_default_from_environment_variable_malformed_file(self): + os.environ['SERVER_SOFTWARE'] = '' + environment_variable_file = datafile( + os.path.join('gcloud', 'credentials_default_malformed_3.json')) + os.environ[GOOGLE_CREDENTIALS_DEFAULT] = environment_variable_file + # we can't use self.assertRaisesRegexp() because it is only in Python 2.7+ + try: + GoogleCredentials.get_default() + self.fail('An exception was expected!') + except DefaultCredentialsError as error: + self.assertTrue(str(error).startswith( + 'An error was encountered while reading json file: ' + + environment_variable_file + ' (pointed to by ' + + GOOGLE_CREDENTIALS_DEFAULT + ' environment variable):')) + + def test_get_default_environment_not_set_up(self): + # It is normal for this test to fail if run inside + # a Google Compute Engine VM or after 'gcloud auth login' command + # has been executed on a non Windows machine. + os.environ['SERVER_SOFTWARE'] = '' + os.environ[GOOGLE_CREDENTIALS_DEFAULT] = '' + os.environ['APPDATA'] = '' + # we can't use self.assertRaisesRegexp() because it is only in Python 2.7+ + try: + GoogleCredentials.get_default() + self.fail('An exception was expected!') + except DefaultCredentialsError as error: + self.assertEqual("The Default Credentials are not available. They are " + "available if running in Google App Engine or Google " + "Compute Engine. They are also available if using the " + "Google Cloud SDK and running 'gcloud auth login'. " + "Otherwise, the environment variable " + + GOOGLE_CREDENTIALS_DEFAULT + " must be defined pointing " + "to a file defining the credentials. See " + "https://developers.google.com/accounts/docs/default-" + "credentials for details.", + str(error)) + + def test_from_stream_service_account(self): + credentials_file = datafile( + os.path.join('gcloud', 'credentials_default.json')) + credentials = ( + self.get_a_google_credentials_object().from_stream(credentials_file)) + self.validate_service_account_credentials(credentials) + + def test_from_stream_authorized_user(self): + credentials_file = datafile( + os.path.join('gcloud', 'credentials_default_authorized_user.json')) + credentials = ( + self.get_a_google_credentials_object().from_stream(credentials_file)) + self.validate_google_credentials(credentials) + + def test_from_stream_malformed_file_1(self): + credentials_file = datafile( + os.path.join('gcloud', 'credentials_default_malformed_1.json')) + # we can't use self.assertRaisesRegexp() because it is only in Python 2.7+ + try: + self.get_a_google_credentials_object().from_stream(credentials_file) + self.fail('An exception was expected!') + except DefaultCredentialsError as error: + self.assertEqual("An error was encountered while reading json file: " + + credentials_file + + " (provided as parameter to the from_stream() method): " + "'type' field should be defined (and have one of the '" + + AUTHORIZED_USER + "' or '" + SERVICE_ACCOUNT + + "' values)", + str(error)) + + def test_from_stream_malformed_file_2(self): + credentials_file = datafile( + os.path.join('gcloud', 'credentials_default_malformed_2.json')) + # we can't use self.assertRaisesRegexp() because it is only in Python 2.7+ + try: + self.get_a_google_credentials_object().from_stream(credentials_file) + self.fail('An exception was expected!') + except DefaultCredentialsError as error: + self.assertEqual('An error was encountered while reading json file: ' + + credentials_file + + ' (provided as parameter to the from_stream() method): ' + 'The following field(s): private_key_id must be ' + 'defined.', + str(error)) + + def test_from_stream_malformed_file_3(self): + credentials_file = datafile( + os.path.join('gcloud', 'credentials_default_malformed_3.json')) + self.assertRaises( + DefaultCredentialsError, + self.get_a_google_credentials_object().from_stream, credentials_file) + + class DummyDeleteStorage(Storage): delete_called = False @@ -245,6 +582,32 @@ class BasicCredentialsTests(unittest.TestCase): instance = OAuth2Credentials.from_json(self.credentials.to_json()) self.assertEqual('foobar', instance.token_response) + def test_get_access_token(self): + token_response_first = {'access_token': 'first_token', 'expires_in': 1} + token_response_second = {'access_token': 'second_token', 'expires_in': 1} + http = HttpMockSequence([ + ({'status': '200'}, simplejson.dumps(token_response_first)), + ({'status': '200'}, simplejson.dumps(token_response_second)), + ]) + + self.assertEqual('first_token', + self.credentials.get_access_token(http=http)) + self.assertFalse(self.credentials.access_token_expired) + self.assertEqual(token_response_first, self.credentials.token_response) + + self.assertEqual('first_token', + self.credentials.get_access_token(http=http)) + self.assertFalse(self.credentials.access_token_expired) + self.assertEqual(token_response_first, self.credentials.token_response) + + time.sleep(1) + self.assertTrue(self.credentials.access_token_expired) + + self.assertEqual('second_token', + self.credentials.get_access_token(http=http)) + self.assertFalse(self.credentials.access_token_expired) + self.assertEqual(token_response_second, self.credentials.token_response) + class AccessTokenCredentialsTests(unittest.TestCase): diff --git a/tests/test_service_account.py b/tests/test_service_account.py new file mode 100644 index 0000000..f31394f --- /dev/null +++ b/tests/test_service_account.py @@ -0,0 +1,120 @@ +#!/usr/bin/python2.4 +# +# Copyright 2014 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. + + +"""Oauth2client tests. + +Unit tests for service account credentials implemented using RSA. +""" + +import os +import rsa +import time +import unittest + +from http_mock import HttpMockSequence +from oauth2client.anyjson import simplejson +from oauth2client.service_account import _ServiceAccountCredentials + + +def datafile(filename): + # TODO(orestica): Refactor this using pkgutil.get_data + f = open(os.path.join(os.path.dirname(__file__), 'data', filename), 'r') + data = f.read() + f.close() + return data + + +class ServiceAccountCredentialsTests(unittest.TestCase): + def setUp(self): + self.service_account_id = '123' + self.service_account_email = 'dummy@google.com' + self.private_key_id = 'ABCDEF' + self.private_key = datafile('pem_from_pkcs12.pem') + self.scopes = ['dummy_scope'] + self.credentials = _ServiceAccountCredentials(self.service_account_id, + self.service_account_email, + self.private_key_id, + self.private_key, + []) + + def test_sign_blob(self): + private_key_id, signature = self.credentials.sign_blob('Google') + self.assertEqual( self.private_key_id, private_key_id) + + pub_key = rsa.PublicKey.load_pkcs1_openssl_pem( + datafile('publickey_openssl.pem')) + + self.assertTrue(rsa.pkcs1.verify('Google', signature, pub_key)) + + try: + rsa.pkcs1.verify('Orest', signature, pub_key) + self.fail('Verification should have failed!') + except rsa.pkcs1.VerificationError: + pass # Expected + + try: + rsa.pkcs1.verify('Google', 'bad signature', pub_key) + self.fail('Verification should have failed!') + except rsa.pkcs1.VerificationError: + pass # Expected + + def test_service_account_email(self): + self.assertEqual(self.service_account_email, + self.credentials.service_account_email) + + def test_create_scoped_required_without_scopes(self): + self.assertTrue(self.credentials.create_scoped_required()) + + def test_create_scoped_required_with_scopes(self): + self.credentials = _ServiceAccountCredentials(self.service_account_id, + self.service_account_email, + self.private_key_id, + self.private_key, + self.scopes) + self.assertFalse(self.credentials.create_scoped_required()) + + def test_create_scoped(self): + new_credentials = self.credentials.create_scoped(self.scopes) + self.assertNotEqual(self.credentials, new_credentials) + self.assertTrue(isinstance(new_credentials, _ServiceAccountCredentials)) + self.assertEqual('dummy_scope', new_credentials._scopes) + + def test_access_token(self): + token_response_first = {'access_token': 'first_token', 'expires_in': 1} + token_response_second = {'access_token': 'second_token', 'expires_in': 1} + http = HttpMockSequence([ + ({'status': '200'}, simplejson.dumps(token_response_first)), + ({'status': '200'}, simplejson.dumps(token_response_second)), + ]) + + self.assertEqual('first_token', + self.credentials.get_access_token(http=http)) + self.assertFalse(self.credentials.access_token_expired) + self.assertEqual(token_response_first, self.credentials.token_response) + + self.assertEqual('first_token', + self.credentials.get_access_token(http=http)) + self.assertFalse(self.credentials.access_token_expired) + self.assertEqual(token_response_first, self.credentials.token_response) + + time.sleep(1) + self.assertTrue(self.credentials.access_token_expired) + + self.assertEqual('second_token', + self.credentials.get_access_token(http=http)) + self.assertFalse(self.credentials.access_token_expired) + self.assertEqual(token_response_second, self.credentials.token_response)