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   25985 use Moose;
  10         34  
  10         84  
3              
4             our $VERSION = '0.72'; # TRIAL VERSION
5              
6 10     10   70325 use MooseX::Types::DateTime qw/ DateTime /;
  10         565643  
  10         65  
7 10     10   19049 use MooseX::Types::Common::String qw/ NonEmptySimpleStr /;
  10         29  
  10         94  
8 10     10   26456 use DateTime;
  10         25  
  10         215  
9 10     10   5347 use DateTime::HiRes;
  10         4779  
  10         342  
10 10     10   3736 use DateTime::Format::XSD;
  10         3649120  
  10         407  
11 10     10   120 use Net::SAML2::XML::Util qw/ no_comments /;
  10         24  
  10         655  
12 10     10   69 use Net::SAML2::XML::Sig;
  10         24  
  10         93  
13 10     10   6584 use XML::Enc;
  10         170603  
  10         321  
14 10     10   79 use XML::LibXML;
  10         24  
  10         54  
15 10     10   1868 use List::Util qw(first);
  10         28  
  10         641  
16 10     10   64 use URN::OASIS::SAML2 qw(STATUS_SUCCESS);
  10         25  
  10         465  
17 10     10   59 use Carp qw(croak);
  10         29  
  10         19115  
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   31 my $self = shift;
52 13         29 my $xml = shift;
53 13         25 my $cacert = shift;
54 13         26 my $key_file = shift;
55 13         35 my $key_name = shift;
56              
57 13         146 my $xpath = XML::LibXML::XPathContext->new($xml);
58 13         83 $xpath->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
59 13         58 $xpath->registerNs('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol');
60 13         63 $xpath->registerNs('dsig', 'http://www.w3.org/2000/09/xmldsig#');
61 13         51 $xpath->registerNs('xenc', 'http://www.w3.org/2001/04/xmlenc#');
62              
63 13 100       59 return $xml unless $xpath->exists('//saml:EncryptedAssertion');
64              
65 1 50       34 croak "Encrypted Assertions require key_file" if !defined $key_file;
66              
67 1         5 $xml = $self->_decrypt(
68             $xml,
69             key_file => $key_file,
70             key_name => $key_name,
71             );
72 1         25862 $xpath->setContextNode($xml);
73              
74 1         4 my $assert_nodes = $xpath->findnodes('//saml:Assertion');
75 1 50       41 return $xml unless $assert_nodes->size;
76 1         10 my $assert = $assert_nodes->get_node(1);
77              
78 1 50       9 return $xml unless $xpath->exists('dsig:Signature', $assert);
79 1         35 my $xml_opts->{ no_xml_declaration } = 1;
80 1         7 my $x = Net::SAML2::XML::Sig->new($xml_opts);
81 1         135 my $ret = $x->verify($assert->toString());
82 1 50       2241 die "Decrypted Assertion signature check failed" unless $ret;
83              
84 1 50       4 return $xml unless $cacert;
85 1         5 my $cert = $x->signer_cert;
86 1 50       6 die "Certificate not provided in SAML Response, cannot validate" unless $cert;
87              
88 1         104 my $ca = Crypt::OpenSSL::Verify->new($cacert, { strict_certs => 0 });
89 1 50       32 die "Unable to verify signer cert with cacert: " . $cert->subject
90             unless $ca->verify($cert);
91 1         43 return $xml;
92             }
93              
94             sub new_from_xml {
95 13     13 1 9000 my($class, %args) = @_;
96              
97 13         38 my $key_file = $args{key_file};
98 13         38 my $cacert = delete $args{cacert};
99              
100 13         358 my $xpath = XML::LibXML::XPathContext->new();
101 13         262 $xpath->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
102 13         56 $xpath->registerNs('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol');
103 13         47 $xpath->registerNs('dsig', 'http://www.w3.org/2000/09/xmldsig#');
104 13         41 $xpath->registerNs('xenc', 'http://www.w3.org/2001/04/xmlenc#');
105              
106 13         68 my $xml = no_comments($args{xml});
107 13         85 $xpath->setContextNode($xml);
108              
109             $xml = $class->_verify_encrypted_assertion(
110             $xml,
111             $cacert,
112             $key_file,
113             $args{key_name},
114 13         78 );
115              
116             my $dec = $class->_decrypt(
117             $xml,
118             key_file => $key_file,
119             key_name => $args{key_name}
120 13         670 );
121 13         1159 $xpath->setContextNode($dec);
122              
123 13         27 my $attributes = {};
124 13         53 for my $node ($xpath->findnodes('//saml:Assertion/saml:AttributeStatement/saml:Attribute/saml:AttributeValue/..'))
125             {
126 68         1894 my @values = $xpath->findnodes("saml:AttributeValue", $node);
127 68         2412 $attributes->{$node->getAttribute('Name')} = [map $_->string_value, @values];
128             }
129              
130 13         298 my $xpath_base = '//samlp:Response/saml:Assertion/saml:Conditions/';
131              
132 13         451 my $not_before;
133 13 100       62 if (my $value = $xpath->findvalue($xpath_base . '@NotBefore')) {
    50          
134 12         1226 $not_before = DateTime::Format::XSD->parse_datetime($value);
135             }
136             elsif (my $global = $xpath->findvalue('//saml:Conditions/@NotBefore')) {
137 1         125 $not_before = DateTime::Format::XSD->parse_datetime($global);
138             }
139             else {
140 0         0 $not_before = DateTime::HiRes->now();
141             }
142              
143 13         11082 my $not_after;
144 13 100       89 if (my $value = $xpath->findvalue($xpath_base . '@NotOnOrAfter')) {
    50          
145 12         953 $not_after = DateTime::Format::XSD->parse_datetime($value);
146             }
147             elsif (my $global = $xpath->findvalue('//saml:Conditions/@NotOnOrAfter')) {
148 1         105 $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         8147 my $nameid;
155 13 100       93 if (my $node = $xpath->findnodes('/samlp:Response/saml:Assertion/saml:Subject/saml:NameID')) {
    100          
156 8         665 $nameid = $node->get_node(1);
157             }
158             elsif (my $global = $xpath->findnodes('//saml:Subject/saml:NameID')) {
159 3         426 $nameid = $global->get_node(1);
160             }
161              
162 13         522 my $authnstatement;
163 13 100       45 if (my $node = $xpath->findnodes('/samlp:Response/saml:Assertion/saml:AuthnStatement')) {
164 10         589 $authnstatement = $node->get_node(1);
165             }
166              
167 13         234 my $nodeset = $xpath->findnodes('/samlp:Response/samlp:Status/samlp:StatusCode|/samlp:ArtifactResponse/samlp:Status/samlp:StatusCode');
168              
169 13 50       519 croak("Unable to parse status from assertion") unless $nodeset->size;
170              
171 13         99 my $status_node = $nodeset->get_node(1);
172 13         97 my $status = $status_node->getAttribute('Value');
173 13         190 my $sub_status;
174              
175 13 100   3   123 if (my $s = first { $_->isa('XML::LibXML::Element') } $status_node->childNodes) {
  3         61  
176 1         9 $sub_status = $s->getAttribute('Value');
177             }
178              
179 13 100       240 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         81872 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 22 my $self = shift;
209 6 50       224 return unless $self->has_nameid;
210 6         167 return $self->nameid_object->textContent;
211             }
212              
213              
214             sub nameid_format {
215 1     1 1 4 my $self = shift;
216 1 50       42 return unless $self->has_nameid;
217 1         32 return $self->nameid_object->getAttribute('Format');
218             }
219              
220              
221             sub nameid_name_qualifier {
222 1     1 1 681 my $self = shift;
223 1 50       43 return unless $self->has_nameid;
224 1         31 return $self->nameid_object->getAttribute('NameQualifier');
225             }
226              
227              
228             sub nameid_sp_name_qualifier {
229 1     1 1 673 my $self = shift;
230 1 50       42 return unless $self->has_nameid;
231 1         32 return $self->nameid_object->getAttribute('SPNameQualifier');
232             }
233              
234              
235             sub nameid_sp_provided_id {
236 1     1 1 647 my $self = shift;
237 1 50       44 return unless $self->has_nameid;
238 1         32 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 1000 my $self = shift;
251 3 50       134 return unless $self->has_authnstatement;
252 3         98 return $self->authnstatement_object->getAttribute('AuthnInstant');
253             }
254              
255              
256             sub authnstatement_sessionindex {
257 3     3 1 1989 my $self = shift;
258 3 50       132 return unless $self->has_authnstatement;
259 3         97 return $self->authnstatement_object->getAttribute('SessionIndex');
260             }
261              
262              
263             sub authnstatement_subjectlocality {
264 6     6 1 11 my $self = shift;
265 6 50       211 return unless $self->has_authnstatement;
266              
267 6         141 my $xpc = XML::LibXML::XPathContext->new;
268 6         36 $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
269 6         9 my $subjectlocality;
270 6         12 my $xpath_base = '//saml:AuthnStatement/saml:SubjectLocality';
271 6 100       200 if (my $nodes = $xpc->find($xpath_base, $self->authnstatement_object)) {
272 2         174 my $node = $nodes->get_node(1);
273 2         14 $subjectlocality = $node;
274             }
275 6         407 return $subjectlocality;
276             }
277              
278              
279             sub subjectlocality_address {
280 3     3 1 1738 my $self = shift;
281 3 50       164 return unless $self->has_authnstatement;
282 3         10 my $subjectlocality = $self->authnstatement_subjectlocality;
283 3 100       17 return unless $subjectlocality;
284 1         8 return $subjectlocality->getAttribute('Address');
285             }
286              
287              
288             sub subjectlocality_dnsname {
289 3     3 1 655 my $self = shift;
290 3 50       131 return unless $self->has_authnstatement;
291 3         10 my $subjectlocality = $self->authnstatement_subjectlocality;
292 3 100       20 return unless $subjectlocality;
293 1         8 return $subjectlocality->getAttribute('DNSName');
294             }
295              
296              
297             sub authnstatement_authncontext {
298 3     3 1 5 my $self = shift;
299 3 50       120 return unless $self->has_authnstatement;
300              
301 3         77 my $xpc = XML::LibXML::XPathContext->new;
302 3         17 $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
303 3         6 my $authncontext;
304 3         6 my $xpath_base = '//saml:AuthnStatement/saml:AuthnContext';
305 3 50       102 if (my $nodes = $xpc->find($xpath_base, $self->authnstatement_object)) {
306 3         252 my $node = $nodes->get_node(1);
307 3         24 $authncontext = $node;
308             }
309 3         50 return $authncontext;
310             }
311              
312              
313             sub contextclass_authncontextclassref {
314 3     3 1 418 my $self = shift;
315 3 50       134 return unless $self->has_authnstatement;
316 3         13 my $authncontextclassref = $self->authnstatement_authncontext;
317 3 50       16 return unless $authncontextclassref;
318 3         54 my $xpc = XML::LibXML::XPathContext->new;
319 3         16 $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
320 3 50       113 if (my $value = $xpc->findvalue('//saml:AuthnContextClassRef', $self->authnstatement_object)) {
321 3         273 $authncontextclassref = $value;
322             }
323 3         11 return $authncontextclassref;
324             }
325              
326              
327             sub valid {
328 22     22 1 8561 my ($self, $audience, $in_response_to) = @_;
329              
330 22 50       59 return 0 unless defined $audience;
331 22 100       721 return 0 unless($audience eq $self->audience);
332              
333 16 100 100     302 return 0 unless !defined $in_response_to
334             or $in_response_to eq $self->in_response_to;
335              
336 13         70 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       4182 return 0 unless DateTime::->compare($now, $self->not_before) > -1;
341 9 100       1006 return 0 unless DateTime::->compare($self->not_after, $now) > 0;
342              
343 7         634 return 1;
344             }
345              
346              
347             sub success {
348 1     1 1 141 my $self = shift;
349 1 50       44 return 1 if $self->response_status eq STATUS_SUCCESS;
350 1         7 return 0;
351             }
352              
353             sub _decrypt {
354 14     14   39 my $self = shift;
355 14         29 my $xml = shift;
356 14         60 my %options = @_;
357              
358 14 100       65 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         13 );
366 2         2520 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.72
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