Merge "Federated authentication via ECP functional tests"
diff --git a/keystone_tempest_plugin/clients.py b/keystone_tempest_plugin/clients.py
index a72c9f5..9865b91 100644
--- a/keystone_tempest_plugin/clients.py
+++ b/keystone_tempest_plugin/clients.py
@@ -18,6 +18,8 @@
     mapping_rules_client)
 from keystone_tempest_plugin.services.identity.v3 import (
     service_providers_client)
+from keystone_tempest_plugin.services.identity.v3 import auth_client
+from keystone_tempest_plugin.services.identity.v3 import saml2_client
 
 from tempest import clients
 
@@ -27,12 +29,14 @@
     def __init__(self, credentials, service=None):
         super(Manager, self).__init__(credentials, service)
 
+        self.auth_client = auth_client.AuthClient(self.auth_provider)
         self.identity_providers_client = (
             identity_providers_client.IdentityProvidersClient(
                 self.auth_provider))
         self.mapping_rules_client = (
             mapping_rules_client.MappingRulesClient(
                 self.auth_provider))
+        self.saml2_client = saml2_client.Saml2Client()
         self.service_providers_client = (
             service_providers_client.ServiceProvidersClient(
                 self.auth_provider))
diff --git a/keystone_tempest_plugin/config.py b/keystone_tempest_plugin/config.py
index 79cbad3..2f3e7e2 100644
--- a/keystone_tempest_plugin/config.py
+++ b/keystone_tempest_plugin/config.py
@@ -24,4 +24,47 @@
 identity_feature_group = cfg.OptGroup(name='identity-feature-enabled',
                                       title='Enabled Identity Features')
 
-IdentityFeatureGroup = []
+IdentityFeatureGroup = [
+    cfg.BoolOpt('federation',
+                default=False,
+                help='Does the environment support the Federated Identity '
+                     'feature?'),
+]
+
+fed_scenario_group = cfg.OptGroup(name='fed_scenario',
+                                  title='Federation Scenario Tests Options')
+
+FedScenarioGroup = [
+    # Identity Provider
+    cfg.StrOpt('idp_id',
+               help='The Identity Provider ID'),
+    cfg.ListOpt('idp_remote_ids',
+                default=[],
+                help='The Identity Provider remote IDs list'),
+    cfg.StrOpt('idp_username',
+               help='Username used to login in the Identity Provider'),
+    cfg.StrOpt('idp_password',
+               help='Password used to login in the Identity Provider'),
+    cfg.StrOpt('idp_ecp_url',
+               help='Identity Provider SAML2/ECP URL'),
+
+    # Mapping rules
+    cfg.StrOpt('mapping_remote_type',
+               help='The assertion attribute to be used in the remote rules'),
+    cfg.StrOpt('mapping_user_name',
+               default='{0}',
+               help='The username to be used in the local rules.'),
+    cfg.StrOpt('mapping_group_name',
+               default='federated_users',
+               help='The group name to be used in the local rules. The group '
+                    'must have at least one assignment in one project.'),
+    cfg.StrOpt('mapping_group_domain_name',
+               default='federated_domain',
+               help='The domain name where the "mapping_group_name" is '
+                    'created.'),
+
+    # Protocol
+    cfg.StrOpt('protocol_id',
+               default='mapped',
+               help='The Protocol ID')
+]
diff --git a/keystone_tempest_plugin/plugin.py b/keystone_tempest_plugin/plugin.py
index 9fe67e8..9311409 100644
--- a/keystone_tempest_plugin/plugin.py
+++ b/keystone_tempest_plugin/plugin.py
@@ -33,7 +33,15 @@
     def register_opts(self, conf):
         config.register_opt_group(conf, project_config.identity_group,
                                   project_config.IdentityGroup)
