File Coverage

blib/lib/Net/SAML2/Protocol/Assertion.pm
Criterion Covered Total %
statement 187 194 96.3
branch 63 92 68.4
condition 3 3 100.0
subroutine 31 33 93.9
pod 17 17 100.0
total 301 339 88.7


line stmt bran cond sub pod time code
1             package Net::SAML2::Protocol::Assertion;
2 10     10   26509 use Moose;
  10         28  
  10         90  
3              
4             our $VERSION = '0.74'; # VERSION
5              
6 10     10   75572 use MooseX::Types::DateTime qw/ DateTime /;
  10         588502  
  10         66  
7 10     10   19951 use MooseX::Types::Common::String qw/ NonEmptySimpleStr /;
  10         34  
  10         150  
8 10     10   28203 use DateTime;
  10         37  
  10         243  
9 10     10   5882 use DateTime::HiRes;
  10         5092  
  10         369  
10 10     10   3904 use DateTime::Format::XSD;
  10         3772766  
  10         482  
11 10     10   106 use Net::SAML2::XML::Util qw/ no_comments /;
  10         28  
  10         635  
12 10     10   74 use Net::SAML2::XML::Sig;
  10         25  
  10         103  
13 10     10   7224 use XML::Enc;
  10         184144  
  10         363  
14 10     10   94 use XML::LibXML::XPathContext;
  10         25  
  10         292  
15 10     10   83 use List::Util qw(first);
  10         31  
  10         730  
16 10     10   78 use URN::OASIS::SAML2 qw(STATUS_SUCCESS);
  10         35  
  10         469  
17 10     10   65 use Carp qw(croak);
  10         28  
  10         20359  
