File Coverage

lib/PAGI/Middleware/Auth/Bearer.pm
Criterion Covered Total %
statement 105 120 87.5
branch 25 52 48.0
condition 5 11 45.4
subroutine 18 18 100.0
pod 1 1 100.0
total 154 202 76.2


line stmt bran cond sub pod time code
1             package PAGI::Middleware::Auth::Bearer;
2              
3 2     2   501 use strict;
  2         2  
  2         75  
4 2     2   5 use warnings;
  2         3  
  2         77  
5 2     2   10 use parent 'PAGI::Middleware';
  2         3  
  2         7  
6 2     2   91 use Future::AsyncAwait;
  2         7  
  2         9  
7 2     2   469 use JSON::MaybeXS ();
  2         7969  
  2         57  
8 2     2   8 use MIME::Base64 qw(decode_base64url);
  2         3  
  2         95  
9 2     2   447 use Digest::SHA qw(hmac_sha256);
  2         3284  
  2         3523  
10              
11             =head1 NAME
12              
13             PAGI::Middleware::Auth::Bearer - Bearer token authentication middleware
14              
15             =head1 SYNOPSIS
16              
17             use PAGI::Middleware::Builder;
18              
19             my $app = builder {
20             enable 'Auth::Bearer',
21             secret => 'your-jwt-secret',
22             algorithms => ['HS256'];
23             $my_app;
24             };
25              
26             # In your app:
27             async sub app {
28             my ($scope, $receive, $send) = @_;
29              
30             my $auth = $scope->{'pagi.auth'};
31             my $user_id = $auth->{claims}{sub};
32             }
33              
34             =head1 DESCRIPTION
35              
36             PAGI::Middleware::Auth::Bearer validates Bearer tokens in the Authorization
37             header. It supports JWT (JSON Web Tokens) with HMAC-SHA256 signatures.
38              
39             =head1 CONFIGURATION
40              
41             =over 4
42              
43             =item * secret (required for JWT)
44              
45             Secret key for JWT signature verification.
46              
47             =item * algorithms (default: ['HS256'])
48              
49             Allowed JWT algorithms.
50              
51             =item * validator (optional)
52              
53             Custom token validator coderef. Receives ($token) and returns claims hashref or undef.
54             If provided, bypasses built-in JWT validation.
55              
56             =item * realm (default: 'Bearer')
57              
58             The authentication realm for WWW-Authenticate header.
59              
60             =item * paths (optional)
61              
62             Arrayref of path patterns to protect.
63              
64             =back
65              
66             =cut
67              
68             sub _init {
69 4     4   6 my ($self, $config) = @_;
70              
71 4         10 $self->{secret} = $config->{secret};
72 4   50     18 $self->{algorithms} = $config->{algorithms} // ['HS256'];
73 4         8 $self->{validator} = $config->{validator};
74 4   50     11 $self->{realm} = $config->{realm} // 'Bearer';
75 4         7 $self->{paths} = $config->{paths};
76              
77             die "Auth::Bearer requires 'secret' or 'validator' option"
78 4 0 33     29 unless $self->{secret} || $self->{validator};
79             }
80              
81             sub wrap {
82 4     4 1 166 my ($self, $app) = @_;
83              
84 4     4   77 return async sub {
85 4         8 my ($scope, $receive, $send) = @_;
86 4 50       10 if ($scope->{type} ne 'http') {
87 0         0 await $app->($scope, $receive, $send);
88 0         0 return;
89             }
90              
91             # Check if path requires authentication
92 4 50       9 unless ($self->_requires_auth($scope->{path})) {
93 0         0 await $app->($scope, $receive, $send);
94 0         0 return;
95             }
96              
97             # Get Authorization header
98 4         8 my $auth_header = $self->_get_header($scope, 'authorization');
99              
100 4 100       21 unless ($auth_header) {
101 1         3 await $self->_send_unauthorized($send, 'Token required');
102 1         46 return;
103             }
104              
105             # Parse Bearer token
106 3         6 my $token = $self->_parse_bearer_token($auth_header);
107              
108 3 50       6 unless ($token) {
109 0         0 await $self->_send_unauthorized($send, 'Invalid authorization header');
110 0         0 return;
111             }
112              
113             # Validate token
114 3         6 my $claims = $self->_validate_token($token);
115              
116 3 100       12 unless ($claims) {
117 2         3 await $self->_send_unauthorized($send, 'Invalid token');
118 2         91 return;
119             }
120              
121             # Add auth info to scope
122 1         7 my $new_scope = {
123             %$scope,
124             'pagi.auth' => {
125             type => 'bearer',
126             token => $token,
127             claims => $claims,
128             },
129             };
130              
131 1         3 await $app->($new_scope, $receive, $send);
132 4         14 };
133             }
134              
135             sub _requires_auth {
136 4     4   8 my ($self, $path) = @_;
137              
138 4 50       11 return 1 unless $self->{paths};
139              
140 0         0 for my $pattern (@{$self->{paths}}) {
  0         0  
141 0 0       0 if (ref $pattern eq 'Regexp') {
142 0 0       0 return 1 if $path =~ $pattern;
143             } else {
144 0 0       0 return 1 if index($path, $pattern) == 0;
145             }
146             }
147 0         0 return 0;
148             }
149              
150             sub _parse_bearer_token {
151 3     3   6 my ($self, $header) = @_;
152              
153 3 50       16 return unless $header =~ /^Bearer\s+(.+)$/i;
154 3         7 return $1;
155             }
156              
157             sub _validate_token {
158 3     3   5 my ($self, $token) = @_;
159              
160             # Use custom validator if provided
161 3 50       7 if ($self->{validator}) {
162 0         0 return $self->{validator}->($token);
163             }
164              
165             # Validate as JWT
166 3         5 return $self->_validate_jwt($token);
167             }
168              
169             sub _validate_jwt {
170 3     3   4 my ($self, $token) = @_;
171              
172 3         9 my @parts = split /\./, $token;
173 3 50       5 return unless @parts == 3;
174              
175 3         6 my ($header_b64, $payload_b64, $signature_b64) = @parts;
176              
177             # Decode and parse header
178 3         4 my $header_json = eval { decode_base64url($header_b64) };
  3         12  
179 3 50       26 return unless $header_json;
180 3         4 my $header = eval { JSON::MaybeXS::decode_json($header_json) };
  3         19  
181 3 50       4 return unless $header;
182              
183             # Check algorithm
184 3   50     7 my $alg = $header->{alg} // '';
185 3 50       4 return unless grep { $_ eq $alg } @{$self->{algorithms}};
  3         8  
  3         6  
186              
187             # Verify signature
188 3         5 my $signature_input = "$header_b64.$payload_b64";
189 3         17 my $expected_signature;
190              
191 3 50       5 if ($alg eq 'HS256') {
192             $expected_signature = $self->_base64url_encode(
193             hmac_sha256($signature_input, $self->{secret})
194 3         25 );
195             } else {
196 0         0 return; # Unsupported algorithm
197             }
198              
199 3 100       7 return unless $self->_secure_compare($signature_b64, $expected_signature);
200              
201             # Decode payload
202 2         3 my $payload_json = eval { decode_base64url($payload_b64) };
  2         4  
203 2 50       16 return unless $payload_json;
204 2         2 my $claims = eval { JSON::MaybeXS::decode_json($payload_json) };
  2         8  
205 2 50       3 return unless $claims;
206              
207             # Check expiration
208 2 50       13 if (exists $claims->{exp}) {
209 2 100       8 return if time() > $claims->{exp};
210             }
211              
212             # Check not-before
213 1 50       3 if (exists $claims->{nbf}) {
214 0 0       0 return if time() < $claims->{nbf};
215             }
216              
217 1         5 return $claims;
218             }
219              
220             sub _base64url_encode {
221 3     3   5 my ($self, $data) = @_;
222              
223 3         6 my $encoded = MIME::Base64::encode_base64($data, '');
224 3         3 $encoded =~ tr{+/}{-_};
225 3         11 $encoded =~ s/=+$//;
226 3         5 return $encoded;
227             }
228              
229             sub _secure_compare {
230 3     3   5 my ($self, $a, $b) = @_;
231              
232 3 50       5 return 0 unless length($a) == length($b);
233 3         4 my $result = 0;
234 3         9 for my $i (0 .. length($a) - 1) {
235 129         164 $result |= ord(substr($a, $i, 1)) ^ ord(substr($b, $i, 1));
236             }
237 3         8 return $result == 0;
238             }
239              
240 3     3   5 async sub _send_unauthorized {
241 3         5 my ($self, $send, $error) = @_;
242              
243 3         4 my $body = $error;
244              
245 3         17 await $send->({
246             type => 'http.response.start',
247             status => 401,
248             headers => [
249             ['Content-Type', 'text/plain'],
250             ['Content-Length', length($body)],
251             ['WWW-Authenticate', qq{Bearer realm="$self->{realm}"}],
252             ],
253             });
254 3         123 await $send->({
255             type => 'http.response.body',
256             body => $body,
257             more => 0,
258             });
259             }
260              
261             sub _get_header {
262 4     4   8 my ($self, $scope, $name) = @_;
263              
264 4         7 $name = lc($name);
265 4   50     4 for my $h (@{$scope->{headers} // []}) {
  4         12  
266 3 50       12 return $h->[1] if lc($h->[0]) eq $name;
267             }
268 1         2 return;
269             }
270              
271             1;
272              
273             __END__