File Coverage

blib/lib/WebService/Hydra/Client.pm
Criterion Covered Total %
statement 143 180 79.4
branch 22 30 73.3
condition n/a
subroutine 25 29 86.2
pod 16 16 100.0
total 206 255 80.7


line stmt bran cond sub pod time code
1             package WebService::Hydra::Client;
2              
3 2     2   405567 use strict;
  2         3  
  2         80  
4 2     2   9 use warnings;
  2         3  
  2         119  
5              
6 2     2   1426 use Object::Pad;
  2         22167  
  2         8  
7              
8             class WebService::Hydra::Client;
9              
10 2     2   1422 use HTTP::Tiny;
  2         106171  
  2         130  
11 2     2   1195 use Log::Any qw( $log );
  2         25972  
  2         11  
12 2     2   7379 use Crypt::JWT qw(decode_jwt);
  2         208426  
  2         222  
13 2     2   844 use JSON::MaybeUTF8;
  2         7162  
  2         133  
14 2     2   1194 use WebService::Hydra::Exception;
  2         6  
  2         15  
15 2     2   1261 use Syntax::Keyword::Try;
  2         3479  
  2         11  
16              
17 2     2   244 use constant OK_STATUS_CODE => 200;
  2         4  
  2         214  
18 2     2   13 use constant OK_NO_CONTENT_CODE => 204;
  2         4  
  2         108  
19 2     2   10 use constant BAD_REQUEST_STATUS_CODE => 400;
  2         5  
  2         117  
20 2     2   10 use constant HTTP_TIMEOUT_SECONDS => 10;
  2         3  
  2         22677  
21              
22             our $VERSION = '0.005';
23              
24             field $http;
25             field $jwks;
26             field $oidc_config;
27 1     1 1 8 field $admin_endpoint :param :reader;
  1         7  
28 1     1 1 5 field $public_endpoint :param :reader;
  1         4  
