File Coverage

blib/lib/Net/Async/Zitadel/OIDC.pm
Criterion Covered Total %
statement 127 134 94.7
branch 36 44 81.8
condition 34 52 65.3
subroutine 29 30 96.6
pod 9 10 90.0
total 235 270 87.0


line stmt bran cond sub pod time code
1             package Net::Async::Zitadel::OIDC;
2              
3             # ABSTRACT: Async OIDC client for Zitadel - token verification, JWKS, discovery
4              
5 3     3   422987 use Moo;
  3         30015  
  3         24  
6 3     3   7514 use Crypt::JWT qw(decode_jwt);
  3         256898  
  3         324  
7 3     3   1245 use JSON::MaybeXS qw(decode_json);
  3         10095  
  3         275  
8 3     3   1183 use HTTP::Request;
  3         57543  
  3         160  
9 3     3   31 use URI;
  3         6  
  3         90  
10 3     3   732 use Future;
  3         36764  
  3         103  
11 3     3   1924 use Net::Async::Zitadel::Error;
  3         19  
  3         159  
12 3     3   34 use namespace::clean;
  3         8  
  3         30  
13              
14             our $VERSION = '0.001';
15              
16             has issuer => (
17             is => 'ro',
18             required => 1,
19             );
20              
21             sub BUILD {
22 21     21 0 324 my $self = shift;
23 21 100       238 die Net::Async::Zitadel::Error::Validation->new(
24             message => 'issuer must not be empty',
25             ) unless length $self->issuer;
26             }
27              
28             has http => (
29             is => 'ro',
30             required => 1,
31             doc => 'Net::Async::HTTP instance (shared from parent)',
32             );
33              
34              
35             has discovery_ttl => (
36             is => 'ro',
37             default => 3600,
38             );
39              
40             has jwks_ttl => (
41             is => 'ro',
42             default => 300,
43             );
44              
45             has _discovery_cache => (
46             is => 'rw',
47             default => sub { undef },
48             );
49              
50             has _discovery_expires => (
51             is => 'rw',
52             default => sub { 0 },
53             );
54              
55             has _jwks_cache => (
56             is => 'rw',
57             default => sub { undef },
58             );
59              
60             has _jwks_expires => (
61             is => 'rw',
62             default => sub { 0 },
63             );
64              
65             # Stores the in-flight JWKS Future to coalesce concurrent refresh requests.
66             has _jwks_inflight => (
67             is => 'rw',
68             default => sub { undef },
69             );
70              
71             # --- Discovery ---
72              
73             sub discovery_f {
74 24     24 1 3640 my ($self) = @_;
75              
76 24 100 100     193 if ($self->_discovery_cache && time() < $self->_discovery_expires) {
77 7         46 return Future->done($self->_discovery_cache);
78             }
79              
80 17         52 my $url = $self->issuer . '/.well-known/openid-configuration';
81              
82             return $self->http->GET(URI->new($url))->then(sub {
83 16     16   18575 my ($response) = @_;
84 16 100       70 unless ($response->is_success) {
85 1         8 return Future->fail(Net::Async::Zitadel::Error::Network->new(
86             message => 'Discovery failed: ' . $response->status_line,
87             ));
88             }
89 15         135 my $doc = decode_json($response->decoded_content);
90 15         298 $self->_discovery_cache($doc);
91 15         73 $self->_discovery_expires(time() + $self->discovery_ttl);
92 15         58 return Future->done($doc);
93 17         111 });
94             }
95              
96             # --- JWKS ---
97              
98             sub jwks_f {
99 13     13 1 277 my ($self, %args) = @_;
100 13   100     65 my $force = $args{force_refresh} // 0;
101              
102 13 100 100     121 if (!$force && $self->_jwks_cache && time() < $self->_jwks_expires) {
      66        
103 2         11 return Future->done($self->_jwks_cache);
104             }
105              
106             # Coalesce concurrent JWKS refreshes: if a fetch is already in-flight,
107             # return the same Future rather than issuing a second HTTP request.
108 11 50 66     73 if (!$force && $self->_jwks_inflight) {
109 0         0 return $self->_jwks_inflight;
110             }
111              
112             my $f = $self->discovery_f->then(sub {
113 11     11   1621 my ($doc) = @_;
114             my $jwks_uri = $doc->{jwks_uri}
115 11   100     83 // return Future->fail(Net::Async::Zitadel::Error::Validation->new(
116             message => 'No jwks_uri in discovery document',
117             ));
118 10         66 return $self->http->GET(URI->new($jwks_uri));
119             })->then(sub {
120 10     10   2648 my ($response) = @_;
121 10 100       42 unless ($response->is_success) {
122 1         9 return Future->fail(Net::Async::Zitadel::Error::Network->new(
123             message => 'JWKS fetch failed: ' . $response->status_line,
124             ));
125             }
126 9         80 my $jwks = decode_json($response->decoded_content);
127 8         108 $self->_jwks_cache($jwks);
128 8         39 $self->_jwks_expires(time() + $self->jwks_ttl);
129 8         22 $self->_jwks_inflight(undef);
130 8         29 return Future->done($jwks);
131             })->on_fail(sub {
132 3     3   328 $self->_jwks_inflight(undef);
133 11         36 });
134              
135             # Only store the in-flight Future if it is still pending.
136             # Synchronous (already-resolved) chains must not be stored, because
137             # the on_fail/on_done clearing inside the chain already ran before we
138             # reach this line, and storing $f here would re-populate the slot.
139 11 50 66     679 $self->_jwks_inflight($f) unless $force || $f->is_ready;
140 11         126 return $f;
141             }
142              
143             # --- Token verification ---
144              
145             sub verify_token_f {
146 4     4 1 38 my ($self, $token, %args) = @_;
147              
148 4 100       38 return Future->fail(Net::Async::Zitadel::Error::Validation->new(
149             message => 'No token provided',
150             )) unless defined $token;
151              
152             return $self->jwks_f->then(sub {
153 3     3   298 my ($jwks) = @_;
154              
155 3         8 my $claims;
156 3         6 eval {
157             $claims = $self->_decode_jwt(
158             token => $token,
159             kid_keys => $jwks,
160             verify_exp => $args{verify_exp} // 1,
161             verify_iat => $args{verify_iat} // 0,
162             verify_nbf => $args{verify_nbf} // 0,
163             verify_iss => $self->issuer,
164             verify_aud => $args{audience},
165 3   50     68 accepted_key_alg => $args{accepted_key_alg} // ['RS256', 'RS384', 'RS512'],
      50        
      50        
      50        
166             );
167             };
168 3 100 66     123 if ($@ && !$args{no_retry}) {
    50          
169             # Key rotation: refresh JWKS and retry once
170             return $self->jwks_f(force_refresh => 1)->then(sub {
171 2         222 my ($fresh_jwks) = @_;
172             my $retry_claims = $self->_decode_jwt(
173             token => $token,
174             kid_keys => $fresh_jwks,
175             verify_exp => $args{verify_exp} // 1,
176             verify_iat => $args{verify_iat} // 0,
177             verify_nbf => $args{verify_nbf} // 0,
178             verify_iss => $self->issuer,
179             verify_aud => $args{audience},
180 2   50     73 accepted_key_alg => $args{accepted_key_alg} // ['RS256', 'RS384', 'RS512'],
      50        
      50        
      50        
181             );
182 1         39 return Future->done($retry_claims);
183 2         8 });
184             }
185             elsif ($@) {
186 1         13 return Future->fail($@);
187             }
188              
189 0         0 return Future->done($claims);
190 3         13 });
191             }
192              
193             sub _decode_jwt {
194 0     0   0 my ($self, %args) = @_;
195 0         0 return decode_jwt(%args);
196             }
197              
198             # --- UserInfo ---
199              
200             sub userinfo_f {
201 2     2 1 15 my ($self, $access_token) = @_;
202              
203 2 100       35 return Future->fail(Net::Async::Zitadel::Error::Validation->new(
204             message => 'No access token provided',
205             )) unless defined $access_token;
206              
207             return $self->discovery_f->then(sub {
208 1     1   139 my ($doc) = @_;
209             my $endpoint = $doc->{userinfo_endpoint}
210 1   50     6 // return Future->fail(Net::Async::Zitadel::Error::Validation->new(
211             message => 'No userinfo_endpoint in discovery document',
212             ));
213 1         17 my $req = HTTP::Request->new(GET => $endpoint);
214 1         241 $req->header(Authorization => "Bearer $access_token");
215 1         234 return $self->http->do_request(request => $req);
216             })->then(sub {
217 1     1   172 my ($response) = @_;
218 1 50       6 unless ($response->is_success) {
219 0         0 return Future->fail(Net::Async::Zitadel::Error::Network->new(
220             message => 'UserInfo failed: ' . $response->status_line,
221             ));
222             }
223 1         9 return Future->done(decode_json($response->decoded_content));
224 1         6 });
225             }
226              
227             # --- Token Introspection ---
228              
229             sub introspect_f {
230 2     2 1 45 my ($self, $token, %args) = @_;
231              
232 2 50       9 return Future->fail(Net::Async::Zitadel::Error::Validation->new(
233             message => 'No token provided',
234             )) unless defined $token;
235              
236             return Future->fail(Net::Async::Zitadel::Error::Validation->new(
237             message => 'Introspection requires client_id and client_secret',
238 2 100 66     34 )) unless $args{client_id} && $args{client_secret};
239              
240             return $self->discovery_f->then(sub {
241 1     1   163 my ($doc) = @_;
242             my $endpoint = $doc->{introspection_endpoint}
243 1   50     7 // return Future->fail(Net::Async::Zitadel::Error::Validation->new(
244             message => 'No introspection_endpoint in discovery document',
245             ));
246              
247 1         6 my $form = URI->new;
248             $form->query_form(
249             token => $token,
250             client_id => $args{client_id},
251             client_secret => $args{client_secret},
252 1   50     89 token_type_hint => $args{token_type_hint} // 'access_token',
253             );
254              
255 1         320 my $req = HTTP::Request->new(POST => $endpoint);
256 1         215 $req->header('Content-Type' => 'application/x-www-form-urlencoded');
257 1         138 $req->content($form->query);
258              
259 1         62 return $self->http->do_request(request => $req);
260             })->then(sub {
261 1     1   175 my ($response) = @_;
262 1 50       7 unless ($response->is_success) {
263 0         0 return Future->fail(Net::Async::Zitadel::Error::Network->new(
264             message => 'Introspection failed: ' . $response->status_line,
265             ));
266             }
267 1         9 return Future->done(decode_json($response->decoded_content));
268 1         4 });
269             }
270              
271             # --- Token Endpoint ---
272              
273             sub token_f {
274 4     4 1 23 my ($self, %args) = @_;
275              
276 4         12 my $grant_type = delete $args{grant_type};
277 4 100       39 return Future->fail(Net::Async::Zitadel::Error::Validation->new(
278             message => 'grant_type required',
279             )) unless defined $grant_type;
280              
281             return $self->discovery_f->then(sub {
282 3     3   429 my ($doc) = @_;
283             my $endpoint = $doc->{token_endpoint}
284 3   50     13 // return Future->fail(Net::Async::Zitadel::Error::Validation->new(
285             message => 'No token_endpoint in discovery document',
286             ));
287              
288 3         17 my $form = URI->new;
289 3         185 $form->query_form(grant_type => $grant_type, %args);
290              
291 3         640 my $req = HTTP::Request->new(POST => $endpoint);
292 3         553 $req->header('Content-Type' => 'application/x-www-form-urlencoded');
293 3         220 $req->content($form->query);
294              
295 3         133 return $self->http->do_request(request => $req);
296             })->then(sub {
297 3     3   429 my ($response) = @_;
298 3 50       52 unless ($response->is_success) {
299 0         0 return Future->fail(Net::Async::Zitadel::Error::Network->new(
300             message => 'Token endpoint failed: ' . $response->status_line,
301             ));
302             }
303 3         23 return Future->done(decode_json($response->decoded_content));
304 3         9 });
305             }
306              
307             sub client_credentials_token_f {
308 2     2 1 16 my ($self, %args) = @_;
309              
310 2         5 my $client_id = delete $args{client_id};
311 2 100       44 return Future->fail(Net::Async::Zitadel::Error::Validation->new(
312             message => 'client_id required',
313             )) unless defined $client_id;
314              
315 1         3 my $client_secret = delete $args{client_secret};
316 1 50       3 return Future->fail(Net::Async::Zitadel::Error::Validation->new(
317             message => 'client_secret required',
318             )) unless defined $client_secret;
319              
320 1         7 return $self->token_f(
321             grant_type => 'client_credentials',
322             client_id => $client_id,
323             client_secret => $client_secret,
324             %args,
325             );
326             }
327              
328             sub refresh_token_f {
329 2     2 1 1549 my ($self, $refresh_token, %args) = @_;
330              
331 2 100 66     53 return Future->fail(Net::Async::Zitadel::Error::Validation->new(
332             message => 'refresh_token required',
333             )) unless defined $refresh_token && length $refresh_token;
334              
335 1         25 return $self->token_f(
336             grant_type => 'refresh_token',
337             refresh_token => $refresh_token,
338             %args,
339             );
340             }
341              
342             sub exchange_authorization_code_f {
343 3     3 1 1973 my ($self, %args) = @_;
344              
345 3         10 my $code = delete $args{code};
346 3 100       46 return Future->fail(Net::Async::Zitadel::Error::Validation->new(
347             message => 'code required',
348             )) unless defined $code;
349              
350 2         5 my $redirect_uri = delete $args{redirect_uri};
351 2 100       37 return Future->fail(Net::Async::Zitadel::Error::Validation->new(
352             message => 'redirect_uri required',
353             )) unless defined $redirect_uri;
354              
355 1         7 return $self->token_f(
356             grant_type => 'authorization_code',
357             code => $code,
358             redirect_uri => $redirect_uri,
359             %args,
360             );
361             }
362              
363             1;
364              
365             __END__