Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor the OneLogin::RubySaml::Metadata class #602

Merged
merged 6 commits into from
Aug 16, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -821,3 +821,27 @@ end
```

The `attribute_value` option additionally accepts an array of possible values.

## Custom Metadata Fields

Some IdPs may require to add SPs to add additional fields (Organization, ContactPerson, etc.)
into the SP metadata. This can be acheived by extending the `OneLogin::RubySaml::Metadata`
class and overriding the `#add_extras` method as per the following example:

```ruby
class MyMetadata < OneLogin::RubySaml::Metadata
def add_extras(root, _settings)
org = root.add_element("md:Organization")
org.add_element("md:OrganizationName", 'xml:lang' => "en-US").text = 'ACME Inc.'
org.add_element("md:OrganizationDisplayName", 'xml:lang' => "en-US").text = 'ACME'
org.add_element("md:OrganizationURL", 'xml:lang' => "en-US").text = 'https://www.acme.com'

cp = root.add_element("md:ContactPerson", 'contactType' => 'technical')
cp.add_element("md:GivenName").text = 'ACME SAML Team'
cp.add_element("md:EmailAddress").text = '[email protected]'
end
end

# Output XML with custom metadata
MyMetadata.new.generate(settings)
```
81 changes: 59 additions & 22 deletions lib/onelogin/ruby-saml/metadata.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,50 @@ class Metadata
#
def generate(settings, pretty_print=false, valid_until=nil, cache_duration=nil)
meta_doc = XMLSecurity::Document.new
add_xml_declaration(meta_doc)
root = add_root_element(meta_doc, settings, valid_until, cache_duration)
sp_sso = add_sp_sso_element(root, settings)
add_sp_certificates(sp_sso, settings)
add_sp_service_elements(sp_sso, settings)
add_extras(root, settings)
embed_signature(meta_doc, settings)
output_xml(meta_doc, pretty_print)
end

protected

def add_xml_declaration(meta_doc)
meta_doc << REXML::XMLDecl.new('1.0', 'UTF-8')
end

def add_root_element(meta_doc, settings, valid_until, cache_duration)
namespaces = {
"xmlns:md" => "urn:oasis:names:tc:SAML:2.0:metadata"
}

