File Coverage

blib/lib/Trickster/Cookie.pm
Criterion Covered Total %
statement 48 51 94.1
branch 14 24 58.3
condition 2 5 40.0
subroutine 10 10 100.0
pod 0 4 0.0
total 74 94 78.7


line stmt bran cond sub pod time code
1             package Trickster::Cookie;
2              
3 1     1   864 use strict;
  1         2  
  1         30  
4 1     1   4 use warnings;
  1         1  
  1         32  
5 1     1   25 use v5.14;
  1         3  
6              
7 1     1   3 use URI::Escape qw(uri_escape uri_unescape);
  1         1  
  1         57  
8 1     1   442 use Digest::SHA qw(hmac_sha256_hex);
  1         2081  
  1         572  
9              
10             sub new {
11 1     1 0 990 my ($class, %opts) = @_;
12            
13             return bless {
14             secret => $opts{secret},
15             default_options => {
16             path => '/',
17             httponly => 1,
18             samesite => 'Lax',
19 1 50       2 %{$opts{defaults} || {}},
  1         9  
20             },
21             }, $class;
22             }
23              
24             sub get {
25 1     1 0 2 my ($self, $req, $name) = @_;
26            
27             # Get raw cookie header to avoid double-decoding
28 1   50     2 my $cookie_header = $req->env->{HTTP_COOKIE} || '';
29            
30             # Parse cookie manually to avoid Plack::Request's URI decoding
31 1         4 my $value;
32 1 50       36 if ($cookie_header =~ /(?:^|;\s*)$name=([^;]+)/) {
33 1         4 $value = $1;
34             } else {
35 0         0 return undef;
36             }
37            
38             # Verify signature if secret is set
39 1 50 33     8 if ($self->{secret} && $value =~ /^(.+)\.([^.]+)$/) {
40 1         29 my ($data, $sig) = ($1, $2);
41 1         3 my $expected_sig = $self->_sign($data);
42            
43 1 50       3 return undef unless $sig eq $expected_sig;
44 1         4 return uri_unescape($data);
45             }
46            
47 0         0 return uri_unescape($value);
48             }
49              
50             sub set {
51 2     2 0 6 my ($self, $res, $name, $value, %opts) = @_;
52            
53 2         2 my %options = (%{$self->{default_options}}, %opts);
  2         8  
54            
55             # Sign the value if secret is set
56 2 50       4 if ($self->{secret}) {
57 2         8 my $escaped = uri_escape($value);
58 2         85 my $sig = $self->_sign($escaped);
59 2         11 $value = "$escaped.$sig";
60             } else {
61 0         0 $value = uri_escape($value);
62             }
63            
64 2         4 my @cookie_parts = ("$name=$value");
65            
66 2 50       6 push @cookie_parts, "Path=$options{path}" if $options{path};
67 2 50       4 push @cookie_parts, "Domain=$options{domain}" if $options{domain};
68 2 100       5 push @cookie_parts, "Max-Age=$options{max_age}" if defined $options{max_age};
69 2 50       4 push @cookie_parts, "Expires=$options{expires}" if $options{expires};
70 2 100       4 push @cookie_parts, "Secure" if $options{secure};
71 2 50       9 push @cookie_parts, "HttpOnly" if $options{httponly};
72 2 50       6 push @cookie_parts, "SameSite=$options{samesite}" if $options{samesite};
73            
74 2         11 $res->header('Set-Cookie' => join('; ', @cookie_parts));
75            
76 2         102 return $res;
77             }
78              
79             sub delete {
80 1     1 0 2 my ($self, $res, $name, %opts) = @_;
81            
82 1         2 return $self->set($res, $name, '', %opts, max_age => 0);
83             }
84              
85             sub _sign {
86 3     3   6 my ($self, $data) = @_;
87            
88 3         45 return hmac_sha256_hex($data, $self->{secret});
89             }
90              
91             1;
92              
93             __END__