IdP SAML Metadata generator

Keystone acting as a SAML2 Identity Provider needs to be able to
generate and expose metadata that is consumed by federated
Service Providers.

Co-Authored-By: Steve Martinelli <stevemar@ca.ibm.com>
Change-Id: I9e4b2f068a8190215749b95f31d634eb09c1e3f1
Implements: bp keystone-to-keystone-federation
This commit is contained in:
Marek Denis 2014-08-18 00:50:44 +02:00 committed by Steve Martinelli
parent 6a3ad23805
commit 57ca6e2358
5 changed files with 365 additions and 1 deletions

View File

@ -1294,6 +1294,52 @@
# contain a comma. (string value)
#keyfile=/etc/keystone/ssl/private/signing_key.pem
# Entity ID value for unique Identity Provider identification.
# Usually FQDN is set with a suffix. A value is required to
# generate IDP Metadata. For example:
# https://keystone.example.com/v3/OS-FEDERATION/saml2/idp
# (string value)
#idp_entity_id=<None>
# Identity Provider Single-Sign-On service value, required in
# the Identity Provider's metadata. A value is required to
# generate IDP Metadata. For example:
# https://keystone.example.com/v3/OS-FEDERATION/saml2/sso
# (string value)
#idp_sso_endpoint=<None>
# Language used by the organization. (string value)
#idp_lang=en
# Organization name the installation belongs to. (string
# value)
#idp_organization_name=<None>
# Organization name to be displayed. (string value)
#idp_organization_display_name=<None>
# URL of the organization. (string value)
#idp_organization_url=<None>
# Company of contact person. (string value)
#idp_contact_company=<None>
# Given name of contact person (string value)
#idp_contact_name=<None>
# Surname of contact person. (string value)
#idp_contact_surname=<None>
# Email address of contact person. (string value)
#idp_contact_email=<None>
# Telephone number of contact person. (string value)
#idp_contact_telephone=<None>
# Contact type. Allowed values are: technical, support,
# administrative billing, and other (string value)
#idp_contact_type=other
[signing]

View File

@ -824,6 +824,40 @@ FILE_OPTIONS = {
default=_KEYFILE,
help='Path of the keyfile for SAML signing. Note, the path '
'cannot contain a comma.'),
cfg.StrOpt('idp_entity_id',
help='Entity ID value for unique Identity Provider '
'identification. Usually FQDN is set with a suffix. '
'A value is required to generate IDP Metadata. '
'For example: https://keystone.example.com/v3/'
'OS-FEDERATION/saml2/idp'),
cfg.StrOpt('idp_sso_endpoint',
help='Identity Provider Single-Sign-On service value, '
'required in the Identity Provider\'s metadata. '
'A value is required to generate IDP Metadata. '
'For example: https://keystone.example.com/v3/'
'OS-FEDERATION/saml2/sso'),
cfg.StrOpt('idp_lang', default='en',
help='Language used by the organization.'),
cfg.StrOpt('idp_organization_name',
help='Organization name the installation belongs to.'),
cfg.StrOpt('idp_organization_display_name',
help='Organization name to be displayed.'),
cfg.StrOpt('idp_organization_url',
help='URL of the organization.'),
cfg.StrOpt('idp_contact_company',
help='Company of contact person.'),
cfg.StrOpt('idp_contact_name',
help='Given name of contact person'),
cfg.StrOpt('idp_contact_surname',
help='Surname of contact person.'),
cfg.StrOpt('idp_contact_email',
help='Email address of contact person.'),
cfg.StrOpt('idp_contact_telephone',
help='Telephone number of contact person.'),
cfg.StrOpt('idp_contact_type', default='other',
help='Contact type. Allowed values are: '
'technical, support, administrative '
'billing, and other'),
],
}

View File