if settings.attribute_consuming_service.configured?
namespaces["xmlns:saml"] = "urn:oasis:names:tc:SAML:2.0:assertion"
end
root = meta_doc.add_element "md:EntityDescriptor", namespaces
sp_sso = root.add_element "md:SPSSODescriptor", {

root = meta_doc.add_element("md:EntityDescriptor", namespaces)
root.attributes["ID"] = OneLogin::RubySaml::Utils.uuid
root.attributes["entityID"] = settings.sp_entity_id if settings.sp_entity_id
root.attributes["validUntil"] = valid_until.strftime('%Y-%m-%dT%H:%M:%S%z') if valid_until
root.attributes["cacheDuration"] = "PT" + cache_duration.to_s + "S" if cache_duration
root
end

def add_sp_sso_element(root, settings)
root.add_element "md:SPSSODescriptor", {
"protocolSupportEnumeration" => "urn:oasis:names:tc:SAML:2.0:protocol",
"AuthnRequestsSigned" => settings.security[:authn_requests_signed],
"WantAssertionsSigned" => settings.security[:want_assertions_signed],
}
end

# Add KeyDescriptor if messages will be signed / encrypted
# with SP certificate, and new SP certificate if any
# Add KeyDescriptor if messages will be signed / encrypted
# with SP certificate, and new SP certificate if any
def add_sp_certificates(sp_sso, settings)
cert = settings.get_sp_cert
cert_new = settings.get_sp_cert_new

Expand All @@ -58,27 +87,23 @@ def generate(settings, pretty_print=false, valid_until=nil, cache_duration=nil)
end
end

root.attributes["ID"] = OneLogin::RubySaml::Utils.uuid
if settings.sp_entity_id
root.attributes["entityID"] = settings.sp_entity_id
end
if valid_until
root.attributes["validUntil"] = valid_until.strftime('%Y-%m-%dT%H:%M:%S%z')
end
if cache_duration
root.attributes["cacheDuration"] = "PT" + cache_duration.to_s + "S"
end
sp_sso
end

def add_sp_service_elements(sp_sso, settings)
if settings.single_logout_service_url
sp_sso.add_element "md:SingleLogoutService", {
"Binding" => settings.single_logout_service_binding,
"Location" => settings.single_logout_service_url,
"ResponseLocation" => settings.single_logout_service_url
}
end

if settings.name_identifier_format
nameid = sp_sso.add_element "md:NameIDFormat"
nameid.text = settings.name_identifier_format
end

if settings.assertion_consumer_service_url
sp_sso.add_element "md:AssertionConsumerService", {
"Binding" => settings.assertion_consumer_service_binding,
Expand Down Expand Up @@ -117,23 +142,35 @@ def generate(settings, pretty_print=false, valid_until=nil, cache_duration=nil)
# <md:RoleDescriptor xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:query="urn:oasis:names:tc:SAML:metadata:ext:query" xsi:type="query:AttributeQueryDescriptorType" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>
# <md:XACMLAuthzDecisionQueryDescriptor WantAssertionsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"/>

meta_doc << REXML::XMLDecl.new("1.0", "UTF-8")
sp_sso
end

# embed signature
if settings.security[:metadata_signed] && settings.private_key && settings.certificate
private_key = settings.get_sp_key
meta_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
end
# can be overridden in subclass
def add_extras(root, _settings)
root
end

def embed_signature(meta_doc, settings)
return unless settings.security[:metadata_signed]

private_key = settings.get_sp_key
cert = settings.get_sp_cert
return unless private_key && cert

meta_doc.sign_document(private_key, cert, settings.security[:signature_method], settings.security[:digest_method])
johnnyshields marked this conversation as resolved.
Show resolved Hide resolved
end

def output_xml(meta_doc, pretty_print)
ret = ''

ret = ""
# pretty print the XML so IdP administrators can easily see what the SP supports
if pretty_print
meta_doc.write(ret, 1)
else
ret = meta_doc.to_s
end

return ret
ret
end
end
end
Expand Down
12 changes: 5 additions & 7 deletions lib/xml_security.rb
Original file line number Diff line number Diff line change
Expand Up @@ -159,15 +159,13 @@ def sign_document(private_key, certificate, signature_method = RSA_SHA1, digest_
x509_cert_element.text = Base64.encode64(certificate.to_der).gsub(/\n/, "")

# add the signature
issuer_element = self.elements["//saml:Issuer"]
issuer_element = elements["//saml:Issuer"]
if issuer_element
self.root.insert_after issuer_element, signature_element
root.insert_after(issuer_element, signature_element)
elsif first_child = root.children[0]
root.insert_before(first_child, signature_element)
else
if sp_sso_descriptor = self.elements["/md:EntityDescriptor"]
self.root.insert_before sp_sso_descriptor, signature_element
else
self.root.add_element(signature_element)
end
root.add_element(signature_element)
end
end

Expand Down
47 changes: 45 additions & 2 deletions test/metadata_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ class MetadataTest < Minitest::Test
assert_match %r[<ds:SignatureValue>([a-zA-Z0-9/+=]+)</ds:SignatureValue>]m, xml_text
assert_match %r[<ds:SignatureMethod Algorithm='http://www.w3.org/2000/09/xmldsig#rsa-sha1'/>], xml_text
assert_match %r[<ds:DigestMethod Algorithm='http://www.w3.org/2000/09/xmldsig#sha1'/>], xml_text

signed_metadata = XMLSecurity::SignedDocument.new(xml_text)
assert signed_metadata.validate_document(ruby_saml_cert_fingerprint, false)

Expand All @@ -331,9 +332,51 @@ class MetadataTest < Minitest::Test
assert_match %r[<ds:SignatureMethod Algorithm='http://www.w3.org/2001/04/xmldsig-more#rsa-sha256'/>], xml_text
assert_match %r[<ds:DigestMethod Algorithm='http://www.w3.org/2001/04/xmlenc#sha512'/>], xml_text

signed_metadata_2 = XMLSecurity::SignedDocument.new(xml_text)
signed_metadata = XMLSecurity::SignedDocument.new(xml_text)
assert signed_metadata.validate_document(ruby_saml_cert_fingerprint, false)

assert validate_xml!(xml_text, "saml-schema-metadata-2.0.xsd")
end
end

describe "when custom metadata elements have been inserted" do
let(:xml_text) { subclass.new.generate(settings, false) }
let(:subclass) do
Class.new(OneLogin::RubySaml::Metadata) do
def add_extras(root, _settings)
idp = REXML::Element.new("md:IDPSSODescriptor")
idp.attributes['protocolSupportEnumeration'] = 'urn:oasis:names:tc:SAML:2.0:protocol'

nid = REXML::Element.new("md:NameIDFormat")
nid.text = 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress'
idp.add_element(nid)

sso = REXML::Element.new("md:SingleSignOnService")
sso.attributes['Binding'] = 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST'
sso.attributes['Location'] = 'https://foobar.com/sso'
idp.add_element(sso)
root.insert_before(root.children[0], idp)

org = REXML::Element.new("md:Organization")
org.add_element("md:OrganizationName", 'xml:lang' => "en-US").text = 'ACME Inc.'
org.add_element("md:OrganizationDisplayName", 'xml:lang' => "en-US").text = 'ACME'
org.add_element("md:OrganizationURL", 'xml:lang' => "en-US").text = 'https://www.acme.com'
root.insert_after(root.children[3], org)
end
end
end

it "inserts signature as the first child of root element" do
first_child = xml_doc.root.children[0]
assert_equal first_child.prefix, 'ds'
assert_equal first_child.name, 'Signature'

assert_match %r[<ds:SignatureValue>([a-zA-Z0-9/+=]+)</ds:SignatureValue>]m, xml_text
assert_match %r[<ds:SignatureMethod Algorithm='http://www.w3.org/2000/09/xmldsig#rsa-sha1'/>], xml_text
assert_match %r[<ds:DigestMethod Algorithm='http://www.w3.org/2000/09/xmldsig#sha1'/>], xml_text

assert signed_metadata_2.validate_document(ruby_saml_cert_fingerprint, false)
signed_metadata = XMLSecurity::SignedDocument.new(xml_text)
assert signed_metadata.validate_document(ruby_saml_cert_fingerprint, false)

assert validate_xml!(xml_text, "saml-schema-metadata-2.0.xsd")
end
Expand Down