File Coverage

blib/lib/Net/SAML2/Binding/Redirect.pm
Criterion Covered Total %
statement 100 108 92.5
branch 26 40 65.0
condition 3 3 100.0
subroutine 20 20 100.0
pod 3 4 75.0
total 152 175 86.8


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