File Coverage

blib/lib/WWW/Zitadel/OIDC.pm
Criterion Covered Total %
statement 91 94 96.8
branch 25 28 89.2
condition 38 52 73.0
subroutine 24 25 96.0
pod 9 15 60.0
total 187 214 87.3


line stmt bran cond sub pod time code
1             package WWW::Zitadel::OIDC;
2              
3             # ABSTRACT: OIDC client for Zitadel - token verification, JWKS, discovery
4              
5 4     4   322676 use Moo;
  4         20854  
  4         30  
6 4     4   7782 use Crypt::JWT qw(decode_jwt);
  4         342705  
  4         454  
7 4     4   2013 use JSON::MaybeXS qw(decode_json);
  4         21234  
  4         337  
8 4     4   3710 use LWP::UserAgent;
  4         305595  
  4         243  
9 4     4   47 use URI;
  4         11  
  4         153  
10 4     4   2748 use WWW::Zitadel::Error;
  4         23  
  4         326  
11 4     4   40 use namespace::clean;
  4         7  
  4         56  
12              
13             our $VERSION = '0.001';
14              
15             has issuer => (
16             is => 'ro',
17             required => 1,
18             );
19              
20             sub BUILD {
21 17     17 0 137 my $self = shift;
22 17 100       140 die WWW::Zitadel::Error::Validation->new(
23             message => 'issuer must not be empty',
24             ) unless length $self->issuer;
25             }
26              
27             has ua => (
28             is => 'lazy',
29 1     1   1100 builder => sub { LWP::UserAgent->new(timeout => 10) },
30             );
31              
32             has _discovery => (
33             is => 'lazy',
34             builder => sub {
35 14     14   114 my $self = shift;
36 14         39 my $url = $self->issuer . '/.well-known/openid-configuration';
37 14         231 my $res = $self->ua->get($url);
38 14 100       274 unless ($res->is_success) {
39 1         49 die WWW::Zitadel::Error::Network->new(
40             message => 'Discovery failed: ' . $res->status_line,
41             );
42             }
43 13         64 decode_json($res->decoded_content);
44             },
45             );
46              
47             has _jwks_cache => (
48             is => 'rw',
49             default => sub { undef },
50             );
51              
52 2     2 1 70 sub discovery { $_[0]->_discovery }
53              
54             sub jwks_uri {
55 14     14 0 132 my $self = shift;
56             $self->_discovery->{jwks_uri}
57 14   100     287 // die "No jwks_uri in discovery document\n";
58             }
59              
60             sub token_endpoint {
61 5     5 0 39 my $self = shift;
62             $self->_discovery->{token_endpoint}
63 5   50     95 // die "No token_endpoint in discovery document\n";
64             }
65              
66             sub userinfo_endpoint {
67 4     4 0 574 my $self = shift;
68             $self->_discovery->{userinfo_endpoint}
69 4   100     91 // die "No userinfo_endpoint in discovery document\n";
70             }
71              
72             sub authorization_endpoint {
73 1     1 0 490 my $self = shift;
74             $self->_discovery->{authorization_endpoint}
75 1   50     36 // die "No authorization_endpoint in discovery document\n";
76             }
77              
78             sub introspection_endpoint {
79 3     3 0 457 my $self = shift;
80             $self->_discovery->{introspection_endpoint}
81 3   100     75 // die "No introspection_endpoint in discovery document\n";
82             }
83              
84             sub jwks {
85 13     13 1 97 my ($self, %args) = @_;
86 13   100     70 my $force = $args{force_refresh} // 0;
87              
88 13 100 100     67 if (!$force && $self->_jwks_cache) {
89 1         11 return $self->_jwks_cache;
90             }
91              
92 12         266 my $res = $self->ua->get($self->jwks_uri);
93 12 100       256 unless ($res->is_success) {
94 1         5 die WWW::Zitadel::Error::Network->new(
95             message => 'JWKS fetch failed: ' . $res->status_line,
96             );
97             }
98 11         43 my $jwks = decode_json($res->decoded_content);
99 9         56 $self->_jwks_cache($jwks);
100 9         29 return $jwks;
101             }
102              
103             sub verify_token {
104 5     5 1 157 my ($self, $token, %args) = @_;
105 5 100       48 die WWW::Zitadel::Error::Validation->new(message => 'No token provided')
106             unless defined $token;
107              
108 4         17 my $jwks = $self->jwks;
109 4         7 my $claims;
110 4         8 eval {
111             $claims = $self->_decode_jwt(
112             token => $token,
113             kid_keys => $jwks,
114             verify_exp => $args{verify_exp} // 1,
115             verify_iat => $args{verify_iat} // 0,
116             verify_nbf => $args{verify_nbf} // 0,
117             verify_iss => $self->issuer,
118             verify_aud => $args{audience},
119 4   50     64 accepted_key_alg => $args{accepted_key_alg} // ['RS256', 'RS384', 'RS512'],
      50        
      50        
      50        
120             );
121             };
122 4 100 66     126 if ($@ && !$args{no_retry}) {
    50          
123             # Key rotation: refresh JWKS and retry once
124 3         8 $jwks = $self->jwks(force_refresh => 1);
125             $claims = $self->_decode_jwt(
126             token => $token,
127             kid_keys => $jwks,
128             verify_exp => $args{verify_exp} // 1,
129             verify_iat => $args{verify_iat} // 0,
130             verify_nbf => $args{verify_nbf} // 0,
131             verify_iss => $self->issuer,
132             verify_aud => $args{audience},
133 3   50     53 accepted_key_alg => $args{accepted_key_alg} // ['RS256', 'RS384', 'RS512'],
      50        
      50        
      50        
134             );
135             }
136             elsif ($@) {
137 1         5 die $@;
138             }
139              
140 1         23 return $claims;
141             }
142              
143             sub _decode_jwt {
144 0     0   0 my ($self, %args) = @_;
145 0         0 return decode_jwt(%args);
146             }
147              
148             sub userinfo {
149 3     3 1 135 my ($self, $access_token) = @_;
150 3 100       46 die WWW::Zitadel::Error::Validation->new(message => 'No access token provided')
151             unless defined $access_token;
152              
153 2         41 my $res = $self->ua->get(
154             $self->userinfo_endpoint,
155             Authorization => "Bearer $access_token",
156             );
157 2 100       67 unless ($res->is_success) {
158 1         9 die WWW::Zitadel::Error::Network->new(
159             message => 'UserInfo failed: ' . $res->status_line,
160             );
161             }
162 1         4 return decode_json($res->decoded_content);
163             }
164              
165             sub introspect {
166 3     3 1 6827 my ($self, $token, %args) = @_;
167 3 50       16 die WWW::Zitadel::Error::Validation->new(message => 'No token provided')
168             unless defined $token;
169             die WWW::Zitadel::Error::Validation->new(
170             message => 'Introspection requires client_id and client_secret',
171 3 100 66     40 ) unless $args{client_id} && $args{client_secret};
172              
173             my $res = $self->ua->post(
174             $self->introspection_endpoint,
175             Content_Type => 'application/x-www-form-urlencoded',
176             Content => {
177             token => $token,
178             client_id => $args{client_id},
179             client_secret => $args{client_secret},
180 2   100     70 token_type_hint => $args{token_type_hint} // 'access_token',
181             },
182             );
183 2 50       71 unless ($res->is_success) {
184 0         0 die WWW::Zitadel::Error::Network->new(
185             message => 'Introspection failed: ' . $res->status_line,
186             );
187             }
188 2         19 return decode_json($res->decoded_content);
189             }
190              
191             sub token {
192 5     5 1 6022 my ($self, %args) = @_;
193              
194             my $grant_type = delete $args{grant_type}
195 5   100     39 // die WWW::Zitadel::Error::Validation->new(message => 'grant_type required');
196              
197 4         164 my $res = $self->ua->post(
198             $self->token_endpoint,
199             Content_Type => 'application/x-www-form-urlencoded',
200             Content => {
201             grant_type => $grant_type,
202             %args,
203             },
204             );
205 4 100       117 unless ($res->is_success) {
206 1         28 die WWW::Zitadel::Error::Network->new(
207             message => 'Token endpoint failed: ' . $res->status_line,
208             );
209             }
210 3         15 return decode_json($res->decoded_content);
211             }
212              
213             sub client_credentials_token {
214 2     2 1 357 my ($self, %args) = @_;
215              
216             my $client_id = delete $args{client_id}
217 2   100     33 // die WWW::Zitadel::Error::Validation->new(message => 'client_id required');
218             my $client_secret = delete $args{client_secret}
219 1   50     5 // die WWW::Zitadel::Error::Validation->new(message => 'client_secret required');
220              
221 1         6 return $self->token(
222             grant_type => 'client_credentials',
223             client_id => $client_id,
224             client_secret => $client_secret,
225             %args,
226             );
227             }
228              
229             sub refresh_token {
230 2     2 1 1058 my ($self, $refresh_token, %args) = @_;
231 2 100 66     31 die WWW::Zitadel::Error::Validation->new(message => 'refresh_token required')
232             unless defined $refresh_token && length $refresh_token;
233              
234 1         4 return $self->token(
235             grant_type => 'refresh_token',
236             refresh_token => $refresh_token,
237             %args,
238             );
239             }
240              
241             sub exchange_authorization_code {
242 3     3 1 1454 my ($self, %args) = @_;
243              
244             my $code = delete $args{code}
245 3   100     74 // die WWW::Zitadel::Error::Validation->new(message => 'code required');
246             my $redirect_uri = delete $args{redirect_uri}
247 2   100     27 // die WWW::Zitadel::Error::Validation->new(message => 'redirect_uri required');
248              
249 1         6 return $self->token(
250             grant_type => 'authorization_code',
251             code => $code,
252             redirect_uri => $redirect_uri,
253             %args,
254             );
255             }
256              
257             1;
258              
259             __END__