Playing with Keystone to Keystone Federation - Juno

Edit: This tutorial is for the OpenStack Juno release, in Kilo we have several modifications and improvements.

One of the coolest features from Juno release for Keystone was the ability to Federate Multiple Keystones or Keystone-to-Keystone (K2K) federation. With this feature, two (or more) cloud providers will be able to share resources between them.

As an addition to the Federation concept from the Icehouse release, now Keystone has the ability to be an Identity Provider (IdP) to another Keystone, that would be the Service Provider (SP). Turning Keystone into an IdP means that it will have to recognize an external Keystone as SP, and will also need the ability to generate SAML assertions. An external cloud (Keystone SP) is represented as a region; for that, an url field was added to the well known region table.

Although it was released as experimental, here at the Distributed Systems Lab - UFCG we decided to try to make a toy deployment using Devstack. The following alias will be used to represent both the Devstack with the Keystone IdP and with the Keystone SP: keystone.idp and keystone.sp. Both installations were made over Ubuntu 14.04 LTS virtual machines.

We also need to be aware about some SAML 2.0 SSO profiles. In the moment this tutorial was written, for K2K federation only the Enhanced Client or Proxy (ECP) profile could be used for authentication, handling SAML assertions and getting a OpenStack token.

Keystone as an IdP

Since the SP part remained the same as it was in the Icehouse Federation, this is the experimental phase.

First, we need to change some configurations at the keystone.conf file (usually located at /etc/keystone/keystone.conf):

1. Enable federation (OS-FEDERATION) extension

2. Add the capabilities to sign/generate SAML assertions

The configurations should be added within the [saml] area of the keystone.conffile. The mandatory ones are:

[saml]
certfile=/etc/keystone/ssl/certs/ca.pem  
keyfile=/etc/keystone/ssl/private/cakey.pem  
idp_entity_id=http://keystone.idp/v3/OS-FEDERATION/saml2/idp  
idp_sso_endpoint=http://keystone.idp/v3/OS-FEDERATION/saml2/sso  
idp_metadata_path=/etc/keystone/keystone_idp_metadata.xml  

Also, there are some optional configurations regarding the organization that can be skipped, but it's advised to provide them:

idp_organization_name=rodrigods  
idp_organization_display_name=rodrigods  
idp_organization_url=rodrigods.com  
idp_contact_company=rodrigods  
idp_contact_name=Rodrigo  
idp_contact_surname=Duarte  
idp_contact_email=rodrigodsousa@gmail.com  
idp_contact_telephone=555-55-5555  
idp_contact_type=technical  

3. Generate the IdP metadata

To be able to perform federated requests, we need to generate the IdP metadata via the keystone-manage CLI and provide it to the SP. Details on how to do the second part will be shown at the Keystone as an SP section.

keystone-manage saml_idp_metadata > /etc/keystone/keystone_idp_metadata.xml  

The output file should be the one pointed at the idp_metadata_path config.

4. Restart Keystone server

Once everything is in place, we can restart the Keystone server:

sudo service apache2 restart  

Or you can use the rejoin_stack.sh script (devstack/rejoin_stack.sh).

Keystone as a SP

The steps described here do not differ from a regular Icehouse federation setup.

In order to provide federation support as a Service Provider, Keystone consumes SAML assertions issued by external Identity Providers (for this guide, the IdP is another Keystone). This is done via Shibboleth SP, a single sign-in or logging-in system.

There are two main steps:
1. Install and configure Shibboleth SP
2. Enable the federation extension (same as for Keystone IdP)

Install and configure Shibboleth SP

Install Shibboleth package:

sudo apt-get install libapache2-mod-shib2  

Now we need to configure the Keystone virtual host to properly handle SAML2 workflow. The file where these configurations are placed is /etc/apache2/sites-available/keystone.conf.

First, we add WSGIScriptAliasMatch ^(/v3/OS-FEDERATION/identity_providers/.*?/protocols/.*?/auth)$ /var/www/keystone/main/$1 under the <VirtualHost *:5000> section
(the port 5000 is being used, otherwise it should be under <VirtualHost *:35357>, or both). Also, we need to append the following lines to the end of the file:

<Location /Shibboleth.sso>  
    SetHandler shib
</Location>

<LocationMatch /v3/OS-FEDERATION/identity_providers/.*?/protocols/saml2/auth>  
    ShibRequestSetting requireSession 1
    AuthType shibboleth
    ShibExportAssertion Off
    Require valid-user
</LocationMatch>  
  • Note: there are two configurations (ShibRequireAll and ShibRequireSession) from the regular tutorial that are omitted due conflicts with the version 2.4 from Apache (the one that comes with Ubuntu 14.04 LTS).

