File Coverage

blib/lib/Plack/Middleware/Zitadel.pm
Criterion Covered Total %
statement 70 71 98.5
branch 29 32 90.6
condition 11 16 68.7
subroutine 13 13 100.0
pod 2 2 100.0
total 125 134 93.2


line stmt bran cond sub pod time code
1             package Plack::Middleware::Zitadel;
2              
3             # ABSTRACT: Verify Bearer tokens via ZITADEL OIDC in Plack apps
4              
5 2     2   559823 use strict;
  2         5  
  2         78  
6 2     2   11 use warnings;
  2         3  
  2         105  
7              
8 2     2   9 use parent 'Plack::Middleware';
  2         4  
  2         11  
9 2     2   13558 use Plack::Util::Accessor qw(issuer audience required_scopes claims_env_key realm oidc);
  2         16  
  2         9  
10 2     2   525 use JSON::MaybeXS qw(encode_json);
  2         7640  
  2         153  
11 2     2   926 use WWW::Zitadel::OIDC;
  2         295035  
  2         1835  
12              
13             our $VERSION = '0.010';
14              
15             sub prepare_app {
16 12     12 1 328332 my ($self) = @_;
17              
18 12 100       49 $self->claims_env_key('zitadel.claims')
19             unless defined $self->claims_env_key;
20 12 100       210 $self->realm('api')
21             unless defined $self->realm;
22              
23 12         99 my $oidc = $self->oidc;
24 12 100       62 if (!$oidc) {
25 1 50       7 die "issuer required\n" unless $self->issuer;
26 0         0 $oidc = WWW::Zitadel::OIDC->new(issuer => $self->issuer);
27             }
28              
29 11 100       71 die "oidc object must implement verify_token\n"
30             unless $oidc->can('verify_token');
31              
32 10         35 $self->{_oidc} = $oidc;
33             }
34              
35             sub call {
36 19     19 1 53974 my ($self, $env) = @_;
37              
38 19         65 my ($ok, $token_or_error) = $self->_extract_bearer($env->{HTTP_AUTHORIZATION});
39 19 100       58 return $self->_unauthorized('invalid_request', $token_or_error)
40             unless $ok;
41              
42 15         24 my %verify_args;
43 15 100 100     44 if (defined $self->audience && length $self->audience) {
44 1         11 $verify_args{audience} = $self->audience;
45             }
46              
47 15         118 my $claims = eval {
48 15         66 $self->{_oidc}->verify_token($token_or_error, %verify_args);
49             };
50 15 100       287 if (my $err = $@) {
51 2         13 $err =~ s/\s+\z//;
52 2   50     13 return $self->_unauthorized('invalid_token', $err || 'token verification failed');
53             }
54              
55 13         41 my @required = $self->_required_scope_list;
56 13 100 100     41 if (@required && !$self->_has_required_scopes($claims, \@required)) {
57 2         7 return $self->_forbidden('insufficient_scope', 'required scopes are missing');
58             }
59              
60 11         29 $env->{ $self->claims_env_key } = $claims;
61 11         79 $env->{'zitadel.token'} = $token_or_error;
62              
63 11         41 return $self->app->($env);
64             }
65              
66             sub _extract_bearer {
67 19     19   47 my ($self, $header_value) = @_;
68              
69 19 100 66     112 return (0, 'missing Authorization header')
70             unless defined $header_value && length $header_value;
71              
72 16 100       93 return (0, 'Authorization must use Bearer token')
73             unless $header_value =~ /^Bearer\s+(.+)\z/i;
74              
75 15         34 my $token = $1;
76 15 50 33     54 return (0, 'empty bearer token') unless defined $token && length $token;
77              
78 15         41 return (1, $token);
79             }
80              
81             sub _required_scope_list {
82 13     13   28 my ($self) = @_;
83 13         29 my $scopes = $self->required_scopes;
84              
85 13 100       61 return () unless defined $scopes;
86              
87 4 100       10 if (ref($scopes) eq 'ARRAY') {
88 2 50       4 return grep { defined $_ && length $_ } @$scopes;
  4         14  
89             }
90              
91 2         7 return grep { length $_ } split /\s+/, "$scopes";
  4         8  
92             }
93              
94             sub _has_required_scopes {
95 4     4   7 my ($self, $claims, $required) = @_;
96              
97 4   50     10 my $scope_str = $claims->{scope} // '';
98 4         9 my %have = map { $_ => 1 } grep { length $_ } split /\s+/, $scope_str;
  7         17  
  7         11  
99              
100 4         7 for my $need (@$required) {
101 8 100       20 return 0 unless $have{$need};
102             }
103              
104 2         6 return 1;
105             }
106              
107             sub _unauthorized {
108 6     6   17 my ($self, $error, $description) = @_;
109 6         23 my $header = sprintf(
110             'Bearer realm="%s", error="%s", error_description="%s"',
111             $self->realm,
112             $error,
113             $description,
114             );
115              
116             return [
117 6         199 401,
118             [
119             'Content-Type' => 'application/json',
120             'WWW-Authenticate' => $header,
121             ],
122             [ encode_json({ error => $error, error_description => $description }) ],
123             ];
124             }
125              
126             sub _forbidden {
127 2     2   4 my ($self, $error, $description) = @_;
128              
129             return [
130 2         32 403,
131             [ 'Content-Type' => 'application/json' ],
132             [ encode_json({ error => $error, error_description => $description }) ],
133             ];
134             }
135              
136             1;
137              
138             __END__