+        config.register_opt_group(conf, project_config.identity_feature_group,
+                                  project_config.IdentityFeatureGroup)
+        config.register_opt_group(conf, project_config.fed_scenario_group,
+                                  project_config.FedScenarioGroup)
 
     def get_opt_lists(self):
         return [(project_config.identity_group.name,
-                 project_config.IdentityGroup)]
+                 project_config.IdentityGroup),
+                (project_config.identity_feature_group.name,
+                 project_config.IdentityFeatureGroup),
+                (project_config.fed_scenario_group.name,
+                 project_config.FedScenarioGroup)]
diff --git a/keystone_tempest_plugin/services/identity/v3/auth_client.py b/keystone_tempest_plugin/services/identity/v3/auth_client.py
new file mode 100644
index 0000000..72dc35e
--- /dev/null
+++ b/keystone_tempest_plugin/services/identity/v3/auth_client.py
@@ -0,0 +1,39 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+import json
+
+from tempest.lib.common import rest_client
+
+from keystone_tempest_plugin.services.identity import clients
+
+
+class AuthClient(clients.Identity):
+
+    def _get_scopes(self, url, token_id):
+        resp, body = self.raw_request(
+            url, 'GET', headers={'X-Auth-Token': token_id})
+        self.expected_success(200, resp.status)
+        body = json.loads(body)
+        return rest_client.ResponseBody(resp, body)
+
+    def get_available_projects_scopes(self, keystone_v3_endpoint, token_id):
+        """Get projects that are available to be scoped to based on a token."""
+        url = '%s/auth/projects' % keystone_v3_endpoint
+        return self._get_scopes(url, token_id)
+
+    def get_available_domains_scopes(self, keystone_v3_endpoint, token_id):
+        """Get domains that are available to be scoped to based on a token."""
+        url = '%s/auth/domains' % keystone_v3_endpoint
+        return self._get_scopes(url, token_id)
diff --git a/keystone_tempest_plugin/services/identity/v3/saml2_client.py b/keystone_tempest_plugin/services/identity/v3/saml2_client.py
new file mode 100644
index 0000000..b70a389
--- /dev/null
+++ b/keystone_tempest_plugin/services/identity/v3/saml2_client.py
@@ -0,0 +1,92 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from lxml import etree
+import requests
+
+
+class Saml2Client(object):
+
+    ECP_SP_EMPTY_REQUEST_HEADERS = {
+        'Accept': 'text/html, application/vnd.paos+xml',
+        'PAOS': ('ver="urn:liberty:paos:2003-08";"urn:oasis:names:tc:'
+                 'SAML:2.0:profiles:SSO:ecp"')
+    }
+
+    ECP_SP_SAML2_REQUEST_HEADERS = {'Content-Type': 'application/vnd.paos+xml'}
+
+    def __init__(self):
+        self.reset_session()
+
+    def reset_session(self):
+        self.session = requests.Session()
+
+    def _idp_auth_url(self, keystone_v3_endpoint, idp_id, protocol_id):
+        subpath = 'OS-FEDERATION/identity_providers/%s/protocols/%s/auth' % (
+            idp_id, protocol_id)
+        return '%s/%s' % (keystone_v3_endpoint, subpath)
+
+    def send_service_provider_request(self, keystone_v3_endpoint,
+                                      idp_id, protocol_id):
+        return self.session.get(
+            self._idp_auth_url(keystone_v3_endpoint, idp_id, protocol_id),
+            headers=self.ECP_SP_EMPTY_REQUEST_HEADERS
+        )
+
+    def _prepare_sp_saml2_authn_response(self, saml2_idp_authn_response,
+                                         relay_state):
+        # Replace the header contents of the Identity Provider response with
+        # the relay state initially sent by the Service Provider. The response
+        # is a SOAP envelope with the following structure:
+        #
+        #   <S:Envelope
+        #       <S:Header>
+        #           ...
+        #       </S:Header>
+        #       <S:Body>
+        #           ...
+        #       </S:Body>
+        #   </S:Envelope>
+        saml2_idp_authn_response[0][0] = relay_state
+
+    def send_identity_provider_authn_request(self, saml2_authn_request,
+                                             idp_url, username, password):
+
+        saml2_authn_request.remove(saml2_authn_request[0])
+        return self.session.post(
+            idp_url,
+            headers={'Content-Type': 'text/xml'},
+            data=etree.tostring(saml2_authn_request),
+            auth=(username, password)
+        )
+
+    def send_service_provider_saml2_authn_response(
+            self, saml2_idp_authn_response, relay_state, idp_consumer_url):
+
+        self._prepare_sp_saml2_authn_response(
+            saml2_idp_authn_response, relay_state)
+
+        return self.session.post(
+            idp_consumer_url,
+            headers=self.ECP_SP_SAML2_REQUEST_HEADERS,
+            data=etree.tostring(saml2_idp_authn_response),
+            # Do not follow HTTP redirect
+            allow_redirects=False
+        )
+
+    def send_service_provider_unscoped_token_request(self, sp_url):
+        return self.session.get(
+            sp_url,
+            headers=self.ECP_SP_SAML2_REQUEST_HEADERS
+        )
diff --git a/keystone_tempest_plugin/tests/api/identity/v3/test_identity_providers.py b/keystone_tempest_plugin/tests/api/identity/v3/test_identity_providers.py
index 619fc65..97f651f 100644
--- a/keystone_tempest_plugin/tests/api/identity/v3/test_identity_providers.py
+++ b/keystone_tempest_plugin/tests/api/identity/v3/test_identity_providers.py
@@ -16,8 +16,8 @@
 from tempest.lib import decorators
 from tempest.lib import exceptions as lib_exc
 
