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   32616 use Moose;
  10         29  
  10         96  
3              
4             our $VERSION = '0.73'; # VERSION
5              
6 10     10   81450 use MooseX::Types::DateTime qw/ DateTime /;
  10         632927  
  10         71  
7 10     10   20874 use MooseX::Types::Common::String qw/ NonEmptySimpleStr /;
  10         43  
  10         126  
8 10     10   30481 use DateTime;
  10         36  
  10         279  
9 10     10   6363 use DateTime::HiRes;
  10         5515  
  10         398  
10 10     10   4463 use DateTime::Format::XSD;
  10         4075886  
  10         509  
11 10     10   114 use Net::SAML2::XML::Util qw/ no_comments /;
  10         27  
  10         656  
12 10     10   82 use Net::SAML2::XML::Sig;
  10         33  
  10         127  
13 10     10   7761 use XML::Enc;
  10         196400  
  10         372  
14 10     10   96 use XML::LibXML;
  10         26  
  10         57  
15 10     10   1651 use List::Util qw(first);
  10         44  
  10         701  
16 10     10   100 use URN::OASIS::SAML2 qw(STATUS_SUCCESS);
  10         25  
  10         504  
17 10     10   77 use Carp qw(croak);
  10         29  
  10         21722  
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   32 my $self = shift;
52 13         26 my $xml = shift;
53 13         30 my $cacert = shift;
54 13         25 my $key_file = shift;
55 13         39 my $key_name = shift;
56              
57 13         159 my $xpath = XML::LibXML::XPathContext->new($xml);
58 13         108 $xpath->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
59 13         61 $xpath->registerNs('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol');
60 13         58 $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       65 return $xml unless $xpath->exists('//saml:EncryptedAssertion');
64              
65 1 50       45 croak "Encrypted Assertions require key_file" if !defined $key_file;
66              
67 1         9 $xml = $self->_decrypt(
68             $xml,
69             key_file => $key_file,
70             key_name => $key_name,
71             );
72 1         32361 $xpath->setContextNode($xml);
73              
74 1         5 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       10 return $xml unless $xpath->exists('dsig:Signature', $assert);
79 1         40 my $xml_opts->{ no_xml_declaration } = 1;
80 1         9 my $x = Net::SAML2::XML::Sig->new($xml_opts);
81 1         99 my $ret = $x->verify($assert->toString());
82 1 50       2809 die "Decrypted Assertion signature check failed" unless $ret;
83              
84 1 50       6 return $xml unless $cacert;
85 1         5 my $cert = $x->signer_cert;
86 1 50       8 die "Certificate not provided in SAML Response, cannot validate" unless $cert;
87              
88 1         122 my $ca = Crypt::OpenSSL::Verify->new($cacert, { strict_certs => 0 });
89 1 50       44 die "Unable to verify signer cert with cacert: " . $cert->subject
90             unless $ca->verify($cert);
91 1         57 return $xml;
92             }
93              
94             sub new_from_xml {
95 13     13 1 10715 my($class, %args) = @_;
96              
97 13         50 my $key_file = $args{key_file};
98 13         71 my $cacert = delete $args{cacert};
99              
100 13         388 my $xpath = XML::LibXML::XPathContext->new();
101 13         270 $xpath->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
102 13         60 $xpath->registerNs('samlp', 'urn:oasis:names:tc:SAML:2.0:protocol');
103 13         46 $xpath->registerNs('dsig', 'http://www.w3.org/2000/09/xmldsig#');
104 13         48 $xpath->registerNs('xenc', 'http://www.w3.org/2001/04/xmlenc#');
105              
106 13         85 my $xml = no_comments($args{xml});
107 13         89 $xpath->setContextNode($xml);
108              
109             $xml = $class->_verify_encrypted_assertion(
110             $xml,
111             $cacert,
112             $key_file,
113             $args{key_name},
114 13         113 );
115              
116             my $dec = $class->_decrypt(
117             $xml,
118             key_file => $key_file,
119             key_name => $args{key_name}
120 13         842 );
121 13         1409 $xpath->setContextNode($dec);
122              
123 13         35 my $attributes = {};
124 13         71 for my $node ($xpath->findnodes('//saml:Assertion/saml:AttributeStatement/saml:Attribute/saml:AttributeValue/..'))
125             {
126 68         2160 my @values = $xpath->findnodes("saml:AttributeValue", $node);
127 68         2724 $attributes->{$node->getAttribute('Name')} = [map $_->string_value, @values];
128             }
129              
130 13         361 my $xpath_base = '//samlp:Response/saml:Assertion/saml:Conditions/';
131              
132 13         527 my $not_before;
133 13 100       74 if (my $value = $xpath->findvalue($xpath_base . '@NotBefore')) {
    50          
134 12         1385 $not_before = DateTime::Format::XSD->parse_datetime($value);
135             }
136             elsif (my $global = $xpath->findvalue('//saml:Conditions/@NotBefore')) {
137 1         158 $not_before = DateTime::Format::XSD->parse_datetime($global);
138             }
139             else {
140 0         0 $not_before = DateTime::HiRes->now();
141             }
142              
143 13         13026 my $not_after;
144 13 100       114 if (my $value = $xpath->findvalue($xpath_base . '@NotOnOrAfter')) {
    50          
145 12         1087 $not_after = DateTime::Format::XSD->parse_datetime($value);
146             }
147             elsif (my $global = $xpath->findvalue('//saml:Conditions/@NotOnOrAfter')) {
148 1         148 $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         8570 my $nameid;
155 13 100       107 if (my $node = $xpath->findnodes('/samlp:Response/saml:Assertion/saml:Subject/saml:NameID')) {
    100          
156 8         689 $nameid = $node->get_node(1);
157             }
158             elsif (my $global = $xpath->findnodes('//saml:Subject/saml:NameID')) {
159 3         468 $nameid = $global->get_node(1);
160             }
161              
162 13         537 my $authnstatement;
163 13 100       51 if (my $node = $xpath->findnodes('/samlp:Response/saml:Assertion/saml:AuthnStatement')) {
164 10         623 $authnstatement = $node->get_node(1);
165             }
166              
167 13         263 my $nodeset = $xpath->findnodes('/samlp:Response/samlp:Status/samlp:StatusCode|/samlp:ArtifactResponse/samlp:Status/samlp:StatusCode');
168              
169 13 50       577 croak("Unable to parse status from assertion") unless $nodeset->size;
170              
171 13         108 my $status_node = $nodeset->get_node(1);
172 13         122 my $status = $status_node->getAttribute('Value');
173 13         171 my $sub_status;
174              
175 13 100   3   142 if (my $s = first { $_->isa('XML::LibXML::Element') } $status_node->childNodes) {
  3         61  
176 1         10 $sub_status = $s->getAttribute('Value');
177             }
178              
179 13 100       253 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         97019 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         195 return $self->nameid_object->textContent;
211             }
212              
213              
214             sub nameid_format {
215 1     1 1 6 my $self = shift;
216 1 50       42 return unless $self->has_nameid;
217 1         30 return $self->nameid_object->getAttribute('Format');
218             }
219              
220              
221             sub nameid_name_qualifier {
222 1     1 1 749 my $self = shift;
223 1 50       44 return unless $self->has_nameid;
224 1         33 return $self->nameid_object->getAttribute('NameQualifier');
225             }
226              
227              
228             sub nameid_sp_name_qualifier {
229 1     1 1 749 my $self = shift;
230 1 50       52 return unless $self->has_nameid;
231 1         34 return $self->nameid_object->getAttribute('SPNameQualifier');
232             }
233              
234              
235             sub nameid_sp_provided_id {
236 1     1 1 699 my $self = shift;
237 1 50       43 return unless $self->has_nameid;
238 1         35 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 1059 my $self = shift;
251 3 50       135 return unless $self->has_authnstatement;
252 3         97 return $self->authnstatement_object->getAttribute('AuthnInstant');
253             }
254              
255              
256             sub authnstatement_sessionindex {
257 3     3 1 2047 my $self = shift;
258 3 50       131 return unless $self->has_authnstatement;
259 3         95 return $self->authnstatement_object->getAttribute('SessionIndex');
260             }
261              
262              
263             sub authnstatement_subjectlocality {
264 6     6 1 13 my $self = shift;
265 6 50       208 return unless $self->has_authnstatement;
266              
267 6         121 my $xpc = XML::LibXML::XPathContext->new;
268 6         40 $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
269 6         12 my $subjectlocality;
270 6         15 my $xpath_base = '//saml:AuthnStatement/saml:SubjectLocality';
271 6 100       201 if (my $nodes = $xpc->find($xpath_base, $self->authnstatement_object)) {
272 2         186 my $node = $nodes->get_node(1);
273 2         13 $subjectlocality = $node;
274             }
275 6         447 return $subjectlocality;
276             }
277              
278              
279             sub subjectlocality_address {
280 3     3 1 1659 my $self = shift;
281 3 50       133 return unless $self->has_authnstatement;
282 3         18 my $subjectlocality = $self->authnstatement_subjectlocality;
283 3 100       32 return unless $subjectlocality;
284 1         13 return $subjectlocality->getAttribute('Address');
285             }
286              
287              
288             sub subjectlocality_dnsname {
289 3     3 1 648 my $self = shift;
290 3 50       136 return unless $self->has_authnstatement;
291 3         7 my $subjectlocality = $self->authnstatement_subjectlocality;
292 3 100       36 return unless $subjectlocality;
293 1         16 return $subjectlocality->getAttribute('DNSName');
294             }
295              
296              
297             sub authnstatement_authncontext {
298 3     3 1 7 my $self = shift;
299 3 50       145 return unless $self->has_authnstatement;
300              
301 3         79 my $xpc = XML::LibXML::XPathContext->new;
302 3         18 $xpc->registerNs('saml', 'urn:oasis:names:tc:SAML:2.0:assertion');
303 3         5 my $authncontext;
304 3         9 my $xpath_base = '//saml:AuthnStatement/saml:AuthnContext';
305 3 50       114 if (my $nodes = $xpc->find($xpath_base, $self->authnstatement_object)) {
306 3         307 my $node = $nodes->get_node(1);
307 3         37 $authncontext = $node;
308             }
309 3         31 return $authncontext;
310             }
311              
312              
313             sub contextclass_authncontextclassref {
314 3     3 1 464 my $self = shift;
315 3 50       132 return unless $self->has_authnstatement;
316 3         12 my $authncontextclassref = $self->authnstatement_authncontext;
317 3 50       20 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       112 if (my $value = $xpc->findvalue('//saml:AuthnContextClassRef', $self->authnstatement_object)) {
321 3         289 $authncontextclassref = $value;
322             }
323 3         10 return $authncontextclassref;
324             }
325              
326              
327             sub valid {
328 22     22 1 10088 my ($self, $audience, $in_response_to) = @_;
329              
330 22 50       78 return 0 unless defined $audience;
331 22 100       824 return 0 unless($audience eq $self->audience);
332              
333 16 100 100     338 return 0 unless !defined $in_response_to
334             or $in_response_to eq $self->in_response_to;
335              
336 13         92 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       4531 return 0 unless DateTime::->compare($now, $self->not_before) > -1;
341 9 100       1110 return 0 unless DateTime::->compare($self->not_after, $now) > 0;
342              
343 7         712 return 1;
344             }
345              
346              
347             sub success {
348 1     1 1 130 my $self = shift;
349 1 50       45 return 1 if $self->response_status eq STATUS_SUCCESS;
350 1         9 return 0;
351             }
352              
353             sub _decrypt {
354 14     14   42 my $self = shift;
355 14         32 my $xml = shift;
356 14         69 my %options = @_;
357              
358 14 100       72 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         19 );
366 2         3343 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.73
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