package IdPAccountManager::SAMLMetadata; ## Copyright (c) GEANT ## This software was developed by RENATER. The research leading to these results has received funding ## from the European Community¹s Seventh Framework Programme (FP7/2007-2013) under grant agreement nº 238875 (GÉANT). use strict; use warnings; use IdPAccountManager::Tools; use IdPAccountManager::Logger; use XML::LibXML; use Carp; sub new { my ($pkg) = shift; my %args = @_; my $self = { logger => $args{logger} }; bless $self, $pkg; return $self; } ## Load metadata sub load { my $self = shift; my %in = @_; unless ($in{'federation_metadata_file_path'}) { $self->{logger}->log( level => LOG_ERROR, message => "Missing parameter 'federation_metadata_file_path'" ); return undef; } $self->{'federation_metadata_file_path'} = $in{'federation_metadata_file_path'}; unless (-r $self->{'federation_metadata_file_path'}) { $self->{logger}->log( level => LOG_ERROR, message => "Failed to read $in{'federation_metadata_file_path'} : $!" ); return undef; } unless ($self->{'federation_metadata_as_xml'} = $self->_get_xml_object($in{'federation_metadata_file_path'})) { $self->{logger}->log( level => LOG_ERROR, message => "Failed to parse file $in{'metadata_file'} : $!" ); return undef; } my $root = $self->{'federation_metadata_as_xml'}->documentElement(); unless ($root->nodeName() =~ /EntitiesDescriptor$/) { $self->{logger}->( level => LOG_ERROR, message => sprintf( "Root element of file $in{'federation_metadata_file_path'} is of type '%s'; should be 'EntitiesDescriptor'", $root->nodeName() ) ); return undef; } return 1; } ## Parse XML structure of metadata to fill a hashref sub parse { my $self = shift; my %options = @_; my %parser_args = ( 'metadata_as_xml' => $self->{'federation_metadata_as_xml'}, 'filter_entity_type' => 'sp' ); if ($options{'filter_entity_id'}) { $parser_args{'filter_entity_id'} = $options{'filter_entity_id'}; } $self->{'federation_metadata_as_hashref'} = $self->_parse_saml_metadata(%parser_args); unless (defined $self->{'federation_metadata_as_hashref'}) { $self->{logger}->log( level => LOG_ERROR, message => "Failed to parse federation metadata" ); return undef; } return 1; } ## Dumps the SAML metadata content sub print { my $self = shift; my $fd = shift || \*STDOUT; my $root = $self->{'federation_metadata_as_xml'}->documentElement(); print $fd $root->toString(); return 1.; } ## returns a Lib::XML représenting an XML file sub _get_xml_object { my $self = shift; my $metadata_file = shift; $self->{logger}->log(level => LOG_DEBUG, message => ""); unless (-f $metadata_file) { $self->{logger}->log( level => LOG_ERROR, message => "File $metadata_file not found: $!" ); return undef; } unless (open FH, $metadata_file) { $self->{logger}->log( level => LOG_ERROR, message => "Failed to open file $metadata_file: $!" ); return undef; } my $parser; unless ($parser = XML::LibXML->new()) { $self->{logger}->log( level => LOG_ERROR, message => "Failed to initialize XML parser" ); return undef; } $parser->line_numbers(1); my $doc; ## Eval() prevents the parsing from killing the main process eval { $doc = $parser->parse_fh(\*FH) }; if ($@) { $self->{logger}->log( level => LOG_ERROR, message => "Failed to parse file $metadata_file : $@" ); return undef; } unless ($doc) { $self->{logger}->log( level => LOG_ERROR, message => "Failed to parse file $metadata_file : $!" ); return undef; } return $doc; } ## Parse a SAML federation metadata file sub _parse_saml_metadata { my $self = shift; my %options = @_; my $root = $options{'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 ($options{'filter_entity_id'} && ($options{'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') ) }; #$self->{logger}->log( # level => LOG_TRACE, # message => sprintf( # "Endpoint: type:%s ; binding=%s ; location=%s ; index=%s ; isdefault=%s", 'AssertionConsumerService', # $sso->getAttribute('Binding'), # $sso->getAttribute('Location'), # $sso->getAttribute('index'), # $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')) { $self->{logger} ->log(level => LOG_TRACE, message => "ContactPerson"); my %contact_details; $contact_details{'type'} = $contact->getAttribute('contactType'); foreach my $contact_child ($EntityDescriptor->childNodes()) { $self->{logger}->log( level => LOG_TRACE, message => "Contact : %s", $contact_child->localName ); $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 $options{'filter_entity_type'} && ($options{'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; #$self->{logger}->log(level => LOG_DEBUG, message => "Scopes : %s", $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) =head1 AUTHOR Olivier Salaün (olivier.salaun@renater.fr) =head1 LICENSE Copyright (c) GEANT This software was developed by RENATER. The research leading to these results has received funding from the European Community¹s Seventh Framework Programme (FP7/2007-2013) under grant agreement nº 238875 (GÉANT).