-from keystone_tempest_plugin.tests.api.identity import base
 from keystone_tempest_plugin.tests.api.identity.v3 import fixtures
+from keystone_tempest_plugin.tests import base
 
 
 class IndentityProvidersTest(base.BaseIdentityTest):
diff --git a/keystone_tempest_plugin/tests/api/identity/v3/test_mapping_rules.py b/keystone_tempest_plugin/tests/api/identity/v3/test_mapping_rules.py
index e7ac47b..1c0743a 100644
--- a/keystone_tempest_plugin/tests/api/identity/v3/test_mapping_rules.py
+++ b/keystone_tempest_plugin/tests/api/identity/v3/test_mapping_rules.py
@@ -17,8 +17,8 @@
 from tempest.lib import exceptions as lib_exc
 from tempest import test
 
-from keystone_tempest_plugin.tests.api.identity import base
 from keystone_tempest_plugin.tests.api.identity.v3 import fixtures
+from keystone_tempest_plugin.tests import base
 
 
 class MappingRulesTest(base.BaseIdentityTest):
diff --git a/keystone_tempest_plugin/tests/api/identity/v3/test_service_providers.py b/keystone_tempest_plugin/tests/api/identity/v3/test_service_providers.py
index 26363d4..d56b35b 100644
--- a/keystone_tempest_plugin/tests/api/identity/v3/test_service_providers.py
+++ b/keystone_tempest_plugin/tests/api/identity/v3/test_service_providers.py
@@ -17,8 +17,8 @@
 from tempest.lib import exceptions as lib_exc
 from tempest import test
 
-from keystone_tempest_plugin.tests.api.identity import base
 from keystone_tempest_plugin.tests.api.identity.v3 import fixtures
+from keystone_tempest_plugin.tests import base
 
 DEFAULT_RELAY_STATE_PREFIX = 'ss:mem:'
 