Also, we need to edit the Attribute Map file (/etc/shibboleth/attribute-map.xml) to add the attributes used by Keystone IdP:

<Attribute name="openstack_user" id="openstack_user"/>  
<Attribute name="openstack_roles" id="openstack_roles"/>  
<Attribute name="openstack_project" id="openstack_project"/>  

To configure Shibboleth we first need to generate a key-pair using the command below:

sudo shib-keygen  

Now we need to edit the /etc/shibboleth/shibboleth2.xml file to add the Keystone IdP entityID and MetadataProvider:

<SSO entityID="https://keystone.idp/v3/OS-FEDERATION/saml2/idp">  
    SAML2 SAML1
</SSO>

<MetadataProvider type="XML" file="/etc/shibboleth/keystone_idp_metadata.xml"/>  
  • Note: on the date on which this tutorial was written, there was a bug already with a fix in review that prevented the SP from automatically getting the IdP metadata via GET https://keystone.idp/v3/OS-FEDERATION/saml2/metadata. For this reason, the IdP metadata (/etc/keystone/keystone_idp_metadata.xml) was manually copied into the SP host.

We also need to remove the REMOTE_USER entry in the shibboleth2.xml file since it implies external authentication on Keystone:

sudo sed -r 's/REMOTE_USER="\w*"//' -i /etc/shibboleth/shibboleth2.xml  

Finally, ensure that shib2 module is enabled and restart Apache:

sudo a2enmod shib2  
sudo service apache2 restart  

Make Keystone IdP and SP know each other

For the Keystone IdP side, the Keystone SP appears as an external region. We should add the new region using the Keystone SP authentication URL, which is present at the SP's metadata and for the ECP profile it's usually something like http://keystone.sp/Shibboleth.sso/ECP.

For the SP, we need to add the IdP, create the mapping rules and register a protocol.

The examples in this post are using the python-keystoneclient. To perform most of the actions needed here it's necessary to have an admin scoped token. For a regular Devstack setup, the following method should be enough to build a client for this admin user:

import os

from keystoneclient.v3 import client

try:  
    # Used for creating the ADMIN user
    OS_PASSWORD = os.environ['OS_PASSWORD']
    OS_USERNAME = os.environ['OS_USERNAME']
    # This will vary according to the entity:
    # the IdP or the SP
    OS_AUTH_URL = os.environ['OS_AUTH_URL']
    OS_PROJECT_NAME = os.environ['OS_PROJECT_NAME']
    OS_DOMAIN_NAME = os.environ['OS_DOMAIN_NAME']
except KeyError as e:  
    raise SystemExit('%s environment variable not set.' % e)


def client_for_admin_user():  
    return client.Client(auth_url=OS_AUTH_URL,
                         username=OS_USERNAME,
                         password=OS_PASSWORD,
                         project_name=OS_PROJECT_NAME,
                         project_domain_name=OS_DOMAIN_NAME)

# Used to execute all admin actions
client = client.client_for_admin_user()  

Add the Keystone SP region in the IdP

To create a region to represent the Keystone SP we need to provide an id and a url:

def create_region(client, id, url):  
    try:
         r = client.regions.create(id=id, url=url)
    except:
         r = client.regions.find(id=id)
    return r

sp_region = create_region(client, 'keystone.sp', 'http://keystone.sp/Shibboleth.sso/SAML2/ECP')  

Setup the Keystone IdP in the SP

When we have a federated user accessing a Keystone SP, this user is mapped into a local domain and group. So we need to create the domain, the group and the role which the federated users comming from the Keystone IdP will be mapped to.

def create_domain(client, name):  
    try:
         d = client.domains.create(name=name)
    except:
         d = client.domains.find(name=name)
    return d

def create_group(client, name, domain):  
    try:
         g = client.groups.create(name=name, domain=domain)
    except:
         g = client.groups.find(name=name)
    return g

def create_role(client, name):  
    try:
        r = client.roles.create(name=name)
    except:
        r = client.roles.find(name=name)
    return r

print('\nCreating domain1')  
domain1 = create_domain(client, 'domain1')

print('\nCreating group1')  
group1 = create_group(client, 'group1', domain1)

print('\nCreating role Member')  
role1 = create_role(client, 'Member')

print('\nGrant role Member to group1 in domain1')  
client.roles.grant(role1, group=group1, domain=domain1)

print('\nList group1 role assignments')  
client.role_assignments.list(group=group1)  

Once we have created the necessary entities, we can register the mapping rules. In this example, we are going to map the openstack_user attribute from the SAML assertion to group1. To create a mapping we need to provide a mapping_id and a list containing the mapping rules.

