diff --git a/Makefile.PL b/Makefile.PL index fe427b4a..2b93c60d 100644 --- a/Makefile.PL +++ b/Makefile.PL @@ -16,6 +16,7 @@ my %WriteMakefileArgs = ( "MIN_PERL_VERSION" => "5.008001", "NAME" => "Net::SAML2", "PREREQ_PM" => { + "Carp" => 0, "Crypt::OpenSSL::Bignum" => 0, "Crypt::OpenSSL::RSA" => 0, "Crypt::OpenSSL::Random" => 0, @@ -42,6 +43,7 @@ my %WriteMakefileArgs = ( "URI" => 0, "URI::Encode" => 0, "URI::QueryParam" => 0, + "URN::OASIS::SAML2" => "0.002", "XML::Enc" => "0.05", "XML::Generator" => 0, "XML::LibXML" => 0, @@ -77,6 +79,7 @@ my %WriteMakefileArgs = ( my %FallbackPrereqs = ( + "Carp" => 0, "Crypt::OpenSSL::Bignum" => 0, "Crypt::OpenSSL::RSA" => 0, "Crypt::OpenSSL::Random" => 0, @@ -115,6 +118,7 @@ my %FallbackPrereqs = ( "URI::Encode" => 0, "URI::QueryParam" => 0, "URI::URL" => 0, + "URN::OASIS::SAML2" => "0.002", "XML::Enc" => "0.05", "XML::Generator" => 0, "XML::LibXML" => 0, diff --git a/cpanfile b/cpanfile index d9dd12c7..f46cc402 100644 --- a/cpanfile +++ b/cpanfile @@ -1,5 +1,6 @@ # Do not edit this file directly. To change prereqs, edit the `dist.ini` file. +requires "Carp" => "0"; requires "Crypt::OpenSSL::Bignum" => "0"; requires "Crypt::OpenSSL::RSA" => "0"; requires "Crypt::OpenSSL::Random" => "0"; @@ -26,6 +27,7 @@ requires "MooseX::Types::URI" => "0"; requires "URI" => "0"; requires "URI::Encode" => "0"; requires "URI::QueryParam" => "0"; +requires "URN::OASIS::SAML2" => "0.002"; requires "XML::Enc" => "0.05"; requires "XML::Generator" => "0"; requires "XML::LibXML" => "0"; diff --git a/dist.ini b/dist.ini index 0a1d7894..6c05e9e0 100644 --- a/dist.ini +++ b/dist.ini @@ -59,6 +59,7 @@ XML::Writer = 0.625 XML::LibXML::XPathContext = 0 ; Here because it isn't provided by Crypt::OpenSSL::RSA Crypt::OpenSSL::Bignum = 0 +URN::OASIS::SAML2 = 0.002 [Prereqs / TestRequires] Test::Pod = 1.14 diff --git a/lib/Net/SAML2/SP.pm b/lib/Net/SAML2/SP.pm index f93b2bbb..b7450a72 100644 --- a/lib/Net/SAML2/SP.pm +++ b/lib/Net/SAML2/SP.pm @@ -4,14 +4,22 @@ package Net::SAML2::SP; # VERSION use Moose; + +use Carp qw(croak); +use Crypt::OpenSSL::X509; +use Digest::MD5 (); use MooseX::Types::URI qw/ Uri /; +use Net::SAML2::Binding::POST; +use Net::SAML2::Binding::Redirect; +use Net::SAML2::Binding::SOAP; +use Net::SAML2::Protocol::AuthnRequest; +use Net::SAML2::Protocol::LogoutRequest; +use Net::SAML2::Util (); +use URN::OASIS::SAML2 qw(:bindings :urn); +use XML::Generator; # ABSTRACT: Net::SAML2::SP - SAML Service Provider object -=head1 NAME - -Net::SAML2::SP - SAML Service Provider object - =head1 SYNOPSIS my $sp = Net::SAML2::SP->new( @@ -25,17 +33,6 @@ Net::SAML2::SP - SAML Service Provider object =cut -use Crypt::OpenSSL::X509; -use XML::Generator; - -use Digest::MD5 (); - -use Net::SAML2::Binding::POST; -use Net::SAML2::Binding::Redirect; -use Net::SAML2::Binding::SOAP; -use Net::SAML2::Protocol::AuthnRequest; -use Net::SAML2::Protocol::LogoutRequest; -use Net::SAML2::Util (); =head2 new( ... ) @@ -93,6 +90,53 @@ Specifies in the metadata whether the SP wants the Assertion from the IdP to be signed Optional (0 or 1) defaults to 1 (TRUE) if not specified. +=item B + +Sign the metadata, defaults to 1 (TRUE) if not specified. + +=item B + +The following option replaces the previous C, C and +C constructor parameters. The former options are mapped to +this new structure. + +This expects an array of hash refs where you define one or more Single Logout +Services + + [ + { + Binding => BINDING_HTTP_POST, + Location => https://foo.example.com/your-post-endpoint, + } + { + Binding => BINDING_HTTP_ARTIFACT, + Location => https://foo.example.com/your-artifact-endpoint, + } + ] + +=item B + +The following option replaces the previous C and +C constructor parameters. The former options are mapped to +this new structure. + +This expects an array of hash refs where you define one or more Assertion +Consumer Services. + + [ + # Order decides the index + { + Binding => BINDING_HTTP_POST, + Location => https://foo.example.com/your-post-endpoint, + isDefault => 'false', + } + { + Binding => BINDING_HTTP_ARTIFACT, + Location => https://foo.example.com/your-artifact-endpoint, + isDefault => 'true', + } + ] + =back =cut @@ -104,17 +148,19 @@ has 'key' => (isa => 'Str', is => 'ro', required => 1); has 'cacert' => (isa => 'Maybe[Str]', is => 'ro', required => 1); has 'error_url' => (isa => 'Str', is => 'ro', required => 1); -has 'slo_url_soap' => (isa => 'Str', is => 'ro', required => 0, predicate => 'has_slo_url_soap'); -has 'slo_url_post' => (isa => 'Str', is => 'ro', required => 0, predicate => 'has_slo_url_post'); -has 'slo_url_redirect' => (isa => 'Str', is => 'ro', required => 1); -has 'acs_url_post' => (isa => 'Str', is => 'ro', required => 1); -has 'acs_url_artifact' => (isa => 'Str', is => 'ro', required => 1); - has 'org_name' => (isa => 'Str', is => 'ro', required => 1); has 'org_display_name' => (isa => 'Str', is => 'ro', required => 1); has 'org_contact' => (isa => 'Str', is => 'ro', required => 1); has 'org_url' => (isa => 'Str', is => 'ro', required => 0); +# These are no longer in use, but are not removed by the off change that +# someone that extended us or added a role to us with these params. +has 'slo_url_soap' => (isa => 'Str', is => 'ro', required => 0); +has 'slo_url_post' => (isa => 'Str', is => 'ro', required => 0); +has 'slo_url_redirect' => (isa => 'Str', is => 'ro', required => 0); +has 'acs_url_post' => (isa => 'Str', is => 'ro', required => 0); +has 'acs_url_artifact' => (isa => 'Str', is => 'ro', required => 0); + has '_cert_text' => (isa => 'Str', is => 'ro', init_arg => undef, builder => '_build_cert_text', lazy => 1); has 'authnreq_signed' => (isa => 'Bool', is => 'ro', required => 0, default => 1); @@ -122,6 +168,84 @@ has 'want_assertions_signed' => (isa => 'Bool', is => 'ro', required => 0, defa has 'sign_metadata' => (isa => 'Bool', is => 'ro', required => 0, default => 1); +has assertion_consumer_service => (is => 'ro', isa => 'ArrayRef', required => 1); +has single_logout_service => (is => 'ro', isa => 'ArrayRef', required => 1); + +around BUILDARGS => sub { + my $orig = shift; + my $self = shift; + + my %args = @_; + + if (!$args{single_logout_service}) { + #warn "Deprecation warning, please upgrade your code to use .."; + my @slo; + if (my $slo = $args{slo_url_soap}) { + push( + @slo, + { + Binding => BINDING_SOAP, + Location => $args{url} . $slo, + } + ); + } + if (my $slo = $args{slo_url_redirect}) { + push( + @slo, + { + Binding => BINDING_HTTP_REDIRECT, + Location => $args{url} . $slo, + } + ); + } + if (my $slo = $args{slo_url_post}) { + push( + @slo, + { + Binding => BINDING_HTTP_POST, + Location => $args{url} . $slo, + } + ); + } + $args{single_logout_service} = \@slo; + } + + if (!@{$args{single_logout_service}}) { + croak("You don't have any Single Logout Services configured!"); + } + + if (!$args{assertion_consumer_service}) { + #warn "Deprecation warning, please upgrade your code to use .."; + my @acs; + if (my $acs = delete $args{acs_url_post}) { + push( + @acs, + { + Binding => BINDING_HTTP_POST, + Location => $args{url} . $acs, + isDefault => 'true', + } + ); + } + if (my $acs = $args{acs_url_artifact}) { + push( + @acs, + { + Binding => BINDING_HTTP_ARTIFACT, + Location => $args{url} . $acs, + isDefault => 'false', + } + ); + } + + $args{assertion_consumer_service} = \@acs; + } + if (!@{$args{assertion_consumer_service}}) { + croak("You don't have any Assertion Consumer Services configured!"); + } + return $self->$orig(%args); +}; + sub _build_cert_text { my ($self) = @_; @@ -319,12 +443,13 @@ Generate the metadata XML document for this SP. =cut +my $md = ['md' => 'urn:oasis:names:tc:SAML:2.0:metadata']; +my $ds = ['ds' => 'http://www.w3.org/2000/09/xmldsig#']; + sub generate_metadata { my $self = shift; my $x = XML::Generator->new(':pretty', conformance => 'loose'); - my $md = ['md' => 'urn:oasis:names:tc:SAML:2.0:metadata']; - my $ds = ['ds' => 'http://www.w3.org/2000/09/xmldsig#']; return $x->EntityDescriptor( $md, @@ -334,106 +459,102 @@ sub generate_metadata { }, $x->SPSSODescriptor( $md, - { AuthnRequestsSigned => $self->authnreq_signed, - WantAssertionsSigned => $self->want_assertions_signed, - errorURL => $self->url . $self->error_url, - protocolSupportEnumeration => 'urn:oasis:names:tc:SAML:2.0:protocol', + { + AuthnRequestsSigned => $self->authnreq_signed, + WantAssertionsSigned => $self->want_assertions_signed, + errorURL => $self->url . $self->error_url, + protocolSupportEnumeration => + 'urn:oasis:names:tc:SAML:2.0:protocol', }, - $x->KeyDescriptor( - $md, - { - use => 'signing' }, - $x->KeyInfo( - $ds, - $x->X509Data( - $ds, - $x->X509Certificate( - $ds, - $self->_cert_text, - ) - ), - $x->KeyName( - $ds, - Digest::MD5::md5_hex($self->_cert_text) - ), - ) - ), - $self->has_slo_url_soap ? - $x->SingleLogoutService( - $md, - { Binding => 'urn:oasis:names:tc:SAML:2.0:bindings:SOAP', - Location => $self->url . $self->slo_url_soap }, - ) : (), - - $x->SingleLogoutService( - $md, - { Binding => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', - Location => $self->url . $self->slo_url_redirect }, - ), + $self->_generate_key_descriptors($x), - $self->has_slo_url_post ? - $x->SingleLogoutService( - $md, - { - Binding => - 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', - Location => $self->url . $self->slo_url_post - }, - ) : (), + $self->_generate_single_logout_service($x), + + $self->_generate_assertion_consumer_service($x), - $x->AssertionConsumerService( - $md, - { Binding => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', - Location => $self->url . $self->acs_url_post, - index => '1', - isDefault => 'true' }, - ), - $x->AssertionConsumerService( - $md, - { Binding => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact', - Location => $self->url . $self->acs_url_artifact, - index => '2', - isDefault => 'false' }, - ), ), $x->Organization( $md, $x->OrganizationName( - $md, - { - 'xml:lang' => 'en' }, - $self->org_name, + $md, { 'xml:lang' => 'en' }, $self->org_name, ), $x->OrganizationDisplayName( - $md, - { - 'xml:lang' => 'en' }, + $md, { 'xml:lang' => 'en' }, $self->org_display_name, ), $x->OrganizationURL( $md, - { - 'xml:lang' => 'en' }, - defined($self->org_url) ? $self->org_url :$self->url + { 'xml:lang' => 'en' }, + defined($self->org_url) ? $self->org_url : $self->url ) ), $x->ContactPerson( $md, - { - contactType => 'other' }, - $x->Company( - $md, - $self->org_display_name, + { contactType => 'other' }, + $x->Company($md, $self->org_display_name,), + $x->EmailAddress($md, $self->org_contact,), + ) + ); +} + +sub _generate_key_descriptors { + my $self = shift; + my $x = shift; + + return + if !$self->authnreq_signed + && !$self->want_assertions_signed + && !$self->sign_metadata; + + return $x->KeyDescriptor( + $md, + { use => 'signing' }, + $x->KeyInfo( + $ds, + $x->X509Data( + $ds, + $x->X509Certificate( + $ds, + $self->_cert_text, + ) ), - $x->EmailAddress( - $md, - $self->org_contact, + $x->KeyName( + $ds, + Digest::MD5::md5_hex($self->_cert_text) ), + ) ); } +sub _generate_single_logout_service { + my $self = shift; + my $x = shift; + return map { $x->SingleLogoutService($md, $_) } @{ $self->single_logout_service }; +} + +sub _generate_assertion_consumer_service { + my $self = shift; + my $x = shift; + + my @services = @{ $self->assertion_consumer_service }; + my $size = @services; + + my @acs; + for (my $i = 0; $i < $size; ++$i) { + push( + @acs, + $x->AssertionConsumerService( + $md, { %{ $services[$i] }, index => $i + 1, }, + ) + ); + } + return @acs; + +} + + =head2 metadata( ) Returns the metadata XML document for this SP. @@ -441,7 +562,7 @@ Returns the metadata XML document for this SP. =cut sub metadata { - my ($self) = @_; + my $self = shift; my $metadata = $self->generate_metadata(); return $metadata unless $self->sign_metadata; diff --git a/t/02-create-sp.t b/t/02-create-sp.t index e40b4f2d..ba087ef1 100644 --- a/t/02-create-sp.t +++ b/t/02-create-sp.t @@ -2,6 +2,7 @@ use strict; use warnings; use Test::Lib; use Test::Net::SAML2; +use URN::OASIS::SAML2 qw(:bindings :urn); { my $sp = net_saml2_sp( @@ -13,8 +14,8 @@ use Test::Net::SAML2; my $xpath = get_xpath( $sp->metadata, - md => 'urn:oasis:names:tc:SAML:2.0:metadata', - ds => 'http://www.w3.org/2000/09/xmldsig#' + md => URN_METADATA, + ds => URN_SIGNATURE, ); my $node @@ -73,8 +74,8 @@ use Test::Net::SAML2; my $sp = net_saml2_sp(sign_metadata => 0); my $xpath = get_xpath( $sp->metadata, - md => 'urn:oasis:names:tc:SAML:2.0:metadata', - ds => 'http://www.w3.org/2000/09/xmldsig#' + md => URN_METADATA, + ds => URN_SIGNATURE, ); my $nodes = $xpath->findnodes('//ds:Signature'); @@ -87,8 +88,8 @@ use Test::Net::SAML2; my $xpath = get_xpath( $sp->metadata, - md => 'urn:oasis:names:tc:SAML:2.0:metadata', - ds => 'http://www.w3.org/2000/09/xmldsig#' + md => URN_METADATA, + ds => URN_SIGNATURE, ); my $node = get_single_node_ok($xpath, '/md:EntityDescriptor'); @@ -189,4 +190,96 @@ use Test::Net::SAML2; } +{ + + my $sp = net_saml2_sp( + single_logout_service => [ + { + Binding => BINDING_HTTP_POST, + Location => 'https://foo.example.com/slo-http-post' + } + ], + assertion_consumer_service => [ + { + Binding => BINDING_HTTP_POST, + Location => 'https://foo.example.com/acs-http-post', + isDefault => 'false' + }, + { + Binding => BINDING_HTTP_ARTIFACT, + Location => 'https://foo.example.com/acs-http-artifact', + isDefault => 'true' + } + ], + ); + + my $xpath = get_xpath( + $sp->metadata, + md => URN_METADATA, + ds => URN_SIGNATURE, + ); + + my @ssos + = $xpath->findnodes( + '//md:EntityDescriptor/md:SPSSODescriptor/md:AssertionConsumerService' + ); + + if (is(@ssos, 2, "Got two assertionConsumerService(s)")) { + + is( + $ssos[0]->getAttribute('Binding'), + BINDING_HTTP_POST, + "Returns the correct binding: HTTP-POST" + ); + is($ssos[0]->getAttribute('isDefault'), + 'false', "... and is the default"); + + is($ssos[0]->getAttribute('index'), 1, + "... and has the correct index"); + + is( + $ssos[1]->getAttribute('Binding'), + BINDING_HTTP_ARTIFACT, + "Returns the correct binding: HTTP-Artifact" + ); + + is($ssos[1]->getAttribute('isDefault'), + 'true', "... and is the default"); + + is($ssos[1]->getAttribute('index'), 2, + "... and has the correct index"); + } + + throws_ok( + sub { + my $sp = net_saml2_sp( + single_logout_service => [ + ], + assertion_consumer_service => [ + ], + ); + }, + qr/You don't have any Single Logout Services configured/, + "Needs at least one SLO", + ); + + throws_ok( + sub { + my $sp = net_saml2_sp( + single_logout_service => [ + { + Binding => 'foo', + Location => 'bar', + } + ], + assertion_consumer_service => [ + ], + ); + }, + qr/You don't have any Assertion Consumer Services configured/, + "Needs at least one ASC", + ); + +} + done_testing;