1#!/usr/bin/env python
2"""Common credentials classes and constructors."""
3from __future__ import print_function
4
5import datetime
6import json
7import os
8import threading
9
10import httplib2
11import oauth2client
12import oauth2client.client
13import oauth2client.gce
14import oauth2client.locked_file
15import oauth2client.multistore_file
16import oauth2client.service_account
17from oauth2client import tools  # for gflags declarations
18from six.moves import http_client
19from six.moves import urllib
20
21from apitools.base.py import exceptions
22from apitools.base.py import util
23
24try:
25    import gflags
26    FLAGS = gflags.FLAGS
27except ImportError:
28    FLAGS = None
29
30
31__all__ = [
32    'CredentialsFromFile',
33    'GaeAssertionCredentials',
34    'GceAssertionCredentials',
35    'GetCredentials',
36    'GetUserinfo',
37    'ServiceAccountCredentials',
38    'ServiceAccountCredentialsFromFile',
39]
40
41
42# Lock when accessing the cache file to avoid resource contention.
43cache_file_lock = threading.Lock()
44
45
46def SetCredentialsCacheFileLock(lock):
47    global cache_file_lock  # pylint: disable=global-statement
48    cache_file_lock = lock
49
50
51# List of additional methods we use when attempting to construct
52# credentials. Users can register their own methods here, which we try
53# before the defaults.
54_CREDENTIALS_METHODS = []
55
56
57def _RegisterCredentialsMethod(method, position=None):
58    """Register a new method for fetching credentials.
59
60    This new method should be a function with signature:
61      client_info, **kwds -> Credentials or None
62    This method can be used as a decorator, unless position needs to
63    be supplied.
64
65    Note that method must *always* accept arbitrary keyword arguments.
66
67    Args:
68      method: New credential-fetching method.
69      position: (default: None) Where in the list of methods to
70        add this; if None, we append. In all but rare cases,
71        this should be either 0 or None.
72    Returns:
73      method, for use as a decorator.
74
75    """
76    if position is None:
77        position = len(_CREDENTIALS_METHODS)
78    else:
79        position = min(position, len(_CREDENTIALS_METHODS))
80    _CREDENTIALS_METHODS.insert(position, method)
81    return method
82
83
84def GetCredentials(package_name, scopes, client_id, client_secret, user_agent,
85                   credentials_filename=None,
86                   api_key=None,  # pylint: disable=unused-argument
87                   client=None,  # pylint: disable=unused-argument
88                   oauth2client_args=None,
89                   **kwds):
90    """Attempt to get credentials, using an oauth dance as the last resort."""
91    scopes = util.NormalizeScopes(scopes)
92    client_info = {
93        'client_id': client_id,
94        'client_secret': client_secret,
95        'scope': ' '.join(sorted(scopes)),
96        'user_agent': user_agent or '%s-generated/0.1' % package_name,
97    }
98    for method in _CREDENTIALS_METHODS:
99        credentials = method(client_info, **kwds)
100        if credentials is not None:
101            return credentials
102    credentials_filename = credentials_filename or os.path.expanduser(
103        '~/.apitools.token')
104    credentials = CredentialsFromFile(credentials_filename, client_info,
105                                      oauth2client_args=oauth2client_args)
106    if credentials is not None:
107        return credentials
108    raise exceptions.CredentialsError('Could not create valid credentials')
109
110
111def ServiceAccountCredentialsFromFile(
112        service_account_name, private_key_filename, scopes,
113        service_account_kwargs=None):
114    with open(private_key_filename) as key_file:
115        return ServiceAccountCredentials(
116            service_account_name, key_file.read(), scopes,
117            service_account_kwargs=service_account_kwargs)
118
119
120def ServiceAccountCredentials(service_account_name, private_key, scopes,
121                              service_account_kwargs=None):
122    service_account_kwargs = service_account_kwargs or {}
123    scopes = util.NormalizeScopes(scopes)
124    return oauth2client.client.SignedJwtAssertionCredentials(
125        service_account_name, private_key, scopes, **service_account_kwargs)
126
127
128def _EnsureFileExists(filename):
129    """Touches a file; returns False on error, True on success."""
130    if not os.path.exists(filename):
131        old_umask = os.umask(0o177)
132        try:
133            open(filename, 'a+b').close()
134        except OSError:
135            return False
136        finally:
137            os.umask(old_umask)
138    return True
139
140
141def _GceMetadataRequest(relative_url, use_metadata_ip=False):
142    """Request the given url from the GCE metadata service."""
143    if use_metadata_ip:
144        base_url = 'http://169.254.169.254/'
145    else:
146        base_url = 'http://metadata.google.internal/'
147    url = base_url + 'computeMetadata/v1/' + relative_url
148    # Extra header requirement can be found here:
149    # https://developers.google.com/compute/docs/metadata
150    headers = {'Metadata-Flavor': 'Google'}
151    request = urllib.request.Request(url, headers=headers)
152    opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
153    try:
154        response = opener.open(request)
155    except urllib.error.URLError as e:
156        raise exceptions.CommunicationError(
157            'Could not reach metadata service: %s' % e.reason)
158    return response
159
160
161class GceAssertionCredentials(oauth2client.gce.AppAssertionCredentials):
162
163    """Assertion credentials for GCE instances."""
164
165    def __init__(self, scopes=None, service_account_name='default', **kwds):
166        """Initializes the credentials instance.
167
168        Args:
169          scopes: The scopes to get. If None, whatever scopes that are
170              available to the instance are used.
171          service_account_name: The service account to retrieve the scopes
172              from.
173          **kwds: Additional keyword args.
174
175        """
176        # If there is a connectivity issue with the metadata server,
177        # detection calls may fail even if we've already successfully
178        # identified these scopes in the same execution. However, the
179        # available scopes don't change once an instance is created,
180        # so there is no reason to perform more than one query.
181        self.__service_account_name = service_account_name
182        cached_scopes = None
183        cache_filename = kwds.get('cache_filename')
184        if cache_filename:
185            cached_scopes = self._CheckCacheFileForMatch(
186                cache_filename, scopes)
187
188        scopes = cached_scopes or self._ScopesFromMetadataServer(scopes)
189
190        if cache_filename and not cached_scopes:
191            self._WriteCacheFile(cache_filename, scopes)
192
193        super(GceAssertionCredentials, self).__init__(scopes, **kwds)
194
195    @classmethod
196    def Get(cls, *args, **kwds):
197        try:
198            return cls(*args, **kwds)
199        except exceptions.Error:
200            return None
201
202    def _CheckCacheFileForMatch(self, cache_filename, scopes):
203        """Checks the cache file to see if it matches the given credentials.
204
205        Args:
206          cache_filename: Cache filename to check.
207          scopes: Scopes for the desired credentials.
208
209        Returns:
210          List of scopes (if cache matches) or None.
211        """
212        creds = {  # Credentials metadata dict.
213            'scopes': sorted(list(scopes)) if scopes else None,
214            'svc_acct_name': self.__service_account_name,
215        }
216        with cache_file_lock:
217            if _EnsureFileExists(cache_filename):
218                locked_file = oauth2client.locked_file.LockedFile(
219                    cache_filename, 'r+b', 'rb')
220                try:
221                    locked_file.open_and_lock()
222                    cached_creds_str = locked_file.file_handle().read()
223                    if cached_creds_str:
224                        # Cached credentials metadata dict.
225                        cached_creds = json.loads(cached_creds_str)
226                        if (creds['svc_acct_name'] ==
227                                cached_creds['svc_acct_name']):
228                            if (creds['scopes'] in
229                                    (None, cached_creds['scopes'])):
230                                scopes = cached_creds['scopes']
231                finally:
232                    locked_file.unlock_and_close()
233        return scopes
234
235    def _WriteCacheFile(self, cache_filename, scopes):
236        """Writes the credential metadata to the cache file.
237
238        This does not save the credentials themselves (CredentialStore class
239        optionally handles that after this class is initialized).
240
241        Args:
242          cache_filename: Cache filename to check.
243          scopes: Scopes for the desired credentials.
244        """
245        with cache_file_lock:
246            if _EnsureFileExists(cache_filename):
247                locked_file = oauth2client.locked_file.LockedFile(
248                    cache_filename, 'r+b', 'rb')
249                try:
250                    locked_file.open_and_lock()
251                    if locked_file.is_locked():
252                        creds = {  # Credentials metadata dict.
253                            'scopes': sorted(list(scopes)),
254                            'svc_acct_name': self.__service_account_name}
255                        locked_file.file_handle().write(
256                            json.dumps(creds, encoding='ascii'))
257                        # If it's not locked, the locking process will
258                        # write the same data to the file, so just
259                        # continue.
260                finally:
261                    locked_file.unlock_and_close()
262
263    def _ScopesFromMetadataServer(self, scopes):
264        if not util.DetectGce():
265            raise exceptions.ResourceUnavailableError(
266                'GCE credentials requested outside a GCE instance')
267        if not self.GetServiceAccount(self.__service_account_name):
268            raise exceptions.ResourceUnavailableError(
269                'GCE credentials requested but service account '
270                '%s does not exist.' % self.__service_account_name)
271        if scopes:
272            scope_ls = util.NormalizeScopes(scopes)
273            instance_scopes = self.GetInstanceScopes()
274            if scope_ls > instance_scopes:
275                raise exceptions.CredentialsError(
276                    'Instance did not have access to scopes %s' % (
277                        sorted(list(scope_ls - instance_scopes)),))
278        else:
279            scopes = self.GetInstanceScopes()
280        return scopes
281
282    def GetServiceAccount(self, account):
283        relative_url = 'instance/service-accounts'
284        response = _GceMetadataRequest(relative_url)
285        response_lines = [line.rstrip('/\n\r')
286                          for line in response.readlines()]
287        return account in response_lines
288
289    def GetInstanceScopes(self):
290        relative_url = 'instance/service-accounts/{0}/scopes'.format(
291            self.__service_account_name)
292        response = _GceMetadataRequest(relative_url)
293        return util.NormalizeScopes(scope.strip()
294                                    for scope in response.readlines())
295
296    def _refresh(self, do_request):
297        """Refresh self.access_token.
298
299        This function replaces AppAssertionCredentials._refresh, which
300        does not use the credential store and is therefore poorly
301        suited for multi-threaded scenarios.
302
303        Args:
304          do_request: A function matching httplib2.Http.request's signature.
305
306        """
307        # pylint: disable=protected-access
308        oauth2client.client.OAuth2Credentials._refresh(self, do_request)
309        # pylint: enable=protected-access
310
311    def _do_refresh_request(self, unused_http_request):
312        """Refresh self.access_token by querying the metadata server.
313
314        If self.store is initialized, store acquired credentials there.
315        """
316        relative_url = 'instance/service-accounts/{0}/token'.format(
317            self.__service_account_name)
318        try:
319            response = _GceMetadataRequest(relative_url)
320        except exceptions.CommunicationError:
321            self.invalid = True
322            if self.store:
323                self.store.locked_put(self)
324            raise
325        content = response.read()
326        try:
327            credential_info = json.loads(content)
328        except ValueError:
329            raise exceptions.CredentialsError(
330                'Could not parse response as JSON: %s' % content)
331
332        self.access_token = credential_info['access_token']
333        if 'expires_in' in credential_info:
334            expires_in = int(credential_info['expires_in'])
335            self.token_expiry = (
336                datetime.timedelta(seconds=expires_in) +
337                datetime.datetime.utcnow())
338        else:
339            self.token_expiry = None
340        self.invalid = False
341        if self.store:
342            self.store.locked_put(self)
343
344    @classmethod
345    def from_json(cls, json_data):
346        data = json.loads(json_data)
347        kwargs = {}
348        if 'cache_filename' in data.get('kwargs', []):
349            kwargs['cache_filename'] = data['kwargs']['cache_filename']
350        credentials = GceAssertionCredentials(scopes=[data['scope']],
351                                              **kwargs)
352        if 'access_token' in data:
353            credentials.access_token = data['access_token']
354        if 'token_expiry' in data:
355            credentials.token_expiry = datetime.datetime.strptime(
356                data['token_expiry'], oauth2client.client.EXPIRY_FORMAT)
357        if 'invalid' in data:
358            credentials.invalid = data['invalid']
359        return credentials
360
361    @property
362    def serialization_data(self):
363        raise NotImplementedError(
364            'Cannot serialize credentials for GCE service accounts.')
365
366
367# TODO(craigcitro): Currently, we can't even *load*
368# `oauth2client.appengine` without being on appengine, because of how
369# it handles imports. Fix that by splitting that module into
370# GAE-specific and GAE-independent bits, and guarding imports.
371class GaeAssertionCredentials(oauth2client.client.AssertionCredentials):
372
373    """Assertion credentials for Google App Engine apps."""
374
375    def __init__(self, scopes, **kwds):
376        if not util.DetectGae():
377            raise exceptions.ResourceUnavailableError(
378                'GCE credentials requested outside a GCE instance')
379        self._scopes = list(util.NormalizeScopes(scopes))
380        super(GaeAssertionCredentials, self).__init__(None, **kwds)
381
382    @classmethod
383    def Get(cls, *args, **kwds):
384        try:
385            return cls(*args, **kwds)
386        except exceptions.Error:
387            return None
388
389    @classmethod
390    def from_json(cls, json_data):
391        data = json.loads(json_data)
392        return GaeAssertionCredentials(data['_scopes'])
393
394    def _refresh(self, _):
395        """Refresh self.access_token.
396
397        Args:
398          _: (ignored) A function matching httplib2.Http.request's signature.
399        """
400        from google.appengine.api import app_identity
401        try:
402            token, _ = app_identity.get_access_token(self._scopes)
403        except app_identity.Error as e:
404            raise exceptions.CredentialsError(str(e))
405        self.access_token = token
406
407
408def _GetRunFlowFlags(args=None):
409    # There's one rare situation where gsutil will not have argparse
410    # available, but doesn't need anything depending on argparse anyway,
411    # since they're bringing their own credentials. So we just allow this
412    # to fail with an ImportError in those cases.
413    #
414    # TODO(craigcitro): Move this import back to the top when we drop
415    # python 2.6 support (eg when gsutil does).
416    import argparse
417
418    parser = argparse.ArgumentParser(parents=[tools.argparser])
419    # Get command line argparse flags.
420    flags, _ = parser.parse_known_args(args=args)
421
422    # Allow `gflags` and `argparse` to be used side-by-side.
423    if hasattr(FLAGS, 'auth_host_name'):
424        flags.auth_host_name = FLAGS.auth_host_name
425    if hasattr(FLAGS, 'auth_host_port'):
426        flags.auth_host_port = FLAGS.auth_host_port
427    if hasattr(FLAGS, 'auth_local_webserver'):
428        flags.noauth_local_webserver = (not FLAGS.auth_local_webserver)
429    return flags
430
431
432# TODO(craigcitro): Switch this from taking a path to taking a stream.
433def CredentialsFromFile(path, client_info, oauth2client_args=None):
434    """Read credentials from a file."""
435    credential_store = oauth2client.multistore_file.get_credential_storage(
436        path,
437        client_info['client_id'],
438        client_info['user_agent'],
439        client_info['scope'])
440    if hasattr(FLAGS, 'auth_local_webserver'):
441        FLAGS.auth_local_webserver = False
442    credentials = credential_store.get()
443    if credentials is None or credentials.invalid:
444        print('Generating new OAuth credentials ...')
445        for _ in range(20):
446            # If authorization fails, we want to retry, rather than let this
447            # cascade up and get caught elsewhere. If users want out of the
448            # retry loop, they can ^C.
449            try:
450                flow = oauth2client.client.OAuth2WebServerFlow(**client_info)
451                flags = _GetRunFlowFlags(args=oauth2client_args)
452                credentials = tools.run_flow(flow, credential_store, flags)
453                break
454            except (oauth2client.client.FlowExchangeError, SystemExit) as e:
455                # Here SystemExit is "no credential at all", and the
456                # FlowExchangeError is "invalid" -- usually because
457                # you reused a token.
458                print('Invalid authorization: %s' % (e,))
459            except httplib2.HttpLib2Error as e:
460                print('Communication error: %s' % (e,))
461                raise exceptions.CredentialsError(
462                    'Communication error creating credentials: %s' % e)
463    return credentials
464
465
466# TODO(craigcitro): Push this into oauth2client.
467def GetUserinfo(credentials, http=None):  # pylint: disable=invalid-name
468    """Get the userinfo associated with the given credentials.
469
470    This is dependent on the token having either the userinfo.email or
471    userinfo.profile scope for the given token.
472
473    Args:
474      credentials: (oauth2client.client.Credentials) incoming credentials
475      http: (httplib2.Http, optional) http instance to use
476
477    Returns:
478      The email address for this token, or None if the required scopes
479      aren't available.
480    """
481    http = http or httplib2.Http()
482    url_root = 'https://www.googleapis.com/oauth2/v2/tokeninfo'
483    query_args = {'access_token': credentials.access_token}
484    url = '?'.join((url_root, urllib.parse.urlencode(query_args)))
485    # We ignore communication woes here (i.e. SSL errors, socket
486    # timeout), as handling these should be done in a common location.
487    response, content = http.request(url)
488    if response.status == http_client.BAD_REQUEST:
489        credentials.refresh(http)
490        response, content = http.request(url)
491    return json.loads(content or '{}')  # Save ourselves from an empty reply.
492
493
494@_RegisterCredentialsMethod
495def _GetServiceAccountCredentials(
496        client_info, service_account_name=None, service_account_keyfile=None,
497        service_account_json_keyfile=None, **unused_kwds):
498    if ((service_account_name and not service_account_keyfile) or
499            (service_account_keyfile and not service_account_name)):
500        raise exceptions.CredentialsError(
501            'Service account name or keyfile provided without the other')
502    scopes = client_info['scope'].split()
503    user_agent = client_info['user_agent']
504    if service_account_json_keyfile:
505        with open(service_account_json_keyfile) as keyfile:
506            service_account_info = json.load(keyfile)
507        account_type = service_account_info.get('type')
508        if account_type != oauth2client.client.SERVICE_ACCOUNT:
509            raise exceptions.CredentialsError(
510                'Invalid service account credentials: %s' % (
511                    service_account_json_keyfile,))
512        # pylint: disable=protected-access
513        credentials = oauth2client.service_account._ServiceAccountCredentials(
514            service_account_id=service_account_info['client_id'],
515            service_account_email=service_account_info['client_email'],
516            private_key_id=service_account_info['private_key_id'],
517            private_key_pkcs8_text=service_account_info['private_key'],
518            scopes=scopes, user_agent=user_agent)
519        # pylint: enable=protected-access
520        return credentials
521    if service_account_name is not None:
522        credentials = ServiceAccountCredentialsFromFile(
523            service_account_name, service_account_keyfile, scopes,
524            service_account_kwargs={'user_agent': user_agent})
525        if credentials is not None:
526            return credentials
527
528
529@_RegisterCredentialsMethod
530def _GetGaeServiceAccount(unused_client_info, scopes, **unused_kwds):
531    return GaeAssertionCredentials.Get(scopes=scopes)
532
533
534@_RegisterCredentialsMethod
535def _GetGceServiceAccount(unused_client_info, scopes, **unused_kwds):
536    return GceAssertionCredentials.Get(scopes=scopes)
537
538
539@_RegisterCredentialsMethod
540def _GetApplicationDefaultCredentials(
541        unused_client_info, scopes, skip_application_default_credentials=False,
542        **unused_kwds):
543    if skip_application_default_credentials:
544        return None
545    gc = oauth2client.client.GoogleCredentials
546    with cache_file_lock:
547        try:
548            # pylint: disable=protected-access
549            # We've already done our own check for GAE/GCE
550            # credentials, we don't want to pay for checking again.
551            credentials = gc._implicit_credentials_from_files()
552        except oauth2client.client.ApplicationDefaultCredentialsError:
553            return None
554    # If we got back a non-service account credential, we need to use
555    # a heuristic to decide whether or not the application default
556    # credential will work for us. We assume that if we're requesting
557    # cloud-platform, our scopes are a subset of cloud scopes, and the
558    # ADC will work.
559    cp = 'https://www.googleapis.com/auth/cloud-platform'
560    if not isinstance(credentials, gc) or cp in scopes:
561        return credentials
562    return None
563