diff --git a/keystone_tempest_plugin/tests/api/identity/base.py b/keystone_tempest_plugin/tests/base.py
similarity index 89%
rename from keystone_tempest_plugin/tests/api/identity/base.py
rename to keystone_tempest_plugin/tests/base.py
index 8fa3c79..83f5c12 100644
--- a/keystone_tempest_plugin/tests/api/identity/base.py
+++ b/keystone_tempest_plugin/tests/base.py
@@ -33,6 +33,9 @@
         credentials = common_creds.get_configured_admin_credentials(
             cls.credential_type, identity_version=cls.identity_version)
         cls.keystone_manager = clients.Manager(credentials=credentials)
+        cls.auth_client = cls.keystone_manager.auth_client
         cls.idps_client = cls.keystone_manager.identity_providers_client
         cls.mappings_client = cls.keystone_manager.mapping_rules_client
+        cls.saml2_client = cls.keystone_manager.saml2_client
         cls.sps_client = cls.keystone_manager.service_providers_client
+        cls.tokens_client = cls.keystone_manager.token_v3_client
diff --git a/keystone_tempest_plugin/tests/scenario/test_federated_authentication.py b/keystone_tempest_plugin/tests/scenario/test_federated_authentication.py
new file mode 100644
index 0000000..2ac6958
--- /dev/null
+++ b/keystone_tempest_plugin/tests/scenario/test_federated_authentication.py
@@ -0,0 +1,177 @@
+# Copyright 2016 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+from lxml import etree
+from six.moves import http_client
+
+from tempest import config
+from tempest.lib.common.utils import data_utils
+from testtools import skipUnless
+
+from keystone_tempest_plugin.tests import base
+
+
+CONF = config.CONF
+
+
+class TestSaml2EcpFederatedAuthentication(base.BaseIdentityTest):
+
+    ECP_SAML2_NAMESPACES = {
+        'ecp': 'urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp',
+        'S': 'http://schemas.xmlsoap.org/soap/envelope/',
+        'paos': 'urn:liberty:paos:2003-08'
+    }
+
+    ECP_SERVICE_PROVIDER_CONSUMER_URL = ('/S:Envelope/S:Header/paos:Request/'
+                                         '@responseConsumerURL')
+
+    ECP_IDP_CONSUMER_URL = ('/S:Envelope/S:Header/ecp:Response/'
+                            '@AssertionConsumerServiceURL')
+
+    ECP_RELAY_STATE = '//ecp:RelayState'
+
+    def _setup_settings(self):
+        self.idp_id = CONF.fed_scenario.idp_id
+        self.idp_url = CONF.fed_scenario.idp_ecp_url
+        self.keystone_v3_endpoint = CONF.identity.uri_v3
+        self.password = CONF.fed_scenario.idp_password
+        self.protocol_id = CONF.fed_scenario.protocol_id
+        self.username = CONF.fed_scenario.idp_username
+
+    def _setup_idp(self):
+        remote_ids = CONF.fed_scenario.idp_remote_ids
+        self.idps_client.create_identity_provider(
+            self.idp_id, remote_ids=remote_ids, enabled=True)
+        self.addCleanup(
+            self.idps_client.delete_identity_provider, self.idp_id)
+
+    def _setup_mapping(self):
+        self.mapping_id = data_utils.rand_uuid_hex()
+        mapping_remote_type = CONF.fed_scenario.mapping_remote_type
+        mapping_user_name = CONF.fed_scenario.mapping_user_name
+        mapping_group_name = CONF.fed_scenario.mapping_group_name
+        mapping_group_domain_name = CONF.fed_scenario.mapping_group_domain_name
+
+        rules = [{
+            'local': [
+                {
+                    'user': {'name': mapping_user_name}
+                },
+                {
+                    'group': {
+                        'domain': {'name': mapping_group_domain_name},
+                        'name': mapping_group_name
+                    }
+                }
+            ],
+            'remote': [
+                {
+                    'type': mapping_remote_type
+                }
+            ]
+        }]
+        mapping_ref = {'rules': rules}
+        self.mappings_client.create_mapping_rule(self.mapping_id, mapping_ref)
+        self.addCleanup(
+            self.mappings_client.delete_mapping_rule, self.mapping_id)
+
+    def _setup_protocol(self):
+        self.idps_client.add_protocol_and_mapping(
+            self.idp_id, self.protocol_id, self.mapping_id)
+        self.addCleanup(
+            self.idps_client.delete_protocol_and_mapping,
+            self.idp_id,
+            self.protocol_id)
+
+    def setUp(self):
+        super(TestSaml2EcpFederatedAuthentication, self).setUp()
+        self._setup_settings()
+
+        # Reset client's session to avoid getting garbage from another runs
+        self.saml2_client.reset_session()
+
+        # Setup identity provider, mapping and protocol
+        self._setup_idp()
+        self._setup_mapping()
+        self._setup_protocol()
+
+    def _str_from_xml(self, xml, path):
+        l = xml.xpath(path, namespaces=self.ECP_SAML2_NAMESPACES)
+        self.assertEqual(1, len(l))
+        return l[0]
+
+    def _request_unscoped_token(self):
+        resp = self.saml2_client.send_service_provider_request(
+            self.keystone_v3_endpoint, self.idp_id, self.protocol_id)
+        self.assertEqual(http_client.OK, resp.status_code)
+        saml2_authn_request = etree.XML(resp.content)
+
+        relay_state = self._str_from_xml(
+            saml2_authn_request, self.ECP_RELAY_STATE)
+        sp_consumer_url = self._str_from_xml(
+            saml2_authn_request, self.ECP_SERVICE_PROVIDER_CONSUMER_URL)
+
+        # Perform the authn request to the identity provider
+        resp = self.saml2_client.send_identity_provider_authn_request(
+            saml2_authn_request, self.idp_url, self.username, self.password)
+        self.assertEqual(http_client.OK, resp.status_code)
+        saml2_idp_authn_response = etree.XML(resp.content)
+
+        idp_consumer_url = self._str_from_xml(
+            saml2_idp_authn_response, self.ECP_IDP_CONSUMER_URL)
+
+        # Assert that both saml2_authn_request and saml2_idp_authn_response
+        # have the same consumer URL.
+        self.assertEqual(sp_consumer_url, idp_consumer_url)
+
+        # Present the identity provider authn response to the service provider.
+        resp = self.saml2_client.send_service_provider_saml2_authn_response(
+            saml2_idp_authn_response, relay_state, idp_consumer_url)
+        # Must receive a redirect from service provider to the URL where the
+        # unscoped token can be retrieved.
+        self.assertIn(resp.status_code,
+                      [http_client.FOUND, http_client.SEE_OTHER])
+
+        # We can receive multiple types of errors here, the response depends on
+        # the mapping and the username used to authenticate in the Identity
+        # Provider and also in the Identity Provider remote ID validation.
+        # If everything works well, we receive an unscoped token.
+        sp_url = resp.headers['location']
+        resp = (
+            self.saml2_client.send_service_provider_unscoped_token_request(
+                sp_url))
+        self.assertEqual(http_client.CREATED, resp.status_code)
+        self.assertIn('X-Subject-Token', resp.headers)
+        self.assertNotEmpty(resp.json())
+
+        return resp
+
+    @skipUnless(CONF.identity_feature_enabled.federation,
+                "Federated Identity feature not enabled")
+    def test_request_unscoped_token(self):
+        self._request_unscoped_token()
+
+    @skipUnless(CONF.identity_feature_enabled.federation,
+                "Federated Identity feature not enabled")
+    def test_request_scoped_token(self):
+        resp = self._request_unscoped_token()
+        token_id = resp.headers['X-Subject-Token']
+
+        projects = self.auth_client.get_available_projects_scopes(
+            self.keystone_v3_endpoint, token_id)['projects']
+        self.assertNotEmpty(projects)
+
+        # Get a scoped token to one of the listed projects
+        self.tokens_client.auth(
+            project_id=projects[0]['id'], token=token_id)