summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJenkins <jenkins@review.openstack.org>2014-09-21 06:49:52 +0000
committerGerrit Code Review <review@openstack.org>2014-09-21 06:49:52 +0000
commitb0241ad2da13409d477490f6b9bea8a639874c19 (patch)
treecd5ebf35b1bf7cf362ff35a71bc45d649f1e5c93
parent026b6201bf0594b348e6cf30a3ff26bdb688d5f6 (diff)
parentcbe8c0a178fd589a06657846157518b3ac70aada (diff)
downloadpython-keystoneclient-b0241ad2da13409d477490f6b9bea8a639874c19.tar.gz
Merge "SAML2 federated authentication for ADFS."
-rw-r--r--keystoneclient/contrib/auth/v3/saml2.py531
-rw-r--r--keystoneclient/tests/v3/examples/xml/ADFS_RequestSecurityTokenResponse.xml132
-rw-r--r--keystoneclient/tests/v3/examples/xml/ADFS_fault.xml19
-rw-r--r--keystoneclient/tests/v3/test_auth_saml2.py285
-rw-r--r--setup.cfg2
5 files changed, 917 insertions, 52 deletions
diff --git a/keystoneclient/contrib/auth/v3/saml2.py b/keystoneclient/contrib/auth/v3/saml2.py
index f2b458e..c9eedac 100644
--- a/keystoneclient/contrib/auth/v3/saml2.py
+++ b/keystoneclient/contrib/auth/v3/saml2.py
@@ -10,14 +10,72 @@
# License for the specific language governing permissions and limitations
# under the License.
+import datetime
+import uuid
+
from lxml import etree
from oslo.config import cfg
+from six.moves import urllib
from keystoneclient import access
from keystoneclient.auth.identity import v3
from keystoneclient import exceptions
+class _BaseSAMLPlugin(v3.AuthConstructor):
+
+ HTTP_MOVED_TEMPORARILY = 302
+ PROTOCOL = 'saml2'
+
+ @staticmethod
+ def _first(_list):
+ if len(_list) != 1:
+ raise IndexError("Only single element list is acceptable")
+ return _list[0]
+
+ @staticmethod
+ def str_to_xml(content, msg=None, include_exc=True):
+ try:
+ return etree.XML(content)
+ except etree.XMLSyntaxError as e:
+ if not msg:
+ msg = str(e)
+ else:
+ msg = msg % e if include_exc else msg
+ raise exceptions.AuthorizationFailure(msg)
+
+ @staticmethod
+ def xml_to_str(content, **kwargs):
+ return etree.tostring(content, **kwargs)
+
+ @property
+ def 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
+
+ @classmethod
+ def get_options(cls):
+ options = super(_BaseSAMLPlugin, 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
+
+
class Saml2UnscopedTokenAuthMethod(v3.AuthMethod):
_method_parameters = []
@@ -26,7 +84,7 @@ class Saml2UnscopedTokenAuthMethod(v3.AuthMethod):
'be called'))
-class Saml2UnscopedToken(v3.AuthConstructor):
+class Saml2UnscopedToken(_BaseSAMLPlugin):
"""Implement authentication plugin for SAML2 protocol.
ECP stands for ``Enhanced Client or Proxy`` and is a SAML2 extension
@@ -47,8 +105,6 @@ class Saml2UnscopedToken(v3.AuthConstructor):
_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',
@@ -118,19 +174,6 @@ class Saml2UnscopedToken(v3.AuthConstructor):
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:
@@ -140,11 +183,6 @@ class Saml2UnscopedToken(v3.AuthConstructor):
return session.request(location, method, authenticated=False,
**kwargs)
- def _first(self, _list):
- if len(_list) != 1:
- raise IndexError("Only single element is acceptable")
- 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)
@@ -230,8 +268,7 @@ class Saml2UnscopedToken(v3.AuthConstructor):
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)
+ self.sp_response_consumer_url = self._first(sp_response_consumer_url)
return False
def _send_idp_saml2_authn_request(self, session):
@@ -259,8 +296,7 @@ class Saml2UnscopedToken(v3.AuthConstructor):
self.ECP_IDP_CONSUMER_URL,
namespaces=self.ECP_SAML2_NAMESPACES)
- self.idp_response_consumer_url = self._first(
- idp_response_consumer_url)
+ 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)
@@ -300,21 +336,7 @@ class Saml2UnscopedToken(v3.AuthConstructor):
self.authenticated_response = response
- @property
- def 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):
+ def _get_unscoped_token(self, session):
"""Get unscoped OpenStack token after federated authentication.
This is a multi-step process including multiple HTTP requests.
@@ -408,11 +430,438 @@ class Saml2UnscopedToken(v3.AuthConstructor):
unscoped token json included.
"""
- token, token_json = self._get_unscoped_token(session, **kwargs)
+ token, token_json = self._get_unscoped_token(session)
return access.AccessInfoV3(token,
**token_json)
+class ADFSUnscopedToken(_BaseSAMLPlugin):
+ """Authentication plugin for Microsoft ADFS2.0 IdPs."""
+
+ _auth_method_class = Saml2UnscopedTokenAuthMethod
+
+ DEFAULT_ADFS_TOKEN_EXPIRATION = 120
+
+ HEADER_SOAP = {"Content-Type": "application/soap+xml; charset=utf-8"}
+ HEADER_X_FORM = {"Content-Type": "application/x-www-form-urlencoded"}
+
+ NAMESPACES = {
+ 's': 'http://www.w3.org/2003/05/soap-envelope',
+ 'a': 'http://www.w3.org/2005/08/addressing',
+ 'u': ('http://docs.oasis-open.org/wss/2004/01/oasis-200401-'
+ 'wss-wssecurity-utility-1.0.xsd')
+ }
+
+ ADFS_TOKEN_NAMESPACES = {
+ 's': 'http://www.w3.org/2003/05/soap-envelope',
+ 't': 'http://docs.oasis-open.org/ws-sx/ws-trust/200512'
+ }
+ ADFS_ASSERTION_XPATH = ('/s:Envelope/s:Body'
+ '/t:RequestSecurityTokenResponseCollection'
+ '/t:RequestSecurityTokenResponse')
+
+ def __init__(self, auth_url, identity_provider, identity_provider_url,
+ service_provider_endpoint, username, password, **kwargs):
+ """Constructor for ``ADFSUnscopedToken``.
+
+ :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
+ authentication request will be sent.
+ :type identity_provider_url: string
+
+ :param service_provider_endpoint: Endpoint where an assertion is being
+ sent, for instance: ``https://host.domain/Shibboleth.sso/ADFS``
+ :type service_provider_endpoint: string
+
+ :param username: User's login
+ :type username: string
+
+ :param password: User's password
+ :type password: string
+
+ """
+
+ super(ADFSUnscopedToken, self).__init__(auth_url=auth_url, **kwargs)
+ self.identity_provider = identity_provider
+ self.identity_provider_url = identity_provider_url
+ self.service_provider_endpoint = service_provider_endpoint
+ self.username, self.password = username, password
+
+ @classmethod
+ def get_options(cls):
+ options = super(ADFSUnscopedToken, cls).get_options()
+
+ options.extend([
+ cfg.StrOpt('service-provider-endpoint',
+ help="Service Provider's Endpoint")
+ ])
+ return options
+
+ @property
+ def _uuid4(self):
+ return str(uuid.uuid4())
+
+ def _cookies(self, session):
+ """Check if cookie jar is not empty.
+
+ keystoneclient.session.Session object doesn't have a cookies attribute.
+ We should then try fetching cookies from the underlying
+ requests.Session object. If that fails too, there is something wrong
+ and let Python raise the AttributeError.
+
+ :param session
+ :return: True if cookie jar is nonempty, False otherwise
+ :raises: AttributeError in case cookies are not find anywhere
+
+ """
+ try:
+ return bool(session.cookies)
+ except AttributeError:
+ pass
+
+ return bool(session.session.cookies)
+
+ def _token_dates(self, fmt='%Y-%m-%dT%H:%M:%S.%fZ'):
+ """Calculate created and expires datetime objects.
+
+ The method is going to be used for building ADFS Request Security
+ Token message. Time interval between ``created`` and ``expires``
+ dates is now static and equals to 120 seconds. ADFS security tokens
+ should not be live too long, as currently ``keystoneclient``
+ doesn't have mechanisms for reusing such tokens (every time ADFS authn
+ method is called, keystoneclient will login with the ADFS instance).
+
+ :param fmt: Datetime format for specifying string format of a date.
+ It should not be changed if the method is going to be used
+ for building the ADFS security token request.
+ :type fmt: string
+
+ """
+
+ date_created = datetime.datetime.utcnow()
+ date_expires = date_created + datetime.timedelta(
+ seconds=self.DEFAULT_ADFS_TOKEN_EXPIRATION)
+ return [_time.strftime(fmt) for _time in (date_created, date_expires)]
+
+ def _prepare_adfs_request(self):
+ """Build the ADFS Request Security Token SOAP message.
+
+ Some values like username or password are inserted in the request.
+
+ """
+
+ WSS_SECURITY_NAMESPACE = {
+ 'o': ('http://docs.oasis-open.org/wss/2004/01/oasis-200401-'
+ 'wss-wssecurity-secext-1.0.xsd')
+ }
+
+ TRUST_NAMESPACE = {
+ 'trust': 'http://docs.oasis-open.org/ws-sx/ws-trust/200512'
+ }
+
+ WSP_NAMESPACE = {
+ 'wsp': 'http://schemas.xmlsoap.org/ws/2004/09/policy'
+ }
+
+ WSA_NAMESPACE = {
+ 'wsa': 'http://www.w3.org/2005/08/addressing'
+ }
+
+ root = etree.Element(
+ '{http://www.w3.org/2003/05/soap-envelope}Envelope',
+ nsmap=self.NAMESPACES)
+
+ header = etree.SubElement(
+ root, '{http://www.w3.org/2003/05/soap-envelope}Header')
+ action = etree.SubElement(
+ header, "{http://www.w3.org/2005/08/addressing}Action")
+ action.set(
+ "{http://www.w3.org/2003/05/soap-envelope}mustUnderstand", "1")
+ action.text = ('http://docs.oasis-open.org/ws-sx/ws-trust/200512'
+ '/RST/Issue')
+
+ messageID = etree.SubElement(
+ header, '{http://www.w3.org/2005/08/addressing}MessageID')
+ messageID.text = 'urn:uuid:' + self._uuid4
+ replyID = etree.SubElement(
+ header, '{http://www.w3.org/2005/08/addressing}ReplyTo')
+ address = etree.SubElement(
+ replyID, '{http://www.w3.org/2005/08/addressing}Address')
+ address.text = 'http://www.w3.org/2005/08/addressing/anonymous'
+
+ to = etree.SubElement(
+ header, '{http://www.w3.org/2005/08/addressing}To')
+ to.set("{http://www.w3.org/2003/05/soap-envelope}mustUnderstand", "1")
+
+ security = etree.SubElement(
+ header, '{http://docs.oasis-open.org/wss/2004/01/oasis-200401-'
+ 'wss-wssecurity-secext-1.0.xsd}Security',
+ nsmap=WSS_SECURITY_NAMESPACE)
+
+ security.set(
+ "{http://www.w3.org/2003/05/soap-envelope}mustUnderstand", "1")
+
+ timestamp = etree.SubElement(
+ security, ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-'
+ 'wss-wssecurity-utility-1.0.xsd}Timestamp'))
+ timestamp.set(
+ ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-'
+ 'wss-wssecurity-utility-1.0.xsd}Id'), '_0')
+
+ created = etree.SubElement(
+ timestamp, ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-'
+ 'wss-wssecurity-utility-1.0.xsd}Created'))
+
+ expires = etree.SubElement(
+ timestamp, ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-'
+ 'wss-wssecurity-utility-1.0.xsd}Expires'))
+
+ created.text, expires.text = self._token_dates()
+
+ usernametoken = etree.SubElement(
+ security, '{http://docs.oasis-open.org/wss/2004/01/oasis-200401-'
+ 'wss-wssecurity-secext-1.0.xsd}UsernameToken')
+ usernametoken.set(
+ ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-'
+ 'wssecurity-utility-1.0.xsd}u'), "uuid-%s-1" % self._uuid4)
+
+ username = etree.SubElement(
+ usernametoken, ('{http://docs.oasis-open.org/wss/2004/01/oasis-'
+ '200401-wss-wssecurity-secext-1.0.xsd}Username'))
+ password = etree.SubElement(
+ usernametoken, ('{http://docs.oasis-open.org/wss/2004/01/oasis-'
+ '200401-wss-wssecurity-secext-1.0.xsd}Password'),
+ Type=('http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-'
+ 'username-token-profile-1.0#PasswordText'))
+
+ body = etree.SubElement(
+ root, "{http://www.w3.org/2003/05/soap-envelope}Body")
+
+ request_security_token = etree.SubElement(
+ body, ('{http://docs.oasis-open.org/ws-sx/ws-trust/200512}'
+ 'RequestSecurityToken'), nsmap=TRUST_NAMESPACE)
+
+ applies_to = etree.SubElement(
+ request_security_token,
+ '{http://schemas.xmlsoap.org/ws/2004/09/policy}AppliesTo',
+ nsmap=WSP_NAMESPACE)
+
+ endpoint_reference = etree.SubElement(
+ applies_to,
+ '{http://www.w3.org/2005/08/addressing}EndpointReference',
+ nsmap=WSA_NAMESPACE)
+
+ wsa_address = etree.SubElement(
+ endpoint_reference,
+ '{http://www.w3.org/2005/08/addressing}Address')
+
+ keytype = etree.SubElement(
+ request_security_token,
+ '{http://docs.oasis-open.org/ws-sx/ws-trust/200512}KeyType')
+ keytype.text = ('http://docs.oasis-open.org/ws-sx/'
+ 'ws-trust/200512/Bearer')
+
+ request_type = etree.SubElement(
+ request_security_token,
+ '{http://docs.oasis-open.org/ws-sx/ws-trust/200512}RequestType')
+ request_type.text = ('http://docs.oasis-open.org/ws-sx/'
+ 'ws-trust/200512/Issue')
+ token_type = etree.SubElement(
+ request_security_token,
+ '{http://docs.oasis-open.org/ws-sx/ws-trust/200512}TokenType')
+ token_type.text = 'urn:oasis:names:tc:SAML:1.0:assertion'
+
+ # After constructing the request, let's plug in some values
+ username.text = self.username
+ password.text = self.password
+ to.text = self.identity_provider_url
+ wsa_address.text = self.service_provider_endpoint
+
+ self.prepared_request = root
+
+ def _get_adfs_security_token(self, session):
+ """Send ADFS Security token to the ADFS server.
+
+ Store the result in the instance attribute and raise an exception in
+ case the response is not valid XML data.
+
+ If a user cannot authenticate due to providing bad credentials, the
+ ADFS2.0 server will return a HTTP 500 response and a XML Fault message.
+ If ``exceptions.InternalServerError`` is caught, the method tries to
+ parse the XML response.
+ If parsing is unsuccessful, an ``exceptions.AuthorizationFailure`` is
+ raised with a reason from the XML fault. Otherwise an original
+ ``exceptions.InternalServerError`` is re-raised.
+
+ :param session : a session object to send out HTTP requests.
+ :type session: keystoneclient.session.Session
+
+ :raises: exceptions.AuthorizationFailure when HTTP response from the
+ ADFS server is not a valid XML ADFS security token.
+ :raises: exceptions.InternalServerError: If response status code is
+ HTTP 500 and the response XML cannot be recognized.
+
+
+ """
+ def _get_failure(e):
+ xpath = '/s:Envelope/s:Body/s:Fault/s:Code/s:Subcode/s:Value'
+ content = e.response.content
+ try:
+ obj = self.str_to_xml(content).xpath(
+ xpath, namespaces=self.NAMESPACES)
+ obj = self._first(obj)
+ return obj.text
+ # NOTE(marek-denis): etree.Element.xpath() doesn't raise an
+ # exception, it just returns an empty list. In that case, _first()
+ # will raise IndexError and we should treat it as an indication XML
+ # is not valid. exceptions.AuthorizationFailure can be raised from
+ # str_to_xml(), however since server returned HTTP 500 we should
+ # re-raise exceptions.InternalServerError.
+ except (IndexError, exceptions.AuthorizationFailure):
+ raise e
+
+ request_security_token = self.xml_to_str(self.prepared_request)
+ try:
+ response = session.post(
+ url=self.identity_provider_url, headers=self.HEADER_SOAP,
+ data=request_security_token, authenticated=False)
+ except exceptions.InternalServerError as e:
+ reason = _get_failure(e)
+ raise exceptions.AuthorizationFailure(reason)
+ msg = ("Error parsing XML returned from "
+ "the ADFS Identity Provider, reason: %s")
+ self.adfs_token = self.str_to_xml(response.content, msg)
+
+ def _prepare_sp_request(self):
+ """Prepare ADFS Security Token to be sent to the Service Provider.
+
+ The method works as follows:
+ * Extract SAML2 assertion from the ADFS Security Token.
+ * Replace namespaces
+ * urlencode assertion
+ * concatenate static string with the encoded assertion
+
+ """
+ assertion = self.adfs_token.xpath(
+ self.ADFS_ASSERTION_XPATH, namespaces=self.ADFS_TOKEN_NAMESPACES)
+ assertion = self._first(assertion)
+ assertion = self.xml_to_str(assertion)
+ # TODO(marek-denis): Ideally no string replacement should occur.
+ # Unfortunately lxml doesn't allow for namespaces changing in-place and
+ # probably the only solution good for now is to build the assertion
+ # from scratch and reuse values from the adfs security token.
+ assertion = assertion.replace(
+ b'http://docs.oasis-open.org/ws-sx/ws-trust/200512',
+ b'http://schemas.xmlsoap.org/ws/2005/02/trust')
+
+ encoded_assertion = urllib.parse.quote(assertion)
+ self.encoded_assertion = 'wa=wsignin1.0&wresult=' + encoded_assertion
+
+ def _send_assertion_to_service_provider(self, session):
+ """Send prepared assertion to a service provider.
+
+ As the assertion doesn't contain a protected resource, the value from
+ the ``location`` header is not valid and we should not let the Session
+ object get redirected there. The aim of this call is to get a cookie in
+ the response which is required for entering a protected endpoint.
+
+ :param session : a session object to send out HTTP requests.
+ :type session: keystoneclient.session.Session
+
+ :raises: Corresponding HTTP error exception
+
+ """
+ session.post(
+ url=self.service_provider_endpoint, data=self.encoded_assertion,
+ headers=self.HEADER_X_FORM, redirect=False, authenticated=False)
+
+ def _access_service_provider(self, session):
+ """Access protected endpoint and fetch unscoped token.
+
+ After federated authentication workflow a protected endpoint should be
+ accessible with the session object. The access is granted basing on the
+ cookies stored within the session object. If, for some reason no
+ cookies are present (quantity test) it means something went wrong and
+ user will not be able to fetch an unscoped token. In that case an
+ ``exceptions.AuthorizationFailure` exception is raised and no HTTP call
+ is even made.
+
+ :param session : a session object to send out HTTP requests.
+ :type session: keystoneclient.session.Session
+
+ :raises: exceptions.AuthorizationFailure: in case session object
+ has empty cookie jar.
+
+ """
+ if self._cookies(session) is False:
+ raise exceptions.AuthorizationFailure(
+ "Session object doesn't contain a cookie, therefore you are "
+ "not allowed to enter the Identity Provider's protected area.")
+ self.authenticated_response = session.get(self.token_url,
+ authenticated=False)
+
+ def _get_unscoped_token(self, session, *kwargs):
+ """Retrieve unscoped token after authentcation with ADFS server.
+
+ This is a multistep process::
+
+ * Prepare ADFS Request Securty Token -
+ build a etree.XML object filling certain attributes with proper user
+ credentials, created/expires dates (ticket is be valid for 120 seconds
+ as currently we don't handle reusing ADFS issued security tokens) .
+ Step handled by ``ADFSUnscopedToken._prepare_adfs_request()`` method.
+
+ * Send ADFS Security token to the ADFS server. Step handled by
+ ``ADFSUnscopedToken._get_adfs_security_token()`` method.
+
+ * Receive and parse security token, extract actual SAML assertion and
+ prepare a request addressed for the Service Provider endpoint.
+ This also includes changing namespaces in the XML document. Step
+ handled by ``ADFSUnscopedToken._prepare_sp_request()`` method.
+
+ * Send prepared assertion to the Service Provider endpoint. Usually
+ the server will respond with HTTP 301 code which should be ignored as
+ the 'location' header doesn't contain protected area. The goal of this
+ operation is fetching the session cookie which later allows for
+ accessing protected URL endpoints. Step handed by
+ ``ADFSUnscopedToken._send_assertion_to_service_provider()`` method.
+
+ * Once the session cookie is issued, the protected endpoint can be
+ accessed and an unscoped token can be retrieved. Step handled by
+ ``ADFSUnscopedToken._access_service_provider()`` method.
+
+ :param session : a session object to send out HTTP requests.
+ :type session: keystoneclient.session.Session
+
+ :returns (Unscoped federated token, token JSON body)
+
+ """
+ self._prepare_adfs_request()
+ self._get_adfs_security_token(session)
+ self._prepare_sp_request()
+ self._send_assertion_to_service_provider(session)
+ self._access_service_provider(session)
+
+ try:
+ return (self.authenticated_response.headers['X-Subject-Token'],
+ self.authenticated_response.json()['token'])
+ except (KeyError, ValueError):
+ raise exceptions.InvalidResponse(
+ response=self.authenticated_response)
+
+ def get_auth_ref(self, session, **kwargs):
+ token, token_json = self._get_unscoped_token(session)
+ return access.AccessInfoV3(token, **token_json)
+
+
class Saml2ScopedTokenMethod(v3.TokenMethod):
_method_name = 'saml2'
diff --git a/keystoneclient/tests/v3/examples/xml/ADFS_RequestSecurityTokenResponse.xml b/keystoneclient/tests/v3/examples/xml/ADFS_RequestSecurityTokenResponse.xml
new file mode 100644
index 0000000..487bcac
--- /dev/null
+++ b/keystoneclient/tests/v3/examples/xml/ADFS_RequestSecurityTokenResponse.xml
@@ -0,0 +1,132 @@
+<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing" xmlns:u="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">
+ <s:Header>
+ <a:Action s:mustUnderstand="1">http://docs.oasis-open.org/ws-sx/ws-trust/200512/RSTRC/IssueFinal</a:Action>
+ <a:RelatesTo>urn:uuid:487c064b-b7c6-4654-b4d4-715f9961170e</a:RelatesTo>
+ <o:Security s:mustUnderstand="1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
+ <u:Timestamp u:Id="_0">
+ <u:Created>2014-08-05T18:36:14.235Z</u:Created>
+ <u:Expires>2014-08-05T18:41:14.235Z</u:Expires>
+ </u:Timestamp>
+ </o:Security>
+ </s:Header>
+ <s:Body>
+ <trust:RequestSecurityTokenResponseCollection xmlns:trust="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
+ <trust:RequestSecurityTokenResponse>
+ <trust:Lifetime>
+ <wsu:Created xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">2014-08-05T18:36:14.063Z</wsu:Created>
+ <wsu:Expires xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd">2014-08-05T19:36:14.063Z</wsu:Expires>
+ </trust:Lifetime>
+ <wsp:AppliesTo xmlns:wsp="http://schemas.xmlsoap.org/ws/2004/09/policy">
+ <wsa:EndpointReference xmlns:wsa="http://www.w3.org/2005/08/addressing">
+ <wsa:Address>https://ltartari2.cern.ch:5000/Shibboleth.sso/ADFS</wsa:Address>
+ </wsa:EndpointReference>
+ </wsp:AppliesTo>
+ <trust:RequestedSecurityToken>
+ <saml:Assertion MajorVersion="1" MinorVersion="1" AssertionID="_c9e77bc4-a81b-4da7-88c2-72a6ba376d3f" Issuer="https://cern.ch/login" IssueInstant="2014-08-05T18:36:14.235Z" xmlns:saml="urn:oasis:names:tc:SAML:1.0:assertion">
+ <saml:Conditions NotBefore="2014-08-05T18:36:14.063Z" NotOnOrAfter="2014-08-05T19:36:14.063Z">
+ <saml:AudienceRestrictionCondition>
+ <saml:Audience>https://ltartari2.cern.ch:5000/Shibboleth.sso/ADFS</saml:Audience>
+ </saml:AudienceRestrictionCondition>
+ </saml:Conditions>
+ <saml:AttributeStatement>
+ <saml:Subject>
+ <saml:NameIdentifier Format="http://schemas.xmlsoap.org/claims/UPN">marek.denis@cern.ch</saml:NameIdentifier>
+ <saml:SubjectConfirmation>
+ <saml:ConfirmationMethod>urn:oasis:names:tc:SAML:1.0:cm:bearer</saml:ConfirmationMethod>
+ </saml:SubjectConfirmation>
+ </saml:Subject>
+ <saml:Attribute AttributeName="UPN" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>marek.denis@cern.ch</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="EmailAddress" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>marek.denis@cern.ch</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="CommonName" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>madenis</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="role" AttributeNamespace="http://schemas.microsoft.com/ws/2008/06/identity/claims">
+ <saml:AttributeValue>CERN Users</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="Group" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>Domain Users</saml:AttributeValue>
+ <saml:AttributeValue>occupants-bldg-31</saml:AttributeValue>
+ <saml:AttributeValue>CERN-Direct-Employees</saml:AttributeValue>
+ <saml:AttributeValue>ca-dev-allowed</saml:AttributeValue>
+ <saml:AttributeValue>cernts-cerntstest-users</saml:AttributeValue>
+ <saml:AttributeValue>staf-fell-pjas-at-cern</saml:AttributeValue>
+ <saml:AttributeValue>ELG-CERN</saml:AttributeValue>
+ <saml:AttributeValue>student-club-new-members</saml:AttributeValue>
+ <saml:AttributeValue>pawel-dynamic-test-82</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="DisplayName" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>Marek Kamil Denis</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="MobileNumber" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>+5555555</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="Building" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>31S-013</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="Firstname" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>Marek Kamil</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="Lastname" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>Denis</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="IdentityClass" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>CERN Registered</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="Federation" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>CERN</saml:AttributeValue>
+ </saml:Attribute>
+ <saml:Attribute AttributeName="AuthLevel" AttributeNamespace="http://schemas.xmlsoap.org/claims">
+ <saml:AttributeValue>Normal</saml:AttributeValue>
+ </saml:Attribute>
+ </saml:AttributeStatement>
+ <saml:AuthenticationStatement AuthenticationMethod="urn:oasis:names:tc:SAML:1.0:am:password" AuthenticationInstant="2014-08-05T18:36:14.032Z">
+ <saml:Subject>
+ <saml:NameIdentifier Format="http://schemas.xmlsoap.org/claims/UPN">marek.denis@cern.ch</saml:NameIdentifier>
+ <saml:SubjectConfirmation>
+ <saml:ConfirmationMethod>urn:oasis:names:tc:SAML:1.0:cm:bearer</saml:ConfirmationMethod>
+ </saml:SubjectConfirmation>
+ </saml:Subject>
+ </saml:AuthenticationStatement>
+ <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
+ <SignedInfo>
+ <CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
+ <SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256" />
+ <Reference URI="#_c9e77bc4-a81b-4da7-88c2-72a6ba376d3f">
+ <Transforms>
+ <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
+ <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
+ </Transforms>
+ <DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256" />
+ <DigestValue>EaZ/2d0KAY5un9akV3++Npyk6hBc8JuTYs2S3lSxUeQ=</DigestValue>
+ </Reference>
+ </SignedInfo>
+ <SignatureValue>CxYiYvNsbedhHdmDbb9YQCBy6Ppus3bNJdw2g2HLq0VU2yRhv23mUW05I89Hs4yG4OcCo0uOZ3zaeNFbSNXMW+Mr996tAXtujKjgyrCXNJAToE+gwltvGxwY1EluSbe3IzoSM3Ao87mKhxGOSzlDhuN7dQ9Rv6l/J4gUjbOO5SIX4pdZ6mVF7cHEfe9x+H8Lg15YjnElQUEaPi+NSW5jYTdtIpsB4ORxJvALuSt6+4doDYc9wuwBiWkEdnBHAQBINoKpAV2oy0/C85SBX3IdRhxUznmL5yEUmf8JvPccXecMPqJow0L43mnCdu74xPwU0as3MNfYQ10kLvHXHfIExg==</SignatureValue>
+ <KeyInfo>
+ <X509Data>
+ <X509Certificate>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</X509Certificate>
+ </X509Data>
+ </KeyInfo>
+ </Signature>
+ </saml:Assertion>
+ </trust:RequestedSecurityToken>
+ <trust:RequestedAttachedReference>
+ <o:SecurityTokenReference k:TokenType="http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV1.1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:k="http://docs.oasis-open.org/wss/oasis-wss-wssecurity-secext-1.1.xsd">
+ <o:KeyIdentifier ValueType="http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.0#SAMLAssertionID">_c9e77bc4-a81b-4da7-88c2-72a6ba376d3f</o:KeyIdentifier>
+ </o:SecurityTokenReference>
+ </trust:RequestedAttachedReference>
+ <trust:RequestedUnattachedReference>
+ <o:SecurityTokenReference k:TokenType="http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.1#SAMLV1.1" xmlns:o="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" xmlns:k="http://docs.oasis-open.org/wss/oasis-wss-wssecurity-secext-1.1.xsd">
+ <o:KeyIdentifier ValueType="http://docs.oasis-open.org/wss/oasis-wss-saml-token-profile-1.0#SAMLAssertionID">_c9e77bc4-a81b-4da7-88c2-72a6ba376d3f</o:KeyIdentifier>
+ </o:SecurityTokenReference>
+ </trust:RequestedUnattachedReference>
+ <trust:TokenType>urn:oasis:names:tc:SAML:1.0:assertion</trust:TokenType>
+ <trust:RequestType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue</trust:RequestType>
+ <trust:KeyType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer</trust:KeyType>
+ </trust:RequestSecurityTokenResponse>
+ </trust:RequestSecurityTokenResponseCollection>
+ </s:Body>
+</s:Envelope> \ No newline at end of file
diff --git a/keystoneclient/tests/v3/examples/xml/ADFS_fault.xml b/keystoneclient/tests/v3/examples/xml/ADFS_fault.xml
new file mode 100644
index 0000000..913252e
--- /dev/null
+++ b/keystoneclient/tests/v3/examples/xml/ADFS_fault.xml
@@ -0,0 +1,19 @@
+<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope" xmlns:a="http://www.w3.org/2005/08/addressing">
+ <s:Header>
+ <a:Action s:mustUnderstand="1">http://www.w3.org/2005/08/addressing/soap/fault</a:Action>
+ <a:RelatesTo>urn:uuid:89c47849-2622-4cdc-bb06-1d46c89ed12d</a:RelatesTo>
+ </s:Header>
+ <s:Body>
+ <s:Fault>
+ <s:Code>
+ <s:Value>s:Sender</s:Value>
+ <s:Subcode>
+ <s:Value xmlns:a="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">a:FailedAuthentication</s:Value>
+ </s:Subcode>
+ </s:Code>
+ <s:Reason>
+ <s:Text xml:lang="en-US">At least one security token in the message could not be validated.</s:Text>
+ </s:Reason>
+ </s:Fault>
+ </s:Body>
+</s:Envelope> \ No newline at end of file
diff --git a/keystoneclient/tests/v3/test_auth_saml2.py b/keystoneclient/tests/v3/test_auth_saml2.py
index 053fdf6..bdb7a87 100644
--- a/keystoneclient/tests/v3/test_auth_saml2.py
+++ b/keystoneclient/tests/v3/test_auth_saml2.py
@@ -10,10 +10,13 @@
# License for the specific language governing permissions and limitations
# under the License.
+import os
import uuid
from lxml import etree
from oslo.config import fixture as config
+import requests
+from six.moves import urllib
from keystoneclient.auth import conf
from keystoneclient.contrib.auth.v3 import saml2
@@ -23,6 +26,18 @@ from keystoneclient.tests.v3 import client_fixtures
from keystoneclient.tests.v3 import saml2_fixtures
from keystoneclient.tests.v3 import utils
+ROOTDIR = os.path.dirname(os.path.abspath(__file__))
+XMLDIR = os.path.join(ROOTDIR, 'examples', 'xml/')
+
+
+def make_oneline(s):
+ return etree.tostring(etree.XML(s)).replace(b'\n', b'')
+
+
+def _load_xml(filename):
+ with open(XMLDIR + filename, 'rb') as f:
+ return make_oneline(f.read())
+
class AuthenticateviaSAML2Tests(utils.TestCase):
@@ -87,9 +102,6 @@ class AuthenticateviaSAML2Tests(utils.TestCase):
self.IDENTITY_PROVIDER, self.IDENTITY_PROVIDER_URL,
self.TEST_USER, self.TEST_TOKEN)
- 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
@@ -119,15 +131,15 @@ class AuthenticateviaSAML2Tests(utils.TestCase):
self.requests.register_uri(
'GET',
self.FEDERATION_AUTH_URL,
- content=self.make_oneline(saml2_fixtures.SP_SOAP_RESPONSE))
+ content=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(
+ fixture_soap_response = make_oneline(
saml2_fixtures.SP_SOAP_RESPONSE)
- sp_soap_response = self.make_oneline(
+ sp_soap_response = make_oneline(
etree.tostring(self.saml2plugin.saml2_authn_request))
error_msg = "Expected %s instead of %s" % (fixture_soap_response,
@@ -191,10 +203,10 @@ class AuthenticateviaSAML2Tests(utils.TestCase):
saml2_fixtures.SP_SOAP_RESPONSE)
self.saml2plugin._send_idp_saml2_authn_request(self.session)
- idp_response = self.make_oneline(etree.tostring(
+ idp_response = make_oneline(etree.tostring(
self.saml2plugin.saml2_idp_authn_response))
- saml2_assertion_oneline = self.make_oneline(
+ saml2_assertion_oneline = make_oneline(
saml2_fixtures.SAML2_ASSERTION)
error = "Expected %s instead of %s" % (saml2_fixtures.SAML2_ASSERTION,
idp_response)
@@ -228,7 +240,7 @@ class AuthenticateviaSAML2Tests(utils.TestCase):
self.saml2plugin.relay_state = etree.XML(
saml2_fixtures.SP_SOAP_RESPONSE).xpath(
- self.ECP_RELAY_STATE, namespaces=self.ECP_SAML2_NAMESPACES)[0]
+ self.ECP_RELAY_STATE, namespaces=self.ECP_SAML2_NAMESPACES)[0]
self.saml2plugin.saml2_idp_authn_response = etree.XML(
saml2_fixtures.SAML2_ASSERTION)
@@ -290,7 +302,7 @@ class AuthenticateviaSAML2Tests(utils.TestCase):
self.requests.register_uri(
'GET',
self.FEDERATION_AUTH_URL,
- content=self.make_oneline(saml2_fixtures.SP_SOAP_RESPONSE))
+ content=make_oneline(saml2_fixtures.SP_SOAP_RESPONSE))
self.requests.register_uri('POST',
self.IDENTITY_PROVIDER_URL,
@@ -375,3 +387,256 @@ class ScopeFederationTokenTests(AuthenticateviaSAML2Tests):
self.assertRaises(exceptions.ValidationError,
saml2.Saml2ScopedToken,
self.TEST_URL, client_fixtures.AUTH_SUBJECT_TOKEN)
+
+
+class AuthenticateviaADFSTests(utils.TestCase):
+
+ GROUP = 'auth'
+
+ NAMESPACES = {
+ 's': 'http://www.w3.org/2003/05/soap-envelope',
+ 'trust': 'http://docs.oasis-open.org/ws-sx/ws-trust/200512',
+ 'wsa': 'http://www.w3.org/2005/08/addressing',
+ 'wsp': 'http://schemas.xmlsoap.org/ws/2004/09/policy',
+ 'a': 'http://www.w3.org/2005/08/addressing',
+ 'o': ('http://docs.oasis-open.org/wss/2004/01/oasis'
+ '-200401-wss-wssecurity-secext-1.0.xsd')
+ }
+
+ USER_XPATH = ('/s:Envelope/s:Header'
+ '/o:Security'
+ '/o:UsernameToken'
+ '/o:Username')
+ PASSWORD_XPATH = ('/s:Envelope/s:Header'
+ '/o:Security'
+ '/o:UsernameToken'
+ '/o:Password')
+ ADDRESS_XPATH = ('/s:Envelope/s:Body'
+ '/trust:RequestSecurityToken'
+ '/wsp:AppliesTo/wsa:EndpointReference'
+ '/wsa:Address')
+ TO_XPATH = ('/s:Envelope/s:Header'
+ '/a:To')
+
+ @property
+ def _uuid4(self):
+ return '4b911420-4982-4009-8afc-5c596cd487f5'
+
+ def setUp(self):
+ super(AuthenticateviaADFSTests, self).setUp()
+
+ self.conf_fixture = self.useFixture(config.Config())
+ conf.register_conf_options(self.conf_fixture.conf, group=self.GROUP)
+ self.session = session.Session(session=requests.Session())
+
+ self.IDENTITY_PROVIDER = 'adfs'
+ self.IDENTITY_PROVIDER_URL = ('http://adfs.local/adfs/service/trust/13'
+ '/usernamemixed')
+ self.FEDERATION_AUTH_URL = '%s/%s' % (
+ self.TEST_URL,
+ 'OS-FEDERATION/identity_providers/adfs/protocols/saml2/auth')
+ self.SP_ENDPOINT = 'https://openstack4.local/Shibboleth.sso/ADFS'
+
+ self.adfsplugin = saml2.ADFSUnscopedToken(
+ self.TEST_URL, self.IDENTITY_PROVIDER,
+ self.IDENTITY_PROVIDER_URL, self.SP_ENDPOINT,
+ self.TEST_USER, self.TEST_TOKEN)
+
+ self.ADFS_SECURITY_TOKEN_RESPONSE = _load_xml(
+ 'ADFS_RequestSecurityTokenResponse.xml')
+ self.ADFS_FAULT = _load_xml('ADFS_fault.xml')
+
+ def test_conf_params(self):
+ section = uuid.uuid4().hex
+ identity_provider = uuid.uuid4().hex
+ identity_provider_url = uuid.uuid4().hex
+ sp_endpoint = 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.ADFSUnscopedToken.get_options(),
+ group=section)
+ self.conf_fixture.config(auth_plugin='v3unscopedadfs',
+ identity_provider=identity_provider,
+ identity_provider_url=identity_provider_url,
+ service_provider_endpoint=sp_endpoint,
+ 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(sp_endpoint, a.service_provider_endpoint)
+ self.assertEqual(username, a.username)
+ self.assertEqual(password, a.password)
+
+ def test_get_adfs_security_token(self):
+ """Test ADFSUnscopedToken._get_adfs_security_token()."""
+
+ self.requests.register_uri(
+ 'POST', self.IDENTITY_PROVIDER_URL,
+ content=make_oneline(self.ADFS_SECURITY_TOKEN_RESPONSE),
+ status_code=200)
+
+ self.adfsplugin._prepare_adfs_request()
+ self.adfsplugin._get_adfs_security_token(self.session)
+
+ adfs_response = etree.tostring(self.adfsplugin.adfs_token)
+ fixture_response = self.ADFS_SECURITY_TOKEN_RESPONSE
+
+ self.assertEqual(fixture_response, adfs_response)
+
+ def test_adfs_request_user(self):
+ self.adfsplugin._prepare_adfs_request()
+ user = self.adfsplugin.prepared_request.xpath(
+ self.USER_XPATH, namespaces=self.NAMESPACES)[0]
+ self.assertEqual(self.TEST_USER, user.text)
+
+ def test_adfs_request_password(self):
+ self.adfsplugin._prepare_adfs_request()
+ password = self.adfsplugin.prepared_request.xpath(
+ self.PASSWORD_XPATH, namespaces=self.NAMESPACES)[0]
+ self.assertEqual(self.TEST_TOKEN, password.text)
+
+ def test_adfs_request_to(self):
+ self.adfsplugin._prepare_adfs_request()
+ to = self.adfsplugin.prepared_request.xpath(
+ self.TO_XPATH, namespaces=self.NAMESPACES)[0]
+ self.assertEqual(self.IDENTITY_PROVIDER_URL, to.text)
+
+ def test_prepare_adfs_request_address(self):
+ self.adfsplugin._prepare_adfs_request()
+ address = self.adfsplugin.prepared_request.xpath(
+ self.ADDRESS_XPATH, namespaces=self.NAMESPACES)[0]
+ self.assertEqual(self.SP_ENDPOINT, address.text)
+
+ def test_prepare_sp_request(self):
+ assertion = etree.XML(self.ADFS_SECURITY_TOKEN_RESPONSE)
+ assertion = assertion.xpath(
+ saml2.ADFSUnscopedToken.ADFS_ASSERTION_XPATH,
+ namespaces=saml2.ADFSUnscopedToken.ADFS_TOKEN_NAMESPACES)
+ assertion = assertion[0]
+ assertion = etree.tostring(assertion)
+
+ assertion = assertion.replace(
+ b'http://docs.oasis-open.org/ws-sx/ws-trust/200512',
+ b'http://schemas.xmlsoap.org/ws/2005/02/trust')
+ assertion = urllib.parse.quote(assertion)
+ assertion = 'wa=wsignin1.0&wresult=' + assertion
+
+ self.adfsplugin.adfs_token = etree.XML(
+ self.ADFS_SECURITY_TOKEN_RESPONSE)
+ self.adfsplugin._prepare_sp_request()
+
+ self.assertEqual(assertion, self.adfsplugin.encoded_assertion)
+
+ def test_get_adfs_security_token_authn_fail(self):
+ """Test proper parsing XML fault after bad authentication.
+
+ An exceptions.AuthorizationFailure should be raised including
+ error message from the XML message indicating where was the problem.
+ """
+ self.requests.register_uri(
+ 'POST', self.IDENTITY_PROVIDER_URL,
+ content=make_oneline(self.ADFS_FAULT), status_code=500)
+
+ self.adfsplugin._prepare_adfs_request()
+ self.assertRaises(exceptions.AuthorizationFailure,
+ self.adfsplugin._get_adfs_security_token,
+ self.session)
+ # TODO(marek-denis): Python3 tests complain about missing 'message'
+ # attributes
+ # self.assertEqual('a:FailedAuthentication', e.message)
+
+ def test_get_adfs_security_token_bad_response(self):
+ """Test proper handling HTTP 500 and mangled (non XML) response.
+
+ This should never happen yet, keystoneclient should be prepared
+ and correctly raise exceptions.InternalServerError once it cannot
+ parse XML fault message
+ """
+ self.requests.register_uri(
+ 'POST', self.IDENTITY_PROVIDER_URL,
+ content=b'NOT XML',
+ status_code=500)
+ self.adfsplugin._prepare_adfs_request()
+ self.assertRaises(exceptions.InternalServerError,
+ self.adfsplugin._get_adfs_security_token,
+ self.session)
+
+ # TODO(marek-denis): Need to figure out how to properly send cookies
+ # from the request_uri() method.
+ def _send_assertion_to_service_provider(self):
+ """Test whether SP issues a cookie."""
+ cookie = uuid.uuid4().hex
+
+ self.requests.register_uri('POST', self.SP_ENDPOINT,
+ headers={"set-cookie": cookie},
+ status_code=302)
+
+ self.adfsplugin.adfs_token = self._build_adfs_request()
+ self.adfsplugin._prepare_sp_request()
+ self.adfsplugin._send_assertion_to_service_provider(self.session)
+
+ self.assertEqual(1, len(self.session.session.cookies))
+
+ def test_send_assertion_to_service_provider_bad_status(self):
+ self.requests.register_uri('POST', self.SP_ENDPOINT,
+ status_code=500)
+
+ self.adfsplugin.adfs_token = etree.XML(
+ self.ADFS_SECURITY_TOKEN_RESPONSE)
+ self.adfsplugin._prepare_sp_request()
+
+ self.assertRaises(
+ exceptions.InternalServerError,
+ self.adfsplugin._send_assertion_to_service_provider,
+ self.session)
+
+ def test_access_sp_no_cookies_fail(self):
+ # clean cookie jar
+ self.session.session.cookies = []
+
+ self.assertRaises(exceptions.AuthorizationFailure,
+ self.adfsplugin._access_service_provider,
+ self.session)
+
+ def test_check_valid_token_when_authenticated(self):
+ self.requests.register_uri(
+ 'GET', self.FEDERATION_AUTH_URL,
+ json=saml2_fixtures.UNSCOPED_TOKEN,
+ headers=client_fixtures.AUTH_RESPONSE_HEADERS)
+
+ self.session.session.cookies = [object()]
+ self.adfsplugin._access_service_provider(self.session)
+ response = self.adfsplugin.authenticated_response
+
+ self.assertEqual(client_fixtures.AUTH_RESPONSE_HEADERS,
+ response.headers)
+
+ self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN['token'],
+ response.json()['token'])
+
+ def test_end_to_end_workflow(self):
+ self.requests.register_uri(
+ 'POST', self.IDENTITY_PROVIDER_URL,
+ content=self.ADFS_SECURITY_TOKEN_RESPONSE,
+ status_code=200)
+ self.requests.register_uri(
+ 'POST', self.SP_ENDPOINT,
+ headers={"set-cookie": 'x'},
+ status_code=302)
+ self.requests.register_uri(
+ 'GET', self.FEDERATION_AUTH_URL,
+ json=saml2_fixtures.UNSCOPED_TOKEN,
+ headers=client_fixtures.AUTH_RESPONSE_HEADERS)
+
+ # NOTE(marek-denis): We need to mimic this until self.requests can
+ # issue cookies properly.
+ self.session.session.cookies = [object()]
+ token, token_json = self.adfsplugin._get_unscoped_token(self.session)
+ self.assertEqual(token, client_fixtures.AUTH_SUBJECT_TOKEN)
+ self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN['token'], token_json)
diff --git a/setup.cfg b/setup.cfg
index db21d75..e88046e 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -36,7 +36,7 @@ keystoneclient.auth.plugin =
v3token = keystoneclient.auth.identity.v3:Token
v3unscopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2UnscopedToken
v3scopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2ScopedToken
-
+ v3unscopedadfs = keystoneclient.contrib.auth.v3.saml2:ADFSUnscopedToken
[build_sphinx]
source-dir = doc/source