File Coverage

blib/lib/Net/SAML2/IdP.pm
Criterion Covered Total %
statement 105 106 99.0
branch 20 24 83.3
condition 6 7 85.7
subroutine 18 18 100.0
pod 8 8 100.0
total 157 163 96.3


line stmt bran cond sub pod time code
1             package Net::SAML2::IdP;
2 11     11   41835 use Moose;
  11         866033  
  11         95  
3              
4             our $VERSION = '0.74'; # VERSION
5              
6              
7             # ABSTRACT: SAML Identity Provider object
8              
9              
10 11     11   82479 use Crypt::OpenSSL::Verify;
  11         94893  
  11         376  
11 11     11   90 use Crypt::OpenSSL::X509;
  11         24  
  11         486  
12 11     11   1101 use HTTP::Request::Common;
  11         45040  
  11         838  
13 11     11   1446 use LWP::UserAgent;
  11         53875  
  11         410  
14 11     11   1001 use MooseX::Types::URI qw/ Uri /;
  11         230560  
  11         129  
15 11     11   22022 use Try::Tiny;
  11         29  
  11         689  
16 11     11   1191 use XML::LibXML::XPathContext;
  11         75424  
  11         396  
17              
18 11     11   951 use Net::SAML2::XML::Util qw/ no_comments /;
  11         28  
  11         18729  
