File Coverage

blib/lib/Authen/NZRealMe/ServiceProvider.pm
Criterion Covered Total %
statement 19 21 90.4
branch n/a
condition n/a
subroutine 7 7 100.0
pod n/a
total 26 28 92.8


line stmt bran cond sub pod time code
1             package Authen::NZRealMe::ServiceProvider;
2             {
3             $Authen::NZRealMe::ServiceProvider::VERSION = '1.16';
4             }
5              
6 1     1   4 use strict;
  1         1  
  1         24  
7 1     1   4 use warnings;
  1         1  
  1         56  
8              
9             require XML::LibXML;
10             require XML::LibXML::XPathContext;
11             require XML::Generator;
12             require Crypt::OpenSSL::X509;
13             require HTTP::Response;
14              
15 1     1   401 use URI::Escape qw(uri_escape uri_unescape);
  1         1004  
  1         54  
16 1     1   449 use POSIX qw(strftime);
  1         4246  
  1         4  
17 1     1   1220 use Date::Parse qw();
  1         4927  
  1         24  
18 1     1   5 use File::Spec qw();
  1         1  
  1         21  
19              
20 0           use WWW::Curl::Easy qw(
21             CURLOPT_URL
22             CURLOPT_POST
23             CURLOPT_HTTPHEADER
24             CURLOPT_POSTFIELDS
25             CURLOPT_SSLCERT
26             CURLOPT_SSLKEY
27             CURLOPT_SSL_VERIFYPEER
28             CURLOPT_WRITEDATA
29             CURLOPT_WRITEHEADER
30             CURLOPT_CAPATH
31 1     1   257 );
  0            
