File Coverage

blib/lib/Net/SAML2/Binding/Redirect.pm
Criterion Covered Total %
statement 100 108 92.5
branch 24 38 63.1
condition 3 3 100.0
subroutine 20 20 100.0
pod 3 4 75.0
total 150 173 86.7


line stmt bran cond sub pod time code
1             package Net::SAML2::Binding::Redirect;
2 25     25   185 use Moose;
  25         47  
  25         200  
3              
4             our $VERSION = '0.72'; # TRIAL VERSION
5              
6 25     25   154114 use Carp qw(croak);
  25         77  
  25         1396  
7 25     25   11239 use Crypt::OpenSSL::RSA;
  25         105049  
  25         863  
8 25     25   176 use Crypt::OpenSSL::X509;
  25         55  
  25         941  
9 25     25   10334 use File::Slurper qw/ read_text /;
  25         67677  
  25         1715  
10 25     25   12476 use IO::Compress::RawDeflate qw/ rawdeflate /;
  25         630525  
  25         2005  
11 25     25   13340 use IO::Uncompress::RawInflate qw/ rawinflate /;
  25         276301  
  25         1524  
12 25     25   230 use MIME::Base64 qw/ encode_base64 decode_base64 /;
  25         61  
  25         1261  
13 25     25   160 use MooseX::Types::URI qw/ Uri /;
  25         58  
  25         326  
14 25     25   49047 use Net::SAML2::Types qw(signingAlgorithm SAMLRequestType);
  25         63  
  25         184  
15 25     25   43193 use URI::Encode qw/uri_decode/;
  25         32930  
  25         1409  
16 25     25   203 use URI::Escape qw(uri_unescape);
  25         58  
  25         1141  
17 25     25   149 use URI::QueryParam;
  25         74  
  25         489  
18 25     25   140 use URI;
  25         59  
  25         25529  
