diff options
-rw-r--r-- | keystoneclient/contrib/auth/v3/saml2.py | 531 | ||||
-rw-r--r-- | keystoneclient/tests/v3/examples/xml/ADFS_RequestSecurityTokenResponse.xml | 132 | ||||
-rw-r--r-- | keystoneclient/tests/v3/examples/xml/ADFS_fault.xml | 19 | ||||
-rw-r--r-- | keystoneclient/tests/v3/test_auth_saml2.py | 285 | ||||
-rw-r--r-- | setup.cfg | 2 |
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) @@ -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 |