| 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__ |