def create_mapping(client, mapping_id, rules):  
    try:
        m = client.federation.mappings.create(
            mapping_id=mapping_id, rules=rules)
    except:
        m = client.federation.mappings.find(
            mapping_id=mapping_id)
    return m

print('Creating mapping')  
rules = [  
{
    "local": [
        {
            "user": {
                "name": "federated_user"
            },
            "group": {
                "id": group1.id
            }
        }
    ],
    "remote": [
        {
            "type": "openstack_user",
            "any_one_of": [
                "user1",
                "admin"
            ]
        }
    ]
}
]

mapping1 = create_mapping(client, mapping_id='keystone-idp-mapping', rules=rules)  

Now we can create the IdP and link it with the mapping created above using a protocol. We are going to call the IdP keystone-idp and protocol saml2. It's important to remember their ids because it is going to be part of the auth URL in the federation workflow.

def create_idp(client, id):  
    try:
        i = client.federation.identity_providers.create(id=id, enabled=True)
    except:
        i = client.federation.identity_providers.find(id=id)
    return i

def create_protocol(client, protocol_id, idp, mapping):  
    try:
        p = client.federation.protocols.create(protocol_id=protocol_id,
                                               identity_provider=idp,
                                               mapping=mapping)
    except:
        p = client.federation.protocols.find(protocol_id=protocol_id)
    return p


print('Register keystone-idp')  
idp1 = create_idp(client, id='keystone-idp')

print('\nRegister protocol')  
protocol1 = create_protocol(client, protocol_id='saml2', idp=idp1,  
                        mapping=mapping1)

Get a unscoped token from the SP using a SAML assertion generated by the Keystone IdP

Now that everything is in place, we need to be able to ask the Keystone IdP for a SAML assertion and use it to get an unscoped token from the SP. To ask for a SAML assertion we need to be authenticated and provide the token_id along with the region_id from the SP and do a GET http://keystone.idp/auth/OS-FEDERATION/saml2. The following script uses a recent concept added to python-keystoneclient called Session Objects:

import json  
import os  
import requests

from keystoneclient.auth.identity import v3  
from keystoneclient import session


class K2KClient(object):

    def __init__(self):
        self.token_id = os.environ.get('OS_TOKEN')
        # Keystone SP region
        self.region_id = os.environ.get('OS_REGION')
        # Keystone IdP auth URL
        self.auth_url = os.environ.get('OS_AUTH_URL')
        self.project_id = os.environ.get('OS_PROJECT_ID')
        self.username = os.environ.get('OS_USERNAME')
        self.password = os.environ.get('OS_PASSWORD')
        self.domain_id = os.environ.get('OS_DOMAIN_ID')
        self.session = requests.Session()
        self.verify = False

    def v3_authenticate(self):
        auth = v3.Password(auth_url=self.auth_url,
                           username=self.username,
                           password=self.password,
                           user_domain_id=self.domain_id,
                           project_id=self.project_id)

        self.auth_session = session.Session(session=requests.session(),
                                       auth=auth, verify=self.verify)
        auth_ref = self.auth_session.auth.get_auth_ref(self.auth_session)
        self.token = self.auth_session.auth.get_token(self.auth_session)

    def _generate_token_json(self):
        return {
            "auth": {
                "identity": {
                    "methods": [
                        "token"
                    ],
                    "token": {
                        "id": self.token
                    }
                },
                "scope": {
                    "region": {
                        "id": self.region_id
                    }
                }
            }
        }

    def get_saml2_assertion(self):
        token = json.dumps(self._generate_token_json())
        url = self.auth_url + '/auth/OS-FEDERATION/saml2'
        r = self.session.post(url=url,
                              data=token,
                              verify=self.verify)
        if not r.ok:
            raise Exception("Something went wrong, %s" % r.__dict__)
        self.assertion = r.text


if __name__ == "__main__":  
    client = K2KClient()
    client.v3_authenticate()
    client.get_saml2_assertion()
    print('SAML assertion: %s' % client.assertion)