@ -16,13 +16,15 @@ import subprocess
import uuid
import saml2
from saml2 import md
from saml2 import saml
from saml2 import samlp
from saml2 import sigver
import xmldsig
from keystone.common import config
from keystone import exception
from keystone.i18n import _LE
from keystone.i18n import _, _LE
from keystone.openstack.common import fileutils
from keystone.openstack.common import log
from keystone.openstack.common import timeutils
@ -413,3 +415,141 @@ def _sign_assertion(assertion):
pass
return saml2.create_class_from_xml_string(saml.Assertion, stdout)
class MetadataGenerator(object):
"""A class for generating SAML IdP Metadata."""
def generate_metadata(self):
"""Generate Identity Provider Metadata.
Generate and format metadata into XML that can be exposed and
consumed by a federated Service Provider.
:return: XML <EntityDescriptor> object.
:raises: keystone.exception.ValidationError: Raises if the required
config options aren't set.
"""
self._ensure_required_values_present()
entity_descriptor = self._create_entity_descriptor()
entity_descriptor.idpsso_descriptor = (
self._create_idp_sso_descriptor())
return entity_descriptor
def _create_entity_descriptor(self):
ed = md.EntityDescriptor()
ed.entity_id = CONF.saml.idp_entity_id
return ed
def _create_idp_sso_descriptor(self):
def get_cert():
try:
return sigver.read_cert_from_file(CONF.saml.certfile, 'pem')
except (IOError, sigver.CertificateError) as e:
msg = _('Cannot open certificate %(cert_file)s. '
'Reason: %(reason)s')
msg = msg % {'cert_file': CONF.saml.certfile, 'reason': e}
LOG.error(msg)
raise IOError(msg)
def key_descriptor():
cert = get_cert()
return md.KeyDescriptor(
key_info=xmldsig.KeyInfo(
x509_data=xmldsig.X509Data(
x509_certificate=xmldsig.X509Certificate(text=cert)
)
), use='signing'
)
def single_sign_on_service():
idp_sso_endpoint = CONF.saml.idp_sso_endpoint
return md.SingleSignOnService(
binding=saml2.BINDING_URI,
location=idp_sso_endpoint)
def organization():
name = md.OrganizationName(lang=CONF.saml.idp_lang,
text=CONF.saml.idp_organization_name)
display_name = md.OrganizationDisplayName(
lang=CONF.saml.idp_lang,
text=CONF.saml.idp_organization_display_name)
url = md.OrganizationURL(lang=CONF.saml.idp_lang,
text=CONF.saml.idp_organization_url)
return md.Organization(
organization_display_name=display_name,
organization_url=url, organization_name=name)
def contact_person():
company = md.Company(text=CONF.saml.idp_contact_company)
given_name = md.GivenName(text=CONF.saml.idp_contact_name)
surname = md.SurName(text=CONF.saml.idp_contact_surname)
email = md.EmailAddress(text=CONF.saml.idp_contact_email)
telephone = md.TelephoneNumber(
text=CONF.saml.idp_contact_telephone)
contact_type = CONF.saml.idp_contact_type
return md.ContactPerson(
company=company, given_name=given_name, sur_name=surname,
email_address=email, telephone_number=telephone,
contact_type=contact_type)
def name_id_format():
return md.NameIDFormat(text=saml.NAMEID_FORMAT_TRANSIENT)
idpsso = md.IDPSSODescriptor()
idpsso.protocol_support_enumeration = samlp.NAMESPACE
idpsso.key_descriptor = key_descriptor()
idpsso.single_sign_on_service = single_sign_on_service()
idpsso.name_id_format = name_id_format()
if self._check_organization_values():
idpsso.organization = organization()
if self._check_contact_person_values():
idpsso.contact_person = contact_person()
return idpsso
def _ensure_required_values_present(self):
"""Ensure idp_sso_endpoint and idp_entity_id have values."""
if CONF.saml.idp_entity_id is None:
msg = _('Ensure configuration option idp_entity_id is set.')
raise exception.ValidationError(msg)
if CONF.saml.idp_sso_endpoint is None:
msg = _('Ensure configuration option idp_sso_endpoint is set.')
raise exception.ValidationError(msg)
def _check_contact_person_values(self):
"""Determine if contact information is included in metadata."""
# Check if we should include contact information
params = [CONF.saml.idp_contact_company,
CONF.saml.idp_contact_name,
CONF.saml.idp_contact_surname,
CONF.saml.idp_contact_email,
CONF.saml.idp_contact_telephone]
for value in params:
if value is None:
return False
# Check if contact type is an invalid value
valid_type_values = ['technical', 'other', 'support', 'administrative',
'billing']
if CONF.saml.idp_contact_type not in valid_type_values:
msg = _('idp_contact_type must be one of: [technical, other, '
'support, administrative or billing.')
raise exception.ValidationError(msg)
return True
def _check_organization_values(self):
"""Determine if organization information is included in metadata."""
params = [CONF.saml.idp_organization_name,
CONF.saml.idp_organization_display_name,
CONF.saml.idp_organization_url]
for value in params:
if value is None:
return False
return True

View File

@ -0,0 +1,28 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
IDP_ENTITY_ID = 'https://localhost/v3/OS-FEDERATION/saml2/idp'
IDP_SSO_ENDPOINT = 'https://localhost/v3/OS-FEDERATION/saml2/SSO'
# Organization info
IDP_ORGANIZATION_NAME = 'ACME INC'
IDP_ORGANIZATION_DISPLAY_NAME = 'ACME'
IDP_ORGANIZATION_URL = 'https://acme.example.com'
# Contact info
IDP_CONTACT_COMPANY = 'ACME Sub'
IDP_CONTACT_GIVEN_NAME = 'Joe'
IDP_CONTACT_SURNAME = 'Hacker'
IDP_CONTACT_EMAIL = 'joe@acme.example.com'
IDP_CONTACT_TELEPHONE_NUMBER = '1234567890'
IDP_CONTACT_TYPE = 'technical'

View File