18              
19             with 'Net::SAML2::Role::ProtocolMessage';
20              
21             # ABSTRACT: SAML2 assertion object
22              
23              
24             has 'attributes' => (isa => 'HashRef[ArrayRef]', is => 'ro', required => 1);
25             has 'audience' => (isa => NonEmptySimpleStr, is => 'ro', required => 1);
26             has 'not_after' => (isa => DateTime, is => 'ro', required => 1);
27             has 'not_before' => (isa => DateTime, is => 'ro', required => 1);
28             has 'session' => (isa => 'Str', is => 'ro', required => 1);
29             has 'in_response_to' => (isa => 'Str', is => 'ro', required => 1);
30             has 'response_status' => (isa => 'Str', is => 'ro', required => 1);
31             has 'response_substatus' => (isa => 'Str', is => 'ro');
32             has 'xpath' => (isa => 'XML::LibXML::XPathContext', is => 'ro', required => 1);
33             has 'nameid_object' => (
34             isa => 'XML::LibXML::Element',
35             is => 'ro',
36             required => 0,
37             init_arg => 'nameid',
38             predicate => 'has_nameid',
39             );
40             has 'authnstatement_object' => (
41             isa => 'XML::LibXML::Element',
42             is => 'ro',
43             required => 0,
44             init_arg => 'authnstatement',
45             predicate => 'has_authnstatement',
46             );
47              
48              
49              
50             sub _verify_encrypted_assertion {
51 13     13   30 my $self = shift;
52 13         24 my $xml = shift;
53 13         20 my $cacert = shift;
54 13         26 my $key_file = shift;
55 13         36 my $key_name = shift;
56              
57 13         141 my $xpath = XML::LibXML::XPathContext->new($xml);
58 13         89 $xpath->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
59 13         51 $xpath->registerNs('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol');
60 13         47 $xpath->registerNs('dsig', 'http://www.w3.org/2000/09/xmldsig#');
61 13         44 $xpath->registerNs('xenc', 'http://www.w3.org/2001/04/xmlenc#');
62              
63 13 100       54 return $xml unless $xpath->exists('//saml:EncryptedAssertion');
64              
65 1 50       43 croak "Encrypted Assertions require key_file" if !defined $key_file;
66              
67 1         7 $xml = $self->_decrypt(
68             $xml,
69             key_file => $key_file,
70             key_name => $key_name,
71             );
72 1         32379 $xpath->setContextNode($xml);
73              
74 1         4 my $assert_nodes = $xpath->findnodes('//saml:Assertion');
75 1 50       50 return $xml unless $assert_nodes->size;
76 1         10 my $assert = $assert_nodes->get_node(1);
77              
78 1 50       11 return $xml unless $xpath->exists('dsig:Signature', $assert);
79 1         44 my $xml_opts->{ no_xml_declaration } = 1;
80 1         14 my $x = Net::SAML2::XML::Sig->new($xml_opts);
81 1         113 my $ret = $x->verify($assert->toString());
82 1 50       3146 die "Decrypted Assertion signature check failed" unless $ret;
83              
84 1 50       5 return $xml unless $cacert;
85 1         4 my $cert = $x->signer_cert;
86 1 50       8 die "Certificate not provided in SAML Response, cannot validate" unless $cert;
87              
88 1         171 my $ca = Crypt::OpenSSL::Verify->new($cacert, { strict_certs => 0 });
89 1 50       62 die "Unable to verify signer cert with cacert: " . $cert->subject
90             unless $ca->verify($cert);
91 1         72 return $xml;
92             }
93              
94             sub new_from_xml {
95 13     13 1 7798 my($class, %args) = @_;
96              
97 13         43 my $key_file = $args{key_file};
98 13         31 my $cacert = delete $args{cacert};
99              
100 13         351 my $xpath = XML::LibXML::XPathContext->new();
101 13         254 $xpath->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
102 13         59 $xpath->registerNs('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol');
103 13         43 $xpath->registerNs('dsig', 'http://www.w3.org/2000/09/xmldsig#');
104 13         38 $xpath->registerNs('xenc', 'http://www.w3.org/2001/04/xmlenc#');
105              
106 13         63 my $xml = no_comments($args{xml});
107 13         63 $xpath->setContextNode($xml);
108              
109             $xml = $class->_verify_encrypted_assertion(
110             $xml,
111             $cacert,
112             $key_file,
113             $args{key_name},
114 13         76 );
115              
116             my $dec = $class->_decrypt(
117             $xml,
118             key_file => $key_file,
119             key_name => $args{key_name}
120 13         737 );
121 13         1484 $xpath->setContextNode($dec);
122              
123 13         25 my $attributes = {};
124 13         55 for my $node ($xpath->findnodes('//saml:Assertion/saml:AttributeStatement/saml:Attribute/saml:AttributeValue/..'))
125             {
126 68         1951 my @values = $xpath->findnodes("saml:AttributeValue", $node);
127 68         2600 $attributes->{$node->getAttribute('Name')} = [map $_->string_value, @values];
128             }
129              
130 13         281 my $xpath_base = '//samlp:Response/saml:Assertion/saml:Conditions/';
131              
132 13         532 my $not_before;
133 13 100       61 if (my $value = $xpath->findvalue($xpath_base . '@NotBefore')) {
    50          
134 12         1218 $not_before = DateTime::Format::XSD->parse_datetime($value);
135             }
136             elsif (my $global = $xpath->findvalue('//saml:Conditions/@NotBefore')) {
137 1         153 $not_before = DateTime::Format::XSD->parse_datetime($global);
138             }
139             else {
140 0         0 $not_before = DateTime::HiRes->now();
141             }
142              
143 13         11314 my $not_after;
144 13 100       84 if (my $value = $xpath->findvalue($xpath_base . '@NotOnOrAfter')) {
    50          
145 12         939 $not_after = DateTime::Format::XSD->parse_datetime($value);
146             }
147             elsif (my $global = $xpath->findvalue('//saml:Conditions/@NotOnOrAfter')) {
148 1         136 $not_after = DateTime::Format::XSD->parse_datetime($global);
149             }
150             else {
151 0         0 $not_after = DateTime->from_epoch(epoch => time() + 1000);
152             }
153              
154 13         7939 my $nameid;
155 13 100       55 if (my $node = $xpath->findnodes('/samlp:Response/saml:Assertion/saml:Subject/saml:NameID')) {
    100          
156 8         680 $nameid = $node->get_node(1);
157             }
158             elsif (my $global = $xpath->findnodes('//saml:Subject/saml:NameID')) {
159 3         440 $nameid = $global->get_node(1);
160             }
161              
162 13         311 my $authnstatement;
163 13 100       44 if (my $node = $xpath->findnodes('/samlp:Response/saml:Assertion/saml:AuthnStatement')) {
164 10         595 $authnstatement = $node->get_node(1);
165             }
166              
167 13         264 my $nodeset = $xpath->findnodes('/samlp:Response/samlp:Status/samlp:StatusCode|/samlp:ArtifactResponse/samlp:Status/samlp:StatusCode');
168              
169 13 50       560 croak("Unable to parse status from assertion") unless $nodeset->size;
170              
171 13         95 my $status_node = $nodeset->get_node(1);
172 13         106 my $status = $status_node->getAttribute('Value');
173 13         154 my $sub_status;
174              
175 13 100   3   223 if (my $s = first { $_->isa('XML::LibXML::Element') } $status_node->childNodes) {
  3         146  
176 1         6 $sub_status = $s->getAttribute('Value');
177             }
178              
179 13 100       239 my $self = $class->new(
    100          
    100          
180             id => $xpath->findvalue('//saml:Assertion/@ID'),
181             issuer => $xpath->findvalue('//saml:Assertion/saml:Issuer'),
182             destination => $xpath->findvalue('/samlp:Response/@Destination'),
183             attributes => $attributes,
184             session => $xpath->findvalue('//saml:AuthnStatement/@SessionIndex'),
185             $nameid ? (nameid => $nameid) : (),
186             audience => $xpath->findvalue('//saml:Conditions/saml:AudienceRestriction/saml:Audience'),
187             not_before => $not_before,
188             not_after => $not_after,
189             xpath => $xpath,
190             in_response_to => $xpath->findvalue('//saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData/@InResponseTo'),
191             response_status => $status,
192             $sub_status ? (response_substatus => $sub_status) : (),
193             $authnstatement ? (authnstatement => $authnstatement) : (),
194             );
195              
196 13         79421 return $self;
197             }
198              
199              
200              
201             sub name {
202 0     0 1 0 my $self = shift;
203 0         0 return $self->attributes->{CN}[0];
204             }
205              
206              
207             sub nameid {
208 6     6 1 25 my $self = shift;
209 6 50       249 return unless $self->has_nameid;
210 6         185 return $self->nameid_object->textContent;
211             }
212              
213              
214             sub nameid_format {
215 1     1 1 3 my $self = shift;
216 1 50       36 return unless $self->has_nameid;
217 1         39 return $self->nameid_object->getAttribute('Format');
218             }
219              
220              
221             sub nameid_name_qualifier {
222 1     1 1 564 my $self = shift;
223 1 50       36 return unless $self->has_nameid;
224 1         26 return $self->nameid_object->getAttribute('NameQualifier');
225             }
226              
227              
228             sub nameid_sp_name_qualifier {
229 1     1 1 569 my $self = shift;
230 1 50       35 return unless $self->has_nameid;
231 1         26 return $self->nameid_object->getAttribute('SPNameQualifier');
232             }
233              
234              
235             sub nameid_sp_provided_id {
236 1     1 1 531 my $self = shift;
237 1 50       35 return unless $self->has_nameid;
238 1         28 return $self->nameid_object->getAttribute('SPProvidedID');
239             }
240              
241              
242             sub authnstatement {
243 0     0 1 0 my $self = shift;
244 0 0       0 return unless $self->has_authnstatement;
245 0         0 return $self->authnstatement_object->textContent;
246             }
247              
248              
249             sub authnstatement_authninstant {
250 3     3 1 861 my $self = shift;
251 3 50       108 return unless $self->has_authnstatement;
252 3         91 return $self->authnstatement_object->getAttribute('AuthnInstant');
253             }
254              
255              
256             sub authnstatement_sessionindex {
257 3     3 1 1626 my $self = shift;
258 3 50       109 return unless $self->has_authnstatement;
259 3         76 return $self->authnstatement_object->getAttribute('SessionIndex');
260             }
261              
262              
263             sub authnstatement_subjectlocality {
264 6     6 1 12 my $self = shift;
265 6 50       168 return unless $self->has_authnstatement;
266              
267 6         107 my $xpc = XML::LibXML::XPathContext->new;
268 6         39 $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
269 6         21 my $subjectlocality;
270 6         10 my $xpath_base = '//saml:AuthnStatement/saml:SubjectLocality';
271 6 100       164 if (my $nodes = $xpc->find($xpath_base, $self->authnstatement_object)) {
272 2         170 my $node = $nodes->get_node(1);
273 2         12 $subjectlocality = $node;
274             }
275 6         328 return $subjectlocality;
276             }
277              
278              
279             sub subjectlocality_address {
280 3     3 1 1461 my $self = shift;
281 3 50       109 return unless $self->has_authnstatement;
282 3         11 my $subjectlocality = $self->authnstatement_subjectlocality;
283 3 100       19 return unless $subjectlocality;
284 1         8 return $subjectlocality->getAttribute('Address');
285             }
286              
287              
288             sub subjectlocality_dnsname {
289 3     3 1 536 my $self = shift;
290 3 50       109 return unless $self->has_authnstatement;
291 3         7 my $subjectlocality = $self->authnstatement_subjectlocality;
292 3 100       14 return unless $subjectlocality;
293 1         8 return $subjectlocality->getAttribute('DNSName');
294             }
295              
296              
297             sub authnstatement_authncontext {
298 3     3 1 4 my $self = shift;
299 3 50       86 return unless $self->has_authnstatement;
300              
301 3         50 my $xpc = XML::LibXML::XPathContext->new;
302 3         23 $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
303 3         4 my $authncontext;
304 3         4 my $xpath_base = '//saml:AuthnStatement/saml:AuthnContext';
305 3 50       81 if (my $nodes = $xpc->find($xpath_base, $self->authnstatement_object)) {
306 3         227 my $node = $nodes->get_node(1);
307 3         16 $authncontext = $node;
308             }
309 3         24 return $authncontext;
310             }
311              
312              
313             sub contextclass_authncontextclassref {
314 3     3 1 334 my $self = shift;
315 3 50       106 return unless $self->has_authnstatement;
316 3         8 my $authncontextclassref = $self->authnstatement_authncontext;
317 3 50       8 return unless $authncontextclassref;
318 3         42 my $xpc = XML::LibXML::XPathContext->new;
319 3         11 $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
320 3 50       89 if (my $value = $xpc->findvalue('//saml:AuthnContextClassRef', $self->authnstatement_object)) {
321 3         206 $authncontextclassref = $value;
322             }
323 3         9 return $authncontextclassref;
324             }
325              
326              
327             sub valid {
328 22     22 1 9777 my ($self, $audience, $in_response_to) = @_;
329              
330 22 50       69 return 0 unless defined $audience;
331 22 100       775 return 0 unless($audience eq $self->audience);
332              
333 16 100 100     326 return 0 unless !defined $in_response_to
334             or $in_response_to eq $self->in_response_to;
335              
336 13         97 my $now = DateTime::HiRes->now;
337              
338             # not_before is "NotBefore" element - exact match is ok
339             # not_after is "NotOnOrAfter" element - exact match is *not* ok
340 13 100       4387 return 0 unless DateTime::->compare($now, $self->not_before) > -1;
341 9 100       1021 return 0 unless DateTime::->compare($self->not_after, $now) > 0;
342              
343 7         673 return 1;
344             }
345              
346              
347             sub success {
348 1     1 1 3 my $self = shift;
349 1 50       33 return 1 if $self->response_status eq STATUS_SUCCESS;
350 1         5 return 0;
351             }
352              
353             sub _decrypt {
354 14     14   34 my $self = shift;
355 14         32 my $xml = shift;
356 14         58 my %options = @_;
357              
358 14 100       79 return $xml unless $options{key_file};
359              
360             my $enc = XML::Enc->new(
361             {
362             no_xml_declaration => 1,
363             key => $options{key_file},
364             }
365 2         24 );
366 2         3245 return XML::LibXML->load_xml(string => $enc->decrypt($xml, %options));
367             }
368              
369             1;
370              
371             __END__
372              
373             =pod
374              
375             =encoding UTF-8
376              
377             =head1 NAME
378              
379             Net::SAML2::Protocol::Assertion - SAML2 assertion object
380              
381             =head1 VERSION
382              
383             version 0.74
384              
385             =head1 SYNOPSIS
386              
387             my $assertion = Net::SAML2::Protocol::Assertion->new_from_xml(
388             xml => decode_base64($SAMLResponse)
389             );
390              
391             =head1 NAME
392              
393             Net::SAML2::Protocol::Assertion - SAML2 assertion object
394              
395             =head1 METHODS
396              
397             =head2 new_from_xml( ... )
398              
399             Constructor. Creates an instance of the Assertion object, parsing the
400             given XML to find the attributes, session and nameid.
401              
402             Arguments:
403              
404             =over
405              
406             =item B<xml>
407              
408             XML data
409              
410             =item B<key_file>
411              
412             Optional but Required handling Encrypted Assertions.
413              
414             path to the SP's private key file that matches the SP's public certificate
415             used by the IdP to Encrypt the response (or parts of the response)
416              
417             =item B<cacert>
418              
419             path to the CA certificate for verification. Optional: This is only used for
420             validating the certificate provided for a signed Assertion that was found
421             when the EncryptedAssertion is decrypted.
422              
423             While optional it is recommended for ensuring that the Assertion in an
424             EncryptedAssertion is properly validated.
425              
426             =back
427              
428             =head2 response_status
429              
430             Returns the response status
431              
432             =head2 response_substatus
433              
434             SAML errors are usually "nested" ("Responder -> RequestDenied" for instance,
435             means that the responder in this transaction (the IdP) denied the login
436             request). For proper error message generation, both levels are needed.
437              
438             =head2 name
439              
440             Returns the CN attribute, if provided.
441              
442             =head2 nameid
443              
444             Returns the NameID
445              
446             =head2 nameid_format
447              
448             Returns the NameID Format
449              
450             =head2 nameid_name_qualifier
451              
452             Returns the NameID NameQualifier
453              
454             =head2 nameid_sp_name_qualifier
455              
456             Returns the NameID SPNameQualifier
457              
458             =head2 nameid_sp_provided_id
459              
460             Returns the NameID SPProvidedID
461              
462             =head2 authnstatement
463              
464             Returns the AuthnStatement
465              
466             =head2 authnstatement_authninstant
467              
468             Returns the AuthnStatement AuthnInstant
469              
470             =head2 authnstatement_sessionindex
471              
472             Returns the AuthnStatement SessionIndex
473              
474             =head2 authnstatement_subjectlocality
475              
476             Returns the AuthnStatement SubjectLocality
477              
478             =head2 subjectlocality_address
479              
480             Returns the SubjectLocality Address
481              
482             =head2 subjectlocality_dnsname
483              
484             Returns the SubjectLocality DNSName
485              
486             =head2 authnstatement_authncontext
487              
488             Returns the AuthnContext for the AuthnStatement
489              
490             =head2 contextclass_authncontextclassref
491              
492             Returns the ContextClass AuthnContextClassRef
493              
494             =head2 valid( $audience, $in_response_to )
495              
496             Returns true if this Assertion is currently valid for the given audience.
497              
498             Also accepts $in_response_to which it checks against the returned
499             Assertion. This is very important for security as it helps ensure
500             that the assertion that was received was for the request that was made.
501              
502             Checks the audience matches, and that the current time is within the
503             Assertions validity period as specified in its Conditions element.
504              
505             =head2 success
506              
507             Returns true if the response status is a success, returns false otherwise.
508             In case the assertion isn't successfull, the L</response_status> and L</response_substatus> calls can be use to see why the assertion wasn't successful.
509              
510             =head1 AUTHORS
511              
512             =over 4
513              
514             =item *
515              
516             Chris Andrews <chrisa@cpan.org>
517              
518             =item *
519              
520             Timothy Legge <timlegge@gmail.com>
521              
522             =back
523              
524             =head1 COPYRIGHT AND LICENSE
525              
526             This software is copyright (c) 2023 by Venda Ltd, see the CONTRIBUTORS file for others.
527              
528             This is free software; you can redistribute it and/or modify it under
529             the same terms as the Perl 5 programming language system itself.
530              
531             =cut