diff --git a/keystoneclient/contrib/auth/__init__.py b/keystoneclient/contrib/auth/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/keystoneclient/contrib/auth/v3/__init__.py b/keystoneclient/contrib/auth/v3/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/keystoneclient/contrib/auth/v3/saml2.py b/keystoneclient/contrib/auth/v3/saml2.py
new file mode 100644
index 000000000..6434bd435
--- /dev/null
+++ b/keystoneclient/contrib/auth/v3/saml2.py
@@ -0,0 +1,411 @@
+# 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 oslo.config import cfg
+
+from keystoneclient import access
+from keystoneclient.auth.identity import v3
+from keystoneclient import exceptions
+
+
+class Saml2UnscopedTokenAuthMethod(v3.AuthMethod):
+ _method_parameters = []
+
+ def get_auth_data(self, session, auth, headers, **kwargs):
+ raise exceptions.MethodNotImplemented(('This method should never '
+ 'be called'))
+
+
+class Saml2UnscopedToken(v3.AuthConstructor):
+ """Implement authentication plugin for SAML2 protocol.
+
+ ECP stands for ``Enhanced Client or Proxy`` and is a SAML2 extension
+ for federated authentication where a transportation layer consists of
+ HTTP protocol and XML SOAP messages.
+
+ Read for more information::
+ ``https://wiki.shibboleth.net/confluence/display/SHIB2/ECP``
+
+ The SAML2 ECP specification can be found at::
+ ``https://www.oasis-open.org/committees/download.php/
+ 49979/saml-ecp-v2.0-wd09.pdf``
+
+ Currently only HTTPBasicAuth mechanism is available for the IdP
+ authenication.
+
+ """
+
+ _auth_method_class = Saml2UnscopedTokenAuthMethod
+
+ PROTOCOL = 'saml2'
+ HTTP_MOVED_TEMPORARILY = 302
+ SAML2_HEADER_INDEX = 0
+ 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'
+ }
+
+ 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_RELAY_STATE = '//ecp:RelayState'
+
+ ECP_SERVICE_PROVIDER_CONSUMER_URL = ('/S:Envelope/S:Header/paos:Request/'
+ '@responseConsumerURL')
+
+ ECP_IDP_CONSUMER_URL = ('/S:Envelope/S:Header/ecp:Response/'
+ '@AssertionConsumerServiceURL')
+
+ SOAP_FAULT = """
+
+
+
+ S:Server
+ responseConsumerURL from SP and
+ assertionConsumerServiceURL from IdP do not match
+
+
+
+
+ """
+
+ def __init__(self, auth_url,
+ identity_provider,
+ identity_provider_url,
+ username, password,
+ **kwargs):
+ """Class constructor accepting following parameters:
+ :param auth_url: URL of the Identity Service
+ :type auth_url: string
+
+ :param identity_provider: name of the Identity Provider the client
+ will authenticate against. This parameter
+ will be used to build a dynamic URL used to
+ obtain unscoped OpenStack token.
+ :type identity_provider: string
+
+ :param identity_provider_url: An Identity Provider URL, where the SAML2
+ authn request will be sent.
+ :type identity_provider_url: string
+
+ :param username: User's login
+ :type username: string
+
+ :param password: User's password
+ :type password: string
+
+ """
+ super(Saml2UnscopedToken, self).__init__(auth_url=auth_url, **kwargs)
+ self.identity_provider = identity_provider
+ self.identity_provider_url = identity_provider_url
+ self.username, self.password = username, password
+
+ @classmethod
+ def get_options(cls):
+ options = super(Saml2UnscopedToken, cls).get_options()
+ options.extend([
+ cfg.StrOpt('identity-provider', help="Identity Provider's name"),
+ cfg.StrOpt('identity-provider-url',
+ help="Identity Provider's URL"),
+ cfg.StrOpt('user-name', dest='username', help='Username',
+ deprecated_name='username'),
+ cfg.StrOpt('password', help='Password')
+ ])
+ return options
+
+ def _handle_http_302_ecp_redirect(self, session, response, method,
+ **kwargs):
+ if response.status_code != self.HTTP_MOVED_TEMPORARILY:
+ return response
+
+ location = response.headers['location']
+ return session.request(location, method, **kwargs)
+
+ def _first(self, _list):
+ if len(_list) != 1:
+ raise IndexError("Only single element list can be flatten")
+ return _list[0]
+
+ def _prepare_idp_saml2_request(self, saml2_authn_request):
+ header = saml2_authn_request[self.SAML2_HEADER_INDEX]
+ saml2_authn_request.remove(header)
+
+ def _check_consumer_urls(self, session, sp_response_consumer_url,
+ idp_sp_response_consumer_url):
+ """Check if consumer URLs issued by SP and IdP are equal.
+
+ In the initial SAML2 authn Request issued by a Service Provider
+ there is a url called ``consumer url``. A trusted Identity Provider
+ should issue identical url. If the URLs are not equal the federated
+ authn process should be interrupted and the user should be warned.
+
+ :param session: session object to send out HTTP requests.
+ :type session: keystoneclient.session.Session
+ :param sp_response_consumer_url: consumer URL issued by a SP
+ :type sp_response_consumer_url: string
+ :param idp_sp_response_consumer_url: consumer URL issued by an IdP
+ :type idp_sp_response_consumer_url: string
+
+ """
+ if sp_response_consumer_url != idp_sp_response_consumer_url:
+ # send fault message to the SP, discard the response
+ session.post(sp_response_consumer_url, data=self.SOAP_FAULT,
+ headers=self.ECP_SP_SAML2_REQUEST_HEADERS,
+ authenticated=False)
+
+ # prepare error message and raise an exception.
+ msg = ("Consumer URLs from Service Provider %(service_provider)s "
+ "%(sp_consumer_url)s and Identity Provider "
+ "%(identity_provider)s %(idp_consumer_url)s are not equal")
+ msg = msg % {
+ 'service_provider': self.saml2_token_url,
+ 'sp_consumer_url': sp_response_consumer_url,
+ 'identity_provider': self.identity_provider,
+ 'idp_consumer_url': idp_sp_response_consumer_url
+ }
+
+ raise exceptions.ValidationError(msg)
+
+ def _send_service_provider_request(self, session):
+ """Initial HTTP GET request to the SAML2 protected endpoint.
+
+ It's crucial to include HTTP headers indicating that the client is
+ willing to take advantage of the ECP SAML2 extension and receive data
+ as the SOAP.
+ Unlike standard authentication methods in the OpenStack Identity,
+ the client accesses::
+ ``/v3/OS-FEDERATION/identity_providers/{identity_providers}/
+ protocols/{protocol}/auth``
+
+ After a successful HTTP call the HTTP response should include SAML2
+ authn request in the XML format.
+
+ If a HTTP response contains ``X-Subject-Token`` in the headers and
+ the response body is a valid JSON assume the user was already
+ authenticated and Keystone returned a valid unscoped token.
+ Return True indicating the user was already authenticated.
+
+ :param session: a session object to send out HTTP requests.
+ :type session: keystoneclient.session.Session
+
+ """
+ sp_response = session.get(self.saml2_token_url,
+ headers=self.ECP_SP_EMPTY_REQUEST_HEADERS,
+ authenticated=False)
+
+ if 'X-Subject-Token' in sp_response.headers:
+ self.authenticated_response = sp_response
+ return True
+
+ try:
+ self.saml2_authn_request = etree.XML(sp_response.content)
+ except etree.XMLSyntaxError as e:
+ msg = ("SAML2: Error parsing XML returned "
+ "from Service Provider, reason: %s" % e)
+ raise exceptions.AuthorizationFailure(msg)
+
+ relay_state = self.saml2_authn_request.xpath(
+ self.ECP_RELAY_STATE, namespaces=self.ECP_SAML2_NAMESPACES)
+ self.relay_state = self._first(relay_state)
+
+ sp_response_consumer_url = self.saml2_authn_request.xpath(
+ self.ECP_SERVICE_PROVIDER_CONSUMER_URL,
+ namespaces=self.ECP_SAML2_NAMESPACES)
+ self.sp_response_consumer_url = self._first(
+ sp_response_consumer_url)
+ return False
+
+ def _send_idp_saml2_authn_request(self, session):
+ """Present modified SAML2 authn assertion from the Service Provider."""
+
+ self._prepare_idp_saml2_request(self.saml2_authn_request)
+ idp_saml2_authn_request = self.saml2_authn_request
+
+ # Currently HTTPBasicAuth method is hardcoded into the plugin
+ idp_response = session.post(
+ self.identity_provider_url,
+ headers={'Content-type': 'text/xml'},
+ data=etree.tostring(idp_saml2_authn_request),
+ requests_auth=(self.username, self.password))
+
+ try:
+ self.saml2_idp_authn_response = etree.XML(idp_response.content)
+ except etree.XMLSyntaxError as e:
+ msg = ("SAML2: Error parsing XML returned "
+ "from Identity Provider, reason: %s" % e)
+ raise exceptions.AuthorizationFailure(msg)
+
+ idp_response_consumer_url = self.saml2_idp_authn_response.xpath(
+ self.ECP_IDP_CONSUMER_URL,
+ namespaces=self.ECP_SAML2_NAMESPACES)
+
+ self.idp_response_consumer_url = self._first(
+ idp_response_consumer_url)
+
+ self._check_consumer_urls(session, self.idp_response_consumer_url,
+ self.sp_response_consumer_url)
+
+ def _send_service_provider_saml2_authn_response(self, session):
+ """Present SAML2 assertion to the Service Provider.
+
+ The assertion is issued by a trusted Identity Provider for the
+ authenticated user. This function directs the HTTP request to SP
+ managed URL, for instance: ``https://:/Shibboleth.sso/
+ SAML2/ECP``.
+ Upon success the there's a session created and access to the protected
+ resource is granted. Many implementations of the SP return HTTP 302
+ status code pointing to the protected URL (``https://:/v3/
+ OS-FEDERATION/identity_providers/{identity_provider}/protocols/
+ {protocol_id}/auth`` in this case). Saml2 plugin should point to that
+ URL again, with HTTP GET method, expecting an unscoped token.
+
+ :param session: a session object to send out HTTP requests.
+
+ """
+ self.saml2_idp_authn_response[0][0] = self.relay_state
+
+ response = session.post(
+ self.idp_response_consumer_url,
+ headers=self.ECP_SP_SAML2_REQUEST_HEADERS,
+ data=etree.tostring(self.saml2_idp_authn_response),
+ authenticated=False, redirect=False)
+
+ # Don't follow HTTP specs - after the HTTP 302 response don't repeat
+ # the call directed to the Location URL. In this case, this is an
+ # indication that saml2 session is now active and protected resource
+ # can be accessed.
+ response = self._handle_http_302_ecp_redirect(
+ session, response, method='GET',
+ headers=self.ECP_SP_SAML2_REQUEST_HEADERS)
+
+ self.authenticated_response = response
+
+ @property
+ def saml2_token_url(self):
+ """Return full URL where authorization data is sent."""
+ values = {
+ 'host': self.auth_url.rstrip('/'),
+ 'identity_provider': self.identity_provider,
+ 'protocol': self.PROTOCOL
+ }
+ url = ("%(host)s/OS-FEDERATION/identity_providers/"
+ "%(identity_provider)s/protocols/%(protocol)s/auth")
+ url = url % values
+
+ return url
+
+ def _get_unscoped_token(self, session, **kwargs):
+ """Get unscoped OpenStack token after federated authentication.
+
+ This is a multi-step process including multiple HTTP requests.
+
+ The federated authentication consists of::
+ * HTTP GET request to the Identity Service (acting as a Service
+ Provider). Client utilizes URL::
+ ``/v3/OS-FEDERATION/identity_providers/{identity_provider}/
+ protocols/saml2/auth``.
+ It's crucial to include HTTP headers indicating we are expecting
+ SOAP message in return.
+ Service Provider should respond with such SOAP message.
+ This step is handed by a method
+ ``Saml2UnscopedToken_send_service_provider_request()``
+
+ * HTTP POST request to the external Identity Provider service with
+ ECP extension enabled. The content sent is a header removed SOAP
+ message returned from the Service Provider. It's also worth noting
+ that ECP extension to the SAML2 doesn't define authentication method.
+ The most popular is HttpBasicAuth with just user and password.
+ Other possibilities could be X509 certificates or Kerberos.
+ Upon successful authentication the user should receive a SAML2
+ assertion.
+ This step is handed by a method
+ ``Saml2UnscopedToken_send_idp_saml2_authn_request(session)``
+
+ * HTTP POST request again to the Service Provider. The body of the
+ request includes SAML2 assertion issued by a trusted Identity
+ Provider. The request should be sent to the Service Provider
+ consumer url specified in the SAML2 assertion.
+ Providing the authentication was successful and both Service Provider
+ and Identity Providers are trusted to each other, the Service
+ Provider will issue an unscoped token with a list of groups the
+ federated user is a member of.
+ This step is handed by a method
+ ``Saml2UnscopedToken_send_service_provider_saml2_authn_response()``
+
+ Unscoped token example::
+
+ {
+ "token": {
+ "methods": [
+ "saml2"
+ ],
+ "user": {
+ "id": "username%40example.com",
+ "name": "username@example.com",
+ "OS-FEDERATION": {
+ "identity_provider": "ACME",
+ "protocol": "saml2",
+ "groups": [
+ {"id": "abc123"},
+ {"id": "bcd234"}
+ ]
+ }
+ }
+ }
+ }
+
+
+ :param session : a session object to send out HTTP requests.
+ :type session: keystoneclient.session.Session
+
+ :returns: (token, token_json)
+
+ """
+ saml_authenticated = self._send_service_provider_request(session)
+ if not saml_authenticated:
+ self._send_idp_saml2_authn_request(session)
+ self._send_service_provider_saml2_authn_response(session)
+ return (self.authenticated_response.headers['X-Subject-Token'],
+ self.authenticated_response.json()['token'])
+
+ def get_auth_ref(self, session, **kwargs):
+ """Authenticate via SAML2 protocol and retrieve unscoped token.
+
+ This is a multi-step process where a client does federated authn
+ receives an unscoped token.
+
+ Federated authentication utilizing SAML2 Enhanced Client or Proxy
+ extension. See ``Saml2UnscopedToken_get_unscoped_token()``
+ for more information on that step.
+ Upon successful authentication and assertion mapping an
+ unscoped token is returned and stored within the plugin object for
+ further use.
+
+ :param session : a session object to send out HTTP requests.
+ :type session: keystoneclient.session.Session
+
+ :return access.AccessInfoV3: an object with scoped token's id and
+ unscoped token json included.
+
+ """
+ token, token_json = self._get_unscoped_token(session, **kwargs)
+ return access.AccessInfoV3(token,
+ **token_json)
diff --git a/keystoneclient/tests/v3/saml2_fixtures.py b/keystoneclient/tests/v3/saml2_fixtures.py
new file mode 100644
index 000000000..3327120e6
--- /dev/null
+++ b/keystoneclient/tests/v3/saml2_fixtures.py
@@ -0,0 +1,121 @@
+# 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.
+
+SP_SOAP_RESPONSE = b"""
+
+
+
+
+https://openstack4.local/shibboleth
+
+
+
+
+
+ss:mem:6f1f20fafbb38433467e9d477df67615
+
+
+ https://openstack4.local/shibboleth
+
+
+
+ """
+
+
+SAML2_ASSERTION = b"""
+
+
+
+
+ x=
+
+
+
+
+
+https://idp.testshib.org/idp/shibboleth
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+VALUE==
+
+
+VALUE=
+
+
+"""
+
+UNSCOPED_TOKEN_HEADER = 'UNSCOPED_TOKEN'
+
+UNSCOPED_TOKEN = {
+ "token": {
+ "issued_at": "2014-06-09T09:48:59.643406Z",
+ "extras": {},
+ "methods": ["saml2"],
+ "expires_at": "2014-06-09T10:48:59.643375Z",
+ "user": {
+ "OS-FEDERATION": {
+ "identity_provider": {
+ "id": "testshib"
+ },
+ "protocol": {
+ "id": "saml2"
+ },
+ "groups": [
+ {"id": "1764fa5cf69a49a4918131de5ce4af9a"}
+ ]
+ },
+ "id": "testhib%20user",
+ "name": "testhib user"
+ }
+ }
+}
diff --git a/keystoneclient/tests/v3/test_auth_saml2.py b/keystoneclient/tests/v3/test_auth_saml2.py
new file mode 100644
index 000000000..d77fe13f3
--- /dev/null
+++ b/keystoneclient/tests/v3/test_auth_saml2.py
@@ -0,0 +1,311 @@
+# 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 uuid
+
+import httpretty
+from lxml import etree
+import requests
+
+from keystoneclient.auth import conf
+from keystoneclient.contrib.auth.v3 import saml2
+from keystoneclient import exceptions
+from keystoneclient.openstack.common.fixture import config
+from keystoneclient.openstack.common import jsonutils
+from keystoneclient import session
+from keystoneclient.tests.auth import utils as auth_utils
+from keystoneclient.tests.v3 import saml2_fixtures
+from keystoneclient.tests.v3 import utils
+
+
+class AuthenticateviaSAML2Tests(auth_utils.TestCase, utils.TestCase):
+
+ class _AuthenticatedResponse(object):
+ headers = {
+ 'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER
+ }
+
+ def json(self):
+ return saml2_fixtures.UNSCOPED_TOKEN
+
+ class _AuthenticatedResponseInvalidJson(_AuthenticatedResponse):
+
+ def json(self):
+ raise ValueError()
+
+ class _AuthentiatedResponseMissingTokenID(_AuthenticatedResponse):
+ headers = {}
+
+ def setUp(self):
+ utils.TestCase.setUp(self)
+ auth_utils.TestCase.setUp(self)
+
+ self.conf_fixture = auth_utils.TestCase.useFixture(self,
+ config.Config())
+ conf.register_conf_options(self.conf_fixture.conf, group=self.GROUP)
+
+ self.session = session.Session(auth=None, verify=False,
+ session=requests.Session())
+ self.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"')
+ }
+
+ self.ECP_SP_SAML2_REQUEST_HEADERS = {
+ 'Content-Type': 'application/vnd.paos+xml'
+ }
+
+ self.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'
+ }
+ self.ECP_RELAY_STATE = '//ecp:RelayState'
+ self.ECP_SERVICE_PROVIDER_CONSUMER_URL = ('/S:Envelope/S:Header/paos:'
+ 'Request/'
+ '@responseConsumerURL')
+ self.ECP_IDP_CONSUMER_URL = ('/S:Envelope/S:Header/ecp:Response/'
+ '@AssertionConsumerServiceURL')
+ self.IDENTITY_PROVIDER = 'testidp'
+ self.IDENTITY_PROVIDER_URL = 'http://local.url'
+ self.PROTOCOL = 'saml2'
+ self.FEDERATION_AUTH_URL = '%s/%s' % (
+ self.TEST_URL,
+ 'OS-FEDERATION/identity_providers/testidp/protocols/saml2/auth')
+ self.SHIB_CONSUMER_URL = ('https://openstack4.local/'
+ 'Shibboleth.sso/SAML2/ECP')
+
+ self.saml2plugin = saml2.Saml2UnscopedToken(
+ self.TEST_URL,
+ self.IDENTITY_PROVIDER, self.IDENTITY_PROVIDER_URL,
+ self.TEST_USER, self.TEST_TOKEN)
+
+ def simple_http(self, method, url, body=b'', content_type=None,
+ headers=None, status=200, **kwargs):
+ self.stub_url(method, base_url=url, body=body, adding_headers=headers,
+ content_type=content_type, status=status, **kwargs)
+
+ def make_oneline(self, s):
+ return etree.tostring(etree.XML(s)).replace(b'\n', b'')
+
+ def test_conf_params(self):
+ section = uuid.uuid4().hex
+ identity_provider = uuid.uuid4().hex
+ identity_provider_url = uuid.uuid4().hex
+ username = uuid.uuid4().hex
+ password = uuid.uuid4().hex
+ self.conf_fixture.config(auth_section=section, group=self.GROUP)
+ conf.register_conf_options(self.conf_fixture.conf, group=self.GROUP)
+
+ self.conf_fixture.register_opts(saml2.Saml2UnscopedToken.get_options(),
+ group=section)
+ self.conf_fixture.config(auth_plugin='v3unscopedsaml',
+ identity_provider=identity_provider,
+ identity_provider_url=identity_provider_url,
+ username=username,
+ password=password,
+ group=section)
+
+ a = conf.load_from_conf_options(self.conf_fixture.conf, self.GROUP)
+ self.assertEqual(identity_provider, a.identity_provider)
+ self.assertEqual(identity_provider_url, a.identity_provider_url)
+ self.assertEqual(username, a.username)
+ self.assertEqual(password, a.password)
+
+ @httpretty.activate
+ def test_initial_sp_call(self):
+ """Test initial call, expect SOAP message."""
+ self.simple_http('GET', self.FEDERATION_AUTH_URL,
+ body=self.make_oneline(
+ saml2_fixtures.SP_SOAP_RESPONSE))
+ a = self.saml2plugin._send_service_provider_request(self.session)
+
+ self.assertFalse(a)
+
+ fixture_soap_response = self.make_oneline(
+ saml2_fixtures.SP_SOAP_RESPONSE)
+
+ sp_soap_response = self.make_oneline(
+ etree.tostring(self.saml2plugin.saml2_authn_request))
+
+ error_msg = "Expected %s instead of %s" % (fixture_soap_response,
+ sp_soap_response)
+
+ self.assertEqual(fixture_soap_response, sp_soap_response, error_msg)
+
+ self.assertEqual(
+ self.saml2plugin.sp_response_consumer_url, self.SHIB_CONSUMER_URL,
+ "Expected consumer_url set to %s instead of %s" % (
+ self.SHIB_CONSUMER_URL,
+ str(self.saml2plugin.sp_response_consumer_url)))
+
+ @httpretty.activate
+ def test_initial_sp_call_when_saml_authenticated(self):
+
+ headers = {'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER}
+ self.simple_http('GET', self.FEDERATION_AUTH_URL,
+ body=jsonutils.dumps(saml2_fixtures.UNSCOPED_TOKEN),
+ headers=headers)
+ a = self.saml2plugin._send_service_provider_request(self.session)
+ self.assertTrue(a)
+ self.assertEqual(
+ saml2_fixtures.UNSCOPED_TOKEN['token'],
+ self.saml2plugin.authenticated_response.json()['token'])
+ self.assertEqual(
+ saml2_fixtures.UNSCOPED_TOKEN_HEADER,
+ self.saml2plugin.authenticated_response.headers['X-Subject-Token'])
+
+ @httpretty.activate
+ def test_get_unscoped_token_when_authenticated(self):
+ headers = {'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER}
+ self.simple_http('GET', self.FEDERATION_AUTH_URL,
+ body=jsonutils.dumps(saml2_fixtures.UNSCOPED_TOKEN),
+ headers=headers)
+ token, token_body = self.saml2plugin._get_unscoped_token(self.session)
+ self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN['token'], token_body)
+
+ self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN_HEADER, token)
+
+ @httpretty.activate
+ def test_initial_sp_call_invalid_response(self):
+ """Send initial SP HTTP request and receive wrong server response."""
+ self.simple_http('GET', self.FEDERATION_AUTH_URL,
+ body="NON XML RESPONSE")
+
+ self.assertRaises(
+ exceptions.AuthorizationFailure,
+ self.saml2plugin._send_service_provider_request,
+ self.session)
+
+ @httpretty.activate
+ def test_send_authn_req_to_idp(self):
+ self.simple_http('POST', self.IDENTITY_PROVIDER_URL,
+ body=saml2_fixtures.SAML2_ASSERTION)
+
+ self.saml2plugin.sp_response_consumer_url = self.SHIB_CONSUMER_URL
+ self.saml2plugin.saml2_authn_request = etree.XML(
+ saml2_fixtures.SP_SOAP_RESPONSE)
+ self.saml2plugin._send_idp_saml2_authn_request(self.session)
+
+ idp_response = self.make_oneline(etree.tostring(
+ self.saml2plugin.saml2_idp_authn_response))
+
+ saml2_assertion_oneline = self.make_oneline(
+ saml2_fixtures.SAML2_ASSERTION)
+ error = "Expected %s instead of %s" % (saml2_fixtures.SAML2_ASSERTION,
+ idp_response)
+ self.assertEqual(idp_response, saml2_assertion_oneline, error)
+
+ @httpretty.activate
+ def test_fail_basicauth_idp_authentication(self):
+ self.simple_http('POST', self.IDENTITY_PROVIDER_URL,
+ status=401)
+
+ self.saml2plugin.sp_response_consumer_url = self.SHIB_CONSUMER_URL
+ self.saml2plugin.saml2_authn_request = etree.XML(
+ saml2_fixtures.SP_SOAP_RESPONSE)
+ self.assertRaises(
+ exceptions.Unauthorized,
+ self.saml2plugin._send_idp_saml2_authn_request,
+ self.session)
+
+ def test_mising_username_password_in_plugin(self):
+ self.assertRaises(TypeError,
+ saml2.Saml2UnscopedToken,
+ self.TEST_URL, self.IDENTITY_PROVIDER,
+ self.IDENTITY_PROVIDER_URL)
+
+ @httpretty.activate
+ def test_send_authn_response_to_sp(self):
+ self.simple_http(
+ 'POST', self.SHIB_CONSUMER_URL,
+ body=jsonutils.dumps(saml2_fixtures.UNSCOPED_TOKEN),
+ content_type='application/json',
+ status=200,
+ headers={'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER})
+
+ self.saml2plugin.relay_state = etree.XML(
+ saml2_fixtures.SP_SOAP_RESPONSE).xpath(
+ self.ECP_RELAY_STATE, namespaces=self.ECP_SAML2_NAMESPACES)[0]
+
+ self.saml2plugin.saml2_idp_authn_response = etree.XML(
+ saml2_fixtures.SAML2_ASSERTION)
+
+ self.saml2plugin.idp_response_consumer_url = self.SHIB_CONSUMER_URL
+ self.saml2plugin._send_service_provider_saml2_authn_response(
+ self.session)
+ token_json = self.saml2plugin.authenticated_response.json()['token']
+ token = self.saml2plugin.authenticated_response.headers[
+ 'X-Subject-Token']
+ self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN['token'],
+ token_json)
+
+ self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN_HEADER,
+ token)
+
+ def test_consumer_url_mismatch_success(self):
+ self.saml2plugin._check_consumer_urls(
+ self.session, self.SHIB_CONSUMER_URL,
+ self.SHIB_CONSUMER_URL)
+
+ @httpretty.activate
+ def test_consumer_url_mismatch(self):
+ self.simple_http('POST', self.SHIB_CONSUMER_URL)
+ invalid_consumer_url = uuid.uuid4().hex
+ self.assertRaises(
+ exceptions.ValidationError,
+ self.saml2plugin._check_consumer_urls,
+ self.session, self.SHIB_CONSUMER_URL,
+ invalid_consumer_url)
+
+ @httpretty.activate
+ def test_custom_302_redirection(self):
+ self.simple_http('POST', self.SHIB_CONSUMER_URL,
+ body='BODY',
+ headers={'location': self.FEDERATION_AUTH_URL},
+ status=302)
+ self.simple_http(
+ 'GET', self.FEDERATION_AUTH_URL,
+ body=jsonutils.dumps(saml2_fixtures.UNSCOPED_TOKEN),
+ headers={'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER})
+ self.session.redirect = False
+ response = self.session.post(
+ self.SHIB_CONSUMER_URL, data='CLIENT BODY')
+ self.assertEqual(302, response.status_code)
+ self.assertEqual(self.FEDERATION_AUTH_URL,
+ response.headers['location'])
+
+ response = self.saml2plugin._handle_http_302_ecp_redirect(
+ self.session, response, 'GET')
+
+ self.assertEqual(self.FEDERATION_AUTH_URL, response.request.url)
+ self.assertEqual('GET', response.request.method)
+
+ @httpretty.activate
+ def test_end_to_end_workflow(self):
+ self.simple_http('GET', self.FEDERATION_AUTH_URL,
+ body=self.make_oneline(
+ saml2_fixtures.SP_SOAP_RESPONSE))
+ self.simple_http('POST', self.IDENTITY_PROVIDER_URL,
+ body=saml2_fixtures.SAML2_ASSERTION)
+ self.simple_http(
+ 'POST', self.SHIB_CONSUMER_URL,
+ body=jsonutils.dumps(saml2_fixtures.UNSCOPED_TOKEN),
+ content_type='application/json',
+ status=200,
+ headers={'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER})
+
+ self.session.redirect = False
+ response = self.saml2plugin.get_auth_ref(self.session)
+ self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN_HEADER,
+ response.auth_token)
diff --git a/requirements.txt b/requirements.txt
index 1dde3a683..133aa374f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,7 @@
argparse
Babel>=1.3
iso8601>=0.1.9
+lxml>=2.3
netaddr>=0.7.6
oslo.config>=1.2.1
pbr>=0.6,!=0.7,<1.0
diff --git a/setup.cfg b/setup.cfg
index 3e5593250..3d6b9af78 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -36,6 +36,7 @@ keystoneclient.auth.plugin =
v2token = keystoneclient.auth.identity.v2:Token
v3password = keystoneclient.auth.identity.v3:Password
v3token = keystoneclient.auth.identity.v3:Token
+ v3unscopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2UnscopedToken
[build_sphinx]
source-dir = doc/source