@ -34,6 +34,7 @@ from keystone import exception
from keystone import notifications
from keystone.openstack.common import jsonutils
from keystone.openstack.common import log
from keystone.tests import federation_fixtures
from keystone.tests import mapping_fixtures
from keystone.tests import test_v3
@ -1895,3 +1896,118 @@ class SAMLGenerationTests(FederationTests):
token_id = uuid.uuid4().hex
body = self._create_generate_saml_request(token_id, region_id)
self.post(self.SAML_GENERATION_ROUTE, body=body, expected_status=404)
class IdPMetadataGenerationTests(FederationTests):
"""A class for testing Identity Provider Metadata generation."""
def setUp(self):
super(IdPMetadataGenerationTests, self).setUp()
self.generator = keystone_idp.MetadataGenerator()
def config_overrides(self):
super(IdPMetadataGenerationTests, self).config_overrides()
self.config_fixture.config(
group='saml',
idp_entity_id=federation_fixtures.IDP_ENTITY_ID,
idp_sso_endpoint=federation_fixtures.IDP_SSO_ENDPOINT,
idp_organization_name=federation_fixtures.IDP_ORGANIZATION_NAME,
idp_organization_display_name=(
federation_fixtures.IDP_ORGANIZATION_DISPLAY_NAME),
idp_organization_url=federation_fixtures.IDP_ORGANIZATION_URL,
idp_contact_company=federation_fixtures.IDP_CONTACT_COMPANY,
idp_contact_name=federation_fixtures.IDP_CONTACT_GIVEN_NAME,
idp_contact_surname=federation_fixtures.IDP_CONTACT_SURNAME,
idp_contact_email=federation_fixtures.IDP_CONTACT_EMAIL,
idp_contact_telephone=(
federation_fixtures.IDP_CONTACT_TELEPHONE_NUMBER),
idp_contact_type=federation_fixtures.IDP_CONTACT_TYPE)
def test_check_entity_id(self):
metadata = self.generator.generate_metadata()
self.assertEqual(federation_fixtures.IDP_ENTITY_ID, metadata.entity_id)
def test_metadata_validity(self):
"""Call md.EntityDescriptor method that does internal verification."""
self.generator.generate_metadata().verify()
def test_serialize_metadata_object(self):
"""Check whether serialization doesn't raise any exceptions."""
self.generator.generate_metadata().to_string()
# TODO(marek-denis): Check values here
def test_check_idp_sso(self):
metadata = self.generator.generate_metadata()
idpsso_descriptor = metadata.idpsso_descriptor
self.assertIsNotNone(metadata.idpsso_descriptor)
self.assertEqual(federation_fixtures.IDP_SSO_ENDPOINT,
idpsso_descriptor.single_sign_on_service.location)
self.assertIsNotNone(idpsso_descriptor.organization)
organization = idpsso_descriptor.organization
self.assertEqual(federation_fixtures.IDP_ORGANIZATION_DISPLAY_NAME,
organization.organization_display_name.text)
self.assertEqual(federation_fixtures.IDP_ORGANIZATION_NAME,
organization.organization_name.text)
self.assertEqual(federation_fixtures.IDP_ORGANIZATION_URL,
organization.organization_url.text)
self.assertIsNotNone(idpsso_descriptor.contact_person)
contact_person = idpsso_descriptor.contact_person
self.assertEqual(federation_fixtures.IDP_CONTACT_GIVEN_NAME,
contact_person.given_name.text)
self.assertEqual(federation_fixtures.IDP_CONTACT_SURNAME,
contact_person.sur_name.text)
self.assertEqual(federation_fixtures.IDP_CONTACT_EMAIL,
contact_person.email_address.text)
self.assertEqual(federation_fixtures.IDP_CONTACT_TELEPHONE_NUMBER,
contact_person.telephone_number.text)
self.assertEqual(federation_fixtures.IDP_CONTACT_TYPE,
contact_person.contact_type)
def test_metadata_no_organization(self):
self.config_fixture.config(
group='saml',
idp_organization_display_name=None,
idp_organization_url=None,
idp_organization_name=None)
metadata = self.generator.generate_metadata()
idpsso_descriptor = metadata.idpsso_descriptor
self.assertIsNotNone(metadata.idpsso_descriptor)
self.assertIsNone(idpsso_descriptor.organization)
self.assertIsNotNone(idpsso_descriptor.contact_person)
def test_metadata_no_contact_person(self):
self.config_fixture.config(
group='saml',
idp_contact_name=None,
idp_contact_surname=None,
idp_contact_email=None,
idp_contact_telephone=None)
metadata = self.generator.generate_metadata()
idpsso_descriptor = metadata.idpsso_descriptor
self.assertIsNotNone(metadata.idpsso_descriptor)
self.assertIsNotNone(idpsso_descriptor.organization)
self.assertEqual([], idpsso_descriptor.contact_person)
def test_metadata_invalid_contact_type(self):
self.config_fixture.config(
group='saml',
idp_contact_type="invalid")
self.assertRaises(exception.ValidationError,
self.generator.generate_metadata)
def test_metadata_invalid_idp_sso_endpoint(self):
self.config_fixture.config(
group='saml',
idp_sso_endpoint=None)
self.assertRaises(exception.ValidationError,
self.generator.generate_metadata)
def test_metadata_invalid_idp_entity_id(self):
self.config_fixture.config(
group='saml',
idp_entity_id=None)
self.assertRaises(exception.ValidationError,
self.generator.generate_metadata)