Since we are using ECP, we need to send a SOAP envelope to the SP containing the SAML assertion generated above. We are going to add a transform_assertion_into_ecp() method in the K2KClient object:

   def transform_assertion_into_ecp(self):
        TEMPLATE = """<soap11:Envelope
        xmlns:soap11="http://schemas.xmlsoap.org/soap/envelope/"><soap11:Header><ecp:Relay
State  
        xmlns:ecp="urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp"
        soap11:actor="http://schemas.xmlsoap.org/soap/actor/next"
        soap11:mustUnderstand="1">ss:mem:f88cd8ad5aeee3456e74900b306b5ed54ec9fb23c614f9fa7
3ece1c97ec004ed</ecp:RelayState><samlec:GeneratedKey  
        xmlns:samlec="urn:ietf:params:xml:ns:samlec"
        soap11:actor="http://schemas.xmlsoap.org/soap/actor/next">yvYbdh49qSJ7LqjFv+rfB8SR
97hPWMwQkL0KKOgSkhY=</samlec:GeneratedKey></soap11:Header>  
        <soap11:Body>%(response)s</soap11:Body></soap11:Envelope>"""

        assertion = '\n'.join(self.assertion.split('\n')[1:])
        assertion = assertion.replace('\n', '')
        self.ecp_assertion = TEMPLATE % {'response': assertion}

...

    client.transform_assertion_into_ecp()
    print("ECP assertion: %s" % client.ecp_assertion)
  • Note: Many thanks to Marek Denis (marekd on irc.freenode.net) for providing the code to handle SAML assertions + ECP part.

The final step is to send the ECP assertion to the Keystone SP URL: POST http://keystone.sp/Shibboleth.sso/ECP, it should respond with a redirect (302) and then the IdP client should do a GET http://keystone.sp/v3/OS-FEDERATION/identity_providers/kestone-idp/protocols/saml2/auth. In this last step, the SP whould respond with the unscoped token: header X-Subject-Token with the token_id and a JSON body containing the full token:

    def _get_sp_url(self):
        url = self.auth_url + '/regions/' + self.region_id
        r = self.auth_session.get(
           url=url,
           verify=self.verify)
        if not r.ok:
            raise Exception("Something went wrong, %s" % r.__dict__)

        region = json.loads(r.text)[u'region']
        return region[u'url']

    def _handle_http_302_ecp_redirect(self, response, method, **kwargs):
        location = os.environ.get('OS_SP_AUTH')
        # We are not following the redirect URL, but the one at OS_SP_AUTH,
        # for our example is
        # http://keystone.sp/v3/OS-FEDERATION/identity_providers/kestone-idp/protocols/saml2/auth
        return self.auth_session.request(location, method, authenticated=False, **kwargs)

    def exchange_assertion(self):
        """Send assertion to a Keystone SP and get token."""
        self.sp_url = self._get_sp_url()
        r = self.auth_session.post(
            self.sp_url,
            headers={'Content-Type': 'application/vnd.paos+xml'},
            data=self.ecp_assertion,
            authenticated=False, redirect=False)

        r = self._handle_http_302_ecp_redirect(r, 'GET',
            headers={'Content-Type': 'application/vnd.paos+xml'})

        self.fed_token_id = r.headers['X-Subject-Token']
        self.fed_token = r.text

...

    client.exchange_assertion()
    print('Unscoped token_id: %s' % client.fed_token_id)
    print('Unscoped token body:\n%s' % client.fed_token)

Here is an example of such unscoped token:

Unscoped token_id: 8589481025d043d2a6233684014df374  
Unscoped token body:  
{
   "token":{
      "methods":[
         "saml2"
      ],
      "expires_at":"2014-11-03T21:09:02.283467Z",
      "extras":{

      },
      "user":{
         "OS-FEDERATION":{
            "identity_provider":{
               "id":"keystone-idp"
            },
            "protocol":{
               "id":"saml2"
            },
            "groups":[
               {
                  "id":"f91e5cd3480b4ca8b53a5cc2f9f3c37e"
               }
            ]
         },
         "id":"%7B0%7D",
         "name":"{0}"
      },
      "audit_ids":[
         "KXStRZkySRCkoXQncw2VNg"
      ],
      "issued_at":"2013-11-04T20:09:02.283506Z"
   }
}
  • Note: During the tests made, the Keystone SP wasn't able to validate the Keystone IdP certificate (self-signed). For this reason, since we were deploying a test enviroment, the NullSecurity rule was used in the Security Policy file from Shibboleth (this file is usually located at /etc/shibboleth/security-policy.xml):
<SecurityPolicies xmlns="urn:mace:shibboleth:2.0:native:sp:config">  
    <Policy id="default" validate="false">
        <PolicyRule type="NullSecurity"/>
    </Policy>
</SecurityPolicies>  
  • Note: Thanks to Guang Yee (gyee on irc.freenode.net), this bug was fixed and it is not necessary to disable Shibboleth's Security Policy in order to make this work: https://review.openstack.org/#/c/150190/

After getting an unscoped token, we can get an scoped one by using the regular federation approach (Icehouse), than we can use it to request resources from the Keystone SP.

comments powered by Disqus