32              
33             use constant DATETIME_BEFORE => -1;
34             use constant DATETIME_EQUAL => 0;
35             use constant DATETIME_AFTER => 1;
36              
37              
38             my %metadata_cache;
39             my $signing_cert_filename = 'sp-sign-crt.pem';
40             my $signing_key_filename = 'sp-sign-key.pem';
41             my $ssl_cert_filename = 'sp-ssl-crt.pem';
42             my $ssl_key_filename = 'sp-ssl-key.pem';
43             my $icms_wsdl_filename = 'metadata-icms.wsdl';
44             my $ca_cert_directory = 'ca-certs';
45              
46              
47             my $ns_md = [ md => 'urn:oasis:names:tc:SAML:2.0:metadata' ];
48             my $ns_ds = [ ds => 'http://www.w3.org/2000/09/xmldsig#' ];
49             my $ns_saml = [ saml => 'urn:oasis:names:tc:SAML:2.0:assertion' ];
50             my $ns_samlp = [ samlp => 'urn:oasis:names:tc:SAML:2.0:protocol' ];
51             my $ns_soap_env = [ 'SOAP-ENV' => 'http://schemas.xmlsoap.org/soap/envelope/' ];
52             my $ns_xpil = [ xpil => "urn:oasis:names:tc:ciq:xpil:3" ];
53             my $ns_xal = [ xal => "urn:oasis:names:tc:ciq:xal:3" ];
54             my $ns_xnl = [ xnl => "urn:oasis:names:tc:ciq:xnl:3" ];
55             my $ns_ct = [ ct => "urn:oasis:names:tc:ciq:ct:3" ];
56             my $ns_soap = [ soap => "http://www.w3.org/2003/05/soap-envelope" ];
57             my $ns_wsse = [ wsse => "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" ];
58             my $ns_wsu = [ wsu => "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" ];
59             my $ns_wst = [ wst => "http://docs.oasis-open.org/ws-sx/ws-trust/200512" ];
60             my $ns_wsa = [ wsa => "http://www.w3.org/2005/08/addressing" ];
61             my $ns_ec = [ ec => "http://www.w3.org/2001/10/xml-exc-c14n#" ];
62             my $ns_icms = [ iCMS => "urn:nzl:govt:ict:stds:authn:deployment:igovt:gls:iCMS:1_0" ];
63             my $ns_wsdl = [ wsdl => 'http://schemas.xmlsoap.org/wsdl/' ];
64             my $ns_soap_12 = [ soap => 'http://schemas.xmlsoap.org/wsdl/soap12/' ];
65             my $ns_wsam = [ wsam => 'http://www.w3.org/2007/05/addressing/metadata' ];
66              
67             my @ivs_namespaces = ( $ns_xpil, $ns_xnl, $ns_ct, $ns_xal );
68             my @avs_namespaces = ( $ns_xpil, $ns_xal );
69             my @icms_namespaces = ( $ns_ds, $ns_saml, $ns_icms, $ns_wsse, $ns_wsu, $ns_wst, $ns_soap );
70             my @wsdl_namespaces = ( $ns_wsdl, $ns_soap_12, $ns_wsam );
71              
72             my %urn_nameid_format = (
73             login => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
74             assertion => 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient',
75             unspec => 'urn:oasis:names:tc:SAML:2.0:nameid-format:unspecified',
76             );
77              
78             my %urn_attr_name = (
79             fit => 'urn:nzl:govt:ict:stds:authn:attribute:igovt:IVS:FIT',
80             ivs => 'urn:nzl:govt:ict:stds:authn:safeb64:attribute:igovt:IVS:Assertion:Identity',
81             avs => 'urn:nzl:govt:ict:stds:authn:safeb64:attribute:NZPost:AVS:Assertion:Address',
82             icms_token => 'urn:nzl:govt:ict:stds:authn:safeb64:attribute:opaque_token',
83             );
84              
85             my $soap_action = 'http://www.oasis-open.org/committees/security';
86              
87              
88             sub new {
89             my $class = shift;
90              
91             my $self = bless {
92             type => 'login',
93             skip_signature_check => 0,
94             @_
95             }, $class;
96              
97             my $conf_dir = $self->{conf_dir} or die "conf_dir not set\n";
98             $self->{conf_dir} = File::Spec->rel2abs($conf_dir);
99              
100             $self->_check_type();
101              
102             $self->_load_metadata();
103              
104             return $self;
105             }
106              
107              
108             sub new_defaults {
109             my $class = shift;
110              
111             my $self = bless {
112             @_,
113             }, $class;
114              
115             return $self;
116             }
117              
118              
119             sub conf_dir { shift->{conf_dir}; }
120             sub type { shift->{type}; }
121             sub entity_id { shift->{entity_id}; }
122             sub url_single_logout { shift->{url_single_logout}; }
123             sub url_assertion_consumer { shift->{url_assertion_consumer}; }
124             sub organization_name { shift->{organization_name}; }
125             sub organization_url { shift->{organization_url}; }
126             sub contact_company { shift->{contact_company}; }
127             sub contact_first_name { shift->{contact_first_name}; }
128             sub contact_surname { shift->{contact_surname}; }
129             sub skip_signature_check { shift->{skip_signature_check}; }
130             sub _x { shift->{x}; }
131             sub nameid_format { return $urn_nameid_format{ shift->type }; }
132             sub signing_cert_pathname { shift->{conf_dir} . '/' . $signing_cert_filename; }
133             sub signing_key_pathname { shift->{conf_dir} . '/' . $signing_key_filename; }
134             sub ssl_cert_pathname { shift->{conf_dir} . '/' . $ssl_cert_filename; }
135             sub ssl_key_pathname { shift->{conf_dir} . '/' . $ssl_key_filename; }
136             sub ca_cert_pathname { shift->{conf_dir} . '/' . $ca_cert_directory; }
137              
138             sub idp {
139             my $self = shift;
140              
141             return $self->{idp} if $self->{idp};
142              
143             $self->{idp} = Authen::NZRealMe->class_for('identity_provider')->new(
144             conf_dir => $self->conf_dir(),
145             type => $self->type,
146             );
147             }
148              
149              
150             sub token_generator {
151             return shift->{token_generator} ||=
152             Authen::NZRealMe->class_for('token_generator')->new();
153             }
154              
155              
156             sub generate_saml_id {
157             return shift->token_generator->saml_id(@_);
158             }
159              
160              
161             sub generate_certs {
162             my($class, $conf_dir, %args) = @_;
163              
164             Authen::NZRealMe->class_for('sp_cert_factory')->generate_certs(
165             $conf_dir, %args
166             );
167             }
168              
169              
170             sub build_meta {
171             my($class, %opt) = @_;
172              
173             Authen::NZRealMe->class_for('sp_builder')->build($class, %opt);
174             }
175              
176              
177             sub _read_file {
178             my($self, $filename) = @_;
179              
180             local($/) = undef; # slurp mode
181             open my $fh, '<', $filename or die "open($filename): $!";
182             my $data = <$fh>;
183             return $data;
184             }
185              
186              
187             sub _write_file {
188             my($self, $filename, $data) = @_;
189              
190             open my $fh, '>', $filename or die "open(>$filename): $!";
191             print $fh $data;
192              
193             close($fh) or die "close(>$filename): $!";
194             }
195              
196              
197             sub make_bundle {
198             my($class, %opt) = @_;
199              
200             my $conf_dir = $opt{conf_dir};
201             foreach my $type (qw(login assertion)) {
202             my $conf_path = $class->_metadata_pathname($conf_dir, $type);
203             if(-r $conf_path) {
204             my $sp = $class->new(
205             conf_dir => $conf_dir,
206             type => $type,
207             );
208             my $zip = Authen::NZRealMe->class_for('sp_builder')->make_bundle($sp);
209             print "Created metadata bundle for '$type' IDP at:\n$zip\n\n";
210             }
211             }
212             }
213              
214              
215             sub _check_type {
216             my $self = shift;
217              
218             my $type = $self->type;
219             if($type ne 'login' and $type ne 'assertion') {
220             warn qq{Unknown service type.\n} .
221             qq{ Got: "$type"\n} .
222             qq{ Expected: "login" or "assertion"\n};
223             }
224             }
225              
226              
227             sub _load_metadata {
228             my $self = shift;
229              
230             my $cache_key = $self->conf_dir . '-' . $self->type;
231             my $params = $metadata_cache{$cache_key} || $self->_read_metadata_from_file;
232              
233             $self->{$_} = $params->{$_} foreach keys %$params;
234             }
235              
236              
237             sub _read_metadata_from_file {
238             my $self = shift;
239              
240             my $metadata_file = $self->_metadata_pathname;
241             die "File does not exist: $metadata_file\n" unless -e $metadata_file;
242              
243             my $xc = $self->_xpath_context_dom($metadata_file, $ns_md);
244              
245             $xc->registerNs( @$ns_md );
246              
247             my %params;
248             foreach (
249             [ id => q{/md:EntityDescriptor/@ID} ],
250             [ entity_id => q{/md:EntityDescriptor/@entityID} ],
251             [ url_single_logout => q{/md:EntityDescriptor/md:SPSSODescriptor/md:SingleLogoutService/@Location} ],
252             [ url_assertion_consumer => q{/md:EntityDescriptor/md:SPSSODescriptor/md:AssertionConsumerService/@Location} ],
253             [ organization_name => q{/md:EntityDescriptor/md:Organization/md:OrganizationName} ],
254             [ organization_url => q{/md:EntityDescriptor/md:Organization/md:OrganizationURL} ],
255             [ contact_company => q{/md:EntityDescriptor/md:ContactPerson/md:Company} ],
256             [ contact_first_name => q{/md:EntityDescriptor/md:ContactPerson/md:GivenName} ],
257             [ contact_surname => q{/md:EntityDescriptor/md:ContactPerson/md:SurName} ],
258             ) {
259             $params{$_->[0]} = $xc->findvalue($_->[1]);
260             }
261              
262             my $cache_key = $self->conf_dir . '-' . $self->type;
263             $metadata_cache{$cache_key} = \%params;
264              
265             my $icms_pathname = $self->_icms_wsdl_pathname;
266              
267             if ( $self->{type} eq 'assertion' && -e $icms_pathname ){
268             $self->_parse_icms_wsdl;
269             }
270              
271             return \%params;
272             }
273              
274             sub _parse_icms_wsdl {
275             my ($self) = @_;
276              
277             my $icms_pathname = $self->_icms_wsdl_pathname;
278             die "No ICMS WSDL file '$icms_wsdl_filename' in config directory"
279             unless -e $icms_pathname;
280             my $description = $self->_read_file($icms_pathname);
281             my $dom = XML::LibXML->load_xml( string => $description );
282             my $xpc = XML::LibXML::XPathContext->new();
283             foreach my $ns ( @wsdl_namespaces ) {
284             $xpc->registerNs(@$ns);
285             }
286             my $result = {};
287             foreach my $type ( 'Issue', 'Validate' ){
288             $result->{$type} = {
289             url => $dom->findvalue('./wsdl:definitions/wsdl:service[@name="igovtContextMappingService"]/wsdl:port[@name="'.$type.'"]/soap:address/@location'),
290             operation => $dom->findvalue('./wsdl:definitions/wsdl:portType[@name="'.$type.'"]/wsdl:operation/wsdl:input/@wsam:Action'),
291             };
292             }
293              
294             my $cache_key = $self->conf_dir . '-' . $self->type . '-icms';
295             $metadata_cache{$cache_key} = $result;
296             }
297              
298             sub _metadata_pathname {
299             my $self = shift;
300             my $conf_dir = shift;
301             my $type = shift;
302              
303             $type //= $self->type;
304              
305             $conf_dir ||= $self->conf_dir or die "conf_dir not set";
306              
307             return $conf_dir . '/metadata-' . $type . '-sp.xml';
308             }
309              
310             sub _icms_wsdl_pathname {
311             my $self = shift;
312             my $conf_dir = shift;
313             my $type = shift;
314              
315             $type //= $self->type;
316              
317             $conf_dir ||= $self->conf_dir or die "conf_dir not set";
318              
319             return $conf_dir . '/' . $icms_wsdl_filename;
320             }
321              
322             sub _icms_method_data {
323             my $self = shift;
324             my $method = shift;
325              
326             my $cache_key = $self->conf_dir . '-' . $self->type . '-icms';
327              
328             my $methods = $metadata_cache{$cache_key} || $self->_parse_icms_wsdl;
329              
330             return $methods->{$method};
331             }
332              
333             sub _xpath_context_dom {
334             my($self, $source, @namespaces) = @_;
335              
336             my $parser = XML::LibXML->new();
337             my $doc = $source =~ /<.*>/
338             ? $parser->parse_string( $source )
339             : $parser->parse_file( $source );
340             my $xc = XML::LibXML::XPathContext->new( $doc->documentElement() );
341              
342             foreach my $ns ( @namespaces ) {
343             $xc->registerNs( @$ns );
344             }
345              
346             return $xc;
347             }
348              
349              
350             sub new_request {
351             my $self = shift;
352              
353             my $req = Authen::NZRealMe->class_for('authen_request')->new($self, @_);
354             return $req;
355             }
356              
357              
358             sub _signing_cert_pem_data {
359             my $self = shift;
360              
361             return $self->{signing_cert_pem_data} if $self->{signing_cert_pem_data};
362              
363             my $path = $self->signing_cert_pathname
364             or die "No path to signing certificate file";
365              
366             my $cert_data = $self->_read_file($path);
367              
368             $cert_data =~ s{\r\n}{\n}g;
369             $cert_data =~ s{\A.*?^-+BEGIN CERTIFICATE-+\n}{}sm;
370             $cert_data =~ s{^-+END CERTIFICATE-+\n?.*\z}{}sm;
371              
372             return $cert_data;
373             }
374              
375              
376             sub metadata_xml {
377             my $self = shift;
378              
379             return $self->_to_xml_string();
380             }
381              
382              
383             sub _sign_xml {
384             my($self, $xml, $target_id) = @_;
385              
386             my $signer = $self->_signer();
387              
388             return $signer->sign($xml, $target_id);
389             }
390              
391              
392             sub sign_query_string {
393             my($self, $qs) = @_;
394              
395             $qs .= '&SigAlg=http%3A%2F%2Fwww.w3.org%2F2000%2F09%2Fxmldsig%23rsa-sha1';
396              
397             my $signer = $self->_signer();
398              
399             my $sig = $signer->rsa_signature( $qs, '' );
400              
401             return $qs . '&Signature=' . uri_escape( $sig );
402             }
403              
404              
405             sub _signer {
406             my($self, $id_attr) = @_;
407              
408             my $key_path = $self->signing_key_pathname
409             or die "No path to signing key file";
410              
411             my %options = (
412             pub_cert_file => $self->signing_cert_pathname,
413             key_file => $key_path
414             );
415             $options{id_attr} = $id_attr if $id_attr;
416              
417             return Authen::NZRealMe->class_for('xml_signer')->new( %options );
418             }
419              
420              
421             sub resolve_artifact {
422             my($self, %args) = @_;
423              
424             my $artifact = $args{artifact}
425             or die "Need artifact from SAMLart URL parameter\n";
426              
427             if($artifact =~ m{\bSAMLart=(.*?)(?:&|$)}) {
428             $artifact = uri_unescape($1);
429             }
430              
431             die "Can't resolve artifact without original request ID\n"
432             unless $args{request_id};
433              
434             my $request = Authen::NZRealMe->class_for('resolution_request')->new($self, $artifact);
435             my $url = $request->destination_url;
436             my $soap_body = $request->soap_request;
437              
438             my $headers = [
439             'User-Agent: Authen-NZRealMe/' . ($Authen::NZRealMe::VERSION // '0.0'),
440             'Content-Type: text/xml',
441             'SOAPAction: http://www.oasis-open.org/committees/security',
442             'Content-Length: ' . length($soap_body),
443             ];
444              
445              
446             my $content;
447             if($args{_from_file_}) {
448             $content = $self->_read_file($args{_from_file_});
449             }
450             else {
451             my $http_resp = $self->_https_post($url, $headers, $soap_body);
452              
453             die "Artifact resolution failed:\n" . $http_resp->as_string
454             unless $http_resp->is_success;
455              
456             $content = $http_resp->content;
457              
458             if($args{_to_file_}) {
459             $self->_write_file($args{_to_file_}, $content);
460             }
461             }
462              
463             my $response = $self->_verify_assertion($content, %args);
464              
465             if($response->is_success) {
466             if($self->type eq 'assertion' and $args{resolve_flt}) {
467             $self->_resolve_flt($response, %args);
468             }
469             }
470              
471             return $response;
472             }
473              
474             sub _resolve_flt {
475             my($self, $idp_response, %args) = @_;
476              
477             my $opaque_token = $idp_response->_icms_token();
478              
479             my $request = Authen::NZRealMe->class_for('icms_resolution_request')->new($self, $opaque_token);
480              
481             my $method = $self->_icms_method_data('Validate');
482              
483             my $request_data = $request->request_data;
484              
485             my $headers = [
486             'User-Agent: Authen-NZRealMe/' . ($Authen::NZRealMe::VERSION // '0.0'),
487             'Content-Type: text/xml',
488             'SOAPAction: ' . $method->{operation},
489             'Content-Length: ' . length($request_data),
490             ];
491              
492             my $response = $self->_https_post($request->destination_url, $headers, $request_data);
493              
494             my $content = $response->content;
495              
496             if ( !$response->is_success ){
497             my $xc = $self->_xpath_context_dom($content, $ns_soap, $ns_icms);
498             # Grab and output the SOAP error explanation, if present.
499             if(my($error) = $xc->findnodes('//soap:Fault')) {
500             my $code = $xc->findvalue('./soap:Code/soap:Value', $error) || 'Unknown';
501             my $string = $xc->findvalue('./soap:Reason/soap:Text', $error) || 'Unknown';
502             die "ICMS error:\n Fault Code: $code\n Fault String: $string";
503             }
504             die "Error resolving FLT\n Response code:$response->code\n Message:$response->message";
505             }
506              
507             if($args{_to_file_}) {
508             # Add a -icms suffix so we don't overwrite the SAML response file
509             my $icms_file = $args{_to_file_};
510             $icms_file =~ s{([.]\w+|)$}{-icms$1};
511             $self->_write_file($icms_file, $content);
512             }
513              
514             my $flt = $self->_extract_flt($content);
515             $idp_response->set_flt($flt);
516             }
517              
518             sub _extract_flt {
519             my($self, $xml, %args) = @_;
520             my $xc = $self->_xpath_context_dom($xml, @icms_namespaces);
521             # We have a SAML assertion, make sure it's signed
522             my $idp = $self->idp;
523             # ICMS responses use wsu:Id's for their ID attribute, and are (for some
524             # bizarre reason) signed with the key the login service uses.
525             eval {
526             my $verifier = Authen::NZRealMe->class_for('xml_signer')->new(
527             pub_cert_text => $idp->login_cert_pem_data(),
528             id_attr => 'wsu:Id',
529             );
530             $verifier->verify($xml);
531             };
532             if($@) {
533             die "Failed to verify signature on assertion from IdP:\n $@\n$xml";
534             }
535             return $xc->findvalue(q{/soap:Envelope/soap:Body/wst:RequestSecurityTokenResponse/wst:RequestedSecurityToken/saml:Assertion/saml:Subject/saml:NameID});
536             }
537              
538             sub _https_post {
539             my($self, $url, $headers, $body) = @_;
540              
541             my $curl = new WWW::Curl::Easy;
542              
543             $curl->setopt(CURLOPT_URL, $url);
544             $curl->setopt(CURLOPT_POST, 1);
545             $curl->setopt(CURLOPT_HTTPHEADER, $headers);
546             $curl->setopt(CURLOPT_POSTFIELDS, $body);
547             $curl->setopt(CURLOPT_SSLCERT, $self->ssl_cert_pathname);
548             $curl->setopt(CURLOPT_SSLKEY, $self->ssl_key_pathname);
549              
550             if ($self->{disable_ssl_verify}){
551             $curl->setopt(CURLOPT_SSL_VERIFYPEER, 0);
552             }
553             else {
554             $curl->setopt(CURLOPT_SSL_VERIFYPEER, 1);
555             $curl->setopt(CURLOPT_CAPATH, $self->ca_cert_pathname);
556             }
557              
558             my($resp_body, $resp_head);
559             open (my $body_fh, ">", \$resp_body);
560             $curl->setopt(CURLOPT_WRITEDATA, $body_fh);
561             open (my $head_fh, ">", \$resp_head);
562             $curl->setopt(CURLOPT_WRITEHEADER, $head_fh);
563              
564             my $resp;
565             my $retcode = $curl->perform;
566             if($retcode == 0) {
567             $resp_head =~ s/\A(?:HTTP\/1\.1 100 Continue)?[\r\n]*//; # Remove any '100' responses and/or leading newlines
568             my($status, @head_lines) = split(/\r?\n/, $resp_head);
569             my($protocol, $code, $message) = split /\s+/, $status, 3;
570             my $headers = [ map { split /:\s+/, $_, 2 } @head_lines];
571             $resp = HTTP::Response->new($code, $message, $headers, $resp_body);
572             }
573             else {
574             $resp = HTTP::Response->new(
575             500, 'Error', [], $curl->strerror($retcode)." ($retcode)\n"
576             );
577             }
578              
579             return $resp;
580             }
581              
582              
583             sub _verify_assertion {
584             my($self, $xml, %args) = @_;
585              
586             my $xc = $self->_xpath_context_dom($xml, $ns_soap_env, $ns_saml, $ns_samlp);
587              
588             # Check for SOAP error
589              
590             if(my($error) = $xc->findnodes('//SOAP-ENV:Fault')) {
591             my $code = $xc->findvalue('./faultcode', $error) || 'Unknown';
592             my $string = $xc->findvalue('./faultstring', $error) || 'Unknown';
593             die "SOAP protocol error:\n Fault Code: $code\n Fault String: $string\n";
594             }
595              
596              
597             # Extract the SAML result code
598              
599             my $response = $self->_build_resolution_response($xc, $xml);
600             return $response if $response->is_error;
601              
602              
603             # Look for the SAML Response Subject payload
604              
605             my($subject) = $xc->findnodes(
606             '//samlp:ArtifactResponse/samlp:Response/saml:Assertion/saml:Subject'
607             ) or die "Unable to find SAML Subject element in:\n$xml\n";
608              
609              
610             # We have a SAML assertion, make sure it's signed
611              
612             my $idp = $self->idp;
613             $self->_verify_assertion_signature($idp, $xml);
614              
615              
616             # Confirm that subject is valid for our SP
617              
618             $self->_check_subject_confirmation($xc, $subject, $args{request_id});
619              
620              
621             # Check that it was generated by the expected IdP
622              
623             my $idp_entity_id = $idp->entity_id;
624             my $from_sp = $xc->findvalue('./saml:NameID/@NameQualifier', $subject) || '';
625             die "SAML assertion created by '$from_sp', expected '$idp_entity_id'. Assertion follows:\n$xml\n"
626             if $from_sp ne $idp_entity_id;
627              
628              
629             # Check that it's intended for our SP
630              
631             if($self->type eq 'login') { # Not provided by assertion IdP
632             my $sp_entity_id = $self->entity_id;
633             my $for_sp = $xc->findvalue('./saml:NameID/@SPNameQualifier', $subject) || '';
634             die "SAML assertion created for '$for_sp', expected '$sp_entity_id'\n$xml\n"
635             if $for_sp ne $sp_entity_id;
636             }
637              
638             # Look for Conditions on the assertion
639              
640             $self->_check_conditions($xc); # will die on failure
641              
642              
643             # Make sure it's in the expected format
644              
645             my $nameid_format = $self->nameid_format();
646             my $format = $xc->findvalue('./saml:NameID/@Format', $subject) || '';
647             die "Unrecognised NameID format '$format', expected '$nameid_format'\n$xml\n"
648             if $format ne $nameid_format;
649              
650              
651             # Check the logon strength (if required)
652              
653             if($self->type eq 'login') { # Not needed for assertion IdP
654             my $strength = $xc->findvalue(
655             q{//samlp:Response/saml:Assertion/saml:AuthnStatement/saml:AuthnContext/saml:AuthnContextClassRef}
656             ) || '';
657             $response->set_logon_strength($strength);
658             if($args{logon_strength}) {
659             $strength = Authen::NZRealMe->class_for('logon_strength')->new($strength);
660             $strength->assert_match($args{logon_strength}, $args{strength_match});
661             }
662             }
663              
664             # Extract the payload
665              
666             if($self->type eq 'login') {
667             $self->_extract_login_payload($response, $xc);
668             }
669             elsif($self->type eq 'assertion') {
670             $self->_extract_assertion_payload($response, $xc);
671             }
672              
673             return $response;
674             }
675              
676              
677             sub _verify_assertion_signature {
678             my($self, $idp, $xml) = @_;
679              
680             my $skip_type = $self->skip_signature_check;
681             return if $skip_type > 1;
682              
683             eval {
684             $idp->verify_signature($xml);
685             };
686             return unless $@; # Signature was good
687              
688             if($skip_type) {
689             warn "WARNING: Continuing after signature verification failure "
690             . "(skip_signature_check is enabled)\n$@\n";
691             return;
692             }
693              
694             die $@; # Re-throw the exception
695             }
696              
697              
698             sub _build_resolution_response {
699             my($self, $xc, $xml) = @_;
700              
701             my $response = Authen::NZRealMe->class_for('resolution_response')->new($xml);
702             $response->set_service_type( $self->type );
703              
704             my($status_code) = $xc->findnodes(
705             '//samlp:ArtifactResponse/samlp:Response/samlp:Status/samlp:StatusCode'
706             ) or die "Could not find a SAML status code\n$xml\n";
707              
708             # Recurse down to find the most specific status code
709              
710             while(
711             my($child_code) = $xc->findnodes('./samlp:StatusCode', $status_code)
712             ) {
713             $status_code = $child_code;
714             }
715              
716             my($urn) = $xc->findvalue('./@Value', $status_code)
717             or die "Couldn't find 'Value' attribute for StatusCode\n$xml\n";
718              
719             $response->set_status_urn($urn);
720              
721             return $response if $response->is_success;
722              
723             my $message = $xc->findvalue(
724             '//samlp:ArtifactResponse/samlp:Response/samlp:Status/samlp:StatusMessage'
725             ) || '';
726             $message =~ s{^\[.*\]}{}; # Strip off [SP EntityID] prefix
727             $response->set_status_message($message) if $message;
728              
729             return $response
730             }
731              
732              
733             sub _check_subject_confirmation {
734             my($self, $xc, $subject, $request_id) = @_;
735              
736             my $xml = $subject->toString();
737              
738             my($conf_data) = $xc->findnodes(
739             './saml:SubjectConfirmation/saml:SubjectConfirmationData',
740             $subject
741             ) or die "SAML assertion does not contain SubjectConfirmationData\n$xml\n";
742              
743              
744             # Check that it's a reply to our request
745              
746             my $response_to = $xc->findvalue('./@InResponseTo', $conf_data) || '';
747             die "SAML response to unexpected request ID\n"
748             . "Original: '$request_id'\n"
749             . "Response To: '$response_to'\n$xml\n" if $request_id ne $response_to;
750              
751             # Check that it has not expired
752              
753             my $now = $self->now_as_iso();
754              
755             if(my($end_time) = $xc->findvalue('./@NotOnOrAfter', $conf_data)) {
756             if($self->_compare_times($now, $end_time) != DATETIME_BEFORE) {
757             die "SAML assertion SubjectConfirmationData expired at '$end_time'\n";
758             }
759             }
760              
761             }
762              
763              
764             sub _check_conditions {
765             my($self, $xc) = @_;
766              
767             my($conditions) = $xc->findnodes(
768             '//samlp:ArtifactResponse/samlp:Response/saml:Assertion/saml:Conditions'
769             ) or return;
770              
771             my $xml = $conditions->toString();
772              
773             my $now = $self->now_as_iso();
774              
775             if(my($start_time) = $xc->findvalue('./@NotBefore', $conditions)) {
776             if($self->_compare_times($start_time, $now) != DATETIME_BEFORE) {
777             die "SAML assertion not valid until '$start_time'\n";
778             }
779             }
780              
781             if(my($end_time) = $xc->findvalue('./@NotOnOrAfter', $conditions)) {
782             if($self->_compare_times($now, $end_time) != DATETIME_BEFORE) {
783             die "SAML assertion not valid after '$end_time'\n";
784             }
785             }
786              
787             foreach my $condition ($xc->findnodes('./saml:*', $conditions)) {
788             my($name) = $condition->localname();
789             my $method = "_check_condition_$name";
790             die "Unimplemented condition: '$name'" unless $self->can($method);
791             $self->$method($xc, $condition);
792             }
793              
794             return; # no problems were encountered
795             }
796              
797              
798             sub _check_condition_AudienceRestriction {
799             my($self, $xc, $condition) = @_;
800              
801             my $entity_id = $self->entity_id;
802             my $audience = $xc->findvalue('./saml:Audience', $condition)
803             or die "Can't find target audience in: " . $condition->toString();
804              
805             die "SAML assertion only valid for audience '$audience' (expected '$entity_id')"
806             if $audience ne $entity_id;
807             }
808              
809              
810             sub _compare_times {
811             my($self, $date1, $date2) = @_;
812              
813             foreach ($date1, $date2) {
814             s/\s+//g;
815             die "Invalid timestamp '$_'\n"
816             unless /\A\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\dZ(.*)\z/s;
817             die "Non-UTC dates are not supported: '$_'" if $1;
818             }
819              
820             return $date1 cmp $date2;
821             }
822              
823              
824             sub _extract_login_payload {
825             my($self, $response, $xc) = @_;
826              
827             # Extract the FLT
828              
829             my $flt = $xc->findvalue(
830             q{//samlp:Response/saml:Assertion/saml:Subject/saml:NameID}
831             ) or die "Can't find NameID element in response:\n" . $response->xml . "\n";
832              
833             $flt =~ s{\s+}{}g;
834              
835             $response->set_flt($flt);
836             }
837              
838              
839             sub _extract_assertion_payload {
840             my($self, $response, $xc) = @_;
841              
842             # Extract the asserted attributes
843              
844             my $attribute_selector =
845             q{//samlp:Response/saml:Assertion/saml:AttributeStatement/saml:Attribute};
846              
847             foreach my $attr ( $xc->findnodes($attribute_selector) ) {
848             my $name = $xc->findvalue('./@Name', $attr) or next;
849             my $value = $xc->findvalue('./saml:AttributeValue', $attr) || '';
850             if($name =~ /:safeb64:/) {
851             $value = MIME::Base64::decode_base64url($value);
852             }
853             if($name eq $urn_attr_name{fit}) {
854             $response->set_fit($value);
855             }
856             elsif($name eq $urn_attr_name{ivs}) {
857             $self->_extract_ivs_details($response, $value);
858             }
859             elsif($name eq $urn_attr_name{avs}) {
860             $self->_extract_avs_details($response, $value);
861             }
862             elsif($name eq $urn_attr_name{icms_token}) {
863             $self->_extract_icms_token($response, $value);
864             }
865             }
866             }
867              
868              
869             sub _extract_ivs_details {
870             my($self, $response, $xml) = @_;
871              
872             my $xc = $self->_xpath_context_dom($xml, @ivs_namespaces);
873              
874             my($dd, $mm, $yyyy);
875              
876             $self->_xc_extract($xc,
877             q{/xpil:Party/xpil:BirthInfo/xpil:BirthInfoElement[@xpil:Type='BirthDay']},
878             sub { $dd = shift; }
879             );
880              
881             $self->_xc_extract($xc,
882             q{/xpil:Party/xpil:BirthInfo/xpil:BirthInfoElement[@xpil:Type='BirthMonth']},
883             sub { $mm = shift; }
884             );
885              
886             $self->_xc_extract($xc,
887             q{/xpil:Party/xpil:BirthInfo/xpil:BirthInfoElement[@xpil:Type='BirthYear']},
888             sub { $yyyy = shift; }
889             );
890              
891             if($dd && $mm && $yyyy) {
892             $response->set_date_of_birth("$yyyy-$mm-$dd");
893             }
894              
895             $self->_xc_extract($xc,
896             q{/xpil:Party/xpil:BirthInfo/xpil:BirthPlaceDetails/xal:Locality/xal:NameElement},
897             sub { $response->set_place_of_birth(shift); }
898             );
899              
900             $self->_xc_extract($xc,
901             q{/xpil:Party/xpil:BirthInfo/xpil:BirthPlaceDetails/xal:Country/xal:NameElement},
902             sub { $response->set_country_of_birth(shift); }
903             );
904              
905             $self->_xc_extract($xc,
906             q{/xpil:Party/xpil:PartyName/xnl:PersonName/xnl:NameElement[@xnl:ElementType='LastName']},
907             sub { $response->set_surname(shift); }
908             );
909              
910             $self->_xc_extract($xc,
911             q{/xpil:Party/xpil:PartyName/xnl:PersonName/xnl:NameElement[@xnl:ElementType='FirstName']},
912             sub { $response->set_first_name(shift); }
913             );
914              
915             $self->_xc_extract($xc,
916             q{/xpil:Party/xpil:PartyName/xnl:PersonName/xnl:NameElement[@xnl:ElementType='MiddleName']},
917             sub { $response->set_mid_names(shift); }
918             );
919              
920             $self->_xc_extract($xc,
921             q{/xpil:Party/xpil:PersonInfo/@xpil:Gender},
922             sub { $response->set_gender(shift); }
923             );
924              
925             }
926              
927              
928             sub _extract_avs_details {
929             my($self, $response, $xml) = @_;
930              
931             my $xc = $self->_xpath_context_dom($xml, @avs_namespaces);
932              
933             $self->_xc_extract($xc,
934             q{/xpil:Party/xal:Addresses/xal:Address[1]/xal:Premises/xal:NameElement[@NameType="NZUnit"]},
935             sub { $response->set_address_unit(shift); }
936             );
937              
938             $self->_xc_extract($xc,
939             q{/xpil:Party/xal:Addresses/xal:Address[1]/xal:Thoroughfare/xal:NameElement[@NameType="NZNumberStreet"]},
940             sub { $response->set_address_street(shift); }
941             );
942              
943             $self->_xc_extract($xc,
944             q{/xpil:Party/xal:Addresses/xal:Address[1]/xal:Locality/xal:NameElement[@NameType="NZSuburb"]},
945             sub { $response->set_address_suburb(shift); }
946             );
947              
948             $self->_xc_extract($xc,
949             q{/xpil:Party/xal:Addresses/xal:Address[1]/xal:Locality/xal:NameElement[@NameType="NZTownCity"]},
950             sub { $response->set_address_town_city(shift); }
951             );
952              
953             $self->_xc_extract($xc,
954             q{/xpil:Party/xal:Addresses/xal:Address[1]/xal:PostCode/xal:Identifier[@Type="NZPostCode"]},
955             sub { $response->set_address_postcode(shift); }
956             );
957              
958             $self->_xc_extract($xc,
959             q{/xpil:Party/xal:Addresses/xal:Address[1]/xal:RuralDelivery/xal:Identifier[@Type="NZRuralDelivery"]},
960             sub { $response->set_address_rural_delivery(shift); }
961             );
962              
963             }
964              
965              
966             sub _extract_icms_token {
967             my($self, $response, $xml) = @_;
968              
969             $response->_set_icms_token($xml);
970             }
971              
972              
973             sub _xc_extract {
974             my($self, $xc, $selector, $handler) = @_;
975              
976             my @match = $xc->findnodes($selector);
977             if(@match > 1) {
978             die "Error: found multiple matches (" . @match . ") for selector:\n '$selector'";
979             }
980             elsif(@match == 1) {
981             $handler->( $match[0]->to_literal, $match[0] );
982             }
983             }
984              
985              
986             sub _to_xml_string {
987             my $self = shift;
988              
989             my $ns_md_uri = $ns_md->[1]; # Used as default namespace, so no prefix required
990             my $x = XML::Generator->new(':pretty',
991             namespace => [ '#default' => $ns_md_uri ],
992             );
993             $self->{x} = $x;
994              
995             my $xml = $x->EntityDescriptor(
996             {
997             entityID => $self->entity_id,
998             validUntil => $self->_valid_until_datetime,
999             },
1000             $self->_gen_sp_sso_descriptor(),
1001             $self->_gen_organization(),
1002             $self->_gen_contact(),
1003             );
1004              
1005             # apply fixups
1006             $xml =~ s{ _xml_lang_attribute="}{ xml:lang="}sg;
1007             $xml =~ s{\s*(.*?)\s*}
1008             {_unindent_element_content($1)}sge;
1009              
1010             return $xml;
1011             }
1012              
1013              
1014             sub _unindent_element_content {
1015             my($content) = @_;
1016              
1017             $content =~ s{^\s+}{}mg;
1018             return $content;
1019             }
1020              
1021              
1022             sub _valid_until_datetime {
1023             my $self = shift;
1024              
1025             my $x509 = Crypt::OpenSSL::X509->new_from_file( $self->signing_cert_pathname );
1026             my $date_time = $x509->notAfter;
1027             my $utime = Date::Parse::str2time($date_time);
1028             return strftime('%FT%TZ', gmtime($utime) );
1029             }
1030              
1031              
1032             sub _gen_sp_sso_descriptor {
1033             my $self = shift;
1034             my $x = $self->_x;
1035              
1036             return $x->SPSSODescriptor(
1037             {
1038             AuthnRequestsSigned => 'true',
1039             WantAssertionsSigned => 'true',
1040             protocolSupportEnumeration => 'urn:oasis:names:tc:SAML:2.0:protocol',
1041             },
1042             $self->_gen_signing_key(),
1043             #$self->_gen_svc_logout(), # No longer required
1044             $self->_name_id_format(),
1045             $self->_gen_svc_assertion_consumer(),
1046             );
1047             }
1048              
1049              
1050             sub _gen_signing_key {
1051             my $self = shift;
1052             my $x = $self->_x;
1053              
1054             return $x->KeyDescriptor(
1055             {
1056             use => 'signing',
1057             },
1058             $x->KeyInfo($ns_ds,
1059             $x->X509Data($ns_ds,
1060             $x->X509Certificate($ns_ds,
1061             $x->NoIndentContent( $self->_signing_cert_pem_data() ),
1062             ),
1063             ),
1064             ),
1065             );
1066             }
1067              
1068              
1069             sub _name_id_format {
1070             my $self = shift;
1071             my $x = $self->_x;
1072              
1073             my @formats = (
1074             $x->NameIDFormat( $self->nameid_format )
1075             );
1076              
1077             if($self->type eq 'assertion') {
1078             push @formats, $x->NameIDFormat( $urn_nameid_format{unspec} );
1079             }
1080              
1081             return @formats;
1082             }
1083              
1084              
1085             sub _gen_svc_logout {
1086             my $self = shift;
1087             my $x = $self->_x;
1088              
1089             my $single_logout_url = $self->url_single_logout or return;
1090             return $x->SingleLogoutService(
1091             {
1092             Binding => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
1093             Location => $single_logout_url,
1094             },
1095             );
1096             }
1097              
1098              
1099             sub _gen_svc_assertion_consumer {
1100             my $self = shift;
1101             my $x = $self->_x;
1102              
1103             return $x->AssertionConsumerService(
1104             {
1105             Binding => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact',
1106             Location => $self->url_assertion_consumer,
1107             index => 0,
1108             isDefault => 'true',
1109             },
1110             );
1111             }
1112              
1113              
1114             sub _gen_organization {
1115             my $self = shift;
1116             my $x = $self->_x;
1117              
1118             return $x->Organization(
1119             $x->OrganizationName(
1120             {
1121             _xml_lang_attribute => 'en-us',
1122             },
1123             $self->organization_name
1124             ),
1125             $x->OrganizationDisplayName(
1126             {
1127             _xml_lang_attribute => 'en-us',
1128             },
1129             $self->organization_name
1130             ),
1131             $x->OrganizationURL(
1132             {
1133             _xml_lang_attribute => 'en-us',
1134             },
1135             $self->organization_url
1136             ),
1137             );
1138             }
1139              
1140              
1141             sub _gen_contact {
1142             my $self = shift;
1143             my $x = $self->_x;
1144              
1145             my $have_contact = $self->contact_company
1146             || $self->contact_first_name
1147             || $self->contact_surname;
1148              
1149             return() unless $have_contact;
1150              
1151             return $x->ContactPerson(
1152             {
1153             contactType => 'technical',
1154             },
1155             $x->Company ($self->contact_company || ''),
1156             $x->GivenName($self->contact_first_name || ''),
1157             $x->SurName ($self->contact_surname || ''),
1158             );
1159             }
1160              
1161              
1162             sub now_as_iso {
1163             return strftime('%FT%TZ', gmtime());
1164             }
1165              
1166              
1167             1;
1168              
1169              
1170             __END__