19              
20             # ABSTRACT: HTTP Redirect binding for SAML
21              
22              
23              
24             has 'cert' => (isa => 'ArrayRef[Str]', is => 'ro', required => 0, predicate => 'has_cert');
25             has 'url' => (isa => Uri, is => 'ro', required => 0, coerce => 1, predicate => 'has_url');
26             has 'key' => (isa => 'Str', is => 'ro', required => 0, predicate => 'has_key');
27              
28             has 'insecure' => (isa => 'Bool', is => 'ro', default => 0 );
29              
30             has 'param' => (
31             isa => SAMLRequestType,
32             is => 'ro',
33             required => 0,
34             default => 'SAMLRequest'
35             );
36              
37             has 'sig_hash' => (
38             isa => signingAlgorithm,
39             is => 'ro',
40             required => 0,
41             default => 'sha1'
42             );
43              
44             has debug => (
45             is => 'ro',
46             isa => 'Bool',
47             required => 0,
48             );
49              
50              
51             sub BUILD {
52 14     14 0 25 my $self = shift;
53              
54 14 100       401 if ($self->param eq 'SAMLRequest') {
    50          
55 9 100       253 croak("Need to have an URL specified") unless $self->has_url;
56 8 100 100     205 croak("Need to have a key specified") unless $self->has_key || $self->insecure;
57             }
58             elsif ($self->param eq 'SAMLResponse') {
59 5 50       154 croak("Need to have a cert specified") unless $self->has_cert;
60             }
61             }
62              
63             # BUILDARGS
64              
65             # Earlier versions expected the cert to be a string. However, metadata
66             # can include multiple signing certificates so the $idp->cert is now
67             # expected to be an arrayref to the certificates. To avoid breaking existing
68             # applications this changes the the cert to an arrayref if it is not
69             # already an array ref.
70              
71             around BUILDARGS => sub {
72             my $orig = shift;
73             my $self = shift;
74              
75             my %params = @_;
76             if ($params{cert} && ref($params{cert}) ne 'ARRAY') {
77             $params{cert} = [$params{cert}];
78             }
79              
80             return $self->$orig(%params);
81             };
82              
83              
84             sub get_redirect_uri {
85 4     4 1 833 my $self = shift;
86 4         6 my $request = shift;
87              
88 4 50       11 if (!defined $request) {
89 0         0 croak("Unable to create redirect URI without a request");
90             }
91              
92 4         6 my $relaystate = shift;
93              
94 4         14 my $input = "$request";
95 4         174 my $output = '';
96              
97 4         19 rawdeflate \$input => \$output;
98 4         7113 my $req = encode_base64($output, '');
99              
100 4         135 my $uri = URI->new($self->url);
101 4         462 $uri->query_param($self->param, $req);
102 4 100       1102 $uri->query_param('RelayState', $relaystate) if defined $relaystate;
103              
104 4 100       1116 return $uri->as_string if $self->insecure;
105 2         10 return $self->_sign_redirect_uri($uri);
106             }
107              
108             sub _sign_redirect_uri {
109 2     2   4 my $self = shift;
110 2         4 my $uri = shift;
111              
112 2         54 my $key_string = read_text($self->key);
113 2         761 my $rsa_priv = Crypt::OpenSSL::RSA->new_private_key($key_string);
114              
115 2         81 my $method = "use_" . $self->sig_hash . "_hash";
116 2         15 $rsa_priv->$method;
117              
118 2 50       54 $uri->query_param('SigAlg',
119             $self->sig_hash eq 'sha1'
120             ? 'http://www.w3.org/2000/09/xmldsig#rsa-sha1'
121             : 'http://www.w3.org/2001/04/xmldsig-more#rsa-' . $self->sig_hash);
122              
123 2         934 my $to_sign = $uri->query;
124 2         3322 my $sig = encode_base64($rsa_priv->sign($to_sign), '');
125 2         15 $uri->query_param('Signature', $sig);
126 2         1234 return $uri->as_string;
127             }
128              
129              
130             sub sign {
131 3     3 1 864 my $self = shift;
132              
133 3 100       107 if ($self->insecure) {
134 1         9 croak("Cannot sign an insecure request!");
135             }
136              
137 2         20 return $self->get_redirect_uri(@_);
138             }
139              
140              
141             sub verify {
142 7     7 1 8692 my ($self, $url) = @_;
143              
144             # This now becomes the query string
145 7         70 $url =~ s#^.*\?##;
146              
147 7         39 my %params = map { split(/=/, $_, 2) } split(/&/, $url);
  22         73  
148              
149 7         37 my $sigalg = uri_unescape($params{SigAlg});
150              
151 7         249 my $encoded_sig = uri_unescape($params{Signature});
152 7         320 my $sig = decode_base64($encoded_sig);
153              
154 7         15 my @signed_parts;
155 7         206 for my $p ($self->param, qw(RelayState SigAlg)) {
156 21 100       81 push @signed_parts, join('=', $p, $params{$p}) if exists $params{$p};
157             }
158 7         24 my $signed = join('&', @signed_parts);
159              
160 7         29 $self->_verify($sigalg, $signed, $sig);
161              
162             # unpack the SAML request
163 7         255 my $deflated = decode_base64(uri_unescape($params{$self->param}));
164 7         385 my $request = '';
165 7         41 rawinflate \$deflated => \$request;
166              
167             # unpack the relaystate
168 7         11699 my $relaystate = uri_unescape($params{'RelayState'});
169 7         100 return ($request, $relaystate);
170             }
171              
172             sub _verify {
173 7     7   34 my ($self, $sigalg, $signed, $sig) = @_;
174              
175 7         13 foreach my $crt (@{$self->cert}) {
  7         192  
176 7         542 my $cert = Crypt::OpenSSL::X509->new_from_string($crt);
177 7         375 my $rsa_pub = Crypt::OpenSSL::RSA->new_public_key($cert->pubkey);
178              
179 7 50       2198 if ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256') {
    50          
    50          
    50          
    50          
180 0         0 $rsa_pub->use_sha256_hash;
181             } elsif ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha224') {
182 0         0 $rsa_pub->use_sha224_hash;
183             } elsif ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha384') {
184 0         0 $rsa_pub->use_sha384_hash;
185             } elsif ($sigalg eq 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha512') {
186 0         0 $rsa_pub->use_sha512_hash;
187             } elsif ($sigalg eq 'http://www.w3.org/2000/09/xmldsig#rsa-sha1') {
188 7         28 $rsa_pub->use_sha1_hash;
189             }
190             else {
191 0 0       0 warn "Unsupported Signature Algorithim: $sigalg, defaulting to sha256" if $self->debug;
192             }
193              
194 7 50       738 return 1 if $rsa_pub->verify($signed, $sig);
195              
196 0 0         warn "Unable to verify with " . $cert->subject if $self->debug;
197             }
198              
199 0           croak("Unable to verify the XML signature");
200             }
201              
202             __PACKAGE__->meta->make_immutable;
203              
204             __END__
205              
206             =pod
207              
208             =encoding UTF-8
209              
210             =head1 NAME
211              
212             Net::SAML2::Binding::Redirect - HTTP Redirect binding for SAML
213              
214             =head1 VERSION
215              
216             version 0.72
217              
218             =head1 SYNOPSIS
219              
220             my $redirect = Net::SAML2::Binding::Redirect->new(
221             key => '/path/to/SPsign-nopw-key.pem', # Service Provider (SP) private key
222             url => $sso_url, # Service Provider Single Sign Out URL
223             param => 'SAMLRequest' OR 'SAMLResponse', # Type of request
224             cert => $idp->cert('signing') # Identity Provider (IdP) certificate
225             sig_hash => 'sha1', 'sha224', 'sha256', 'sha384', 'sha512' # Signature to sign request
226             );
227              
228             my $url = $redirect->sign($authnreq);
229              
230             my $ret = $redirect->verify($url);
231              
232             =head1 METHODS
233              
234             =head2 new( ... )
235              
236             Constructor. Creates an instance of the Redirect binding.
237              
238             Arguments:
239              
240             =over
241              
242             =item B<key>
243              
244             The SP's (Service Provider) also known as your application's signing key
245             that your application uses to sign the AuthnRequest. Some IdPs may not
246             verify the signature.
247              
248             Usually required when B<param> is C<SAMLRequest>.
249              
250             If you don't want to sign the request, you can pass C<< insecure => 1
251             >> and not provide a key; in this case, C<sign> will return a
252             non-signed URL.
253              
254             =item B<cert>
255              
256             IdP's (Identity Provider's) certificate that is used to verify a signed
257             Redirect from the IdP. It is used to verify the signature of the Redirect
258             response.
259             Required with B<param> being C<SAMLResponse>.
260              
261             =item B<url>
262              
263             IdP's SSO (Single Sign Out) service url for the Redirect binding
264             Required with B<param> being C<SAMLRequest>.
265              
266             =item B<param>
267              
268             query param name to use (SAMLRequest, SAMLResponse)
269             Defaults to C<SAMLRequest>.
270              
271             =item B<sig_hash>
272              
273             RSA hash to use to sign request
274              
275             Supported:
276              
277             sha1, sha224, sha256, sha384, sha512
278              
279             Defaults to C<sha1>.
280              
281             =item B<debug>
282              
283             Output extra debugging information
284              
285             =back
286              
287             =for Pod::Coverage BUILD
288              
289             =head2 get_redirect_uri($authn_request, $relaystate)
290              
291             Get the redirect URI for a given request, and returns the URL to which the
292             user's browser should be redirected.
293              
294             Accepts an optional RelayState parameter, a string which will be
295             returned to the requestor when the user returns from the
296             authentication process with the IdP.
297              
298             The request is signed unless the the object has been instantiated with
299             C<<insecure => 1>>.
300              
301             =head2 sign( $request, $relaystate )
302              
303             Signs the given request, and returns the URL to which the user's
304             browser should be redirected.
305              
306             Accepts an optional RelayState parameter, a string which will be
307             returned to the requestor when the user returns from the
308             authentication process with the IdP.
309              
310             Returns the signed (or unsigned) URL for the SAML2 redirect
311              
312             =head2 verify( $query_string )
313              
314             Decode a Redirect binding URL.
315              
316             Verifies the signature on the response.
317              
318             Requires the *raw* query string to be passed, because L<URI> parses and
319             re-encodes URI-escapes in uppercase (C<%3f> becomes C<%3F>, for instance),
320             which leads to signature verification failures if the other party uses lower
321             case (or mixed case).
322              
323             Returns an ARRAY of containing the verified request and relaystate (if it exists).
324             Croaks on errors.
325              
326             =head1 AUTHORS
327              
328             =over 4
329              
330             =item *
331              
332             Chris Andrews <chrisa@cpan.org>
333              
334             =item *
335              
336             Timothy Legge <timlegge@gmail.com>
337              
338             =back
339              
340             =head1 COPYRIGHT AND LICENSE
341              
342             This software is copyright (c) 2023 by Venda Ltd, see the CONTRIBUTORS file for others.
343              
344             This is free software; you can redistribute it and/or modify it under
345             the same terms as the Perl 5 programming language system itself.
346              
347             =cut