19              
20              
21             has 'entityid' => (isa => 'Str', is => 'ro', required => 1);
22             has 'cacert' => (isa => 'Maybe[Str]', is => 'ro', required => 1);
23             has 'sso_urls' => (isa => 'HashRef[Str]', is => 'ro', required => 1);
24             has 'slo_urls' => (isa => 'Maybe[HashRef[Str]]', is => 'ro');
25             has 'art_urls' => (isa => 'Maybe[HashRef[Str]]', is => 'ro');
26             has 'certs' => (isa => 'HashRef[ArrayRef[Str]]', is => 'ro', required => 1);
27              
28             has 'formats' => (
29             isa => 'HashRef[Str]',
30             is => 'ro',
31             required => 0,
32             default => sub { {} }
33             );
34             has 'default_format' => (isa => 'Str', is => 'ro', required => 0);
35             has 'debug' => (isa => 'Bool', is => 'ro', required => 0, default => 0);
36              
37              
38             sub new_from_url {
39 5     5 1 20204 my ($class, %args) = @_;
40              
41 5         24 my $req = GET $args{url};
42 5         7343 my $ua = $args{ua};
43 5 100       16 if (!$ua) {
44 4         20 $ua = LWP::UserAgent->new;
45 4 100       3171 if (defined $args{ssl_opts}) {
46 1         396 require LWP::Protocol::https;
47 1         60496 $ua->ssl_opts(%{ $args{ssl_opts} });
  1         10  
48             }
49             }
50              
51 5         53 my $res = $ua->request($req);
52 5 100       144 if (!$res->is_success) {
53 1         22 die(
54             sprintf(
55             "Error retrieving metadata: %s (%s)\n",
56             $res->message, $res->code
57             )
58             );
59             }
60              
61 4         131 my $xml = $res->decoded_content;
62              
63             return $class->new_from_xml(
64             xml => $xml,
65             cacert => $args{cacert},
66 4         67 );
67             }
68              
69              
70             sub new_from_xml {
71 19     19 1 13670 my($class, %args) = @_;
72              
73 19         110 my $dom = no_comments($args{xml});
74              
75 19         309 my $xpath = XML::LibXML::XPathContext->new($dom);
76 19         138 $xpath->registerNs('md', 'urn:oasis:names:tc:SAML:2.0:metadata');
77 19         114 $xpath->registerNs('ds', 'http://www.w3.org/2000/09/xmldsig#');
78              
79 19         35 my $data;
80              
81 19         40 my $basepath = '//md:EntityDescriptor/md:IDPSSODescriptor';
82              
83 19         123 for my $sso ($xpath->findnodes("$basepath/md:SingleSignOnService")) {
84 55         1494 my $binding = $sso->getAttribute('Binding');
85 55         570 $data->{SSO}->{$binding} = $sso->getAttribute('Location');
86             }
87              
88 19         219 for my $slo ($xpath->findnodes("$basepath/md:SingleLogoutService")) {
89 46         1948 my $binding = $slo->getAttribute('Binding');
90 46         408 $data->{SLO}->{$binding} = $slo->getAttribute('Location');
91             }
92              
93 19         425 for my $art ($xpath->findnodes("$basepath/md:ArtifactResolutionService")) {
94 15         933 my $binding = $art->getAttribute('Binding');
95 15         173 $data->{Art}->{$binding} = $art->getAttribute('Location');
96             }
97              
98 19         303 for my $format ($xpath->findnodes("$basepath/md:NameIDFormat")) {
99 95         1167 $format = $format->string_value;
100 95         220 $format =~ s/^\s+//g;
101 95         1171 $format =~ s/\s+$//g;
102              
103 95         555 my($short_format)
104             = $format =~ /urn:oasis:names:tc:SAML:(?:2.0|1.1):nameid-format:(.*)$/;
105              
106 95 50       228 if(defined $short_format) {
107 95         262 $data->{NameIDFormat}{$short_format} = $format;
108 95 100       235 $data->{DefaultFormat} = $short_format unless exists $data->{DefaultFormat};
109             }
110             }
111              
112 19         131 my %certs = ();
113 19         90 for my $key ($xpath->findnodes("$basepath/md:KeyDescriptor")) {
114 24         737 my $use = $key->getAttribute('use');
115 24         390 my $pem = $class->_get_pem_from_keynode($key);
116 24 100       91 if (!$use) {
117 1         2 push(@{$certs{signing}}, $pem);
  1         5  
118 1         2 push(@{$certs{encryption}}, $pem);
  1         3  
119             }
120             else {
121 23         42 push(@{$certs{$use}}, $pem);
  23         111  
122             }
123             }
124              
125             return $class->new(
126             entityid => $xpath->findvalue('//md:EntityDescriptor/@entityID'),
127             sso_urls => $data->{SSO},
128             slo_urls => $data->{SLO} || {},
129             art_urls => $data->{Art} || {},
130             certs => \%certs,
131             cacert => $args{cacert},
132             debug => $args{debug},
133             $data->{DefaultFormat}
134             ? (
135             default_format => $data->{DefaultFormat},
136             formats => $data->{NameIDFormat},
137             )
138 19 100 100     61 : (),
      100        
139             );
140              
141             }
142              
143             sub _get_pem_from_keynode {
144 24     24   55 my $self = shift;
145 24         43 my $node = shift;
146              
147 24         94 $node->setNamespace('http://www.w3.org/2000/09/xmldsig#', 'ds');
148              
149 24         567 my ($text)
150             = $node->findvalue("ds:KeyInfo/ds:X509Data/ds:X509Certificate", $node)
151             =~ /^\s*(.+?)\s*$/s;
152              
153             # rewrap the base64 data from the metadata; it may not
154             # be wrapped at 64 characters as PEM requires
155 24         5897 $text =~ s/\n//g;
156              
157 24         66 my @lines;
158 24         240 while(length $text > 64) {
159 428         2325 push @lines, substr $text, 0, 64, '';
160             }
161 24         62 push @lines, $text;
162              
163 24         150 $text = join "\n", @lines;
164              
165 24         160 return "-----BEGIN CERTIFICATE-----\n$text\n-----END CERTIFICATE-----\n";
166             }
167              
168              
169             # BUILDARGS ( hashref of the parameters passed to the constructor )
170             #
171             # Called after the object is created to validate the IdP using the cacert
172             #
173              
174             around BUILDARGS => sub {
175             my $orig = shift;
176             my $self = shift;
177              
178             my %params = @_;
179              
180             if ($params{cacert}) {
181             my $ca = Crypt::OpenSSL::Verify->new($params{cacert}, { strict_certs => 0, });
182              
183             my %certificates;
184             my @errors;
185             for my $use (keys %{$params{certs}}) {
186             my $certs = $params{certs}{$use};
187             for my $pem (@{$certs}) {
188             my $cert = Crypt::OpenSSL::X509->new_from_string($pem);
189             try {
190             $ca->verify($cert);
191             push(@{$certificates{$use}}, $pem);
192             }
193             catch { push (@errors, $_); };
194             }
195             }
196              
197             if ( $params{debug} && @errors ) {
198             warn "Can't verify IdP cert(s): " . join(", ", @errors);
199             }
200              
201             $params{certs} = \%certificates;
202             }
203              
204             return $self->$orig(%params);
205             };
206              
207              
208             sub sso_url {
209 19     19 1 4095 my($self, $binding) = @_;
210 19         596 return $self->sso_urls->{$binding};
211             }
212              
213              
214             sub slo_url {
215 8     8 1 5621 my ($self, $binding) = @_;
216 8 50       279 return $self->slo_urls ? $self->slo_urls->{$binding} : undef;
217             }
218              
219              
220             sub art_url {
221 6     6 1 20 my ($self, $binding) = @_;
222 6 50       180 return $self->art_urls ? $self->art_urls->{$binding} : undef;
223             }
224              
225              
226             sub cert {
227 16     16 1 9460 my($self, $use) = @_;
228 16         426 return $self->certs->{$use};
229             }
230              
231              
232             sub binding {
233 20     20 1 7128 my($self, $name) = @_;
234              
235 20         99 my $bindings = {
236             post => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
237             redirect => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
238             soap => 'urn:oasis:names:tc:SAML:2.0:bindings:SOAP',
239             };
240              
241 20 50       67 if(exists $bindings->{$name}) {
242 20         87 return $bindings->{$name};
243             }
244              
245 0         0 return;
246             }
247              
248              
249             sub format {
250 12     12 1 35 my($self, $short_name) = @_;
251              
252 12 100 66     351 if(defined $short_name && exists $self->formats->{$short_name}) {
    100          
253 6         131 return $self->formats->{$short_name};
254             }
255             elsif($self->default_format) {
256 5         108 return $self->formats->{$self->default_format};
257             }
258              
259 1         4 return;
260             }
261              
262             __PACKAGE__->meta->make_immutable;
263              
264             __END__
265              
266             =pod
267              
268             =encoding UTF-8
269              
270             =head1 NAME
271              
272             Net::SAML2::IdP - SAML Identity Provider object
273              
274             =head1 VERSION
275              
276             version 0.74
277              
278             =head1 SYNOPSIS
279              
280             my $idp = Net::SAML2::IdP->new_from_url(
281             url => $url,
282             cacert => $cacert,
283             ssl_opts => # Optional options supported by LWP::Protocol::https
284             {
285             SSL_ca_file => '/your/directory/cacert.pem',
286             SSL_ca_path => '/etc/ssl/certs',
287             verify_hostname => 1,
288             }
289             );
290             my $sso_url = $idp->sso_url('urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect');
291              
292             Note that LWP::UserAgent is used which means that environment variables
293             may affect the use of https see:
294              
295             =over
296              
297             =item * L<PERL_LWP_SSL_CA_FILE and HTTPS_CA_FILE|https://metacpan.org/pod/LWP::UserAgent#SSL_ca_file-=%3E-$path>
298              
299             =item * L<PERL_LWP_SSL_CA_PATH and HTTPS_CA_DIR|https://metacpan.org/pod/LWP::UserAgent#SSL_ca_path-=%3E-$path>
300              
301             =back
302              
303             =head1 NAME
304              
305             Net::SAML2::IdP - SAML Identity Provider object
306              
307             =head1 METHODS
308              
309             =head2 new( )
310              
311             Constructor
312              
313             =over
314              
315             =item B<entityid>
316              
317             =back
318              
319             =head2 new_from_url( url => $url, cacert => $cacert, ssl_opts => {} )
320              
321             Create an IdP object by retrieving the metadata at the given URL.
322              
323             Dies if the metadata can't be retrieved with reason.
324              
325             =head2 new_from_xml( xml => $xml, cacert => $cacert )
326              
327             Constructor. Create an IdP object using the provided metadata XML
328             document.
329              
330             =head2 sso_url( $binding )
331              
332             Returns the url for the SSO service using the given binding. Binding
333             name should be the full URI.
334              
335             =head2 slo_url( $binding )
336              
337             Returns the url for the Single Logout Service using the given
338             binding. Binding name should be the full URI.
339              
340             =head2 art_url( $binding )
341              
342             Returns the url for the Artifact Resolution service using the given
343             binding. Binding name should be the full URI.
344              
345             =head2 cert( $use )
346              
347             Returns the IdP's certificates for the given use (e.g. C<signing>).
348              
349             IdP's are generated from the metadata it is possible for multiple certificates
350             to be contained in the metadata and therefore possible for them to be there to
351             be multiple verified certs in $self->certs. At this point any certs in the IdP
352             have been verified and are valid for the specified use. All certs are of type
353             $use are returned.
354              
355             =head2 binding( $name )
356              
357             Returns the full Binding URI for the given binding name (i.e. C<redirect> or C<soap>).
358             Includes this module's currently-supported bindings.
359              
360             =head2 format( $short_name )
361              
362             Returns the full NameID Format URI for the given short name.
363              
364             If no short name is provided, returns the URI for the default format,
365             the one listed first by the IdP.
366              
367             If no NameID formats were advertised by the IdP, returns undef.
368              
369             =head1 AUTHORS
370              
371             =over 4
372              
373             =item *
374              
375             Chris Andrews <chrisa@cpan.org>
376              
377             =item *
378              
379             Timothy Legge <timlegge@gmail.com>
380              
381             =back
382              
383             =head1 COPYRIGHT AND LICENSE
384              
385             This software is copyright (c) 2023 by Venda Ltd, see the CONTRIBUTORS file for others.
386              
387             This is free software; you can redistribute it and/or modify it under
388             the same terms as the Perl 5 programming language system itself.
389              
390             =cut