package IdPAccountManager::SAMLMetadata; use strict; use warnings; use English qw(-no_match_vars); use XML::LibXML; use IdPAccountManager::Tools; sub new { my ($pkg, %args) = @_; die "missing argument 'file'" unless defined $args{file}; die "non-existing file $args{file}" unless -f $args{file}; die "non-readable file $args{file}" unless -r $args{file}; my $doc; eval { $doc = XML::LibXML->load_xml(location => $args{file}); }; die "Failed to parse file: $EVAL_ERROR" if $EVAL_ERROR; my $root = $doc->documentElement(); my $type = $root->nodeName(); die "incorrect root element type '$type' for file $args{file}, should be 'EntitiesDescriptor'" unless $type =~ /EntitiesDescriptor$/; my $self = { file => $args{file}, doc => $doc }; bless $self, $pkg; return $self; } ## Parse XML structure of metadata to fill a hashref sub parse { my ($self, %args) = @_; my %parser_args = ( metadata_as_xml => $self->{doc}, filter_entity_type => 'sp' ); if ($args{filter_entity_id}) { $parser_args{filter_entity_id} = $args{filter_entity_id}; } $self->{federation_metadata_as_hashref} = $self->_parse_saml_metadata(%parser_args); die "Failed to parse federation metadata" unless defined $self->{federation_metadata_as_hashref}; return 1; } ## Dumps the SAML metadata content sub print { my ($self, $fd) = @_; $fd = \*STDOUT unless $fd; my $root = $self->{doc}->documentElement(); print $fd $root->toString(); } ## Parse a SAML federation metadata file sub _parse_saml_metadata { my ($self, %args) = @_; my $root = $args{metadata_as_xml}; my @extracted_array; foreach my $EntityDescriptor ( @{ $root->getElementsByLocalName('EntityDescriptor') }) { my $extracted_data = {}; if ($EntityDescriptor->hasAttributes()) { foreach my $attr ($EntityDescriptor->getAttribute('entityID')) { $extracted_data->{entityid} = $attr; } } next if ($args{filter_entity_id} && ($args{filter_entity_id} ne $extracted_data->{entityid})); $extracted_data->{xml_md} = IdPAccountManager::Tools::escape_xml($EntityDescriptor->toString()); foreach my $child ($EntityDescriptor->childNodes()) { ## Ignoringnodes of type XML::LibXML::Text or XML::LibXML::Comment next unless (ref($child) =~ /^XML::LibXML::Element/); if ($child->nodeName =~ /IDPSSODescriptor$/) { $extracted_data->{type} = 'idp'; foreach my $sso ( $child->getElementsByLocalName('SingleSignOnService')) { ## On ne prend en compte que les endpoints prévus #next unless ($sso->getAttribute('Binding') && defined $supported_saml_bindings{$sso->getAttribute('Binding')}); ## On extrait les infos sur les endpoints push @{ $extracted_data->{idp_endpoints} }, { type => 'SingleSignOnService', binding => $sso->getAttribute('Binding'), location => $sso->getAttribute('Location'), }; } ## Getting domains declared for scoped attributes foreach my $scope ($child->getElementsByLocalName('Scope')) { push @{ $extracted_data->{domain} }, $scope->textContent(); } } elsif ($child->nodeName =~ /SPSSODescriptor$/) { $extracted_data->{type} = 'sp'; ## We check the Binding of the ACS that should match "urn:oasis:names:tc:SAML:1.0:profiles:browser-post" ## We also check the index to select the ACS that has the lower index my ($index_saml1, $index_saml2); foreach my $sso ( $child->getElementsByLocalName('AssertionConsumerService')) { ## Extracting endpoints information push @{ $extracted_data->{sp_endpoints} }, { type => 'AssertionConsumerService', binding => $sso->getAttribute('Binding'), location => $sso->getAttribute('Location'), index => $sso->getAttribute('index'), isdefault => IdPAccountManager::Tools::boolean2integer( $sso->getAttribute('isDefault') ) }; } foreach my $requestedattribute ( $child->getElementsByLocalName('RequestedAttribute')) { ## Requested attributes information push @{ $extracted_data->{requested_attribute} }, { 'friendly_name' => IdPAccountManager::Tools::encode_utf8( $requestedattribute->getAttribute('FriendlyName') ), 'name' => IdPAccountManager::Tools::encode_utf8( $requestedattribute->getAttribute('Name') ), 'is_required' => IdPAccountManager::Tools::boolean2integer( $requestedattribute->getAttribute('isRequired') ) }; } } elsif ($child->nodeName =~ /Extensions$/) { foreach my $registrationinfo ( $child->getElementsByLocalName('RegistrationInfo')) { $extracted_data->{registration_info} {registration_authority} = $registrationinfo->getAttribute('registrationAuthority'); $extracted_data->{registration_info} {registration_instant} = $registrationinfo->getAttribute('registrationInstant'); foreach my $registrationpolicy ( $registrationinfo->getElementsByLocalName( 'RegistrationPolicy') ) { if ($registrationpolicy->getAttribute('lang') eq 'en') { $extracted_data->{registration_info} {registration_policy} = IdPAccountManager::Tools::encode_utf8( $registrationpolicy->textContent()); } } } } elsif ($child->nodeName =~ /ContactPerson$/) { my %contact_details; $contact_details{type} = $child->getAttribute('contactType'); if (defined $contact_details{type}) { foreach my $contact_child ($child->childNodes()) { $contact_details{ $contact_child->localName } = IdPAccountManager::Tools::encode_utf8( $contact_child->textContent()); } push @{ $extracted_data->{contacts} }, \%contact_details; } } foreach my $displayname ($child->getElementsByLocalName('DisplayName')) { $extracted_data->{display_name} { $displayname->getAttribute('xml:lang') } = IdPAccountManager::Tools::encode_utf8( $displayname->textContent()); ## Set a default displayName in case no English version is provided ## However there is no way to determine the native displayName ## We take the first one as default if ( !$extracted_data->{default_display_name} || $displayname->getAttribute('xml:lang')) { $extracted_data->{default_display_name} = IdPAccountManager::Tools::encode_utf8( $displayname->textContent()); } } foreach my $description ($child->getElementsByLocalName('Description')) { $extracted_data->{description} { $description->getAttribute('xml:lang') } = IdPAccountManager::Tools::encode_utf8( $description->textContent()); } foreach my $contact ($child->getElementsByLocalName('ContactPerson')) { my %contact_details; $contact_details{type} = $contact->getAttribute('contactType'); foreach my $contact_child ($EntityDescriptor->childNodes()) { $contact_details{ $contact_child->localName } = IdPAccountManager::Tools::encode_utf8( $contact_child->textContent()); } push @{ $extracted_data->{contacts} }, \%contact_details; } foreach my $sso ( $child->getElementsByLocalName('OrganizationDisplayName')) { $extracted_data->{organization} = IdPAccountManager::Tools::encode_utf8($sso->textContent()); } ## Getting X.509 certificates foreach my $cert ($child->getElementsByLocalName('X509Certificate')) { $extracted_data->{certificate} = IdPAccountManager::Tools::encode_utf8($cert->textContent()); } } ## Filter entities based on type next if (defined $args{filter_entity_type} && ($args{filter_entity_type} ne $extracted_data->{type})); ## Merge domains in a single string my $domains = join(',', @{ $extracted_data->{domain} }) if ($extracted_data->{domain}); $extracted_data->{domain} = $domains; push @extracted_array, $extracted_data; } return \@extracted_array; } 1; __END__ =head1 NAME SAMLMetadata - loading SAML federation metadata =head1 SYNOPSIS my $federation_metadata = new IdPAccountManager::SAMLMetadata; unless ($federation_metadata->load(federation_metadata_file_path => '/tmp/edugain-saml-metadata.xml')) { die; } my %args; if ($options{sp_entityid}) { $args{filter_entity_id} = $options{sp_entityid}; } unless ($federation_metadata->parse(sp_entityid => 'https://test.federation.renater.fr/test/ressource')) { die; } ## List SAML entities printf "Hashref representing the metadata:\n"; IdPAccountManager::Tools::dump_var($federation_metadata->{federation_metadata_as_hashref}, 0, \*STDOUT); =head1 DESCRIPTION The Test Account manager instanciates test accounts associated to a SAML Identity Provider. This module parses a SAML2 metadata file. =head1 SUBROUTINES/METHODS =over 8 =item C<new ARGS> Class method. Create a new IdPAccountManager::SAMLMetadata object. Example: my $federation_metadata = new IdPAccountManager::SAMLMetadata; =item C<load ARGS> Loads the SAML metadata file. Supported arguments include: =over 12 =item C<federation_metadata_file_path> Path of the SAML metadata file. =back =item C<parse ARGS> Parse the SAML metadata file. Supported arguments include: =over 12 =item C<filter_entity_id> EntityID of SAML entities to filter. =back =item C<print FD> Dumps the content of the SAML metadata to the specified FD file handler (default to STDOUT)