# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
Common / shared code for handling authentication against OpenStack identity
service (Keystone).
"""

import datetime

from libcloud.utils.py3 import httplib
from libcloud.utils.iso8601 import parse_date

from libcloud.common.base import (ConnectionUserAndKey, Response,
                                  CertificateConnection)
from libcloud.compute.types import (LibcloudError, InvalidCredsError,
                                    MalformedResponseError)

try:
    import simplejson as json
except ImportError:
    import json  # type: ignore

AUTH_API_VERSION = '1.1'

# Auth versions which contain token expiration information.
AUTH_VERSIONS_WITH_EXPIRES = [
    '1.1',
    '2.0',
    '2.0_apikey',
    '2.0_password',
    '2.0_voms',
    '3.0',
    '3.x_password',
    '3.x_oidc_access_token'
]

# How many seconds to subtract from the auth token expiration time before
# testing if the token is still valid.
# The time is subtracted to account for the HTTP request latency and prevent
# user from getting "InvalidCredsError" if token is about to expire.
AUTH_TOKEN_EXPIRES_GRACE_SECONDS = 5


__all__ = [
    'OpenStackIdentityVersion',
    'OpenStackIdentityDomain',
    'OpenStackIdentityProject',
    'OpenStackIdentityUser',
    'OpenStackIdentityRole',

    'OpenStackServiceCatalog',
    'OpenStackServiceCatalogEntry',
    'OpenStackServiceCatalogEntryEndpoint',
    'OpenStackIdentityEndpointType',

    'OpenStackIdentityConnection',
    'OpenStackIdentity_1_0_Connection',
    'OpenStackIdentity_1_1_Connection',
    'OpenStackIdentity_2_0_Connection',
    'OpenStackIdentity_2_0_Connection_VOMS',
    'OpenStackIdentity_3_0_Connection',
    'OpenStackIdentity_3_0_Connection_OIDC_access_token',

    'get_class_for_auth_version'
]


class OpenStackIdentityEndpointType(object):
    """
    Enum class for openstack identity endpoint type.
    """
    INTERNAL = 'internal'
    EXTERNAL = 'external'
    ADMIN = 'admin'


class OpenStackIdentityTokenScope(object):
    """
    Enum class for openstack identity token scope.
    """
    PROJECT = 'project'
    DOMAIN = 'domain'
    UNSCOPED = 'unscoped'


class OpenStackIdentityVersion(object):
    def __init__(self, version, status, updated, url):
        self.version = version
        self.status = status
        self.updated = updated
        self.url = url

    def __repr__(self):
        return (('<OpenStackIdentityVersion version=%s, status=%s, '
                 'updated=%s, url=%s>' %
                 (self.version, self.status, self.updated, self.url)))


class OpenStackIdentityDomain(object):
    def __init__(self, id, name, enabled):
        self.id = id
        self.name = name
        self.enabled = enabled

    def __repr__(self):
        return (('<OpenStackIdentityDomain id=%s, name=%s, enabled=%s>' %
                 (self.id, self.name, self.enabled)))


class OpenStackIdentityProject(object):
    def __init__(self, id, name, description, enabled, domain_id=None):
        self.id = id
        self.name = name
        self.description = description
        self.enabled = enabled
        self.domain_id = domain_id

    def __repr__(self):
        return (('<OpenStackIdentityProject id=%s, domain_id=%s, name=%s, '
                 'enabled=%s>' %
                 (self.id, self.domain_id, self.name, self.enabled)))


class OpenStackIdentityRole(object):
    def __init__(self, id, name, description, enabled):
        self.id = id
        self.name = name
        self.description = description
        self.enabled = enabled

    def __repr__(self):
        return (('<OpenStackIdentityRole id=%s, name=%s, description=%s, '
                 'enabled=%s>' % (self.id, self.name, self.description,
                                  self.enabled)))


class OpenStackIdentityUser(object):
    def __init__(self, id, domain_id, name, email, description, enabled):
        self.id = id
        self.domain_id = domain_id
        self.name = name
        self.email = email
        self.description = description
        self.enabled = enabled

    def __repr__(self):
        return (('<OpenStackIdentityUser id=%s, domain_id=%s, name=%s, '
                 'email=%s, enabled=%s>' % (self.id, self.domain_id, self.name,
                                            self.email, self.enabled)))


class OpenStackServiceCatalog(object):
    """
    http://docs.openstack.org/api/openstack-identity-service/2.0/content/

    This class should be instantiated with the contents of the
    'serviceCatalog' in the auth response. This will do the work of figuring
    out which services actually exist in the catalog as well as split them up
    by type, name, and region if available
    """

    _auth_version = None
    _service_catalog = None

    def __init__(self, service_catalog, auth_version=AUTH_API_VERSION):
        self._auth_version = auth_version

        # Check this way because there are a couple of different 2.0_*
        # auth types.
        if '3.x' in self._auth_version:
            entries = self._parse_service_catalog_auth_v3(
                service_catalog=service_catalog)
        elif '2.0' in self._auth_version:
            entries = self._parse_service_catalog_auth_v2(
                service_catalog=service_catalog)
        elif ('1.1' in self._auth_version) or ('1.0' in self._auth_version):
            entries = self._parse_service_catalog_auth_v1(
                service_catalog=service_catalog)
        else:
            raise LibcloudError('auth version "%s" not supported'
                                % (self._auth_version))

        # Force consistent ordering by sorting the entries
        entries = sorted(entries,
                         key=lambda x: x.service_type + (x.service_name or ''))
        self._entries = entries  # stories all the service catalog entries

    def get_entries(self):
        """
        Return all the entries for this service catalog.

        :rtype: ``list`` of :class:`.OpenStackServiceCatalogEntry`
        """
        return self._entries

    def get_catalog(self):
        """
        Deprecated in the favor of ``get_entries`` method.
        """
        return self.get_entries()

    def get_public_urls(self, service_type=None, name=None):
        """
        Retrieve all the available public (external) URLs for the provided
        service type and name.
        """
        endpoints = self.get_endpoints(service_type=service_type,
                                       name=name)

        result = []
        for endpoint in endpoints:
            endpoint_type = endpoint.endpoint_type
            if endpoint_type == OpenStackIdentityEndpointType.EXTERNAL:
                result.append(endpoint.url)

        return result

    def get_endpoints(self, service_type=None, name=None):
        """
        Retrieve all the endpoints for the provided service type and name.

        :rtype: ``list`` of :class:`.OpenStackServiceCatalogEntryEndpoint`
        """
        endpoints = []

        for entry in self._entries:
            # Note: "if XXX and YYY != XXX" comparison is used to support
            # partial lookups.
            # This allows user to pass in only one argument to the method (only
            # service_type or name), both of them or neither.
            if service_type and entry.service_type != service_type:
                continue

            if name and entry.service_name != name:
                continue

            for endpoint in entry.endpoints:
                endpoints.append(endpoint)

        return endpoints

    def get_endpoint(self, service_type=None, name=None, region=None,
                     endpoint_type=OpenStackIdentityEndpointType.EXTERNAL):
        """
        Retrieve a single endpoint using the provided criteria.

        Note: If no or more than one matching endpoint is found, an exception
        is thrown.
        """
        endpoints = []

        for entry in self._entries:
            if service_type and entry.service_type != service_type:
                continue

            if name and entry.service_name != name:
                continue

            for endpoint in entry.endpoints:
                if region and endpoint.region != region:
                    continue

                if endpoint_type and endpoint.endpoint_type != endpoint_type:
                    continue

                endpoints.append(endpoint)

        if len(endpoints) == 1:
            return endpoints[0]
        elif len(endpoints) > 1:
            raise ValueError('Found more than 1 matching endpoint')
        else:
            raise LibcloudError('Could not find specified endpoint')

    def get_regions(self, service_type=None):
        """
        Retrieve a list of all the available regions.

        :param service_type: If specified, only return regions for this
                             service type.
        :type service_type: ``str``

        :rtype: ``list`` of ``str``
        """
        regions = set()

        for entry in self._entries:
            if service_type and entry.service_type != service_type:
                continue

            for endpoint in entry.endpoints:
                if endpoint.region:
                    regions.add(endpoint.region)

        return sorted(list(regions))

    def get_service_types(self, region=None):
        """
        Retrieve all the available service types.

        :param region: Optional region to retrieve service types for.
        :type region: ``str``

        :rtype: ``list`` of ``str``
        """
        service_types = set()

        for entry in self._entries:
            include = True

            for endpoint in entry.endpoints:
                if region and endpoint.region != region:
                    include = False
                    break

            if include:
                service_types.add(entry.service_type)

        return sorted(list(service_types))

    def get_service_names(self, service_type=None, region=None):
        """
        Retrieve list of service names that match service type and region.

        :type service_type: ``str``
        :type region: ``str``

        :rtype: ``list`` of ``str``
        """
        names = set()

        if '2.0' not in self._auth_version:
            raise ValueError('Unsupported version: %s' % (self._auth_version))

        for entry in self._entries:
            if service_type and entry.service_type != service_type:
                continue

            include = True
            for endpoint in entry.endpoints:
                if region and endpoint.region != region:
                    include = False
                    break

            if include and entry.service_name:
                names.add(entry.service_name)

        return sorted(list(names))

    def _parse_service_catalog_auth_v1(self, service_catalog):
        entries = []

        for service, endpoints in service_catalog.items():
            entry_endpoints = []
            for endpoint in endpoints:
                region = endpoint.get('region', None)

                public_url = endpoint.get('publicURL', None)
                private_url = endpoint.get('internalURL', None)

                if public_url:
                    entry_endpoint = OpenStackServiceCatalogEntryEndpoint(
                        region=region, url=public_url,
                        endpoint_type=OpenStackIdentityEndpointType.EXTERNAL)
                    entry_endpoints.append(entry_endpoint)

                if private_url:
                    entry_endpoint = OpenStackServiceCatalogEntryEndpoint(
                        region=region, url=private_url,
                        endpoint_type=OpenStackIdentityEndpointType.INTERNAL)
                    entry_endpoints.append(entry_endpoint)

            entry = OpenStackServiceCatalogEntry(service_type=service,
                                                 endpoints=entry_endpoints)
            entries.append(entry)

        return entries

    def _parse_service_catalog_auth_v2(self, service_catalog):
        entries = []

        for service in service_catalog:
            service_type = service['type']
            service_name = service.get('name', None)

            entry_endpoints = []
            for endpoint in service.get('endpoints', []):
                region = endpoint.get('region', None)

                public_url = endpoint.get('publicURL', None)
                private_url = endpoint.get('internalURL', None)

                if public_url:
                    entry_endpoint = OpenStackServiceCatalogEntryEndpoint(
                        region=region, url=public_url,
                        endpoint_type=OpenStackIdentityEndpointType.EXTERNAL)
                    entry_endpoints.append(entry_endpoint)

                if private_url:
                    entry_endpoint = OpenStackServiceCatalogEntryEndpoint(
                        region=region, url=private_url,
                        endpoint_type=OpenStackIdentityEndpointType.INTERNAL)
                    entry_endpoints.append(entry_endpoint)

            entry = OpenStackServiceCatalogEntry(service_type=service_type,
                                                 endpoints=entry_endpoints,
                                                 service_name=service_name)
            entries.append(entry)

        return entries

    def _parse_service_catalog_auth_v3(self, service_catalog):
        entries = []

        for item in service_catalog:
            service_type = item['type']
            service_name = item.get('name', None)

            entry_endpoints = []
            for endpoint in item['endpoints']:
                region = endpoint.get('region', None)
                url = endpoint['url']
                endpoint_type = endpoint['interface']

                if endpoint_type == 'internal':
                    endpoint_type = OpenStackIdentityEndpointType.INTERNAL
                elif endpoint_type == 'public':
                    endpoint_type = OpenStackIdentityEndpointType.EXTERNAL
                elif endpoint_type == 'admin':
                    endpoint_type = OpenStackIdentityEndpointType.ADMIN

                entry_endpoint = OpenStackServiceCatalogEntryEndpoint(
                    region=region, url=url, endpoint_type=endpoint_type)
                entry_endpoints.append(entry_endpoint)

            entry = OpenStackServiceCatalogEntry(service_type=service_type,
                                                 service_name=service_name,
                                                 endpoints=entry_endpoints)
            entries.append(entry)

        return entries


class OpenStackServiceCatalogEntry(object):
    def __init__(self, service_type, endpoints=None, service_name=None):
        """
        :param service_type: Service type.
        :type service_type: ``str``

        :param endpoints: Endpoints belonging to this entry.
        :type endpoints: ``list``

        :param service_name: Optional service name.
        :type service_name: ``str``
        """
        self.service_type = service_type
        self.endpoints = endpoints or []
        self.service_name = service_name

        # For consistency, sort the endpoints
        self.endpoints = sorted(self.endpoints, key=lambda x: x.url or '')

    def __eq__(self, other):
        return (self.service_type == other.service_type and
                self.endpoints == other.endpoints and
                other.service_name == self.service_name)

    def __ne__(self, other):
        return not self.__eq__(other=other)

    def __repr__(self):
        return (('<OpenStackServiceCatalogEntry service_type=%s, '
                 'service_name=%s, endpoints=%s' %
                 (self.service_type, self.service_name, repr(self.endpoints))))


class OpenStackServiceCatalogEntryEndpoint(object):
    VALID_ENDPOINT_TYPES = [
        OpenStackIdentityEndpointType.INTERNAL,
        OpenStackIdentityEndpointType.EXTERNAL,
        OpenStackIdentityEndpointType.ADMIN,
    ]

    def __init__(self, region, url, endpoint_type='external'):
        """
        :param region: Endpoint region.
        :type region: ``str``

        :param url: Endpoint URL.
        :type url: ``str``

        :param endpoint_type: Endpoint type (external / internal / admin).
        :type endpoint_type: ``str``
        """
        if endpoint_type not in self.VALID_ENDPOINT_TYPES:
            raise ValueError('Invalid type: %s' % (endpoint_type))

        # TODO: Normalize / lowercase all the region names
        self.region = region
        self.url = url
        self.endpoint_type = endpoint_type

    def __eq__(self, other):
        return (self.region == other.region and self.url == other.url and
                self.endpoint_type == other.endpoint_type)

    def __ne__(self, other):
        return not self.__eq__(other=other)

    def __repr__(self):
        return (('<OpenStackServiceCatalogEntryEndpoint region=%s, url=%s, '
                 'type=%s' % (self.region, self.url, self.endpoint_type)))


class OpenStackAuthResponse(Response):
    def success(self):
        return self.status in [httplib.OK, httplib.CREATED,
                               httplib.ACCEPTED, httplib.NO_CONTENT,
                               httplib.MULTIPLE_CHOICES,
                               httplib.UNAUTHORIZED,
                               httplib.INTERNAL_SERVER_ERROR]

    def parse_body(self):
        if not self.body:
            return None

        if 'content-type' in self.headers:
            key = 'content-type'
        elif 'Content-Type' in self.headers:
            key = 'Content-Type'
        else:
            raise LibcloudError('Missing content-type header',
                                driver=OpenStackIdentityConnection)

        content_type = self.headers[key]
        if content_type.find(';') != -1:
            content_type = content_type.split(';')[0]

        if content_type == 'application/json':
            try:
                data = json.loads(self.body)
            except Exception:
                driver = OpenStackIdentityConnection
                raise MalformedResponseError('Failed to parse JSON',
                                             body=self.body,
                                             driver=driver)
        elif content_type == 'text/plain':
            data = self.body
        else:
            data = self.body

        return data


class OpenStackIdentityConnection(ConnectionUserAndKey):
    """
    Base identity connection class which contains common / shared logic.

    Note: This class shouldn't be instantiated directly.
    """
    responseCls = OpenStackAuthResponse
    timeout = None
    auth_version = None  # type: str

    def __init__(self, auth_url, user_id, key, tenant_name=None,
                 tenant_domain_id='default', domain_name='Default',
                 token_scope=OpenStackIdentityTokenScope.PROJECT,
                 timeout=None, proxy_url=None, parent_conn=None):
        super(OpenStackIdentityConnection, self).__init__(user_id=user_id,
                                                          key=key,
                                                          url=auth_url,
                                                          timeout=timeout,
                                                          proxy_url=proxy_url)

        self.parent_conn = parent_conn

        # enable tests to use the same mock connection classes.
        if parent_conn:
            self.conn_class = parent_conn.conn_class
            self.driver = parent_conn.driver
        else:
            self.driver = None

        self.auth_url = auth_url
        self.tenant_name = tenant_name
        self.domain_name = domain_name
        self.token_scope = token_scope
        self.timeout = timeout

        self.urls = {}
        self.auth_token = None
        self.auth_token_expires = None
        self.auth_user_info = None

    def authenticated_request(self, action, params=None, data=None,
                              headers=None, method='GET', raw=False):
        """
        Perform an authenticated request against the identity API.
        """
        if not self.auth_token:
            raise ValueError('Not to be authenticated to perform this request')

        headers = headers or {}
        headers['X-Auth-Token'] = self.auth_token

        return self.request(action=action, params=params, data=data,
                            headers=headers, method=method, raw=raw)

    def morph_action_hook(self, action):
        (_, _, _, request_path) = self._tuple_from_url(self.auth_url)

        if request_path == '':
            # No path is provided in the auth_url, use action passed to this
            # method.
            return action

        return request_path

    def add_default_headers(self, headers):
        headers['Accept'] = 'application/json'
        headers['Content-Type'] = 'application/json; charset=UTF-8'
        return headers

    def is_token_valid(self):
        """
        Return True if the current auth token is already cached and hasn't
        expired yet.

        :return: ``True`` if the token is still valid, ``False`` otherwise.
        :rtype: ``bool``
        """
        if not self.auth_token:
            return False

        if not self.auth_token_expires:
            return False

        expires = self.auth_token_expires - \
            datetime.timedelta(seconds=AUTH_TOKEN_EXPIRES_GRACE_SECONDS)

        time_tuple_expires = expires.utctimetuple()
        time_tuple_now = datetime.datetime.utcnow().utctimetuple()

        if time_tuple_now < time_tuple_expires:
            return True

        return False

    def authenticate(self, force=False):
        """
        Authenticate against the identity API.

        :param force: Forcefully update the token even if it's already cached
                      and still valid.
        :type force: ``bool``
        """
        raise NotImplementedError('authenticate not implemented')

    def list_supported_versions(self):
        """
        Retrieve a list of all the identity versions which are supported by
        this installation.

        :rtype: ``list`` of :class:`.OpenStackIdentityVersion`
        """
        response = self.request('/', method='GET')
        result = self._to_versions(data=response.object['versions']['values'])
        result = sorted(result, key=lambda x: x.version)
        return result

    def _to_versions(self, data):
        result = []
        for item in data:
            version = self._to_version(data=item)
            result.append(version)

        return result

    def _to_version(self, data):
        try:
            updated = parse_date(data['updated'])
        except Exception:
            updated = None

        try:
            url = data['links'][0]['href']
        except IndexError:
            url = None

        version = OpenStackIdentityVersion(version=data['id'],
                                           status=data['status'],
                                           updated=updated,
                                           url=url)
        return version

    def _is_authentication_needed(self, force=False):
        """
        Determine if the authentication is needed or if the existing token (if
        any exists) is still valid.
        """
        if force:
            return True

        if self.auth_version not in AUTH_VERSIONS_WITH_EXPIRES:
            return True

        if self.is_token_valid():
            return False

        return True

    def _to_projects(self, data):
        result = []
        for item in data:
            project = self._to_project(data=item)
            result.append(project)

        return result

    def _to_project(self, data):
        project = OpenStackIdentityProject(id=data['id'],
                                           name=data['name'],
                                           description=data['description'],
                                           enabled=data['enabled'],
                                           domain_id=data.get('domain_id',
                                                              None))
        return project


class OpenStackIdentity_1_0_Connection(OpenStackIdentityConnection):
    """
    Connection class for Keystone API v1.0.
    """

    responseCls = OpenStackAuthResponse
    name = 'OpenStack Identity API v1.0'
    auth_version = '1.0'

    def authenticate(self, force=False):
        if not self._is_authentication_needed(force=force):
            return self

        headers = {
            'X-Auth-User': self.user_id,
            'X-Auth-Key': self.key,
        }

        resp = self.request('/v1.0', headers=headers, method='GET')

        if resp.status == httplib.UNAUTHORIZED:
            # HTTP UNAUTHORIZED (401): auth failed
            raise InvalidCredsError()
        elif resp.status not in [httplib.NO_CONTENT, httplib.OK]:
            body = 'code: %s body:%s headers:%s' % (resp.status,
                                                    resp.body,
                                                    resp.headers)
            raise MalformedResponseError('Malformed response', body=body,
                                         driver=self.driver)
        else:
            headers = resp.headers
            # emulate the auth 1.1 URL list
            self.urls = {}
            self.urls['cloudServers'] = \
                [{'publicURL': headers.get('x-server-management-url', None)}]
            self.urls['cloudFilesCDN'] = \
                [{'publicURL': headers.get('x-cdn-management-url', None)}]
            self.urls['cloudFiles'] = \
                [{'publicURL': headers.get('x-storage-url', None)}]
            self.auth_token = headers.get('x-auth-token', None)
            self.auth_user_info = None

            if not self.auth_token:
                raise MalformedResponseError('Missing X-Auth-Token in'
                                             ' response headers')

        return self


class OpenStackIdentity_1_1_Connection(OpenStackIdentityConnection):
    """
    Connection class for Keystone API v1.1.
    """

    responseCls = OpenStackAuthResponse
    name = 'OpenStack Identity API v1.1'
    auth_version = '1.1'

    def authenticate(self, force=False):
        if not self._is_authentication_needed(force=force):
            return self

        reqbody = json.dumps({'credentials': {'username': self.user_id,
                                              'key': self.key}})
        resp = self.request('/v1.1/auth', data=reqbody, headers={},
                            method='POST')

        if resp.status == httplib.UNAUTHORIZED:
            # HTTP UNAUTHORIZED (401): auth failed
            raise InvalidCredsError()
        elif resp.status != httplib.OK:
            body = 'code: %s body:%s' % (resp.status, resp.body)
            raise MalformedResponseError('Malformed response', body=body,
                                         driver=self.driver)
        else:
            try:
                body = json.loads(resp.body)
            except Exception as e:
                raise MalformedResponseError('Failed to parse JSON', e)

            try:
                expires = body['auth']['token']['expires']

                self.auth_token = body['auth']['token']['id']
                self.auth_token_expires = parse_date(expires)
                self.urls = body['auth']['serviceCatalog']
                self.auth_user_info = None
            except KeyError as e:
                raise MalformedResponseError('Auth JSON response is \
                                             missing required elements', e)

        return self


class OpenStackIdentity_2_0_Connection(OpenStackIdentityConnection):
    """
    Connection class for Keystone API v2.0.
    """

    responseCls = OpenStackAuthResponse
    name = 'OpenStack Identity API v1.0'
    auth_version = '2.0'

    def authenticate(self, auth_type='api_key', force=False):
        if not self._is_authentication_needed(force=force):
            return self

        if auth_type == 'api_key':
            return self._authenticate_2_0_with_api_key()
        elif auth_type == 'password':
            return self._authenticate_2_0_with_password()
        else:
            raise ValueError('Invalid value for auth_type argument')

    def _authenticate_2_0_with_api_key(self):
        # API Key based authentication uses the RAX-KSKEY extension.
        # http://s.apache.org/oAi
        data = {'auth':
                {'RAX-KSKEY:apiKeyCredentials':
                 {'username': self.user_id, 'apiKey': self.key}}}
        if self.tenant_name:
            data['auth']['tenantName'] = self.tenant_name
        reqbody = json.dumps(data)
        return self._authenticate_2_0_with_body(reqbody)

    def _authenticate_2_0_with_password(self):
        # Password based authentication is the only 'core' authentication
        # method in Keystone at this time.
        # 'keystone' - http://s.apache.org/e8h
        data = {'auth':
                {'passwordCredentials':
                 {'username': self.user_id, 'password': self.key}}}
        if self.tenant_name:
            data['auth']['tenantName'] = self.tenant_name
        reqbody = json.dumps(data)
        return self._authenticate_2_0_with_body(reqbody)

    def _authenticate_2_0_with_body(self, reqbody):
        resp = self.request('/v2.0/tokens', data=reqbody,
                            headers={'Content-Type': 'application/json'},
                            method='POST')

        if resp.status == httplib.UNAUTHORIZED:
            raise InvalidCredsError()
        elif resp.status not in [httplib.OK,
                                 httplib.NON_AUTHORITATIVE_INFORMATION]:
            body = 'code: %s body: %s' % (resp.status, resp.body)
            raise MalformedResponseError('Malformed response', body=body,
                                         driver=self.driver)
        else:
            body = resp.object

            try:
                access = body['access']
                expires = access['token']['expires']

                self.auth_token = access['token']['id']
                self.auth_token_expires = parse_date(expires)
                self.urls = access['serviceCatalog']
                self.auth_user_info = access.get('user', {})
            except KeyError as e:
                raise MalformedResponseError('Auth JSON response is \
                                             missing required elements', e)

        return self

    def list_projects(self):
        response = self.authenticated_request('/v2.0/tenants', method='GET')
        result = self._to_projects(data=response.object['tenants'])
        return result

    def list_tenants(self):
        return self.list_projects()


class OpenStackIdentity_3_0_Connection(OpenStackIdentityConnection):
    """
    Connection class for Keystone API v3.x.
    """

    responseCls = OpenStackAuthResponse
    name = 'OpenStack Identity API v3.x'
    auth_version = '3.0'

    VALID_TOKEN_SCOPES = [
        OpenStackIdentityTokenScope.PROJECT,
        OpenStackIdentityTokenScope.DOMAIN,
        OpenStackIdentityTokenScope.UNSCOPED
    ]

    def __init__(self, auth_url, user_id, key, tenant_name=None,
                 domain_name='Default', tenant_domain_id='default',
                 token_scope=OpenStackIdentityTokenScope.PROJECT,
                 timeout=None, proxy_url=None, parent_conn=None):
        """
        :param tenant_name: Name of the project this user belongs to. Note:
                            When token_scope is set to project, this argument
                            control to which project to scope the token to.
        :type tenant_name: ``str``

        :param domain_name: Domain the user belongs to. Note: Then token_scope
                            is set to token, this argument controls to which
                            domain to scope the token to.
        :type domain_name: ``str``

        :param token_scope: Whether to scope a token to a "project", a
                            "domain" or "unscoped"
        :type token_scope: ``str``
        """
        super(OpenStackIdentity_3_0_Connection,
              self).__init__(auth_url=auth_url,
                             user_id=user_id,
                             key=key,
                             tenant_name=tenant_name,
                             domain_name=domain_name,
                             token_scope=token_scope,
                             timeout=timeout,
                             proxy_url=proxy_url,
                             parent_conn=parent_conn)

        if self.token_scope not in self.VALID_TOKEN_SCOPES:
            raise ValueError('Invalid value for "token_scope" argument: %s' %
                             (self.token_scope))

        if (self.token_scope == OpenStackIdentityTokenScope.PROJECT and
                (not self.tenant_name or not self.domain_name)):
            raise ValueError('Must provide tenant_name and domain_name '
                             'argument')
        elif (self.token_scope == OpenStackIdentityTokenScope.DOMAIN and
                not self.domain_name):
            raise ValueError('Must provide domain_name argument')

        self.auth_user_roles = None
        self.tenant_domain_id = tenant_domain_id

    def authenticate(self, force=False):
        """
        Perform authentication.
        """
        if not self._is_authentication_needed(force=force):
            return self

        data = {
            'auth': {
                'identity': {
                    'methods': ['password'],
                    'password': {
                        'user': {
                            'domain': {
                                'name': self.domain_name
                            },
                            'name': self.user_id,
                            'password': self.key
                        }
                    }
                }
            }
        }

        if self.token_scope == OpenStackIdentityTokenScope.PROJECT:
            # Scope token to project (tenant)
            data['auth']['scope'] = {
                'project': {
                    'domain': {
                        'id': self.tenant_domain_id
                    },
                    'name': self.tenant_name
                }
            }
        elif self.token_scope == OpenStackIdentityTokenScope.DOMAIN:
            # Scope token to domain
            data['auth']['scope'] = {
                'domain': {
                    'name': self.domain_name
                }
            }
        elif self.token_scope == OpenStackIdentityTokenScope.UNSCOPED:
            pass
        else:
            raise ValueError('Token needs to be scoped either to project or '
                             'a domain')

        data = json.dumps(data)
        response = self.request('/v3/auth/tokens', data=data,
                                headers={'Content-Type': 'application/json'},
                                method='POST')

        if response.status == httplib.UNAUTHORIZED:
            # Invalid credentials
            raise InvalidCredsError()
        elif response.status in [httplib.OK, httplib.CREATED]:
            headers = response.headers

            try:
                body = json.loads(response.body)
            except Exception as e:
                raise MalformedResponseError('Failed to parse JSON', e)

            try:
                roles = self._to_roles(body['token']['roles'])
            except Exception as e:
                roles = []

            try:
                expires = body['token']['expires_at']

                self.auth_token = headers['x-subject-token']
                self.auth_token_expires = parse_date(expires)
                # Note: catalog is not returned for unscoped tokens
                self.urls = body['token'].get('catalog', None)
                self.auth_user_info = None
                self.auth_user_roles = roles
            except KeyError as e:
                raise MalformedResponseError('Auth JSON response is \
                                             missing required elements', e)
            body = 'code: %s body:%s' % (response.status, response.body)
        elif response.status == 300:
            # ambiguous version request
            raise LibcloudError(
                'Auth request returned ambiguous version error, try'
                'using the version specific URL to connect,'
                ' e.g. identity/v3/auth/tokens')
        else:
            body = 'code: %s body:%s' % (response.status, response.body)
            raise MalformedResponseError('Malformed response', body=body,
                                         driver=self.driver)

        return self

    def list_domains(self):
        """
        List the available domains.

        :rtype: ``list`` of :class:`OpenStackIdentityDomain`
        """
        response = self.authenticated_request('/v3/domains', method='GET')
        result = self._to_domains(data=response.object['domains'])
        return result

    def list_projects(self):
        """
        List the available projects.

        Note: To perform this action, user you are currently authenticated with
        needs to be an admin.

        :rtype: ``list`` of :class:`OpenStackIdentityProject`
        """
        response = self.authenticated_request('/v3/projects', method='GET')
        result = self._to_projects(data=response.object['projects'])
        return result

    def list_users(self):
        """
        List the available users.

        :rtype: ``list`` of :class:`.OpenStackIdentityUser`
        """
        response = self.authenticated_request('/v3/users', method='GET')
        result = self._to_users(data=response.object['users'])
        return result

    def list_roles(self):
        """
        List the available roles.

        :rtype: ``list`` of :class:`.OpenStackIdentityRole`
        """
        response = self.authenticated_request('/v3/roles', method='GET')
        result = self._to_roles(data=response.object['roles'])
        return result

    def get_domain(self, domain_id):
        """
        Retrieve information about a single domain.

        :param domain_id: ID of domain to retrieve information for.
        :type domain_id: ``str``

        :rtype: :class:`.OpenStackIdentityDomain`
        """
        response = self.authenticated_request('/v3/domains/%s' % (domain_id),
                                              method='GET')
        result = self._to_domain(data=response.object['domain'])
        return result

    def get_user(self, user_id):
        """
        Get a user account by ID.

        :param user_id: User's id.
        :type name: ``str``

        :return: Located user.
        :rtype: :class:`.OpenStackIdentityUser`
        """
        response = self.authenticated_request('/v3/users/%s' % user_id)
        user = self._to_user(data=response.object['user'])
        return user

    def list_user_projects(self, user):
        """
        Retrieve all the projects user belongs to.

        :rtype: ``list`` of :class:`.OpenStackIdentityProject`
        """
        path = '/v3/users/%s/projects' % (user.id)
        response = self.authenticated_request(path, method='GET')
        result = self._to_projects(data=response.object['projects'])
        return result

    def list_user_domain_roles(self, domain, user):
        """
        Retrieve all the roles for a particular user on a domain.

        :rtype: ``list`` of :class:`.OpenStackIdentityRole`
        """
        # TODO: Also add "get users roles" and "get assginements" which are
        # available in 3.1 and 3.3
        path = '/v3/domains/%s/users/%s/roles' % (domain.id, user.id)
        response = self.authenticated_request(path, method='GET')
        result = self._to_roles(data=response.object['roles'])
        return result

    def grant_domain_role_to_user(self, domain, role, user):
        """
        Grant domain role to a user.

        Note: This function appears to be idempotent.

        :param domain: Domain to grant the role to.
        :type domain: :class:`.OpenStackIdentityDomain`

        :param role: Role to grant.
        :type role: :class:`.OpenStackIdentityRole`

        :param user: User to grant the role to.
        :type user: :class:`.OpenStackIdentityUser`

        :return: ``True`` on success.
        :rtype: ``bool``
        """
        path = ('/v3/domains/%s/users/%s/roles/%s' %
                (domain.id, user.id, role.id))
        response = self.authenticated_request(path, method='PUT')
        return response.status == httplib.NO_CONTENT

    def revoke_domain_role_from_user(self, domain, user, role):
        """
        Revoke domain role from a user.

        :param domain: Domain to revoke the role from.
        :type domain: :class:`.OpenStackIdentityDomain`

        :param role: Role to revoke.
        :type role: :class:`.OpenStackIdentityRole`

        :param user: User to revoke the role from.
        :type user: :class:`.OpenStackIdentityUser`

        :return: ``True`` on success.
        :rtype: ``bool``
        """
        path = ('/v3/domains/%s/users/%s/roles/%s' %
                (domain.id, user.id, role.id))
        response = self.authenticated_request(path, method='DELETE')
        return response.status == httplib.NO_CONTENT

    def grant_project_role_to_user(self, project, role, user):
        """
        Grant project role to a user.

        Note: This function appears to be idempotent.

        :param project: Project to grant the role to.
        :type project: :class:`.OpenStackIdentityDomain`

        :param role: Role to grant.
        :type role: :class:`.OpenStackIdentityRole`

        :param user: User to grant the role to.
        :type user: :class:`.OpenStackIdentityUser`

        :return: ``True`` on success.
        :rtype: ``bool``
        """
        path = ('/v3/projects/%s/users/%s/roles/%s' %
                (project.id, user.id, role.id))
        response = self.authenticated_request(path, method='PUT')
        return response.status == httplib.NO_CONTENT

    def revoke_project_role_from_user(self, project, role, user):
        """
        Revoke project role from a user.

        :param project: Project to revoke the role from.
        :type project: :class:`.OpenStackIdentityDomain`

        :param role: Role to revoke.
        :type role: :class:`.OpenStackIdentityRole`

        :param user: User to revoke the role from.
        :type user: :class:`.OpenStackIdentityUser`

        :return: ``True`` on success.
        :rtype: ``bool``
        """
        path = ('/v3/projects/%s/users/%s/roles/%s' %
                (project.id, user.id, role.id))
        response = self.authenticated_request(path, method='DELETE')
        return response.status == httplib.NO_CONTENT

    def create_user(self, email, password, name, description=None,
                    domain_id=None, default_project_id=None, enabled=True):
        """
        Create a new user account.

        :param email: User's mail address.
        :type email: ``str``

        :param password: User's password.
        :type password: ``str``

        :param name: User's name.
        :type name: ``str``

        :param description: Optional description.
        :type description: ``str``

        :param domain_id: ID of the domain to add the user to (optional).
        :type domain_id: ``str``

        :param default_project_id: ID of the default user project (optional).
        :type default_project_id: ``str``

        :param enabled: True to enable user after creation.
        :type enabled: ``bool``

        :return: Created user.
        :rtype: :class:`.OpenStackIdentityUser`
        """
        data = {
            'email': email,
            'password': password,
            'name': name,
            'enabled': enabled
        }

        if description:
            data['description'] = description

        if domain_id:
            data['domain_id'] = domain_id

        if default_project_id:
            data['default_project_id'] = default_project_id

        data = json.dumps({'user': data})
        response = self.authenticated_request('/v3/users', data=data,
                                              method='POST')

        user = self._to_user(data=response.object['user'])
        return user

    def enable_user(self, user):
        """
        Enable user account.

        Note: This operation appears to be idempotent.

        :param user: User to enable.
        :type user: :class:`.OpenStackIdentityUser`

        :return: User account which has been enabled.
        :rtype: :class:`.OpenStackIdentityUser`
        """
        data = {
            'enabled': True
        }
        data = json.dumps({'user': data})
        response = self.authenticated_request('/v3/users/%s' % (user.id),
                                              data=data,
                                              method='PATCH')

        user = self._to_user(data=response.object['user'])
        return user

    def disable_user(self, user):
        """
        Disable user account.

        Note: This operation appears to be idempotent.

        :param user: User to disable.
        :type user: :class:`.OpenStackIdentityUser`

        :return: User account which has been disabled.
        :rtype: :class:`.OpenStackIdentityUser`
        """
        data = {
            'enabled': False
        }
        data = json.dumps({'user': data})
        response = self.authenticated_request('/v3/users/%s' % (user.id),
                                              data=data,
                                              method='PATCH')

        user = self._to_user(data=response.object['user'])
        return user

    def _to_domains(self, data):
        result = []
        for item in data:
            domain = self._to_domain(data=item)
            result.append(domain)

        return result

    def _to_domain(self, data):
        domain = OpenStackIdentityDomain(id=data['id'],
                                         name=data['name'],
                                         enabled=data['enabled'])
        return domain

    def _to_users(self, data):
        result = []
        for item in data:
            user = self._to_user(data=item)
            result.append(user)

        return result

    def _to_user(self, data):
        user = OpenStackIdentityUser(id=data['id'],
                                     domain_id=data['domain_id'],
                                     name=data['name'],
                                     email=data.get('email'),
                                     description=data.get('description',
                                                          None),
                                     enabled=data.get('enabled'))
        return user

    def _to_roles(self, data):
        result = []
        for item in data:
            user = self._to_role(data=item)
            result.append(user)

        return result

    def _to_role(self, data):
        role = OpenStackIdentityRole(id=data['id'],
                                     name=data['name'],
                                     description=data.get('description',
                                                          None),
                                     enabled=data.get('enabled', True))
        return role


class OpenStackIdentity_3_0_Connection_OIDC_access_token(
        OpenStackIdentity_3_0_Connection):
    """
    Connection class for Keystone API v3.x. using OpenID Connect tokens

    The OIDC token must be set in the self.key attribute.

    The identity provider name required to get the full path
    must be set in the self.user_id attribute.

    The protocol name required to get the full path
    must be set in the self.tenant_name attribute.

    The self.domain_name attribute can be used either to select the
    domain name in case of domain scoped token or to select the project
    name in case of project scoped token
    """

    responseCls = OpenStackAuthResponse
    name = 'OpenStack Identity API v3.x with OIDC support'
    auth_version = '3.0'

    def authenticate(self, force=False):
        """
        Perform authentication.
        """
        if not self._is_authentication_needed(force=force):
            return self

        subject_token = self._get_unscoped_token_from_oidc_token()

        data = {
            'auth': {
                'identity': {
                    'methods': ['token'],
                    'token': {
                        'id': subject_token
                    }
                }
            }
        }

        if self.token_scope == OpenStackIdentityTokenScope.PROJECT:
            # Scope token to project (tenant)
            project_id = self._get_project_id(token=subject_token)
            data['auth']['scope'] = {
                'project': {
                    'id': project_id
                }
            }
        elif self.token_scope == OpenStackIdentityTokenScope.DOMAIN:
            # Scope token to domain
            data['auth']['scope'] = {
                'domain': {
                    'name': self.domain_name
                }
            }
        elif self.token_scope == OpenStackIdentityTokenScope.UNSCOPED:
            pass
        else:
            raise ValueError('Token needs to be scoped either to project or '
                             'a domain')

        data = json.dumps(data)
        response = self.request('/v3/auth/tokens', data=data,
                                headers={'Content-Type': 'application/json'},
                                method='POST')

        if response.status == httplib.UNAUTHORIZED:
            # Invalid credentials
            raise InvalidCredsError()
        elif response.status in [httplib.OK, httplib.CREATED]:
            headers = response.headers

            try:
                body = json.loads(response.body)
            except Exception as e:
                raise MalformedResponseError('Failed to parse JSON', e)

            try:
                roles = self._to_roles(body['token']['roles'])
            except Exception as e:
                roles = []

            try:
                expires = body['token']['expires_at']

                self.auth_token = headers['x-subject-token']
                self.auth_token_expires = parse_date(expires)
                # Note: catalog is not returned for unscoped tokens
                self.urls = body['token'].get('catalog', None)
                self.auth_user_info = None
                self.auth_user_roles = roles
            except KeyError as e:
                raise MalformedResponseError('Auth JSON response is \
                                             missing required elements', e)
            body = 'code: %s body:%s' % (response.status, response.body)
        else:
            body = 'code: %s body:%s' % (response.status, response.body)
            raise MalformedResponseError('Malformed response', body=body,
                                         driver=self.driver)

        return self

    def _get_unscoped_token_from_oidc_token(self):
        """
        Get unscoped token from OIDC access token
        """
        path = ('/v3/OS-FEDERATION/identity_providers/%s/protocols/%s/auth' %
                (self.user_id, self.tenant_name))
        response = self.request(path,
                                headers={'Content-Type': 'application/json',
                                         'Authorization': 'Bearer %s' %
                                         self.key},
                                method='GET')

        if response.status == httplib.UNAUTHORIZED:
            # Invalid credentials
            raise InvalidCredsError()
        elif response.status in [httplib.OK, httplib.CREATED]:
            if 'x-subject-token' in response.headers:
                return response.headers['x-subject-token']
            else:
                raise MalformedResponseError('No x-subject-token returned',
                                             driver=self.driver)
        else:
            raise MalformedResponseError('Malformed response',
                                         driver=self.driver,
                                         body=response.body)

    def _get_project_id(self, token):
        """
        Get the first project ID accessible with the specified access token
        """
        # Try new path first (from ver 1.1)
        path = '/v3/auth/projects'
        response = self.request(path,
                                headers={'Content-Type': 'application/json',
                                         'X-Auth-Token': token},
                                method='GET')

        if response.status not in [httplib.UNAUTHORIZED, httplib.OK,
                                   httplib.CREATED]:
            # In case of error try old one
            path = '/v3/OS-FEDERATION/projects'
            response = self.request(path,
                                    headers={'Content-Type':
                                             'application/json',
                                             'X-Auth-Token': token},
                                    method='GET')

        if response.status == httplib.UNAUTHORIZED:
            # Invalid credentials
            raise InvalidCredsError()
        elif response.status in [httplib.OK, httplib.CREATED]:
            try:
                body = json.loads(response.body)
                # We use domain_name in both cases of the scoped tokens
                # as we have used tenant as the protocol
                if self.domain_name and self.domain_name != 'Default':
                    for project in body['projects']:
                        if project['name'] == self.domain_name:
                            return project['id']
                    raise ValueError('Project %s not found' % self.domain_name)
                else:
                    return body['projects'][0]['id']
            except Exception as e:
                raise MalformedResponseError('Failed to parse JSON', e)
        else:
            raise MalformedResponseError('Malformed response',
                                         driver=self.driver,
                                         body=response.body)


class OpenStackIdentity_2_0_Connection_VOMS(OpenStackIdentityConnection,
                                            CertificateConnection):
    """
    Connection class for Keystone API v2.0. with VOMS proxy support
    In this case the key parameter will be the path of the VOMS proxy file.
    """

    responseCls = OpenStackAuthResponse
    name = 'OpenStack Identity API v2.0 VOMS support'
    auth_version = '2.0'

    def __init__(self, auth_url, user_id, key, tenant_name=None,
                 domain_name='Default',
                 token_scope=OpenStackIdentityTokenScope.PROJECT,
                 timeout=None, proxy_url=None, parent_conn=None):
        CertificateConnection.__init__(self, cert_file=key,
                                       url=auth_url,
                                       proxy_url=proxy_url,
                                       timeout=timeout)

        self.parent_conn = parent_conn

        # enable tests to use the same mock connection classes.
        if parent_conn:
            self.conn_class = parent_conn.conn_class
            self.driver = parent_conn.driver
        else:
            self.driver = None

        self.auth_url = auth_url
        self.tenant_name = tenant_name
        self.domain_name = domain_name
        self.token_scope = token_scope
        self.timeout = timeout
        self.proxy_url = proxy_url

        self.urls = {}
        self.auth_token = None
        self.auth_token_expires = None
        self.auth_user_info = None

    def authenticate(self, force=False):
        if not self._is_authentication_needed(force=force):
            return self

        tenant = self.tenant_name
        if not tenant:
            # if the tenant name is not specified look for it
            token = self._get_unscoped_token()
            tenant = self._get_tenant_name(token)

        data = {'auth': {'voms': True, 'tenantName': tenant}}

        reqbody = json.dumps(data)
        return self._authenticate_2_0_with_body(reqbody)

    def _get_unscoped_token(self):
        """
        Get unscoped token from VOMS proxy
        """
        data = {'auth': {'voms': True}}
        reqbody = json.dumps(data)

        response = self.request('/v2.0/tokens', data=reqbody,
                                headers={'Content-Type': 'application/json'},
                                method='POST')

        if response.status == httplib.UNAUTHORIZED:
            # Invalid credentials
            raise InvalidCredsError()
        elif response.status in [httplib.OK, httplib.CREATED]:
            try:
                body = json.loads(response.body)
                return body['access']['token']['id']
            except Exception as e:
                raise MalformedResponseError('Failed to parse JSON', e)
        else:
            raise MalformedResponseError('Malformed response',
                                         driver=self.driver,
                                         body=response.body)

    def _get_tenant_name(self, token):
        """
        Get the first available tenant name (usually there are only one)
        """
        headers = {'Accept': 'application/json',
                   'Content-Type': 'application/json',
                   'X-Auth-Token': token}
        response = self.request('/v2.0/tenants', headers=headers, method='GET')

        if response.status == httplib.UNAUTHORIZED:
            # Invalid credentials
            raise InvalidCredsError()
        elif response.status in [httplib.OK, httplib.CREATED]:
            try:
                body = json.loads(response.body)
                return body["tenants"][0]["name"]
            except Exception as e:
                raise MalformedResponseError('Failed to parse JSON', e)
        else:
            raise MalformedResponseError('Malformed response',
                                         driver=self.driver,
                                         body=response.body)

    def _authenticate_2_0_with_body(self, reqbody):
        resp = self.request('/v2.0/tokens', data=reqbody,
                            headers={'Content-Type': 'application/json'},
                            method='POST')

        if resp.status == httplib.UNAUTHORIZED:
            raise InvalidCredsError()
        elif resp.status not in [httplib.OK,
                                 httplib.NON_AUTHORITATIVE_INFORMATION]:
            body = 'code: %s body: %s' % (resp.status, resp.body)
            raise MalformedResponseError('Malformed response', body=body,
                                         driver=self.driver)
        else:
            body = resp.object

            try:
                access = body['access']
                expires = access['token']['expires']

                self.auth_token = access['token']['id']
                self.auth_token_expires = parse_date(expires)
                self.urls = access['serviceCatalog']
                self.auth_user_info = access.get('user', {})
            except KeyError as e:
                raise MalformedResponseError('Auth JSON response is \
                                             missing required elements', e)

        return self


def get_class_for_auth_version(auth_version):
    """
    Retrieve class for the provided auth version.
    """
    if auth_version == '1.0':
        cls = OpenStackIdentity_1_0_Connection
    elif auth_version == '1.1':
        cls = OpenStackIdentity_1_1_Connection
    elif auth_version == '2.0' or auth_version == '2.0_apikey':
        cls = OpenStackIdentity_2_0_Connection
    elif auth_version == '2.0_password':
        cls = OpenStackIdentity_2_0_Connection
    elif auth_version == '2.0_voms':
        cls = OpenStackIdentity_2_0_Connection_VOMS
    elif auth_version == '3.x_password':
        cls = OpenStackIdentity_3_0_Connection
    elif auth_version == '3.x_oidc_access_token':
        cls = OpenStackIdentity_3_0_Connection_OIDC_access_token
    else:
        raise LibcloudError('Unsupported Auth Version requested')

    return cls