29              
30             =head1 NAME
31              
32             WebService::Hydra::Client - Hydra Client Object
33              
34             =head2 Description
35              
36             Object::Pad based class which is used to create a Hydra Client Object which interacts with the Hydra service API.
37              
38             =head1 SYNOPSIS
39              
40             use WebService::Hydra::Client;
41             my $obj = WebService::Hydra::Client->new(admin_endpoint => 'url' , public_endpoint => 'url');
42              
43             =head1 METHODS
44              
45             =head2 new
46              
47             =over 1
48              
49             =item C
50              
51             admin_endpoint is a string which contains admin URL for the hydra service. Eg: http://localhost:4445
52             This is a required parameter when creating Hydra Client Object using new.
53              
54             =item C
55              
56             public_endpoint is a string which contains the public URL for the hydra service. Eg: http://localhost:4444
57             This is a required parameter when creating Hydra Client Object using new.
58              
59             =back
60              
61             =head2 admin_endpoint
62              
63             Returns the base URL for the hydra service.
64              
65             =cut
66              
67             =head2 public_endpoint
68              
69             Returns the base URL for the hydra service.
70              
71             =cut
72              
73             =head2 http
74              
75             Return HTTP object.
76              
77             =cut
78              
79             method http {
80             return $http //= HTTP::Tiny->new(timeout => HTTP_TIMEOUT_SECONDS);
81             }
82              
83             =head2 jwks
84             return jwks object
85             =cut
86              
87             method jwks {
88             return $jwks //= $self->fetch_jwks();
89             }
90              
91             =head2 oidc_config
92              
93             returns an object with oidc configuration
94              
95             =cut
96              
97             method oidc_config {
98             return $oidc_config //= $self->fetch_openid_configuration();
99             }
100              
101             =head2 api_call
102              
103             Takes request method, the endpoint, and the payload. It sends the request to the Hydra service, parses the response and returns:
104              
105             1. JSON object of code and data returned from the service.
106             2. Error string in case an exception is thrown.
107              
108             =cut
109              
110 4     4 1 28903 method api_call ($method, $endpoint, $payload = undef, $content_type = 'json') {
  4         20  
  4         13  
  4         10  
  4         10  
  4         9  
  4         9  
111              
112             try {
113              
114             my @args = ($method, $endpoint);
115             if ($payload) {
116             if ($content_type eq 'FORM') {
117             my $headers = {
118             'Content-Type' => 'application/x-www-form-urlencoded',
119             'Accept' => 'application/json'
120             };
121             push(
122             @args,
123             {
124             headers => $headers,
125             content => $self->http->www_form_urlencode($payload)});
126             } else {
127             my $headers = {'Content-Type' => 'application/json'};
128             push(
129             @args,
130             {
131             headers => $headers,
132             content => JSON::MaybeUTF8::encode_json_utf8($payload)});
133             }
134             }
135              
136             my $response = $self->http->request(@args);
137             my $data = JSON::MaybeUTF8::decode_json_utf8($response->{content} || '{}');
138              
139             WebService::Hydra::Exception::HydraServiceUnreachable->new(
140             details => ["An error happened during the execution of the $endpoint request: $response->{content}"])->throw
141             if $response->{status} == 599;
142              
143             return {
144             code => $response->{status},
145             data => $data
146             };
147 4         12 } catch ($e) {
148             WebService::Hydra::Exception::HydraRequestError->new(
149             details => ["Request to $endpoint failed", $e],
150             )->throw;
151             }
152             }
153              
154             =head2 get_login_request
155              
156             Fetches the OAuth2 login request from hydra.
157              
158             Arguments:
159              
160             =over 1
161              
162             =item C<$login_challenge>
163              
164             Authentication challenge string that is used to identify and fetch information
165             about the OAuth2 request from hydra.
166              
167             =back
168              
169             =cut
170              
171 3     3 1 4618 method get_login_request ($login_challenge) {
  3         12  
  3         7  
  3         6  
172 3         6 my $method = "GET";
173 3         8 my $path = "$admin_endpoint/admin/oauth2/auth/requests/login?challenge=$login_challenge";
174              
175 3         13 my $result = $self->api_call($method, $path);
176              
177             # "410" means that the request was already handled. This can happen on form double-submit or other errors.
178             # It's recommended to redirect the user to `request_url` to re-initiate the flow.
179 2 50       27 if ($result->{code} == 410) {
    100          
180             WebService::Hydra::Exception::InvalidLoginChallenge->new(
181             message => "Login challenge has already been handled",
182             redirect_to => $result->{data}->{redirect_to},
183 0         0 category => 'client_redirecting_error'
184             )->throw;
185             } elsif ($result->{code} != OK_STATUS_CODE) {
186 1         17 WebService::Hydra::Exception::InvalidLoginChallenge->new(
187             message => "Failed to get login request",
188             category => "client",
189             details => $result
190             )->throw;
191             }
192 1         4 return $result->{data};
193             }
194              
195             =head2 accept_login_request
196              
197             Accepts the login request and returns the response from hydra.
198              
199             Arguments:
200              
201             =over 1
202              
203             =item C<$login_challenge>
204              
205             Authentication challenge string that is used to identify the login request.
206              
207             =item C<$accept_payload>
208              
209             Payload to be sent to the Hydra service to confirm the login challenge.
210              
211             =back
212              
213             =cut
214              
215 0     0 1 0 method accept_login_request ($login_challenge, $accept_payload) {
  0         0  
  0         0  
  0         0  
  0         0  
216 0         0 my $method = "PUT";
217 0         0 my $path = "$admin_endpoint/admin/oauth2/auth/requests/login/accept?challenge=$login_challenge";
218              
219 0         0 my $result = $self->api_call($method, $path, $accept_payload);
220 0 0       0 if ($result->{code} != OK_STATUS_CODE) {
221 0         0 WebService::Hydra::Exception::InvalidLoginRequest->new(
222             message => "Failed to accept login request",
223             category => "client",
224             details => $result
225             )->throw;
226             }
227 0         0 return $result->{data};
228             }
229              
230             =head2 reject_login_request
231              
232             Rejects the login request and returns the response from hydra.
233              
234             Arguments:
235              
236             =over 1
237              
238             =item C<$login_challenge>
239              
240             Authentication challenge string that is used to identify the login request.
241              
242             =item C<$reject_payload>
243              
244             Payload to be sent to the Hydra service to reject the login request.
245              
246             =back
247              
248             =cut
249              
250 3     3 1 4829 method reject_login_request ($login_challenge, $reject_payload) {
  3         12  
  3         5  
  3         5  
  3         5  
251 3         4 my $method = "PUT";
252 3         9 my $path = "$admin_endpoint/admin/oauth2/auth/requests/login/reject?challenge=$login_challenge";
253              
254 3         10 my $result = $self->api_call($method, $path, $reject_payload);
255 2 100       16 if ($result->{code} != OK_STATUS_CODE) {
256 1         20 WebService::Hydra::Exception::InvalidLoginRequest->new(
257             message => "Failed to reject login request",
258             category => "client",
259             details => $result
260             )->throw;
261             }
262 1         3 return $result->{data};
263             }
264              
265             =head2 get_logout_request
266              
267             Get the logout request and return the response from Hydra.
268              
269             =cut
270              
271 4     4 1 5469 method get_logout_request ($logout_challenge) {
  4         18  
  4         7  
  4         8  
272 4         8 my $method = "GET";
273 4         12 my $path = "$admin_endpoint/admin/oauth2/auth/requests/logout?challenge=$logout_challenge";
274              
275 4         28 my $result = $self->api_call($method, $path);
276              
277             # "410" means that the request was already handled. This can happen on form double-submit or other errors.
278             # It's recommended to redirect the user to `request_url` to re-initiate the flow.
279 3 100       29 if ($result->{code} == 410) {
    100          
280             WebService::Hydra::Exception::InvalidLogoutChallenge->new(
281             message => "Logout challenge has already been handled",
282             redirect_to => $result->{data}->{redirect_to},
283 1         21 category => 'client_redirecting_error'
284             )->throw;
285             } elsif ($result->{code} != OK_STATUS_CODE) {
286 1         11 WebService::Hydra::Exception::InvalidLogoutChallenge->new(
287             message => "Failed to get logout request",
288             category => "client",
289             details => $result
290             )->throw;
291             }
292 1         3 return $result->{data};
293             }
294              
295             =head2 accept_logout_request
296              
297             The response contains a redirect URL which the logout provider should redirect the user-agent to.
298              
299             =cut
300              
301 4     4 1 5323 method accept_logout_request ($logout_challenge) {
  4         16  
  4         10  
  4         6  
302 4         9 my $method = "PUT";
303 4         10 my $path = "$admin_endpoint/admin/oauth2/auth/requests/logout/accept?challenge=$logout_challenge";
304 4         16 my $result = $self->api_call($method, $path);
305 2 100       21 if ($result->{code} != OK_STATUS_CODE) {
306 1         10 WebService::Hydra::Exception::InvalidLogoutChallenge->new(
307             message => "Failed to accept logout request",
308             category => "client",
309             details => $result
310             )->throw;
311             }
312 1         4 return $result->{data};
313             }
314              
315             =head2 exchange_token
316              
317             Exchanges the authorization code with Hydra service for access and ID tokens.
318              
319             =cut
320              
321 0     0 1 0 method exchange_token ($exchange_payload) {
  0         0  
  0         0  
  0         0  
322 0         0 my $method = "POST";
323 0         0 my $path = "$public_endpoint/oauth2/token";
324 0         0 my $payload = {
325             grant_type => 'authorization_code',
326             $exchange_payload->%*
327             };
328 0         0 my $result = $self->api_call($method, $path, $payload, 'FORM');
329 0 0       0 if ($result->{code} != OK_STATUS_CODE) {
330 0         0 WebService::Hydra::Exception::TokenExchangeFailed->new(
331             message => "Failed to exchange token",
332             category => "client",
333             details => $result
334             )->throw;
335             }
336 0         0 return $result->{data};
337             }
338              
339             =head2 fetch_jwks
340              
341             Fetches the JSON Web Key Set published by Hydra which is used to validate signatures.
342              
343             =cut
344              
345 0     0 1 0 method fetch_jwks () {
  0         0  
  0         0  
346 0         0 my $method = "GET";
347 0         0 my $path = "$public_endpoint/.well-known/jwks.json";
348              
349 0         0 my $result = $self->api_call($method, $path);
350 0 0       0 if ($result->{code} != OK_STATUS_CODE) {
351 0         0 WebService::Hydra::Exception::HydraRequestError->new(
352             category => "hydra",
353             details => $result
354             )->throw;
355             }
356 0         0 return $result->{data};
357             }
358              
359             =head2 fetch_openid_configuration
360              
361             Fetches the openid-configuration from hydra
362              
363             =cut
364              
365 2     2 1 2673 method fetch_openid_configuration () {
  2         7  
  2         3  
366 2         4 my $method = "GET";
367 2         6 my $path = "$public_endpoint/.well-known/openid-configuration";
368              
369 2         6 my $result = $self->api_call($method, $path);
370 2 100       20 if ($result->{code} != OK_STATUS_CODE) {
371 1         63 BOM::OAuth::Exceptions::Type::HydraRequestError->new(
372             category => "hydra",
373             details => $result
374             )->throw;
375             }
376 1         3 return $result->{data};
377             }
378              
379             =head2 validate_id_token
380              
381             Decodes the id_token and validates its signature against Hydra and returns the decoded payload.
382              
383             =cut
384              
385 0     0 1 0 method validate_id_token ($id_token) {
  0         0  
  0         0  
  0         0  
386             try {
387             my $payload = decode_jwt(
388             token => $id_token,
389             kid_keys => $self->jwks
390             );
391             return $payload;
392 0         0 } catch ($e) {
393             WebService::Hydra::Exception::InvalidIdToken->new(
394             message => "Failed to validate id token",
395             category => "client",
396             details => $e
397             )->throw;
398             }
399             }
400              
401             =head2 validate_token
402              
403             Decodes the token and validates its signature against hydra and returns the decoded payload.
404              
405             =over 1
406              
407             =item C<$token> jwt token to be validated
408              
409             =back
410              
411             Returns the decoded payload if the token is valid, otherwise throws an exception.
412              
413             =cut
414              
415 2     2 1 2964 method validate_token ($token) {
  2         10  
  2         6  
  2         3  
416             my $payload = decode_jwt(
417             token => $token,
418             verify_iat => 1,
419             verify_exp => 1,
420             verify_iss => $self->oidc_config->{issuer},
421 2         12 kid_keys => $self->jwks
422             );
423 1         26 return $payload;
424             }
425              
426             =head2 get_consent_request
427              
428             Fetches the consent request from Hydra.
429              
430             =cut
431              
432 4     4 1 5904 method get_consent_request ($consent_challenge) {
  4         20  
  4         9  
  4         6  
433 4         9 my $method = "GET";
434 4         37 my $path = "$admin_endpoint/admin/oauth2/auth/requests/consent?challenge=$consent_challenge";
435              
436 4         15 my $result = $self->api_call($method, $path);
437              
438 3 100       36 if ($result->{code} == 410) {
    100          
439             WebService::Hydra::Exception::InvalidConsentChallenge->new(
440             message => "Consent request has already been handled",
441             redirect_to => $result->{data}->{redirect_to},
442 1         20 category => 'client_redirecting_error'
443             )->throw;
444             } elsif ($result->{code} != OK_STATUS_CODE) {
445 1         9 WebService::Hydra::Exception::InvalidConsentChallenge->new(
446             message => "Failed to get consent request",
447             category => "client",
448             details => $result
449             )->throw;
450             }
451 1         5 return $result->{data};
452             }
453              
454             =head2 accept_consent_request
455              
456             Accepts the consent request and returns the response from Hydra.
457              
458             =cut
459              
460 3     3 1 4503 method accept_consent_request ($consent_challenge, $params) {
  3         13  
  3         9  
  3         5  
  3         6  
461 3         6 my $method = "PUT";
462 3         8 my $path = "$admin_endpoint/admin/oauth2/auth/requests/consent/accept?challenge=$consent_challenge";
463              
464 3         11 my $result = $self->api_call($method, $path, $params);
465 2 100       40 if ($result->{code} != OK_STATUS_CODE) {
466 1         11 WebService::Hydra::Exception::InvalidConsentChallenge->new(
467             message => "Failed to accept consent request",
468             category => "client",
469             details => $result
470             )->throw;
471             }
472 1         5 return $result->{data};
473             }
474              
475             =head2 revoke_login_sessions
476              
477             This endpoint invalidates authentication sessions.
478             It expects a user ID (subject) and invalidates all sessions for this user. or session ID (sid) and invalidates the session.
479              
480             =cut
481              
482 3     3 1 3905 method revoke_login_sessions (%args) {
  3         11  
  3         8  
  3         5  
483 3         5 my $method = "DELETE";
484 3         8 my $path = "$admin_endpoint/admin/oauth2/auth/sessions/login";
485              
486 3         9 my $query = join('&', map { "$_=$args{$_}" } keys %args);
  3         14  
487 3 50       14 $path .= "?$query" if $query;
488              
489 3         9 my $result = $self->api_call($method, $path);
490 3 100       20 if ($result->{code} != OK_NO_CONTENT_CODE) {
491 1         16 WebService::Hydra::Exception::RevokeLoginSessionsFailed->new(
492             message => "Failed to revoke login sessions",
493             category => "client",
494             details => $result
495             )->throw;
496             }
497 2         10 return $result->{data};
498             }
499              
500             1;