1# Licensed to the Apache Software Foundation (ASF) under one or more
2# contributor license agreements.  See the NOTICE file distributed with
3# this work for additional information regarding copyright ownership.
4# The ASF licenses this file to You under the Apache License, Version 2.0
5# (the "License"); you may not use this file except in compliance with
6# the License.  You may obtain a copy of the License at
7#
8#     http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15
16"""
17Common / shared code for handling authentication against OpenStack identity
18service (Keystone).
19"""
20
21from collections import namedtuple
22import datetime
23
24from libcloud.utils.py3 import httplib
25from libcloud.utils.iso8601 import parse_date
26
27from libcloud.common.base import (ConnectionUserAndKey, Response,
28                                  CertificateConnection)
29from libcloud.compute.types import (LibcloudError, InvalidCredsError,
30                                    MalformedResponseError)
31
32try:
33    import simplejson as json
34except ImportError:
35    import json  # type: ignore
36
37AUTH_API_VERSION = '1.1'
38AUTH_TOKEN_HEADER = 'X-Auth-Token'
39
40# Auth versions which contain token expiration information.
41AUTH_VERSIONS_WITH_EXPIRES = [
42    '1.1',
43    '2.0',
44    '2.0_apikey',
45    '2.0_password',
46    '2.0_voms',
47    '3.0',
48    '3.x_password',
49    '3.x_appcred',
50    '3.x_oidc_access_token'
51]
52
53# How many seconds to subtract from the auth token expiration time before
54# testing if the token is still valid.
55# The time is subtracted to account for the HTTP request latency and prevent
56# user from getting "InvalidCredsError" if token is about to expire.
57AUTH_TOKEN_EXPIRES_GRACE_SECONDS = 5
58
59
60__all__ = [
61    'OpenStackAuthenticationCache',
62    'OpenStackAuthenticationCacheKey',
63    'OpenStackAuthenticationContext',
64
65    'OpenStackIdentityVersion',
66    'OpenStackIdentityDomain',
67    'OpenStackIdentityProject',
68    'OpenStackIdentityUser',
69    'OpenStackIdentityRole',
70
71    'OpenStackServiceCatalog',
72    'OpenStackServiceCatalogEntry',
73    'OpenStackServiceCatalogEntryEndpoint',
74    'OpenStackIdentityEndpointType',
75
76    'OpenStackIdentityConnection',
77    'OpenStackIdentity_1_0_Connection',
78    'OpenStackIdentity_1_1_Connection',
79    'OpenStackIdentity_2_0_Connection',
80    'OpenStackIdentity_2_0_Connection_VOMS',
81    'OpenStackIdentity_3_0_Connection',
82    'OpenStackIdentity_3_0_Connection_AppCred',
83    'OpenStackIdentity_3_0_Connection_OIDC_access_token',
84
85    'get_class_for_auth_version'
86]
87
88
89class OpenStackAuthenticationCache:
90    """
91    Base class for external OpenStack authentication caches.
92
93    Authentication tokens are always cached in memory in
94    :class:`OpenStackIdentityConnection`.auth_token and related fields.  These
95    tokens are lost when the driver is garbage collected.  To share tokens
96    among multiple drivers, processes, or systems, use an
97    :class:`OpenStackAuthenticationCache` in
98    OpenStackIdentityConnection.auth_cache.
99
100    Cache implementors should inherit this class and define the methods below.
101    """
102    def get(self, key):
103        """
104        Get an authentication context from the cache.
105
106        :param key: Key to fetch.
107        :type key: :class:`.OpenStackAuthenticationCacheKey`
108
109        :return: The cached context for the given key, if present; None if not.
110        :rtype: :class:`OpenStackAuthenticationContext`
111        """
112        raise NotImplementedError
113
114    def put(self, key, context):
115        """
116        Put an authentication context into the cache.
117
118        :param key: Key where the context will be stored.
119        :type key: :class:`.OpenStackAuthenticationCacheKey`
120
121        :param context: The context to cache.
122        :type context: :class:`.OpenStackAuthenticationContext`
123        """
124        raise NotImplementedError
125
126    def clear(self, key):
127        """
128        Clear an authentication context from the cache.
129
130        :param key: Key to clear.
131        :type key: :class:`.OpenStackAuthenticationCacheKey`
132        """
133        raise NotImplementedError
134
135
136OpenStackAuthenticationCacheKey = namedtuple(
137    'OpenStackAuthenticationCacheKey',
138    ['auth_url', 'user_id', 'token_scope', 'tenant_name', 'domain_name',
139     'tenant_domain_id'])
140
141
142class OpenStackAuthenticationContext:
143    """
144    An authentication token and related context.
145    """
146    def __init__(self, token, expiration=None, user=None, roles=None,
147                 urls=None):
148        self.token = token
149        self.expiration = expiration
150        self.user = user
151        self.roles = roles
152        self.urls = urls
153
154
155class OpenStackIdentityEndpointType(object):
156    """
157    Enum class for openstack identity endpoint type.
158    """
159    INTERNAL = 'internal'
160    EXTERNAL = 'external'
161    ADMIN = 'admin'
162
163
164class OpenStackIdentityTokenScope(object):
165    """
166    Enum class for openstack identity token scope.
167    """
168    PROJECT = 'project'
169    DOMAIN = 'domain'
170    UNSCOPED = 'unscoped'
171
172
173class OpenStackIdentityVersion(object):
174    def __init__(self, version, status, updated, url):
175        self.version = version
176        self.status = status
177        self.updated = updated
178        self.url = url
179
180    def __repr__(self):
181        return (('<OpenStackIdentityVersion version=%s, status=%s, '
182                 'updated=%s, url=%s>' %
183                 (self.version, self.status, self.updated, self.url)))
184
185
186class OpenStackIdentityDomain(object):
187    def __init__(self, id, name, enabled):
188        self.id = id
189        self.name = name
190        self.enabled = enabled
191
192    def __repr__(self):
193        return (('<OpenStackIdentityDomain id=%s, name=%s, enabled=%s>' %
194                 (self.id, self.name, self.enabled)))
195
196
197class OpenStackIdentityProject(object):
198    def __init__(self, id, name, description, enabled, domain_id=None):
199        self.id = id
200        self.name = name
201        self.description = description
202        self.enabled = enabled
203        self.domain_id = domain_id
204
205    def __repr__(self):
206        return (('<OpenStackIdentityProject id=%s, domain_id=%s, name=%s, '
207                 'enabled=%s>' %
208                 (self.id, self.domain_id, self.name, self.enabled)))
209
210
211class OpenStackIdentityRole(object):
212    def __init__(self, id, name, description, enabled):
213        self.id = id
214        self.name = name
215        self.description = description
216        self.enabled = enabled
217
218    def __repr__(self):
219        return (('<OpenStackIdentityRole id=%s, name=%s, description=%s, '
220                 'enabled=%s>' % (self.id, self.name, self.description,
221                                  self.enabled)))
222
223
224class OpenStackIdentityUser(object):
225    def __init__(self, id, domain_id, name, email, description, enabled):
226        self.id = id
227        self.domain_id = domain_id
228        self.name = name
229        self.email = email
230        self.description = description
231        self.enabled = enabled
232
233    def __repr__(self):
234        return (('<OpenStackIdentityUser id=%s, domain_id=%s, name=%s, '
235                 'email=%s, enabled=%s>' % (self.id, self.domain_id, self.name,
236                                            self.email, self.enabled)))
237
238
239class OpenStackServiceCatalog(object):
240    """
241    http://docs.openstack.org/api/openstack-identity-service/2.0/content/
242
243    This class should be instantiated with the contents of the
244    'serviceCatalog' in the auth response. This will do the work of figuring
245    out which services actually exist in the catalog as well as split them up
246    by type, name, and region if available
247    """
248
249    _auth_version = None
250    _service_catalog = None
251
252    def __init__(self, service_catalog, auth_version=AUTH_API_VERSION):
253        self._auth_version = auth_version
254
255        # Check this way because there are a couple of different 2.0_*
256        # auth types.
257        if '3.x' in self._auth_version:
258            entries = self._parse_service_catalog_auth_v3(
259                service_catalog=service_catalog)
260        elif '2.0' in self._auth_version:
261            entries = self._parse_service_catalog_auth_v2(
262                service_catalog=service_catalog)
263        elif ('1.1' in self._auth_version) or ('1.0' in self._auth_version):
264            entries = self._parse_service_catalog_auth_v1(
265                service_catalog=service_catalog)
266        else:
267            raise LibcloudError('auth version "%s" not supported'
268                                % (self._auth_version))
269
270        # Force consistent ordering by sorting the entries
271        entries = sorted(entries,
272                         key=lambda x: x.service_type + (x.service_name or ''))
273        self._entries = entries  # stories all the service catalog entries
274
275    def get_entries(self):
276        """
277        Return all the entries for this service catalog.
278
279        :rtype: ``list`` of :class:`.OpenStackServiceCatalogEntry`
280        """
281        return self._entries
282
283    def get_catalog(self):
284        """
285        Deprecated in the favor of ``get_entries`` method.
286        """
287        return self.get_entries()
288
289    def get_public_urls(self, service_type=None, name=None):
290        """
291        Retrieve all the available public (external) URLs for the provided
292        service type and name.
293        """
294        endpoints = self.get_endpoints(service_type=service_type,
295                                       name=name)
296
297        result = []
298        for endpoint in endpoints:
299            endpoint_type = endpoint.endpoint_type
300            if endpoint_type == OpenStackIdentityEndpointType.EXTERNAL:
301                result.append(endpoint.url)
302
303        return result
304
305    def get_endpoints(self, service_type=None, name=None):
306        """
307        Retrieve all the endpoints for the provided service type and name.
308
309        :rtype: ``list`` of :class:`.OpenStackServiceCatalogEntryEndpoint`
310        """
311        endpoints = []
312
313        for entry in self._entries:
314            # Note: "if XXX and YYY != XXX" comparison is used to support
315            # partial lookups.
316            # This allows user to pass in only one argument to the method (only
317            # service_type or name), both of them or neither.
318            if service_type and entry.service_type != service_type:
319                continue
320
321            if name and entry.service_name != name:
322                continue
323
324            for endpoint in entry.endpoints:
325                endpoints.append(endpoint)
326
327        return endpoints
328
329    def get_endpoint(self, service_type=None, name=None, region=None,
330                     endpoint_type=OpenStackIdentityEndpointType.EXTERNAL):
331        """
332        Retrieve a single endpoint using the provided criteria.
333
334        Note: If no or more than one matching endpoint is found, an exception
335        is thrown.
336        """
337        endpoints = []
338
339        for entry in self._entries:
340            if service_type and entry.service_type != service_type:
341                continue
342
343            if name and entry.service_name != name:
344                continue
345
346            for endpoint in entry.endpoints:
347                if region and endpoint.region != region:
348                    continue
349
350                if endpoint_type and endpoint.endpoint_type != endpoint_type:
351                    continue
352
353                endpoints.append(endpoint)
354
355        if len(endpoints) == 1:
356            return endpoints[0]
357        elif len(endpoints) > 1:
358            raise ValueError('Found more than 1 matching endpoint')
359        else:
360            raise LibcloudError('Could not find specified endpoint')
361
362    def get_regions(self, service_type=None):
363        """
364        Retrieve a list of all the available regions.
365
366        :param service_type: If specified, only return regions for this
367                             service type.
368        :type service_type: ``str``
369
370        :rtype: ``list`` of ``str``
371        """
372        regions = set()
373
374        for entry in self._entries:
375            if service_type and entry.service_type != service_type:
376                continue
377
378            for endpoint in entry.endpoints:
379                if endpoint.region:
380                    regions.add(endpoint.region)
381
382        return sorted(list(regions))
383
384    def get_service_types(self, region=None):
385        """
386        Retrieve all the available service types.
387
388        :param region: Optional region to retrieve service types for.
389        :type region: ``str``
390
391        :rtype: ``list`` of ``str``
392        """
393        service_types = set()
394
395        for entry in self._entries:
396            include = True
397
398            for endpoint in entry.endpoints:
399                if region and endpoint.region != region:
400                    include = False
401                    break
402
403            if include:
404                service_types.add(entry.service_type)
405
406        return sorted(list(service_types))
407
408    def get_service_names(self, service_type=None, region=None):
409        """
410        Retrieve list of service names that match service type and region.
411
412        :type service_type: ``str``
413        :type region: ``str``
414
415        :rtype: ``list`` of ``str``
416        """
417        names = set()
418
419        if '2.0' not in self._auth_version:
420            raise ValueError('Unsupported version: %s' % (self._auth_version))
421
422        for entry in self._entries:
423            if service_type and entry.service_type != service_type:
424                continue
425
426            include = True
427            for endpoint in entry.endpoints:
428                if region and endpoint.region != region:
429                    include = False
430                    break
431
432            if include and entry.service_name:
433                names.add(entry.service_name)
434
435        return sorted(list(names))
436
437    def _parse_service_catalog_auth_v1(self, service_catalog):
438        entries = []
439
440        for service, endpoints in service_catalog.items():
441            entry_endpoints = []
442            for endpoint in endpoints:
443                region = endpoint.get('region', None)
444
445                public_url = endpoint.get('publicURL', None)
446                private_url = endpoint.get('internalURL', None)
447
448                if public_url:
449                    entry_endpoint = OpenStackServiceCatalogEntryEndpoint(
450                        region=region, url=public_url,
451                        endpoint_type=OpenStackIdentityEndpointType.EXTERNAL)
452                    entry_endpoints.append(entry_endpoint)
453
454                if private_url:
455                    entry_endpoint = OpenStackServiceCatalogEntryEndpoint(
456                        region=region, url=private_url,
457                        endpoint_type=OpenStackIdentityEndpointType.INTERNAL)
458                    entry_endpoints.append(entry_endpoint)
459
460            entry = OpenStackServiceCatalogEntry(service_type=service,
461                                                 endpoints=entry_endpoints)
462            entries.append(entry)
463
464        return entries
465
466    def _parse_service_catalog_auth_v2(self, service_catalog):
467        entries = []
468
469        for service in service_catalog:
470            service_type = service['type']
471            service_name = service.get('name', None)
472
473            entry_endpoints = []
474            for endpoint in service.get('endpoints', []):
475                region = endpoint.get('region', None)
476
477                public_url = endpoint.get('publicURL', None)
478                private_url = endpoint.get('internalURL', None)
479
480                if public_url:
481                    entry_endpoint = OpenStackServiceCatalogEntryEndpoint(
482                        region=region, url=public_url,
483                        endpoint_type=OpenStackIdentityEndpointType.EXTERNAL)
484                    entry_endpoints.append(entry_endpoint)
485
486                if private_url:
487                    entry_endpoint = OpenStackServiceCatalogEntryEndpoint(
488                        region=region, url=private_url,
489                        endpoint_type=OpenStackIdentityEndpointType.INTERNAL)
490                    entry_endpoints.append(entry_endpoint)
491
492            entry = OpenStackServiceCatalogEntry(service_type=service_type,
493                                                 endpoints=entry_endpoints,
494                                                 service_name=service_name)
495            entries.append(entry)
496
497        return entries
498
499    def _parse_service_catalog_auth_v3(self, service_catalog):
500        entries = []
501
502        for item in service_catalog:
503            service_type = item['type']
504            service_name = item.get('name', None)
505
506            entry_endpoints = []
507            for endpoint in item['endpoints']:
508                region = endpoint.get('region', None)
509                url = endpoint['url']
510                endpoint_type = endpoint['interface']
511
512                if endpoint_type == 'internal':
513                    endpoint_type = OpenStackIdentityEndpointType.INTERNAL
514                elif endpoint_type == 'public':
515                    endpoint_type = OpenStackIdentityEndpointType.EXTERNAL
516                elif endpoint_type == 'admin':
517                    endpoint_type = OpenStackIdentityEndpointType.ADMIN
518
519                entry_endpoint = OpenStackServiceCatalogEntryEndpoint(
520                    region=region, url=url, endpoint_type=endpoint_type)
521                entry_endpoints.append(entry_endpoint)
522
523            entry = OpenStackServiceCatalogEntry(service_type=service_type,
524                                                 service_name=service_name,
525                                                 endpoints=entry_endpoints)
526            entries.append(entry)
527
528        return entries
529
530
531class OpenStackServiceCatalogEntry(object):
532    def __init__(self, service_type, endpoints=None, service_name=None):
533        """
534        :param service_type: Service type.
535        :type service_type: ``str``
536
537        :param endpoints: Endpoints belonging to this entry.
538        :type endpoints: ``list``
539
540        :param service_name: Optional service name.
541        :type service_name: ``str``
542        """
543        self.service_type = service_type
544        self.endpoints = endpoints or []
545        self.service_name = service_name
546
547        # For consistency, sort the endpoints
548        self.endpoints = sorted(self.endpoints, key=lambda x: x.url or '')
549
550    def __eq__(self, other):
551        return (self.service_type == other.service_type and
552                self.endpoints == other.endpoints and
553                other.service_name == self.service_name)
554
555    def __ne__(self, other):
556        return not self.__eq__(other=other)
557
558    def __repr__(self):
559        return (('<OpenStackServiceCatalogEntry service_type=%s, '
560                 'service_name=%s, endpoints=%s' %
561                 (self.service_type, self.service_name, repr(self.endpoints))))
562
563
564class OpenStackServiceCatalogEntryEndpoint(object):
565    VALID_ENDPOINT_TYPES = [
566        OpenStackIdentityEndpointType.INTERNAL,
567        OpenStackIdentityEndpointType.EXTERNAL,
568        OpenStackIdentityEndpointType.ADMIN,
569    ]
570
571    def __init__(self, region, url, endpoint_type='external'):
572        """
573        :param region: Endpoint region.
574        :type region: ``str``
575
576        :param url: Endpoint URL.
577        :type url: ``str``
578
579        :param endpoint_type: Endpoint type (external / internal / admin).
580        :type endpoint_type: ``str``
581        """
582        if endpoint_type not in self.VALID_ENDPOINT_TYPES:
583            raise ValueError('Invalid type: %s' % (endpoint_type))
584
585        # TODO: Normalize / lowercase all the region names
586        self.region = region
587        self.url = url
588        self.endpoint_type = endpoint_type
589
590    def __eq__(self, other):
591        return (self.region == other.region and self.url == other.url and
592                self.endpoint_type == other.endpoint_type)
593
594    def __ne__(self, other):
595        return not self.__eq__(other=other)
596
597    def __repr__(self):
598        return (('<OpenStackServiceCatalogEntryEndpoint region=%s, url=%s, '
599                 'type=%s' % (self.region, self.url, self.endpoint_type)))
600
601
602class OpenStackAuthResponse(Response):
603    def success(self):
604        return self.status in [httplib.OK, httplib.CREATED,
605                               httplib.ACCEPTED, httplib.NO_CONTENT,
606                               httplib.MULTIPLE_CHOICES,
607                               httplib.UNAUTHORIZED,
608                               httplib.INTERNAL_SERVER_ERROR]
609
610    def parse_body(self):
611        if not self.body:
612            return None
613
614        if 'content-type' in self.headers:
615            key = 'content-type'
616        elif 'Content-Type' in self.headers:
617            key = 'Content-Type'
618        else:
619            raise LibcloudError('Missing content-type header',
620                                driver=OpenStackIdentityConnection)
621
622        content_type = self.headers[key]
623        if content_type.find(';') != -1:
624            content_type = content_type.split(';')[0]
625
626        if content_type == 'application/json':
627            try:
628                data = json.loads(self.body)
629            except Exception:
630                driver = OpenStackIdentityConnection
631                raise MalformedResponseError('Failed to parse JSON',
632                                             body=self.body,
633                                             driver=driver)
634        elif content_type == 'text/plain':
635            data = self.body
636        else:
637            data = self.body
638
639        return data
640
641
642class OpenStackIdentityConnection(ConnectionUserAndKey):
643    """
644    Base identity connection class which contains common / shared logic.
645
646    Note: This class shouldn't be instantiated directly.
647    """
648    responseCls = OpenStackAuthResponse
649    timeout = None
650    auth_version = None  # type: str
651
652    def __init__(self, auth_url, user_id, key, tenant_name=None,
653                 tenant_domain_id='default', domain_name='Default',
654                 token_scope=OpenStackIdentityTokenScope.PROJECT,
655                 timeout=None, proxy_url=None, parent_conn=None,
656                 auth_cache=None):
657        super(OpenStackIdentityConnection, self).__init__(user_id=user_id,
658                                                          key=key,
659                                                          url=auth_url,
660                                                          timeout=timeout,
661                                                          proxy_url=proxy_url)
662
663        self.parent_conn = parent_conn
664
665        # enable tests to use the same mock connection classes.
666        if parent_conn:
667            self.conn_class = parent_conn.conn_class
668            self.driver = parent_conn.driver
669        else:
670            self.driver = None
671
672        self.auth_url = auth_url
673        self.tenant_name = tenant_name
674        self.domain_name = domain_name
675        self.tenant_domain_id = tenant_domain_id
676        self.token_scope = token_scope
677        self.timeout = timeout
678        self.auth_cache = auth_cache
679
680        self.urls = {}
681        self.auth_token = None
682        self.auth_token_expires = None
683        self.auth_user_info = None
684        self.auth_user_roles = None
685
686    def authenticated_request(self, action, params=None, data=None,
687                              headers=None, method='GET', raw=False):
688        """
689        Perform an authenticated request against the identity API.
690        """
691        if not self.auth_token:
692            raise ValueError(
693                'Need to be authenticated to perform this request')
694
695        headers = headers or {}
696        headers[AUTH_TOKEN_HEADER] = self.auth_token
697
698        response = self.request(action=action, params=params, data=data,
699                                headers=headers, method=method, raw=raw)
700        # Evict cached auth token if we receive Unauthorized while using it
701        if response.status == httplib.UNAUTHORIZED:
702            self.clear_cached_auth_context()
703        return response
704
705    def morph_action_hook(self, action):
706        (_, _, _, request_path) = self._tuple_from_url(self.auth_url)
707
708        if request_path == '':
709            # No path is provided in the auth_url, use action passed to this
710            # method.
711            return action
712
713        return request_path
714
715    def add_default_headers(self, headers):
716        headers['Accept'] = 'application/json'
717        headers['Content-Type'] = 'application/json; charset=UTF-8'
718        return headers
719
720    def is_token_valid(self):
721        """
722        Return True if the current auth token is already cached and hasn't
723        expired yet.
724
725        :return: ``True`` if the token is still valid, ``False`` otherwise.
726        :rtype: ``bool``
727        """
728        if not self.auth_token:
729            return False
730
731        if not self.auth_token_expires:
732            return False
733
734        expires = self.auth_token_expires - \
735            datetime.timedelta(seconds=AUTH_TOKEN_EXPIRES_GRACE_SECONDS)
736
737        time_tuple_expires = expires.utctimetuple()
738        time_tuple_now = datetime.datetime.utcnow().utctimetuple()
739
740        if time_tuple_now < time_tuple_expires:
741            return True
742
743        return False
744
745    def authenticate(self, force=False):
746        """
747        Authenticate against the identity API.
748
749        :param force: Forcefully update the token even if it's already cached
750                      and still valid.
751        :type force: ``bool``
752        """
753        raise NotImplementedError('authenticate not implemented')
754
755    def clear_cached_auth_context(self):
756        """
757        Clear the cached authentication context.
758
759        The context is cleared from fields on this connection and from the
760        external cache, if one is configured.
761        """
762        self.auth_token = None
763        self.auth_token_expires = None
764        self.auth_user_info = None
765        self.auth_user_roles = None
766        self.urls = {}
767
768        if self.auth_cache is not None:
769            self.auth_cache.clear(self._cache_key)
770
771    def list_supported_versions(self):
772        """
773        Retrieve a list of all the identity versions which are supported by
774        this installation.
775
776        :rtype: ``list`` of :class:`.OpenStackIdentityVersion`
777        """
778        response = self.request('/', method='GET')
779        result = self._to_versions(data=response.object['versions']['values'])
780        result = sorted(result, key=lambda x: x.version)
781        return result
782
783    def _to_versions(self, data):
784        result = []
785        for item in data:
786            version = self._to_version(data=item)
787            result.append(version)
788
789        return result
790
791    def _to_version(self, data):
792        try:
793            updated = parse_date(data['updated'])
794        except Exception:
795            updated = None
796
797        try:
798            url = data['links'][0]['href']
799        except IndexError:
800            url = None
801
802        version = OpenStackIdentityVersion(version=data['id'],
803                                           status=data['status'],
804                                           updated=updated,
805                                           url=url)
806        return version
807
808    def _is_authentication_needed(self, force=False):
809        """
810        Determine if the authentication is needed or if the existing token (if
811        any exists) is still valid.
812        """
813        if force:
814            return True
815
816        if self.auth_version not in AUTH_VERSIONS_WITH_EXPIRES:
817            return True
818
819        if self.is_token_valid():
820            return False
821
822        # See if there's a new token in the cache
823        self._load_auth_context_from_cache()
824
825        # If there was a token in the cache, it is now stored in our local
826        # auth_token and related fields.  Ensure it is still valid.
827        if self.is_token_valid():
828            return False
829
830        return True
831
832    def _to_projects(self, data):
833        result = []
834        for item in data:
835            project = self._to_project(data=item)
836            result.append(project)
837
838        return result
839
840    def _to_project(self, data):
841        project = OpenStackIdentityProject(id=data['id'],
842                                           name=data['name'],
843                                           description=data['description'],
844                                           enabled=data['enabled'],
845                                           domain_id=data.get('domain_id',
846                                                              None))
847        return project
848
849    @property
850    def _cache_key(self):
851        """
852        The key where this connection's authentication context will be cached.
853
854        :rtype: :class:`OpenStackAuthenticationCacheKey`
855        """
856        return OpenStackAuthenticationCacheKey(
857            self.auth_url, self.user_id, self.token_scope, self.tenant_name,
858            self.domain_name, self.tenant_domain_id)
859
860    def _cache_auth_context(self, context):
861        """
862        Store an authentication context in memory and the cache.
863
864        :param context: Authentication context to cache.
865        :type key: :class:`.OpenStackAuthenticationContext`
866        """
867        self.urls = context.urls
868        self.auth_token = context.token
869        self.auth_token_expires = context.expiration
870        self.auth_user_info = context.user
871        self.auth_user_roles = context.roles
872
873        if self.auth_cache is not None:
874            self.auth_cache.put(self._cache_key, context)
875
876    def _load_auth_context_from_cache(self):
877        """
878        Fetch an authentication context for this connection from the cache.
879
880        :rtype: :class:`OpenStackAuthenticationContext`
881        """
882        if self.auth_cache is None:
883            return None
884
885        context = self.auth_cache.get(self._cache_key)
886        if context is None:
887            return None
888
889        self.urls = context.urls
890        self.auth_token = context.token
891        self.auth_token_expires = context.expiration
892        self.auth_user_info = context.user
893        self.auth_user_roles = context.roles
894        return context
895
896
897class OpenStackIdentity_1_0_Connection(OpenStackIdentityConnection):
898    """
899    Connection class for Keystone API v1.0.
900    """
901
902    responseCls = OpenStackAuthResponse
903    name = 'OpenStack Identity API v1.0'
904    auth_version = '1.0'
905
906    def authenticate(self, force=False):
907        if not self._is_authentication_needed(force=force):
908            return self
909
910        headers = {
911            'X-Auth-User': self.user_id,
912            'X-Auth-Key': self.key,
913        }
914
915        resp = self.request('/v1.0', headers=headers, method='GET')
916
917        if resp.status == httplib.UNAUTHORIZED:
918            # HTTP UNAUTHORIZED (401): auth failed
919            raise InvalidCredsError()
920        elif resp.status not in [httplib.NO_CONTENT, httplib.OK]:
921            body = 'code: %s body:%s headers:%s' % (resp.status,
922                                                    resp.body,
923                                                    resp.headers)
924            raise MalformedResponseError('Malformed response', body=body,
925                                         driver=self.driver)
926        else:
927            headers = resp.headers
928            # emulate the auth 1.1 URL list
929            self.urls = {}
930            self.urls['cloudServers'] = \
931                [{'publicURL': headers.get('x-server-management-url', None)}]
932            self.urls['cloudFilesCDN'] = \
933                [{'publicURL': headers.get('x-cdn-management-url', None)}]
934            self.urls['cloudFiles'] = \
935                [{'publicURL': headers.get('x-storage-url', None)}]
936            self.auth_token = headers.get('x-auth-token', None)
937            self.auth_user_info = None
938
939            if not self.auth_token:
940                raise MalformedResponseError('Missing X-Auth-Token in'
941                                             ' response headers')
942
943        return self
944
945
946class OpenStackIdentity_1_1_Connection(OpenStackIdentityConnection):
947    """
948    Connection class for Keystone API v1.1.
949    """
950
951    responseCls = OpenStackAuthResponse
952    name = 'OpenStack Identity API v1.1'
953    auth_version = '1.1'
954
955    def authenticate(self, force=False):
956        if not self._is_authentication_needed(force=force):
957            return self
958
959        reqbody = json.dumps({'credentials': {'username': self.user_id,
960                                              'key': self.key}})
961        resp = self.request('/v1.1/auth', data=reqbody, headers={},
962                            method='POST')
963
964        if resp.status == httplib.UNAUTHORIZED:
965            # HTTP UNAUTHORIZED (401): auth failed
966            raise InvalidCredsError()
967        elif resp.status != httplib.OK:
968            body = 'code: %s body:%s' % (resp.status, resp.body)
969            raise MalformedResponseError('Malformed response', body=body,
970                                         driver=self.driver)
971        else:
972            try:
973                body = json.loads(resp.body)
974            except Exception as e:
975                raise MalformedResponseError('Failed to parse JSON', e)
976
977            try:
978                expires = body['auth']['token']['expires']
979                self._cache_auth_context(
980                    OpenStackAuthenticationContext(
981                        body['auth']['token']['id'],
982                        expiration=parse_date(expires),
983                        urls=body['auth']['serviceCatalog']))
984            except KeyError as e:
985                raise MalformedResponseError('Auth JSON response is \
986                                             missing required elements', e)
987
988        return self
989
990
991class OpenStackIdentity_2_0_Connection(OpenStackIdentityConnection):
992    """
993    Connection class for Keystone API v2.0.
994    """
995
996    responseCls = OpenStackAuthResponse
997    name = 'OpenStack Identity API v1.0'
998    auth_version = '2.0'
999
1000    def authenticate(self, auth_type='api_key', force=False):
1001        if not self._is_authentication_needed(force=force):
1002            return self
1003
1004        if auth_type == 'api_key':
1005            return self._authenticate_2_0_with_api_key()
1006        elif auth_type == 'password':
1007            return self._authenticate_2_0_with_password()
1008        else:
1009            raise ValueError('Invalid value for auth_type argument')
1010
1011    def _authenticate_2_0_with_api_key(self):
1012        # API Key based authentication uses the RAX-KSKEY extension.
1013        # http://s.apache.org/oAi
1014        data = {'auth':
1015                {'RAX-KSKEY:apiKeyCredentials':
1016                 {'username': self.user_id, 'apiKey': self.key}}}
1017        if self.tenant_name:
1018            data['auth']['tenantName'] = self.tenant_name
1019        reqbody = json.dumps(data)
1020        return self._authenticate_2_0_with_body(reqbody)
1021
1022    def _authenticate_2_0_with_password(self):
1023        # Password based authentication is the only 'core' authentication
1024        # method in Keystone at this time.
1025        # 'keystone' - http://s.apache.org/e8h
1026        data = {'auth':
1027                {'passwordCredentials':
1028                 {'username': self.user_id, 'password': self.key}}}
1029        if self.tenant_name:
1030            data['auth']['tenantName'] = self.tenant_name
1031        reqbody = json.dumps(data)
1032        return self._authenticate_2_0_with_body(reqbody)
1033
1034    def _authenticate_2_0_with_body(self, reqbody):
1035        resp = self.request('/v2.0/tokens', data=reqbody,
1036                            headers={'Content-Type': 'application/json'},
1037                            method='POST')
1038
1039        if resp.status == httplib.UNAUTHORIZED:
1040            raise InvalidCredsError()
1041        elif resp.status not in [httplib.OK,
1042                                 httplib.NON_AUTHORITATIVE_INFORMATION]:
1043            body = 'code: %s body: %s' % (resp.status, resp.body)
1044            raise MalformedResponseError('Malformed response', body=body,
1045                                         driver=self.driver)
1046        else:
1047            body = resp.object
1048
1049            try:
1050                access = body['access']
1051                expires = access['token']['expires']
1052                self._cache_auth_context(
1053                    OpenStackAuthenticationContext(
1054                        access['token']['id'],
1055                        expiration=parse_date(expires),
1056                        urls=access['serviceCatalog'],
1057                        user=access.get('user', {})))
1058            except KeyError as e:
1059                raise MalformedResponseError('Auth JSON response is \
1060                                             missing required elements', e)
1061
1062        return self
1063
1064    def list_projects(self):
1065        response = self.authenticated_request('/v2.0/tenants', method='GET')
1066        result = self._to_projects(data=response.object['tenants'])
1067        return result
1068
1069    def list_tenants(self):
1070        return self.list_projects()
1071
1072
1073class OpenStackIdentity_3_0_Connection(OpenStackIdentityConnection):
1074    """
1075    Connection class for Keystone API v3.x.
1076    """
1077
1078    responseCls = OpenStackAuthResponse
1079    name = 'OpenStack Identity API v3.x'
1080    auth_version = '3.0'
1081
1082    VALID_TOKEN_SCOPES = [
1083        OpenStackIdentityTokenScope.PROJECT,
1084        OpenStackIdentityTokenScope.DOMAIN,
1085        OpenStackIdentityTokenScope.UNSCOPED
1086    ]
1087
1088    def __init__(self, auth_url, user_id, key, tenant_name=None,
1089                 domain_name='Default', tenant_domain_id='default',
1090                 token_scope=OpenStackIdentityTokenScope.PROJECT,
1091                 timeout=None, proxy_url=None, parent_conn=None,
1092                 auth_cache=None):
1093        """
1094        :param tenant_name: Name of the project this user belongs to. Note:
1095                            When token_scope is set to project, this argument
1096                            control to which project to scope the token to.
1097        :type tenant_name: ``str``
1098
1099        :param domain_name: Domain the user belongs to. Note: When token_scope
1100                            is set to token, this argument controls to which
1101                            domain to scope the token to.
1102        :type domain_name: ``str``
1103
1104        :param token_scope: Whether to scope a token to a "project", a
1105                            "domain" or "unscoped"
1106        :type token_scope: ``str``
1107
1108        :param auth_cache: Where to cache authentication tokens.
1109        :type auth_cache: :class:`OpenStackAuthenticationCache`
1110        """
1111        super(OpenStackIdentity_3_0_Connection,
1112              self).__init__(auth_url=auth_url,
1113                             user_id=user_id,
1114                             key=key,
1115                             tenant_name=tenant_name,
1116                             domain_name=domain_name,
1117                             tenant_domain_id=tenant_domain_id,
1118                             token_scope=token_scope,
1119                             timeout=timeout,
1120                             proxy_url=proxy_url,
1121                             parent_conn=parent_conn,
1122                             auth_cache=auth_cache)
1123
1124        if self.token_scope not in self.VALID_TOKEN_SCOPES:
1125            raise ValueError('Invalid value for "token_scope" argument: %s' %
1126                             (self.token_scope))
1127
1128        if (self.token_scope == OpenStackIdentityTokenScope.PROJECT and
1129                (not self.tenant_name or not self.domain_name)):
1130            raise ValueError('Must provide tenant_name and domain_name '
1131                             'argument')
1132        elif (self.token_scope == OpenStackIdentityTokenScope.DOMAIN and
1133                not self.domain_name):
1134            raise ValueError('Must provide domain_name argument')
1135
1136    def authenticate(self, force=False):
1137        """
1138        Perform authentication.
1139        """
1140        if not self._is_authentication_needed(force=force):
1141            return self
1142
1143        data = self._get_auth_data()
1144        data = json.dumps(data)
1145        response = self.request('/v3/auth/tokens', data=data,
1146                                headers={'Content-Type': 'application/json'},
1147                                method='POST')
1148        self._parse_token_response(response, cache_it=True)
1149        return self
1150
1151    def list_domains(self):
1152        """
1153        List the available domains.
1154
1155        :rtype: ``list`` of :class:`OpenStackIdentityDomain`
1156        """
1157        response = self.authenticated_request('/v3/domains', method='GET')
1158        result = self._to_domains(data=response.object['domains'])
1159        return result
1160
1161    def list_projects(self):
1162        """
1163        List the available projects.
1164
1165        Note: To perform this action, user you are currently authenticated with
1166        needs to be an admin.
1167
1168        :rtype: ``list`` of :class:`OpenStackIdentityProject`
1169        """
1170        response = self.authenticated_request('/v3/projects', method='GET')
1171        result = self._to_projects(data=response.object['projects'])
1172        return result
1173
1174    def list_users(self):
1175        """
1176        List the available users.
1177
1178        :rtype: ``list`` of :class:`.OpenStackIdentityUser`
1179        """
1180        response = self.authenticated_request('/v3/users', method='GET')
1181        result = self._to_users(data=response.object['users'])
1182        return result
1183
1184    def list_roles(self):
1185        """
1186        List the available roles.
1187
1188        :rtype: ``list`` of :class:`.OpenStackIdentityRole`
1189        """
1190        response = self.authenticated_request('/v3/roles', method='GET')
1191        result = self._to_roles(data=response.object['roles'])
1192        return result
1193
1194    def get_domain(self, domain_id):
1195        """
1196        Retrieve information about a single domain.
1197
1198        :param domain_id: ID of domain to retrieve information for.
1199        :type domain_id: ``str``
1200
1201        :rtype: :class:`.OpenStackIdentityDomain`
1202        """
1203        response = self.authenticated_request('/v3/domains/%s' % (domain_id),
1204                                              method='GET')
1205        result = self._to_domain(data=response.object['domain'])
1206        return result
1207
1208    def get_user(self, user_id):
1209        """
1210        Get a user account by ID.
1211
1212        :param user_id: User's id.
1213        :type name: ``str``
1214
1215        :return: Located user.
1216        :rtype: :class:`.OpenStackIdentityUser`
1217        """
1218        response = self.authenticated_request('/v3/users/%s' % user_id)
1219        user = self._to_user(data=response.object['user'])
1220        return user
1221
1222    def list_user_projects(self, user):
1223        """
1224        Retrieve all the projects user belongs to.
1225
1226        :rtype: ``list`` of :class:`.OpenStackIdentityProject`
1227        """
1228        path = '/v3/users/%s/projects' % (user.id)
1229        response = self.authenticated_request(path, method='GET')
1230        result = self._to_projects(data=response.object['projects'])
1231        return result
1232
1233    def list_user_domain_roles(self, domain, user):
1234        """
1235        Retrieve all the roles for a particular user on a domain.
1236
1237        :rtype: ``list`` of :class:`.OpenStackIdentityRole`
1238        """
1239        # TODO: Also add "get users roles" and "get assginements" which are
1240        # available in 3.1 and 3.3
1241        path = '/v3/domains/%s/users/%s/roles' % (domain.id, user.id)
1242        response = self.authenticated_request(path, method='GET')
1243        result = self._to_roles(data=response.object['roles'])
1244        return result
1245
1246    def grant_domain_role_to_user(self, domain, role, user):
1247        """
1248        Grant domain role to a user.
1249
1250        Note: This function appears to be idempotent.
1251
1252        :param domain: Domain to grant the role to.
1253        :type domain: :class:`.OpenStackIdentityDomain`
1254
1255        :param role: Role to grant.
1256        :type role: :class:`.OpenStackIdentityRole`
1257
1258        :param user: User to grant the role to.
1259        :type user: :class:`.OpenStackIdentityUser`
1260
1261        :return: ``True`` on success.
1262        :rtype: ``bool``
1263        """
1264        path = ('/v3/domains/%s/users/%s/roles/%s' %
1265                (domain.id, user.id, role.id))
1266        response = self.authenticated_request(path, method='PUT')
1267        return response.status == httplib.NO_CONTENT
1268
1269    def revoke_domain_role_from_user(self, domain, user, role):
1270        """
1271        Revoke domain role from a user.
1272
1273        :param domain: Domain to revoke the role from.
1274        :type domain: :class:`.OpenStackIdentityDomain`
1275
1276        :param role: Role to revoke.
1277        :type role: :class:`.OpenStackIdentityRole`
1278
1279        :param user: User to revoke the role from.
1280        :type user: :class:`.OpenStackIdentityUser`
1281
1282        :return: ``True`` on success.
1283        :rtype: ``bool``
1284        """
1285        path = ('/v3/domains/%s/users/%s/roles/%s' %
1286                (domain.id, user.id, role.id))
1287        response = self.authenticated_request(path, method='DELETE')
1288        return response.status == httplib.NO_CONTENT
1289
1290    def grant_project_role_to_user(self, project, role, user):
1291        """
1292        Grant project role to a user.
1293
1294        Note: This function appears to be idempotent.
1295
1296        :param project: Project to grant the role to.
1297        :type project: :class:`.OpenStackIdentityDomain`
1298
1299        :param role: Role to grant.
1300        :type role: :class:`.OpenStackIdentityRole`
1301
1302        :param user: User to grant the role to.
1303        :type user: :class:`.OpenStackIdentityUser`
1304
1305        :return: ``True`` on success.
1306        :rtype: ``bool``
1307        """
1308        path = ('/v3/projects/%s/users/%s/roles/%s' %
1309                (project.id, user.id, role.id))
1310        response = self.authenticated_request(path, method='PUT')
1311        return response.status == httplib.NO_CONTENT
1312
1313    def revoke_project_role_from_user(self, project, role, user):
1314        """
1315        Revoke project role from a user.
1316
1317        :param project: Project to revoke the role from.
1318        :type project: :class:`.OpenStackIdentityDomain`
1319
1320        :param role: Role to revoke.
1321        :type role: :class:`.OpenStackIdentityRole`
1322
1323        :param user: User to revoke the role from.
1324        :type user: :class:`.OpenStackIdentityUser`
1325
1326        :return: ``True`` on success.
1327        :rtype: ``bool``
1328        """
1329        path = ('/v3/projects/%s/users/%s/roles/%s' %
1330                (project.id, user.id, role.id))
1331        response = self.authenticated_request(path, method='DELETE')
1332        return response.status == httplib.NO_CONTENT
1333
1334    def create_user(self, email, password, name, description=None,
1335                    domain_id=None, default_project_id=None, enabled=True):
1336        """
1337        Create a new user account.
1338
1339        :param email: User's mail address.
1340        :type email: ``str``
1341
1342        :param password: User's password.
1343        :type password: ``str``
1344
1345        :param name: User's name.
1346        :type name: ``str``
1347
1348        :param description: Optional description.
1349        :type description: ``str``
1350
1351        :param domain_id: ID of the domain to add the user to (optional).
1352        :type domain_id: ``str``
1353
1354        :param default_project_id: ID of the default user project (optional).
1355        :type default_project_id: ``str``
1356
1357        :param enabled: True to enable user after creation.
1358        :type enabled: ``bool``
1359
1360        :return: Created user.
1361        :rtype: :class:`.OpenStackIdentityUser`
1362        """
1363        data = {
1364            'email': email,
1365            'password': password,
1366            'name': name,
1367            'enabled': enabled
1368        }
1369
1370        if description:
1371            data['description'] = description
1372
1373        if domain_id:
1374            data['domain_id'] = domain_id
1375
1376        if default_project_id:
1377            data['default_project_id'] = default_project_id
1378
1379        data = json.dumps({'user': data})
1380        response = self.authenticated_request('/v3/users', data=data,
1381                                              method='POST')
1382
1383        user = self._to_user(data=response.object['user'])
1384        return user
1385
1386    def enable_user(self, user):
1387        """
1388        Enable user account.
1389
1390        Note: This operation appears to be idempotent.
1391
1392        :param user: User to enable.
1393        :type user: :class:`.OpenStackIdentityUser`
1394
1395        :return: User account which has been enabled.
1396        :rtype: :class:`.OpenStackIdentityUser`
1397        """
1398        data = {
1399            'enabled': True
1400        }
1401        data = json.dumps({'user': data})
1402        response = self.authenticated_request('/v3/users/%s' % (user.id),
1403                                              data=data,
1404                                              method='PATCH')
1405
1406        user = self._to_user(data=response.object['user'])
1407        return user
1408
1409    def disable_user(self, user):
1410        """
1411        Disable user account.
1412
1413        Note: This operation appears to be idempotent.
1414
1415        :param user: User to disable.
1416        :type user: :class:`.OpenStackIdentityUser`
1417
1418        :return: User account which has been disabled.
1419        :rtype: :class:`.OpenStackIdentityUser`
1420        """
1421        data = {
1422            'enabled': False
1423        }
1424        data = json.dumps({'user': data})
1425        response = self.authenticated_request('/v3/users/%s' % (user.id),
1426                                              data=data,
1427                                              method='PATCH')
1428
1429        user = self._to_user(data=response.object['user'])
1430        return user
1431
1432    def _get_auth_data(self):
1433        data = {
1434            'auth': {
1435                'identity': {
1436                    'methods': ['password'],
1437                    'password': {
1438                        'user': {
1439                            'domain': {
1440                                'name': self.domain_name
1441                            },
1442                            'name': self.user_id,
1443                            'password': self.key
1444                        }
1445                    }
1446                }
1447            }
1448        }
1449
1450        if self.token_scope == OpenStackIdentityTokenScope.PROJECT:
1451            # Scope token to project (tenant)
1452            data['auth']['scope'] = {
1453                'project': {
1454                    'domain': {
1455                        'id': self.tenant_domain_id
1456                    },
1457                    'name': self.tenant_name
1458                }
1459            }
1460        elif self.token_scope == OpenStackIdentityTokenScope.DOMAIN:
1461            # Scope token to domain
1462            data['auth']['scope'] = {
1463                'domain': {
1464                    'name': self.domain_name
1465                }
1466            }
1467        elif self.token_scope == OpenStackIdentityTokenScope.UNSCOPED:
1468            pass
1469        else:
1470            raise ValueError('Token needs to be scoped either to project or '
1471                             'a domain')
1472
1473        return data
1474
1475    def _load_auth_context_from_cache(self):
1476        context = super()._load_auth_context_from_cache()
1477        if context is None:
1478            return None
1479
1480        # Since v3 only caches the token and expiration, fetch the
1481        # service catalog and other bits of the authentication context
1482        # from Keystone.
1483        try:
1484            self._fetch_auth_token()
1485        except InvalidCredsError:
1486            # Unauthorized; cached auth context was cleared as part of
1487            # _fetch_auth_token
1488            return None
1489
1490        # Local auth context variables set in _fetch_auth_token
1491        return context
1492
1493    def _parse_token_response(self, response, cache_it=False,
1494                              raise_ambiguous_version_error=True):
1495        """
1496        Parse a response from /v3/auth/tokens.
1497
1498        :param cache_it: Should we cache the authentication context?
1499        :type cache_it: ``bool``
1500
1501        :param raise_ambiguous_version_error: Should an ambiguous version
1502            error be raised on a 300 response?
1503        :type raise_ambiguous_version_error: ``bool``
1504        """
1505        if response.status == httplib.UNAUTHORIZED:
1506            raise InvalidCredsError()
1507        elif response.status in [httplib.OK, httplib.CREATED]:
1508            headers = response.headers
1509
1510            try:
1511                body = json.loads(response.body)
1512            except Exception as e:
1513                raise MalformedResponseError('Failed to parse JSON', e)
1514
1515            try:
1516                roles = self._to_roles(body['token']['roles'])
1517            except Exception:
1518                roles = []
1519
1520            try:
1521                expires = parse_date(body['token']['expires_at'])
1522                token = headers['x-subject-token']
1523
1524                # Cache the fewest fields required for token reuse to minimize
1525                # cache size. Other fields, especially the service catalog, can
1526                # be quite large. Fetch these from Keystone when the token is
1527                # first loaded from cache.
1528                if cache_it:
1529                    self._cache_auth_context(
1530                        OpenStackAuthenticationContext(
1531                            token, expiration=expires))
1532
1533                self.auth_token = token
1534                self.auth_token_expires = expires
1535                # Note: catalog is not returned for unscoped tokens
1536                self.urls = body['token'].get('catalog', None)
1537                self.auth_user_info = body['token'].get('user', None)
1538                self.auth_user_roles = roles
1539            except KeyError as e:
1540                raise MalformedResponseError('Auth JSON response is \
1541                                             missing required elements', e)
1542        elif raise_ambiguous_version_error and response.status == 300:
1543            # ambiguous version request
1544            raise LibcloudError(
1545                'Auth request returned ambiguous version error, try'
1546                'using the version specific URL to connect,'
1547                ' e.g. identity/v3/auth/tokens')
1548        else:
1549            body = 'code: %s body:%s' % (response.status, response.body)
1550            raise MalformedResponseError('Malformed response', body=body,
1551                                         driver=self.driver)
1552
1553    def _fetch_auth_token(self):
1554        """
1555        Fetch our authentication token and service catalog.
1556        """
1557        headers = {'X-Subject-Token': self.auth_token}
1558        response = self.authenticated_request('/v3/auth/tokens',
1559                                              headers=headers)
1560        self._parse_token_response(response)
1561        return self
1562
1563    def _to_domains(self, data):
1564        result = []
1565        for item in data:
1566            domain = self._to_domain(data=item)
1567            result.append(domain)
1568
1569        return result
1570
1571    def _to_domain(self, data):
1572        domain = OpenStackIdentityDomain(id=data['id'],
1573                                         name=data['name'],
1574                                         enabled=data['enabled'])
1575        return domain
1576
1577    def _to_users(self, data):
1578        result = []
1579        for item in data:
1580            user = self._to_user(data=item)
1581            result.append(user)
1582
1583        return result
1584
1585    def _to_user(self, data):
1586        user = OpenStackIdentityUser(id=data['id'],
1587                                     domain_id=data['domain_id'],
1588                                     name=data['name'],
1589                                     email=data.get('email'),
1590                                     description=data.get('description',
1591                                                          None),
1592                                     enabled=data.get('enabled'))
1593        return user
1594
1595    def _to_roles(self, data):
1596        result = []
1597        for item in data:
1598            user = self._to_role(data=item)
1599            result.append(user)
1600
1601        return result
1602
1603    def _to_role(self, data):
1604        role = OpenStackIdentityRole(id=data['id'],
1605                                     name=data['name'],
1606                                     description=data.get('description',
1607                                                          None),
1608                                     enabled=data.get('enabled', True))
1609        return role
1610
1611
1612class OpenStackIdentity_3_0_Connection_AppCred(
1613        OpenStackIdentity_3_0_Connection):
1614    """
1615    Connection class for Keystone API v3.x using Application Credentials.
1616
1617    'user_id' is the application credential id and 'key' is the application
1618    credential secret.
1619    """
1620    name = 'OpenStack Identity API v3.x with Application Credentials'
1621
1622    def __init__(self, auth_url, user_id, key, tenant_name=None,
1623                 domain_name=None, tenant_domain_id=None, token_scope=None,
1624                 timeout=None, proxy_url=None, parent_conn=None):
1625        """
1626        Tenant, domain and scope options are ignored as they are contained
1627        within the app credential itself and can't be changed.
1628        """
1629        super(OpenStackIdentity_3_0_Connection_AppCred,
1630              self).__init__(auth_url=auth_url,
1631                             user_id=user_id,
1632                             key=key,
1633                             tenant_name=tenant_name,
1634                             domain_name=domain_name,
1635                             token_scope=OpenStackIdentityTokenScope.UNSCOPED,
1636                             timeout=timeout,
1637                             proxy_url=proxy_url,
1638                             parent_conn=parent_conn)
1639
1640    def _get_auth_data(self):
1641        data = {
1642            'auth': {
1643                'identity': {
1644                    'methods': ['application_credential'],
1645                    'application_credential': {
1646                        'id': self.user_id,
1647                        'secret': self.key
1648                    }
1649                }
1650            }
1651        }
1652        return data
1653
1654
1655class OpenStackIdentity_3_0_Connection_OIDC_access_token(
1656        OpenStackIdentity_3_0_Connection):
1657    """
1658    Connection class for Keystone API v3.x. using OpenID Connect tokens
1659
1660    The OIDC token must be set in the self.key attribute.
1661
1662    The identity provider name required to get the full path
1663    must be set in the self.user_id attribute.
1664
1665    The protocol name required to get the full path
1666    must be set in the self.tenant_name attribute.
1667
1668    The self.domain_name attribute can be used either to select the
1669    domain name in case of domain scoped token or to select the project
1670    name in case of project scoped token
1671    """
1672
1673    responseCls = OpenStackAuthResponse
1674    name = 'OpenStack Identity API v3.x with OIDC support'
1675    auth_version = '3.0'
1676
1677    def authenticate(self, force=False):
1678        """
1679        Perform authentication.
1680        """
1681        if not self._is_authentication_needed(force=force):
1682            return self
1683
1684        subject_token = self._get_unscoped_token_from_oidc_token()
1685
1686        data = {
1687            'auth': {
1688                'identity': {
1689                    'methods': ['token'],
1690                    'token': {
1691                        'id': subject_token
1692                    }
1693                }
1694            }
1695        }
1696
1697        if self.token_scope == OpenStackIdentityTokenScope.PROJECT:
1698            # Scope token to project (tenant)
1699            project_id = self._get_project_id(token=subject_token)
1700            data['auth']['scope'] = {
1701                'project': {
1702                    'id': project_id
1703                }
1704            }
1705        elif self.token_scope == OpenStackIdentityTokenScope.DOMAIN:
1706            # Scope token to domain
1707            data['auth']['scope'] = {
1708                'domain': {
1709                    'name': self.domain_name
1710                }
1711            }
1712        elif self.token_scope == OpenStackIdentityTokenScope.UNSCOPED:
1713            pass
1714        else:
1715            raise ValueError('Token needs to be scoped either to project or '
1716                             'a domain')
1717
1718        data = json.dumps(data)
1719        response = self.request('/v3/auth/tokens', data=data,
1720                                headers={'Content-Type': 'application/json'},
1721                                method='POST')
1722        self._parse_token_response(response, cache_it=True,
1723                                   raise_ambiguous_version_error=False)
1724        return self
1725
1726    def _get_unscoped_token_from_oidc_token(self):
1727        """
1728        Get unscoped token from OIDC access token
1729        """
1730        path = ('/v3/OS-FEDERATION/identity_providers/%s/protocols/%s/auth' %
1731                (self.user_id, self.tenant_name))
1732        response = self.request(path,
1733                                headers={'Content-Type': 'application/json',
1734                                         'Authorization': 'Bearer %s' %
1735                                         self.key},
1736                                method='GET')
1737
1738        if response.status == httplib.UNAUTHORIZED:
1739            # Invalid credentials
1740            raise InvalidCredsError()
1741        elif response.status in [httplib.OK, httplib.CREATED]:
1742            if 'x-subject-token' in response.headers:
1743                return response.headers['x-subject-token']
1744            else:
1745                raise MalformedResponseError('No x-subject-token returned',
1746                                             driver=self.driver)
1747        else:
1748            raise MalformedResponseError('Malformed response',
1749                                         driver=self.driver,
1750                                         body=response.body)
1751
1752    def _get_project_id(self, token):
1753        """
1754        Get the first project ID accessible with the specified access token
1755        """
1756        # Try new path first (from ver 1.1)
1757        path = '/v3/auth/projects'
1758        response = self.request(path,
1759                                headers={'Content-Type': 'application/json',
1760                                         AUTH_TOKEN_HEADER: token},
1761                                method='GET')
1762
1763        if response.status not in [httplib.UNAUTHORIZED, httplib.OK,
1764                                   httplib.CREATED]:
1765            # In case of error try old one
1766            path = '/v3/OS-FEDERATION/projects'
1767            response = self.request(path,
1768                                    headers={'Content-Type':
1769                                             'application/json',
1770                                             AUTH_TOKEN_HEADER: token},
1771                                    method='GET')
1772
1773        if response.status == httplib.UNAUTHORIZED:
1774            # Invalid credentials
1775            raise InvalidCredsError()
1776        elif response.status in [httplib.OK, httplib.CREATED]:
1777            try:
1778                body = json.loads(response.body)
1779                # We use domain_name in both cases of the scoped tokens
1780                # as we have used tenant as the protocol
1781                if self.domain_name and self.domain_name != 'Default':
1782                    for project in body['projects']:
1783                        if self.domain_name in [project['name'],
1784                                                project['id']]:
1785                            return project['id']
1786                    raise ValueError('Project %s not found' %
1787                                     (self.domain_name))
1788                else:
1789                    return body['projects'][0]['id']
1790            except ValueError as e:
1791                raise e
1792            except Exception as e:
1793                raise MalformedResponseError('Failed to parse JSON', e)
1794        else:
1795            raise MalformedResponseError('Malformed response',
1796                                         driver=self.driver,
1797                                         body=response.body)
1798
1799
1800class OpenStackIdentity_2_0_Connection_VOMS(OpenStackIdentityConnection,
1801                                            CertificateConnection):
1802    """
1803    Connection class for Keystone API v2.0. with VOMS proxy support
1804    In this case the key parameter will be the path of the VOMS proxy file.
1805    """
1806
1807    responseCls = OpenStackAuthResponse
1808    name = 'OpenStack Identity API v2.0 VOMS support'
1809    auth_version = '2.0'
1810
1811    def __init__(self, auth_url, user_id, key, tenant_name=None,
1812                 domain_name='Default',
1813                 token_scope=OpenStackIdentityTokenScope.PROJECT,
1814                 timeout=None, proxy_url=None, parent_conn=None,
1815                 auth_cache=None):
1816        CertificateConnection.__init__(self, cert_file=key,
1817                                       url=auth_url,
1818                                       proxy_url=proxy_url,
1819                                       timeout=timeout)
1820
1821        self.parent_conn = parent_conn
1822
1823        # enable tests to use the same mock connection classes.
1824        if parent_conn:
1825            self.conn_class = parent_conn.conn_class
1826            self.driver = parent_conn.driver
1827        else:
1828            self.driver = None
1829
1830        self.auth_url = auth_url
1831        self.tenant_name = tenant_name
1832        self.domain_name = domain_name
1833        self.token_scope = token_scope
1834        self.timeout = timeout
1835        self.proxy_url = proxy_url
1836        self.auth_cache = auth_cache
1837
1838        self.urls = {}
1839        self.auth_token = None
1840        self.auth_token_expires = None
1841        self.auth_user_info = None
1842
1843    def authenticate(self, force=False):
1844        if not self._is_authentication_needed(force=force):
1845            return self
1846
1847        tenant = self.tenant_name
1848        if not tenant:
1849            # if the tenant name is not specified look for it
1850            token = self._get_unscoped_token()
1851            tenant = self._get_tenant_name(token)
1852
1853        data = {'auth': {'voms': True, 'tenantName': tenant}}
1854
1855        reqbody = json.dumps(data)
1856        return self._authenticate_2_0_with_body(reqbody)
1857
1858    def _get_unscoped_token(self):
1859        """
1860        Get unscoped token from VOMS proxy
1861        """
1862        data = {'auth': {'voms': True}}
1863        reqbody = json.dumps(data)
1864
1865        response = self.request('/v2.0/tokens', data=reqbody,
1866                                headers={'Content-Type': 'application/json'},
1867                                method='POST')
1868
1869        if response.status == httplib.UNAUTHORIZED:
1870            # Invalid credentials
1871            raise InvalidCredsError()
1872        elif response.status in [httplib.OK, httplib.CREATED]:
1873            try:
1874                body = json.loads(response.body)
1875                return body['access']['token']['id']
1876            except Exception as e:
1877                raise MalformedResponseError('Failed to parse JSON', e)
1878        else:
1879            raise MalformedResponseError('Malformed response',
1880                                         driver=self.driver,
1881                                         body=response.body)
1882
1883    def _get_tenant_name(self, token):
1884        """
1885        Get the first available tenant name (usually there are only one)
1886        """
1887        headers = {'Accept': 'application/json',
1888                   'Content-Type': 'application/json',
1889                   AUTH_TOKEN_HEADER: token}
1890        response = self.request('/v2.0/tenants', headers=headers, method='GET')
1891
1892        if response.status == httplib.UNAUTHORIZED:
1893            # Invalid credentials
1894            raise InvalidCredsError()
1895        elif response.status in [httplib.OK, httplib.CREATED]:
1896            try:
1897                body = json.loads(response.body)
1898                return body["tenants"][0]["name"]
1899            except Exception as e:
1900                raise MalformedResponseError('Failed to parse JSON', e)
1901        else:
1902            raise MalformedResponseError('Malformed response',
1903                                         driver=self.driver,
1904                                         body=response.body)
1905
1906    def _authenticate_2_0_with_body(self, reqbody):
1907        resp = self.request('/v2.0/tokens', data=reqbody,
1908                            headers={'Content-Type': 'application/json'},
1909                            method='POST')
1910
1911        if resp.status == httplib.UNAUTHORIZED:
1912            raise InvalidCredsError()
1913        elif resp.status not in [httplib.OK,
1914                                 httplib.NON_AUTHORITATIVE_INFORMATION]:
1915            body = 'code: %s body: %s' % (resp.status, resp.body)
1916            raise MalformedResponseError('Malformed response', body=body,
1917                                         driver=self.driver)
1918        else:
1919            body = resp.object
1920
1921            try:
1922                access = body['access']
1923                expires = access['token']['expires']
1924                self._cache_auth_context(
1925                    OpenStackAuthenticationContext(
1926                        access['token']['id'],
1927                        expiration=parse_date(expires),
1928                        urls=access['serviceCatalog'],
1929                        user=access.get('user', {})))
1930            except KeyError as e:
1931                raise MalformedResponseError('Auth JSON response is \
1932                                             missing required elements', e)
1933        return self
1934
1935
1936def get_class_for_auth_version(auth_version):
1937    """
1938    Retrieve class for the provided auth version.
1939    """
1940    if auth_version == '1.0':
1941        cls = OpenStackIdentity_1_0_Connection
1942    elif auth_version == '1.1':
1943        cls = OpenStackIdentity_1_1_Connection
1944    elif auth_version == '2.0' or auth_version == '2.0_apikey':
1945        cls = OpenStackIdentity_2_0_Connection
1946    elif auth_version == '2.0_password':
1947        cls = OpenStackIdentity_2_0_Connection
1948    elif auth_version == '2.0_voms':
1949        cls = OpenStackIdentity_2_0_Connection_VOMS
1950    elif auth_version == '3.x_password':
1951        cls = OpenStackIdentity_3_0_Connection
1952    elif auth_version == '3.x_appcred':
1953        cls = OpenStackIdentity_3_0_Connection_AppCred
1954    elif auth_version == '3.x_oidc_access_token':
1955        cls = OpenStackIdentity_3_0_Connection_OIDC_access_token
1956    else:
1957        raise LibcloudError('Unsupported Auth Version requested: %s' %
1958                            (auth_version))
1959
1960    return cls
1961