From d33c6a483b02f33b5025fecf780041c78d90f9c3 Mon Sep 17 00:00:00 2001 From: hani-deriv Date: Fri, 13 Sep 2024 08:48:00 +0000 Subject: [PATCH 01/16] init sdk --- .gitignore | 24 ++ Changes | 14 + README.md | 2 +- cpanfile | 14 + dist.ini | 10 + lib/WebService/Hydra/Client.pm | 376 +++++++++++++++++ lib/WebService/Hydra/Exception.pm | 133 ++++++ .../Hydra/Exception/FeatureUnavailable.pm | 22 + .../Hydra/Exception/HydraRequestError.pm | 22 + .../Exception/HydraServiceUnreachable.pm | 22 + .../Hydra/Exception/InternalServerError.pm | 22 + .../Exception/InvalidConsentChallenge.pm | 22 + .../Hydra/Exception/InvalidIdToken.pm | 22 + .../Hydra/Exception/InvalidLoginChallenge.pm | 22 + .../Hydra/Exception/InvalidLoginRequest.pm | 22 + .../Hydra/Exception/InvalidLogoutChallenge.pm | 21 + .../Hydra/Exception/TokenExchangeFailed.pm | 21 + t/rc/perlcriticrc | 25 ++ t/rc/perltidyrc | 62 +++ t/unit/exception.t | 61 +++ t/unit/exceptions.t | 35 ++ t/unit/hydra_client.t | 393 ++++++++++++++++++ 22 files changed, 1366 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 Changes create mode 100644 cpanfile create mode 100644 dist.ini create mode 100644 lib/WebService/Hydra/Client.pm create mode 100644 lib/WebService/Hydra/Exception.pm create mode 100644 lib/WebService/Hydra/Exception/FeatureUnavailable.pm create mode 100644 lib/WebService/Hydra/Exception/HydraRequestError.pm create mode 100644 lib/WebService/Hydra/Exception/HydraServiceUnreachable.pm create mode 100644 lib/WebService/Hydra/Exception/InternalServerError.pm create mode 100644 lib/WebService/Hydra/Exception/InvalidConsentChallenge.pm create mode 100644 lib/WebService/Hydra/Exception/InvalidIdToken.pm create mode 100644 lib/WebService/Hydra/Exception/InvalidLoginChallenge.pm create mode 100644 lib/WebService/Hydra/Exception/InvalidLoginRequest.pm create mode 100644 lib/WebService/Hydra/Exception/InvalidLogoutChallenge.pm create mode 100644 lib/WebService/Hydra/Exception/TokenExchangeFailed.pm create mode 100644 t/rc/perlcriticrc create mode 100644 t/rc/perltidyrc create mode 100644 t/unit/exception.t create mode 100644 t/unit/exceptions.t create mode 100644 t/unit/hydra_client.t diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b73b77 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +id_dsa +id_rsa +/blib/ +/.build/ +_build/ +cover_db/ +inc/ +Build +!Build/ +Build.bat +.last_cover_stats +/Makefile.PL +/Makefile +/Makefile.old +/MANIFEST.bak +/META.yml +/META.json +/MYMETA.* +nytprof.out +/LICENSE +/pm_to_blib +*.o +*.bs +*.swp diff --git a/Changes b/Changes new file mode 100644 index 0000000..d0e1f56 --- /dev/null +++ b/Changes @@ -0,0 +1,14 @@ +{{$NEXT}} + +0.001 2024-09-12 11:15:49+00:00 UTC + - Initial release + - get_login_request + - accept_login_request + - get_logout_request + - accept_logout_request + - exchange_token + - fetch_jwks + - validate_id_token + - get_consent_request + - accept_consent_request + \ No newline at end of file diff --git a/README.md b/README.md index bc32959..25db1c9 100644 --- a/README.md +++ b/README.md @@ -1 +1 @@ -# perl-WebService-Hydra \ No newline at end of file +# Ory Hydra Simple Client diff --git a/cpanfile b/cpanfile new file mode 100644 index 0000000..550d346 --- /dev/null +++ b/cpanfile @@ -0,0 +1,14 @@ +requires 'Object::Pad', '0.56'; # Required modules with specific versions +requires 'JSON::MaybeUTF8', '0'; +requires 'Scalar::Util', '0'; +requires 'Log::Any', '0'; + +on 'test' => sub { + requires 'Test::More', '0'; # Test dependencies +}; + +on 'develop' => sub { + requires 'strict', '0'; # Meta dependencies for development + requires 'warnings', '0'; + requires 'Test::Pod', '1.45'; # Run pod tests (optional) +}; diff --git a/dist.ini b/dist.ini new file mode 100644 index 0000000..51e4ef6 --- /dev/null +++ b/dist.ini @@ -0,0 +1,10 @@ +name = WebService-Hydra +author = DERIV +license = Perl_5 +copyright_holder = Deriv Services Ltd +copyright_year = 2022 + +[@Author::DERIV] +allow_dirty = lib/WebService/Hydra/Client.pm +[Test::Perl::Critic] +critic_config = t/rc/perlcriticrc diff --git a/lib/WebService/Hydra/Client.pm b/lib/WebService/Hydra/Client.pm new file mode 100644 index 0000000..1116317 --- /dev/null +++ b/lib/WebService/Hydra/Client.pm @@ -0,0 +1,376 @@ +use Object::Pad; + +class WebService::Hydra::Client; + +use strict; +use warnings; + +use HTTP::Tiny; +use Log::Any qw( $log ); +use Crypt::JWT qw(decode_jwt); +use JSON::MaybeUTF8; +use WebService::Hydra::Exception; +use Syntax::Keyword::Try; + +use constant OK_STATUS_CODE => 200; +use constant BAD_REQUEST_STATUS_CODE => 400; + +our $VERSION = '0.01'; + +field $http; +field $admin_endpoint :param :reader; +field $public_endpoint :param :reader; + +=head1 NAME + +WebService::Hydra::Client - Hydra Client Object + +=head2 Description + +Object::Pad based class which is used to create a Hydra Client Object which interacts with the Hydra service API. + +=head1 SYNOPSIS + + use WebService::Hydra::Client; + my $obj = WebService::Hydra::Client->new(admin_endpoint => 'url' , public_endpoint => 'url'); + +=head1 METHODS + +=head2 new + +=over 1 + +=item C + +admin_endpoint is a string which contains admin URL for the hydra service. Eg: http://localhost:4445 +This is a required parameter when creating Hydra Client Object using new. + +=item C + +public_endpoint is a string which contains the public URL for the hydra service. Eg: http://localhost:4444 +This is a required parameter when creating Hydra Client Object using new. + +=back + +=head2 admin_endpoint + +Returns the base URL for the hydra service. + +=cut + +=head2 public_endpoint + +Returns the base URL for the hydra service. + +=cut + +=head2 http + +Return HTTP object. + +=cut + +method http { + return $http //= HTTP::Tiny->new(); +} + +=head2 api_call + +Takes request method, the endpoint, and the payload. It sends the request to the Hydra service, parses the response and returns: + +1. JSON object of code and data returned from the service. +2. Error string in case an exception is thrown. + +=cut + +method api_call ($method, $endpoint, $payload = undef, $content_type = 'json') { + + try { + + my @args = ($method, $endpoint); + if ($payload) { + if ($content_type eq 'FORM') { + my $headers = { + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json' + }; + push( + @args, + { + headers => $headers, + content => $self->http->www_form_urlencode($payload)}); + } else { + my $headers = {'Content-Type' => 'application/json'}; + push( + @args, + { + headers => $headers, + content => JSON::MaybeUTF8::encode_json_utf8($payload)}); + } + } + + my $response = $self->http->request(@args); + my $data = JSON::MaybeUTF8::decode_json_utf8($response->{content} || '{}'); + + WebService::Hydra::Exception::HydraServiceUnreachable->new( + details => ["An error happened during the execution of the $endpoint request: $response->{content}"])->throw + if $response->{status} == 599; + + return { + code => $response->{status}, + data => $data + }; + } catch ($e) { + WebService::Hydra::Exception::HydraRequestError->new( + details => ["Request to $endpoint failed", $e], + )->throw; + } +} + +=head2 get_login_request + +Fetches the OAuth2 login request from hydra. + +Arguments: + +=over 1 + +=item C<$login_challenge> + +Authentication challenge string that is used to identify and fetch information +about the OAuth2 request from hydra. + +=back + +=cut + +method get_login_request ($login_challenge) { + my $method = "GET"; + my $path = "$admin_endpoint/admin/oauth2/auth/requests/login?challenge=$login_challenge"; + + my $result = $self->api_call($method, $path); + + # "410" means that the request was already handled. This can happen on form double-submit or other errors. + # It's recommended to redirect the user to `request_url` to re-initiate the flow. + if ($result->{code} == 410) { + WebService::Hydra::Exception::InvalidLoginChallenge->new( + message => "Login challenge has already been handled", + redirect_to => $result->{data}->{redirect_to}, + category => 'client_redirecting_error' + )->throw; + } elsif ($result->{code} != OK_STATUS_CODE) { + WebService::Hydra::Exception::InvalidLoginChallenge->new( + message => "Failed to get login request", + category => "client", + details => $result + )->throw; + } + return $result->{data}; +} + +=head2 accept_login_request + +Accepts the login request and returns the response from hydra. + +Arguments: + +=over 1 + +=item C<$login_challenge> + +Authentication challenge string that is used to identify the login request. + +=item C<$accept_payload> + +Payload to be sent to the Hydra service to confirm the login challenge. + +=back + +=cut + +method accept_login_request ($login_challenge, $accept_payload) { + my $method = "PUT"; + my $path = "$admin_endpoint/admin/oauth2/auth/requests/login/accept?challenge=$login_challenge"; + + my $result = $self->api_call($method, $path, $accept_payload); + if ($result->{code} != OK_STATUS_CODE) { + WebService::Hydra::Exception::InvalidLoginRequest->new( + message => "Failed to accept login request", + category => "client", + details => $result + )->throw; + } + return $result->{data}; +} + +=head2 get_logout_request + +Get the logout request and return the response from Hydra. + +=cut + +method get_logout_request ($logout_challenge) { + my $method = "GET"; + my $path = "$admin_endpoint/admin/oauth2/auth/requests/logout?challenge=$logout_challenge"; + + my $result = $self->api_call($method, $path); + + # "410" means that the request was already handled. This can happen on form double-submit or other errors. + # It's recommended to redirect the user to `request_url` to re-initiate the flow. + if ($result->{code} == 410) { + WebService::Hydra::Exception::InvalidLogoutChallenge->new( + message => "Logout challenge has already been handled", + redirect_to => $result->{data}->{redirect_to}, + category => 'client_redirecting_error' + )->throw; + } elsif ($result->{code} != OK_STATUS_CODE) { + WebService::Hydra::Exception::InvalidLogoutChallenge->new( + message => "Failed to get logout request", + category => "client", + details => $result + )->throw; + } + return $result->{data}; +} + +=head2 accept_logout_request + +The response contains a redirect URL which the logout provider should redirect the user-agent to. + +=cut + +method accept_logout_request ($logout_challenge) { + my $method = "PUT"; + my $path = "$admin_endpoint/admin/oauth2/auth/requests/logout/accept?challenge=$logout_challenge"; + my $result = $self->api_call($method, $path); + if ($result->{code} != OK_STATUS_CODE) { + WebService::Hydra::Exception::InvalidLogoutChallenge->new( + message => "Failed to accept logout request", + category => "client", + details => $result + )->throw; + } + return $result->{data}; +} + +=head2 exchange_token + +Exchanges the authorization code with Hydra service for access and ID tokens. + +=cut + +method exchange_token ($code, $client_id, $client_secret, $redirect_uri) { + my $method = "POST"; + my $path = "$public_endpoint/oauth2/token"; + my $grant_type = "authorization_code"; + my $payload = { + code => $code, + grant_type => $grant_type, + client_id => $client_id, + client_secret => $client_secret, + redirect_uri => $redirect_uri + }; + my $result = $self->api_call($method, $path, $payload, 'FORM'); + if ($result->{code} != OK_STATUS_CODE) { + WebService::Hydra::Exception::TokenExchangeFailed->new( + message => "Failed to exchange token", + category => "client", + details => $result + )->throw; + } + return $result->{data}; +} + +=head2 fetch_jwks + +Fetches the JSON Web Key Set published by Hydra which is used to validate signatures. + +=cut + +method fetch_jwks () { + my $method = "GET"; + my $path = "$public_endpoint/.well-known/jwks.json"; + + my $result = $self->api_call($method, $path); + if ($result->{code} != OK_STATUS_CODE) { + WebService::Hydra::Exception::HydraRequestError->new( + category => "hydra", + details => $result + )->throw; + } + return $result->{data}; +} + +=head2 validate_id_token + +Decodes the id_token and validates its signature against Hydra and returns the decoded payload. + +=cut + +method validate_id_token ($id_token) { + my $jwks = $self->fetch_jwks(); + try { + my $payload = decode_jwt( + token => $id_token, + kid_keys => $jwks + ); + return $payload; + } catch ($e) { + WebService::Hydra::Exception::InvalidIdToken->new( + message => "Failed to validate id token", + category => "client", + details => $e + )->throw; + } +} + +=head2 get_consent_request + +Fetches the consent request from Hydra. + +=cut + +method get_consent_request ($consent_challenge) { + my $method = "GET"; + my $path = "$admin_endpoint/admin/oauth2/auth/requests/consent?challenge=$consent_challenge"; + + my $result = $self->api_call($method, $path); + + if ($result->{code} == 410) { + WebService::Hydra::Exception::InvalidConsentChallenge->new( + message => "Consent request has already been handled", + redirect_to => $result->{data}->{redirect_to}, + category => 'client_redirecting_error' + )->throw; + } elsif ($result->{code} != OK_STATUS_CODE) { + WebService::Hydra::Exception::InvalidConsentChallenge->new( + message => "Failed to get consent request", + category => "client", + details => $result + )->throw; + } + return $result->{data}; +} + +=head2 accept_consent_request + +Accepts the consent request and returns the response from Hydra. + +=cut + +method accept_consent_request ($consent_challenge, $params) { + my $method = "PUT"; + my $path = "$admin_endpoint/admin/oauth2/auth/requests/consent/accept?challenge=$consent_challenge"; + + my $result = $self->api_call($method, $path, $params); + if ($result->{code} != OK_STATUS_CODE) { + WebService::Hydra::Exception::InvalidConsentChallenge->new( + message => "Failed to accept consent request", + category => "client", + details => $result + )->throw; + } + return $result->{data}; +} + +1; diff --git a/lib/WebService/Hydra/Exception.pm b/lib/WebService/Hydra/Exception.pm new file mode 100644 index 0000000..9370330 --- /dev/null +++ b/lib/WebService/Hydra/Exception.pm @@ -0,0 +1,133 @@ +use Object::Pad; + +class WebService::Hydra::Exception; + +use strict; +use warnings; +use File::Spec; +use Module::Load; +use JSON::MaybeUTF8 qw(encode_json_text); +use Log::Any qw($log); + +## VERSION + +=head1 NAME + +WebService::Hydra::Exception - Base class for all Hydra Exceptions, loading all possible exception types. + +=head1 DESCRIPTION + +The base class cannot be instantiated directly, and it dynamically loads all exception types within WebService::Hydra::Exception::* namespace. + +=cut + +use Scalar::Util qw(blessed); + +# Field definitions commonly inherited by all subclasses +field $message :param :reader = ''; +field $category :param :reader = ''; +field $details :param :reader = []; + +BUILD { + die ref($self) . " is a base class and cannot be instantiated directly." if ref($self) eq __PACKAGE__; +} + + +=head1 Methods + +=head2 throw + +Instantiates a new exception and throws it (using L). + +=cut + +sub throw { + my ($class, @args) = @_; + die "$class is a base class and cannot be thrown directly." if (blessed($class) || $class) eq __PACKAGE__; + my $self = blessed($class) ? $class : $class->new(@args); + die $self; +} + +=head2 as_string + +Returns a string representation of the exception. + +=cut +method as_string { + my $string = blessed($self); + my @substrings = (); + push @substrings, "Category=$category" if $category; + push @substrings, "Message=$message" if $message; + push @substrings, "Details=" . encode_json_text($details) if @$details; + + $string .= "(" . join(", ", @substrings) . ")" if @substrings; + return $string; +} + +=head2 as_json + +Returns a JSON string representation of the exception. + +=cut + +method as_json { + my $data = { + Exception => blessed($self), + Category => $self->category, + Message => $self->message, + Details => $self->details, + }; + return encode_json_text($data); +} + +=head2 log + +Logs the exception using Log::Any and increments a stats counter. + +=cut + +method log { + $log->errorf("Exception: %s", $self->as_string); + my $stats_name = blessed($self); + $stats_name =~ s/::/./g; +} + + +# Exception class names explicitly listed +my @all_exceptions = qw( + HydraServiceUnreachable + FeatureUnavailable + HydraRequestError + InvalidLoginChallenge + InvalidLogoutChallenge + InvalidLoginRequest + TokenExchangeFailed + InvalidIdToken + InvalidConsentChallenge + InternalServerError +); + +=head2 import + +The import method dynamically loads specific exceptions, or all by default. + +=cut + +sub import { + my ($class, @exceptions) = @_; + + # If no specific exceptions are given, load all exceptions + @exceptions = @exceptions ? @exceptions : @all_exceptions; + + for my $exception (@exceptions) { + # Construct the module name: WebService::Hydra::Exception::ExceptionName + my $module_name = "WebService::Hydra::Exception::$exception"; + + eval { + load $module_name; # Load the exception module + 1; + } or warn "Failed to load module $module_name: $@"; + } +} + +1; diff --git a/lib/WebService/Hydra/Exception/FeatureUnavailable.pm b/lib/WebService/Hydra/Exception/FeatureUnavailable.pm new file mode 100644 index 0000000..a8db777 --- /dev/null +++ b/lib/WebService/Hydra/Exception/FeatureUnavailable.pm @@ -0,0 +1,22 @@ +package WebService::Hydra::Exception::FeatureUnavailable; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::FeatureUnavailable :isa(WebService::Hydra::Exception) { + + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'The feature is currently unavailable'; + $args{category} //= 'client'; + + return %args; + } +} + + +1; diff --git a/lib/WebService/Hydra/Exception/HydraRequestError.pm b/lib/WebService/Hydra/Exception/HydraRequestError.pm new file mode 100644 index 0000000..a0cfca6 --- /dev/null +++ b/lib/WebService/Hydra/Exception/HydraRequestError.pm @@ -0,0 +1,22 @@ +package WebService::Hydra::Exception::HydraRequestError; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::HydraRequestError :isa(WebService::Hydra::Exception) { + + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Sorry, something went wrong while processing your request'; + $args{category} //= 'hydra'; + + return %args; + } +} + + +1; diff --git a/lib/WebService/Hydra/Exception/HydraServiceUnreachable.pm b/lib/WebService/Hydra/Exception/HydraServiceUnreachable.pm new file mode 100644 index 0000000..01b0f27 --- /dev/null +++ b/lib/WebService/Hydra/Exception/HydraServiceUnreachable.pm @@ -0,0 +1,22 @@ +package WebService::Hydra::Exception::HydraServiceUnreachable; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::HydraServiceUnreachable :isa(WebService::Hydra::Exception) { + + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Hydra service is unreachable'; + $args{category} //= 'hydra'; + + return %args; + } +} + + +1; diff --git a/lib/WebService/Hydra/Exception/InternalServerError.pm b/lib/WebService/Hydra/Exception/InternalServerError.pm new file mode 100644 index 0000000..d2a0b55 --- /dev/null +++ b/lib/WebService/Hydra/Exception/InternalServerError.pm @@ -0,0 +1,22 @@ +package WebService::Hydra::Exception::InternalServerError; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::InternalServerError :isa(WebService::Hydra::Exception) { + + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Internal server error'; + $args{category} //= 'server'; + + return %args; + } +} + + +1; diff --git a/lib/WebService/Hydra/Exception/InvalidConsentChallenge.pm b/lib/WebService/Hydra/Exception/InvalidConsentChallenge.pm new file mode 100644 index 0000000..02d433c --- /dev/null +++ b/lib/WebService/Hydra/Exception/InvalidConsentChallenge.pm @@ -0,0 +1,22 @@ +package WebService::Hydra::Exception::InvalidConsentChallenge; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::InvalidConsentChallenge :isa(WebService::Hydra::Exception) { + field $redirect_to :param :reader = undef; + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Invalid Consent Challenge'; + $args{category} //= 'client'; + + return %args; + } +} + + +1; diff --git a/lib/WebService/Hydra/Exception/InvalidIdToken.pm b/lib/WebService/Hydra/Exception/InvalidIdToken.pm new file mode 100644 index 0000000..c8ece4b --- /dev/null +++ b/lib/WebService/Hydra/Exception/InvalidIdToken.pm @@ -0,0 +1,22 @@ +package WebService::Hydra::Exception::InvalidIdToken; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::InvalidIdToken :isa(WebService::Hydra::Exception) { + + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Invalid token'; + $args{category} //= 'client'; + + return %args; + } +} + + +1; diff --git a/lib/WebService/Hydra/Exception/InvalidLoginChallenge.pm b/lib/WebService/Hydra/Exception/InvalidLoginChallenge.pm new file mode 100644 index 0000000..171c04c --- /dev/null +++ b/lib/WebService/Hydra/Exception/InvalidLoginChallenge.pm @@ -0,0 +1,22 @@ +package WebService::Hydra::Exception::InvalidLoginChallenge; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::InvalidLoginChallenge :isa(WebService::Hydra::Exception) { + field $redirect_to :param :reader = undef; + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Invalid Login Challenge'; + $args{category} //= 'client'; + + return %args; + } +} + + +1; diff --git a/lib/WebService/Hydra/Exception/InvalidLoginRequest.pm b/lib/WebService/Hydra/Exception/InvalidLoginRequest.pm new file mode 100644 index 0000000..709fd9f --- /dev/null +++ b/lib/WebService/Hydra/Exception/InvalidLoginRequest.pm @@ -0,0 +1,22 @@ +package WebService::Hydra::Exception::InvalidLoginRequest; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::InvalidLoginRequest :isa(WebService::Hydra::Exception) { + + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Invalid Login Request'; + $args{category} //= 'client'; + + return %args; + } +} + + +1; diff --git a/lib/WebService/Hydra/Exception/InvalidLogoutChallenge.pm b/lib/WebService/Hydra/Exception/InvalidLogoutChallenge.pm new file mode 100644 index 0000000..9d72ffe --- /dev/null +++ b/lib/WebService/Hydra/Exception/InvalidLogoutChallenge.pm @@ -0,0 +1,21 @@ +package WebService::Hydra::Exception::InvalidLogoutChallenge; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::InvalidLogoutChallenge :isa(WebService::Hydra::Exception) { + field $redirect_to :param :reader = undef; + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Invalid Logout Challenge'; + $args{category} //= 'client'; + + return %args; + } +} + +1; diff --git a/lib/WebService/Hydra/Exception/TokenExchangeFailed.pm b/lib/WebService/Hydra/Exception/TokenExchangeFailed.pm new file mode 100644 index 0000000..f9f6830 --- /dev/null +++ b/lib/WebService/Hydra/Exception/TokenExchangeFailed.pm @@ -0,0 +1,21 @@ +package WebService::Hydra::Exception::TokenExchangeFailed; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::TokenExchangeFailed :isa(WebService::Hydra::Exception) { + + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Token exchange failed'; + $args{category} //= 'client'; + + return %args; + } +} + +1; diff --git a/t/rc/perlcriticrc b/t/rc/perlcriticrc new file mode 100644 index 0000000..17852cd --- /dev/null +++ b/t/rc/perlcriticrc @@ -0,0 +1,25 @@ +severity = 4 +criticism-fatal = 1 +color = 1 +include = TestingAndDebugging::RequireUseWarnings Subroutines::RequireArgUnpacking TestingAndDebugging::ProhibitNoStrict ErrorHandling::RequireCheckingReturnValueOfEval TestingAndDebugging::RequireUseStrict Freenode::Each Freenode::IndirectObjectNotation Freenode::DollarAB Freenode::DeprecatedFeatures BuiltinFunctions::ProhibitReturnOr Dynamic::NoIndirect ProhibitSmartmatch Subroutines::ProhibitAmbiguousFunctionCalls Modules::ProhibitPOSIXimport +exclude = ValuesAndExpressions::ProhibitConstantPragma Subroutines::ProhibitExplicitReturnUndef Subroutines::RequireFinalReturn Community::PackageMatchesFilename Freenode::PackageMatchesFilename Community::DiscouragedModules Freenode::DiscouragedModules Modules::RequireEndWithOn CodeLayout::RequireTidyCode + +[TestingAndDebugging::RequireUseWarnings] +equivalent_modules=MooseX::Singleton Mojo::Base + +[Subroutines::RequireArgUnpacking] +short_subroutine_statements=3 + +[TestingAndDebugging::ProhibitNoStrict] +allow=refs + +[ErrorHandling::RequireCheckingReturnValueOfEval] +severity=4 + +[TestingAndDebugging::RequireUseStrict] +equivalent_modules=MooseX::Singleton Mojo::Base + +[-Perl::Critic::Policy::Subroutines::ProhibitBuiltinHomonyms] + +[Variables::ProhibitEvilVariables] +variables = $@ diff --git a/t/rc/perltidyrc b/t/rc/perltidyrc new file mode 100644 index 0000000..8750fcf --- /dev/null +++ b/t/rc/perltidyrc @@ -0,0 +1,62 @@ +#line length; keep it quite short so that lists of arguments to subs +#are wrapped +--maximum-line-length=150 + +#Cuddled else +-ce + +#Stack Closing Tokens +#http://perltidy.sourceforge.net/stylekey.html#stack_closing_tokens +#"The manual shows how all of these vertical tightness controls may be +#applied independently to each type of non-block opening and opening token." +--stack-closing-tokens + +## Similarly for opening. +--stack-opening-tokens + +#4 char wide tabs instead of spaces for indentation. +-i=4 + +#Horizontal Tightness +#http://perltidy.sourceforge.net/stylekey.html#define_horizontal_tightness +#parentheses if ((my $len_tab = length($tabstr)) > 0) +-pt=2 + +#square brackets $width = $col[$j + $k] - $col[$j]; +-sbt=2 + +#braces $width = $col[$j + $k] - $col[$j]; +-bt=2 + +#block braces map { $_ => -M $_ } grep { /\.deb$/ } +-bbt=0 + +#no space in front of semi-colons in a for loop +--nospace-for-semicolon + +#no outdenting of long quotes +#http://perltidy.sourceforge.net/stylekey.html#outdenting_long_quotes +--no-outdent-long-quotes + +--add-semicolons + +#always break a new line after a semi-colon +--want-break-after=";" + +#all hash key/values on a separate line +--comma-arrow-breakpoints=0 + +#No newlines before comments +-nbbc + +--no-outdent-long-lines + +#do not outdent labels +--no-outdent-labels + +--check-syntax + +--indent-spaced-block-comments + +#4 character if its breaks the line +--continuation-indentation=4 diff --git a/t/unit/exception.t b/t/unit/exception.t new file mode 100644 index 0000000..6051509 --- /dev/null +++ b/t/unit/exception.t @@ -0,0 +1,61 @@ +use strict; +use warnings; +use Test::More; +use Test::Exception; +use Test::MockModule; +use Object::Pad; +use Log::Any::Test; +use Log::Any qw($log); +use JSON::MaybeUTF8 qw(decode_json_text); + +BEGIN { + use_ok 'WebService::Hydra::Exception'; +} + +# Test WebService::Hydra::Exception itself +throws_ok {WebService::Hydra::Exception->new} qr/WebService::Hydra::Exception is a base class and cannot be instantiated directly/, "WebService::Hydra::Exception cannot be instantiated directly"; + +throws_ok {WebService::Hydra::Exception->throw(message => 'test')} qr/WebService::Hydra::Exception is a base class and cannot be thrown directly/, "WebService::Hydra::Exception cannot be thrown directly"; + +# define a subclass +class Test::Exception :isa(WebService::Hydra::Exception){ +}; + +subtest 'throw' => sub{ + my $e = Test::Exception->new(message => 'this is a test exception', category => 'test'); + throws_ok {$e->throw} $e, "throw from an obj throws exception"; + throws_ok {Test::Exception->throw} 'Test::Exception', "throw from a class throws exception"; +}; + +subtest 'log' => sub { + my $e = Test::Exception->new(message => 'this is a test exception', category => 'test'); + $log->clear; + $e->log; + $log->contains_ok(qr/this is a test exception/, "log contains exception message"); + $log->clear; +}; + +subtest 'as_string' => sub { + my $e = Test::Exception->new(message => 'this is a test exception', category => 'test'); + is $e->as_string, "Test::Exception(Category=test, Message=this is a test exception)", "as_string returns message"; + $e = Test::Exception->new(message => 'this is a test exception', details => ["details 1", "details 2", {context_key => 'context value'}]); + is $e->as_string, 'Test::Exception(Message=this is a test exception, Details=["details 1","details 2",{"context_key":"context value"}])', "details will be encoded as json"; +}; + +subtest 'as_json' => sub{ + my $e = Test::Exception->new(message => 'this is a test exception', category => 'test'); + is_deeply decode_json_text($e->as_json), { + Exception => 'Test::Exception', + Details => [], + Message => 'this is a test exception', + Category => 'test', + }, "as_string returns message"; + $e = Test::Exception->new(message => 'this is a test exception', details => ["details 1", "details 2", {context_key => 'context value'}]); + is_deeply decode_json_text($e->as_json), { + Exception => 'Test::Exception', + Details => ["details 1", "details 2", {"context_key" => "context value"}], + Message => 'this is a test exception', + Category => '', + }, , "details will be encoded as json"; +}; +done_testing(); diff --git a/t/unit/exceptions.t b/t/unit/exceptions.t new file mode 100644 index 0000000..135d40a --- /dev/null +++ b/t/unit/exceptions.t @@ -0,0 +1,35 @@ +use strict; +use warnings; +use Test::More; +use Module::Load; + +my @exceptions_to_test = qw( + HydraRequestError + HydraServiceUnreachable + InvalidLoginChallenge + InvalidLogoutChallenge + InvalidLoginRequest + TokenExchangeFailed + InvalidIdToken + InvalidConsentChallenge + InternalServerError +); + +# Test that all exception classes can be loaded successfully +for my $exception (@exceptions_to_test) { + my $full_class = "WebService::Hydra::Exception::$exception"; + + use_ok $full_class; + + is($@, '', "Loaded $full_class successfully"); + + # Test creating an instance with no additional parameters + my $instance = $full_class->new(); + ok($instance, "$full_class instance created"); + + # Check that required fields like message and category are accessible + ok($instance->message, "$full_class has a message"); + ok($instance->category, "$full_class has a category"); +} + +done_testing(); \ No newline at end of file diff --git a/t/unit/hydra_client.t b/t/unit/hydra_client.t new file mode 100644 index 0000000..106b81d --- /dev/null +++ b/t/unit/hydra_client.t @@ -0,0 +1,393 @@ +use strict; +use warnings; +use Test::More; +use Test::Deep; +use Test::Exception; +use Test::MockModule; +use JSON::MaybeUTF8 qw(:v1); +use HTTP::Tiny; +use WebService::Hydra::Client; + +subtest 'Hydra Client Creation' => sub { + my $admin_url = "http://dummyhydra.com/admin"; + my $public_url = "http://dummyhydra.com"; + my $client = WebService::Hydra::Client->new( + admin_endpoint => $admin_url, + public_endpoint => $public_url + ); + is $client->admin_endpoint, $admin_url, 'Client created successfully with admin endpoint'; + is $client->public_endpoint, $public_url, 'Client created successfully with public endpoint'; +}; + +subtest 'api_call method' => sub { + my $mock_http = Test::MockModule->new("HTTP::Tiny"); + my ($code, $mock_http_response, @params); + + $mock_http->redefine( + 'request', + sub { + (@params) = @_; + return { + status => $code, + content => ref $mock_http_response ? encode_json_utf8($mock_http_response) : $mock_http_response + }; + }); + my $client = WebService::Hydra::Client->new( + admin_endpoint => 'http://dummyhydra.com/admin', + public_endpoint => 'http://dummyhydra.com' + ); + + $code = 200; + $mock_http_response = {key => 'value'}; + my $expected = { + code => $code, + data => $mock_http_response + }; + my $got = $client->api_call('GET', 'http://dummyhydra.com/oauth2/auth'); + is $params[1], 'GET', 'Correct http method is used'; + is $params[2], 'http://dummyhydra.com/oauth2/auth', 'Request sent to correct endpoint'; + cmp_deeply($got, $expected, 'Data returned in expected structure'); + + $mock_http_response = undef; + my $payload = {key => 'value'}; + $got = $client->api_call('POST', 'http://dummyhydra.com/oauth2/auth', $payload); + my $extra_request_params = $params[3]; + is $extra_request_params->{headers}->{'Content-Type'}, 'application/json', 'Content type: JSON used for payload'; + is $extra_request_params->{content}, encode_json_utf8($payload), 'Payload is set correctly'; + is_deeply $got->{data}, {}, 'Returns an empty hash for Empty payload'; + + $mock_http_response = undef; + $payload = { + key => 'value', + key2 => 'value2' + }; + $got = $client->api_call('POST', 'http://dummyhdra.com/oauth2/auth', $payload, 'FORM'); + $extra_request_params = $params[3]; + is $extra_request_params->{headers}->{'Content-Type'}, 'application/x-www-form-urlencoded', 'Content type: form-urlencode used for payload'; + is $extra_request_params->{headers}->{'Accept'}, 'application/json', 'Sets JSON as the accepted response content-type'; + is $extra_request_params->{content}, HTTP::Tiny->new->www_form_urlencode($payload), 'Payload is set correctly'; + + $mock_http->redefine( + 'request', + sub { + die 'Network issue'; + }); + + dies_ok { $client->api_call('GET', 'http://dummyhydra.com/oauth2/auth') } 'Dies if the request fails'; + my $exception = $@; + ok $exception->isa('WebService::Hydra::Exception::HydraRequestError'), 'Error response on die'; + +}; + +subtest 'get_login_request' => sub { + my $mock_hydra = Test::MockModule->new('WebService::Hydra::Client'); + my $mock_api_response; + my @params; + $mock_hydra->redefine( + 'api_call', + sub { + (@params) = @_; + return $mock_api_response; + }); + + my $client = WebService::Hydra::Client->new( + admin_endpoint => 'http://dummyhydra.com/admin', + public_endpoint => 'http://dummyhydra.com' + ); + + $mock_api_response = { + code => 200, + data => { + challenge => 'VALID_CHALLENGE', + client => {}, + request_url => 'url', + skip => 'true', + subject => 'user_id' + }}; + my $got = $client->get_login_request("VALID_CHALLENGE"); + is $params[1], 'GET', 'GET request method'; + is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/requests/login?challenge=VALID_CHALLENGE', + 'Request URL built with correct parameters'; + is_deeply $got , $mock_api_response->{data}, 'api_call response correctly parsed'; + + $mock_api_response = { + code => 400, + data => { + error => "string", + error_description => "string", + status_code => 400 + }}; + dies_ok { $client->get_login_request("INVALID_CHALLENGE") } 'Dies if non 200 status code is received from api_call'; + my $exception = $@; + my $expected_exception = WebService::Hydra::Exception::InvalidLoginChallenge->new( + message => 'Failed to get login request', + category => 'client', + details => $mock_api_response + ); + + is_deeply $exception , $expected_exception, 'Return api_call response for Non 200 status code'; + + $mock_hydra->redefine( + 'api_call', + sub { + die "Request to http://dummyhydra.com/admin/oauth2/auth/requests/login?challenge=VALID_CHALLENGE failed - Network issue"; + }); + + dies_ok { $client->get_login_request("VALID_CHALLENGE") } 'Dies if http request fails for some reason'; +}; + +subtest 'get_consent_request' => sub { + my $mock_hydra = Test::MockModule->new('WebService::Hydra::Client'); + my $mock_api_response; + my @params; + $mock_hydra->redefine( + 'api_call', + sub { + (@params) = @_; + return $mock_api_response; + }); + + my $client = WebService::Hydra::Client->new( + admin_endpoint => 'http://dummyhydra.com/admin', + public_endpoint => 'http://dummyhydra.com' + ); + + # Test for 200 OK status code + $mock_api_response = { + code => 200, + data => { + challenge => 'VALID_CHALLENGE', + client => {}, + request_url => 'url', + skip => 'true', + subject => 'user_id' + }}; + my $got = $client->get_consent_request("VALID_CHALLENGE"); + is $params[1], 'GET', 'GET request method'; + is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/requests/consent?challenge=VALID_CHALLENGE', + 'Request URL built with correct parameters'; + is_deeply $got , $mock_api_response->{data}, 'api_call response correctly parsed'; + + # Test for 410 Gone status code + $mock_api_response = { + code => 410, + data => {redirect_to => 'http://dummyhydra.com/redirect'}}; + dies_ok { $client->get_consent_request("HANDLED_CHALLENGE") } 'Dies if 410 status code is received from api_call'; + my $exception = $@; + + my $expected_exception = WebService::Hydra::Exception::InvalidConsentChallenge->new( + message => 'Consent request has already been handled', + category => 'client_redirecting_error', + redirect_to => $mock_api_response->{data}->{redirect_to}); + is_deeply $exception , $expected_exception, 'Return api_call response for 410 status code'; + + # Test for other non-200 status codes + $mock_api_response = { + code => 400, + data => { + error => "string", + error_description => "string", + status_code => 400 + }}; + dies_ok { $client->get_consent_request("INVALID_CHALLENGE") } 'Dies if non-200 status code is received from api_call'; + $exception = $@; + $expected_exception = WebService::Hydra::Exception::InvalidConsentChallenge->new( + message => 'Failed to get consent request', + category => 'client', + details => $mock_api_response + ); + is_deeply $exception , $expected_exception, 'Return api_call response for Non 200 status code'; + + $mock_hydra->redefine( + 'api_call', + sub { + die "Request to http://dummyhydra.com/admin/oauth2/auth/requests/consent?challenge=VALID_CHALLENGE failed - Network issue"; + }); + + dies_ok { $client->get_consent_request("VALID_CHALLENGE") } 'Dies if http request fails for some reason'; +}; + +subtest 'accept_consent_request' => sub { + my $mock_hydra = Test::MockModule->new('WebService::Hydra::Client'); + my $mock_api_response; + my @params; + $mock_hydra->redefine( + 'api_call', + sub { + (@params) = @_; + return $mock_api_response; + }); + + my $client = WebService::Hydra::Client->new( + admin_endpoint => 'http://dummyhydra.com/admin', + public_endpoint => 'http://dummyhydra.com' + ); + + my $params = { + grant_scope => ['openid', 'offline'], + grant_access_token_audience => ['client_id'], + session => {id_token => {sub => 'user'}}}; + + # Test for 200 OK status code + $mock_api_response = { + code => 200, + data => {redirect_to => 'http://dummyhydra.com/callback'}}; + my $got = $client->accept_consent_request("VALID_CHALLENGE", $params); + is $params[1], 'PUT', 'PUT request method'; + is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/requests/consent/accept?challenge=VALID_CHALLENGE', + 'Request URL built with correct parameters'; + is_deeply $params[3], $params, 'Request parameters are correct'; + is_deeply $got , $mock_api_response->{data}, 'api_call response correctly parsed'; + + # Test for other non-200 status codes + $mock_api_response = { + code => 400, + data => { + error => "string", + error_description => "string", + status_code => 400 + }}; + dies_ok { $client->accept_consent_request("INVALID_CHALLENGE", $params) } 'Dies if non-200 status code is received from api_call'; + my $exception = $@; + my $expected_exception = WebService::Hydra::Exception::InvalidConsentChallenge->new( + message => 'Failed to accept consent request', + category => 'client', + details => $mock_api_response + ); + is_deeply $exception , $expected_exception, 'Return api_call response for Non 200 status code'; + + $mock_hydra->redefine( + 'api_call', + sub { + die "Request to http://dummyhydra.com/admin/oauth2/auth/requests/consent/accept?challenge=VALID_CHALLENGE failed - Network issue"; + }); + + dies_ok { $client->accept_consent_request("VALID_CHALLENGE", $params) } 'Dies if http request fails for some reason'; +}; + +subtest 'get_logout_request' => sub { + my $mock_hydra = Test::MockModule->new('WebService::Hydra::Client'); + my $mock_api_response; + my @params; + $mock_hydra->redefine( + 'api_call', + sub { + (@params) = @_; + return $mock_api_response; + }); + + my $client = WebService::Hydra::Client->new( + admin_endpoint => 'http://dummyhydra.com/admin', + public_endpoint => 'http://dummyhydra.com' + ); + + # Test for 200 OK status code + $mock_api_response = { + code => 200, + data => { + challenge => "5511ea26-6334-4f5c-9fe1-d812f5ca4068", + subject => "1", + sid => "2505a9e4-5e48-4911-9af4-31124c7b2217", + request_url => "/oauth2/sessions/logout", + rp_initiated => 0, + client => undef, + }}; + my $got = $client->get_logout_request("VALID_CHALLENGE"); + is $params[1], 'GET', 'GET request method'; + is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/requests/logout?challenge=VALID_CHALLENGE', + 'Request URL built with correct parameters'; + is_deeply $got , $mock_api_response->{data}, 'api_call response correctly parsed'; + + # Test for 410 Gone status code + $mock_api_response = { + code => 410, + data => {redirect_to => 'http://dummyhydra.com/redirect'}}; + dies_ok { $client->get_logout_request("HANDLED_CHALLENGE") } 'Dies if 410 status code is received from api_call'; + my $exception = $@; + my $expected_exception = WebService::Hydra::Exception::InvalidLogoutChallenge->new( + message => 'Logout challenge has already been handled', + category => 'client_redirecting_error', + redirect_to => $mock_api_response->{data}->{redirect_to}); + is_deeply $exception , $expected_exception, 'Return api_call response for 410 status code'; + + # Test for other non-200 status codes + $mock_api_response = { + code => 400, + data => { + error => "string", + error_description => "string", + status_code => 400 + }}; + dies_ok { $client->get_logout_request("INVALID_CHALLENGE") } 'Dies if non-200 status code is received from api_call'; + $exception = $@; + $expected_exception = WebService::Hydra::Exception::InvalidLogoutChallenge->new( + message => 'Failed to get logout request', + category => 'client', + details => $mock_api_response + ); + is_deeply $exception , $expected_exception, 'Return api_call response for Non 200 status code'; + + $mock_hydra->redefine( + 'api_call', + sub { + die "Request to http://dummyhydra.com/admin/oauth2/auth/requests/logout?challenge=VALID_CHALLENGE failed - Network issue"; + }); + + dies_ok { $client->get_logout_request("VALID_CHALLENGE") } 'Dies if http request fails for some reason'; +}; + +subtest 'accept_logout_request' => sub { + my $mock_hydra = Test::MockModule->new('WebService::Hydra::Client'); + my $mock_api_response; + my @params; + $mock_hydra->redefine( + 'api_call', + sub { + (@params) = @_; + return $mock_api_response; + }); + + my $client = WebService::Hydra::Client->new( + admin_endpoint => 'http://dummyhydra.com/admin', + public_endpoint => 'http://dummyhydra.com' + ); + + # Test for 200 OK status code + $mock_api_response = { + code => 200, + data => {redirect_to => 'http://dummyhydra.com/callback'}}; + my $got = $client->accept_logout_request("VALID_CHALLENGE"); + is $params[1], 'PUT', 'PUT request method'; + is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/requests/logout/accept?challenge=VALID_CHALLENGE', + 'Request URL built with correct parameters'; + is_deeply $got , $mock_api_response->{data}, 'api_call response correctly parsed'; + + # Test for other non-200 status codes + $mock_api_response = { + code => 400, + data => { + error => "string", + error_description => "string", + status_code => 400 + }}; + dies_ok { $client->accept_logout_request("INVALID_CHALLENGE") } 'Dies if non-200 status code is received from api_call'; + my $exception = $@; + my $expected_exception = WebService::Hydra::Exception::InvalidLogoutChallenge->new( + message => 'Failed to accept logout request', + category => 'client', + details => $mock_api_response + ); + is_deeply $exception , $expected_exception, 'Return api_call response for Non 200 status code'; + + $mock_hydra->redefine( + 'api_call', + sub { + die "Request to http://dummyhydra.com/admin/oauth2/auth/requests/consent/accept?challenge=VALID_CHALLENGE failed - Network issue"; + }); + + dies_ok { $client->accept_logout_request("VALID_CHALLENGE") } 'Dies if http request fails for some reason'; +}; + +done_testing(); + +1; From 832d78c81d35946177ed06aaeccf256deb4d230d Mon Sep 17 00:00:00 2001 From: hani-deriv Date: Mon, 23 Sep 2024 05:25:31 +0000 Subject: [PATCH 02/16] add sso sessions revoke api --- Changes | 1 + lib/WebService/Hydra/Client.pm | 28 +++++++++ lib/WebService/Hydra/Exception.pm | 1 + .../Exception/RevokeLoginSessionsFailed.pm | 20 ++++++ t/unit/hydra_client.t | 61 +++++++++++++++++++ 5 files changed, 111 insertions(+) create mode 100644 lib/WebService/Hydra/Exception/RevokeLoginSessionsFailed.pm diff --git a/Changes b/Changes index d0e1f56..a9dac77 100644 --- a/Changes +++ b/Changes @@ -11,4 +11,5 @@ - validate_id_token - get_consent_request - accept_consent_request + - revoke_login_sessions \ No newline at end of file diff --git a/lib/WebService/Hydra/Client.pm b/lib/WebService/Hydra/Client.pm index 1116317..3e18227 100644 --- a/lib/WebService/Hydra/Client.pm +++ b/lib/WebService/Hydra/Client.pm @@ -13,6 +13,7 @@ use WebService::Hydra::Exception; use Syntax::Keyword::Try; use constant OK_STATUS_CODE => 200; +use constant OK_EMPTY_STATUS_CODE => 201; use constant BAD_REQUEST_STATUS_CODE => 400; our $VERSION = '0.01'; @@ -373,4 +374,31 @@ method accept_consent_request ($consent_challenge, $params) { return $result->{data}; } + + +=head2 revoke_login_sessions + +This endpoint invalidates authentication sessions. +It expects a user ID (subject) and invalidates all sessions for this user. or session ID (sid) and invalidates the session. + +=cut + +method revoke_login_sessions (%args) { + my $method = "DELETE"; + my $path = "$admin_endpoint/admin/oauth2/auth/sessions/login"; + + my $query = join('&', map { "$_=$args{$_}" } keys %args); + $path .= "?$query" if $query; + + my $result = $self->api_call($method, $path); + if ($result->{code} != OK_EMPTY_STATUS_CODE) { + WebService::Hydra::Exception::RevokeLoginSessionsFailed->new( + message => "Failed to revoke login sessions", + category => "client", + details => $result + )->throw; + } + return $result->{data}; +} + 1; diff --git a/lib/WebService/Hydra/Exception.pm b/lib/WebService/Hydra/Exception.pm index 9370330..bd68c50 100644 --- a/lib/WebService/Hydra/Exception.pm +++ b/lib/WebService/Hydra/Exception.pm @@ -105,6 +105,7 @@ my @all_exceptions = qw( InvalidIdToken InvalidConsentChallenge InternalServerError + RevokeLoginSessionsFailed ); =head2 import diff --git a/lib/WebService/Hydra/Exception/RevokeLoginSessionsFailed.pm b/lib/WebService/Hydra/Exception/RevokeLoginSessionsFailed.pm new file mode 100644 index 0000000..9ca23d8 --- /dev/null +++ b/lib/WebService/Hydra/Exception/RevokeLoginSessionsFailed.pm @@ -0,0 +1,20 @@ +package WebService::Hydra::Exception::RevokeLoginSessionsFailed; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::RevokeLoginSessionsFailed :isa(WebService::Hydra::Exception) { + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Failed to revoke login sessions'; + $args{category} //= 'client'; + + return %args; + } +} + +1; diff --git a/t/unit/hydra_client.t b/t/unit/hydra_client.t index 106b81d..f8de67e 100644 --- a/t/unit/hydra_client.t +++ b/t/unit/hydra_client.t @@ -388,6 +388,67 @@ subtest 'accept_logout_request' => sub { dies_ok { $client->accept_logout_request("VALID_CHALLENGE") } 'Dies if http request fails for some reason'; }; +subtest 'revoke_login_sessions' => sub { + my $mock_hydra = Test::MockModule->new('WebService::Hydra::Client'); + my $mock_api_response; + my @params; + $mock_hydra->redefine( + 'api_call', + sub { + (@params) = @_; + return $mock_api_response; + }); + + my $client = WebService::Hydra::Client->new( + admin_endpoint => 'http://dummyhydra.com/admin', + public_endpoint => 'http://dummyhydra.com' + ); + + # Test for 200 OK status code + $mock_api_response = { + code => 201, + data => undef}; + my $got = $client->revoke_login_sessions(subject => '1234'); + + is $params[1], 'DELETE', 'DELETE request method'; + is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/sessions/login?subject=1234', + 'Request URL built with correct parameters'; + is_deeply $got , $mock_api_response->{data}, 'api_call response correctly parsed'; + + @params = (); + my $got = $client->revoke_login_sessions(sid => '1234'); + + is $params[1], 'DELETE', 'DELETE request method'; + is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/sessions/login?sid=1234', + 'Request URL built with correct parameters'; + is_deeply $got , $mock_api_response->{data}, 'api_call response correctly parsed'; + + # Test for other non-200 status codes + $mock_api_response = { + code => 401, + data => { + error => "string", + error_description => "string", + status_code => 401 + }}; + dies_ok { $client->revoke_login_sessions(subject => "invalid") } 'Dies if non-200 status code is received from api_call'; + my $exception = $@; + my $expected_exception = WebService::Hydra::Exception::RevokeLoginSessionsFailed->new( + message => 'Failed to revoke login sessions', + category => 'client', + details => $mock_api_response + ); + is_deeply $exception , $expected_exception, 'Return api_call response for Non 200 status code'; + + $mock_hydra->redefine( + 'api_call', + sub { + die "Request to http://dummyhydra.com/admin/oauth2/auth/requests/consent/accept?challenge=VALID_CHALLENGE failed - Network issue"; + }); + + dies_ok { $client->accept_logout_request("VALID_CHALLENGE") } 'Dies if http request fails for some reason'; +}; + done_testing(); 1; From e25af2f6a48b8de3bcd4c4bfd2bfa83d09fd29c8 Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Mon, 23 Sep 2024 06:04:38 +0000 Subject: [PATCH 03/16] cach jwks --- Changes | 1 - lib/WebService/Hydra/Client.pm | 12 +++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Changes b/Changes index a9dac77..f2b845a 100644 --- a/Changes +++ b/Changes @@ -12,4 +12,3 @@ - get_consent_request - accept_consent_request - revoke_login_sessions - \ No newline at end of file diff --git a/lib/WebService/Hydra/Client.pm b/lib/WebService/Hydra/Client.pm index 3e18227..a79b329 100644 --- a/lib/WebService/Hydra/Client.pm +++ b/lib/WebService/Hydra/Client.pm @@ -19,6 +19,7 @@ use constant BAD_REQUEST_STATUS_CODE => 400; our $VERSION = '0.01'; field $http; +field $jwks; field $admin_endpoint :param :reader; field $public_endpoint :param :reader; @@ -75,6 +76,16 @@ method http { return $http //= HTTP::Tiny->new(); } +=head2 jwks + +return jwks object + +=cut + +method jwks { + return $jwks //= $self->fetch_jwks(); +} + =head2 api_call Takes request method, the endpoint, and the payload. It sends the request to the Hydra service, parses the response and returns: @@ -309,7 +320,6 @@ Decodes the id_token and validates its signature against Hydra and returns the d =cut method validate_id_token ($id_token) { - my $jwks = $self->fetch_jwks(); try { my $payload = decode_jwt( token => $id_token, From 00b7072a83368dc89f8ebe5885cccc9d5e4a5d03 Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Mon, 23 Sep 2024 06:09:47 +0000 Subject: [PATCH 04/16] add oidc_config api and token validation --- lib/WebService/Hydra/Client.pm | 58 ++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/lib/WebService/Hydra/Client.pm b/lib/WebService/Hydra/Client.pm index a79b329..bc9bf86 100644 --- a/lib/WebService/Hydra/Client.pm +++ b/lib/WebService/Hydra/Client.pm @@ -20,6 +20,7 @@ our $VERSION = '0.01'; field $http; field $jwks; +field $oidc_config; field $admin_endpoint :param :reader; field $public_endpoint :param :reader; @@ -77,15 +78,23 @@ method http { } =head2 jwks - return jwks object - =cut method jwks { return $jwks //= $self->fetch_jwks(); } +=head2 oidc_config + +returns an object with oidc configuration + +=cut + +method oidc_config { + return $oidc_config //= $self->fetch_openid_configuration(); +} + =head2 api_call Takes request method, the endpoint, and the payload. It sends the request to the Hydra service, parses the response and returns: @@ -313,6 +322,26 @@ method fetch_jwks () { return $result->{data}; } +=head2 fetch_openid_configuration + +Fetches the openid-configuration from hydra + +=cut + +method fetch_openid_configuration () { + my $method = "GET"; + my $path = "$public_endpoint/.well-known/openid-configuration"; + + my $result = $self->api_call($method, $path); + if ($result->{code} != OK_STATUS_CODE) { + BOM::OAuth::Exceptions::Type::HydraRequestError->new( + category => "hydra", + details => $result + )->throw; + } + return $result->{data}; +} + =head2 validate_id_token Decodes the id_token and validates its signature against Hydra and returns the decoded payload. @@ -335,6 +364,31 @@ method validate_id_token ($id_token) { } } +=head2 validate_token + +Decodes the token and validates its signature against hydra and returns the decoded payload. + +=over 1 + +=item C<$token> jwt token to be validated + +=back + +Returns the decoded payload if the token is valid, otherwise throws an exception. + +=cut + +method validate_token ($token) { + my $payload = decode_jwt( + token => $token, + verify_iat => 1, + verify_exp => 1, + verify_iss => $oidc_config->{issuer}, + kid_keys => $jwks + ); + return $payload; +} + =head2 get_consent_request Fetches the consent request from Hydra. From c3843c22d46f4cc570d54c997dc8b1030a870d3a Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Mon, 23 Sep 2024 06:18:52 +0000 Subject: [PATCH 05/16] Add tests --- t/unit/hydra_client.t | 92 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/t/unit/hydra_client.t b/t/unit/hydra_client.t index f8de67e..03c781e 100644 --- a/t/unit/hydra_client.t +++ b/t/unit/hydra_client.t @@ -449,6 +449,98 @@ subtest 'revoke_login_sessions' => sub { dies_ok { $client->accept_logout_request("VALID_CHALLENGE") } 'Dies if http request fails for some reason'; }; + +subtest 'fetch_openid_configuration' => sub { + my $mock_hydra = Test::MockModule->new('WebService::Hydra::Client'); + my $mock_api_response; + my @params; + $mock_hydra->redefine( + 'api_call', + sub { + (@params) = @_; + return $mock_api_response; + }); + + my $client = WebService::Hydra::Client->new( + admin_endpoint => 'http://dummyhydra.com/admin', + public_endpoint => 'http://dummyhydra.com' + ); + + # Test for 200 OK status code + $mock_api_response = { + code => 200, + data => { + issuer => 'http://dummyhydra.com', + authorization_endpoint => 'http://dummyhydra.com/oauth2/auth', + token_endpoint => 'http://dummyhydra.com/oauth2/token', + jwks_uri => 'http://dummyhydra.com/.well-known/jwks.json', + }}; + my $got = $client->fetch_openid_configuration(); + is $params[1], 'GET', 'GET request method'; + is $params[2], 'http://dummyhydra.com/.well-known/openid-configuration', 'Request URL built with correct parameters'; + is_deeply $got , $mock_api_response->{data}, 'api_call response correctly parsed'; + + # Test for other non-200 status codes + $mock_api_response = { + code => 400, + data => { + error => "string", + error_description => "string", + status_code => 400 + }}; + + dies_ok { $client->fetch_openid_configuration() } 'Dies if non-200 status code is received from api_call'; +}; + +subtest 'oidc_config' => sub { + my $mock_hydra = Test::MockModule->new('WebService::Hydra::Client'); + my $mock_api_response; + my @params; + $mock_hydra->redefine( + 'fetch_openid_configuration', + sub { + (@params) = @_; + return $mock_api_response; + }); + + my $client = WebService::Hydra::Client->new( + admin_endpoint => 'http://dummyhydra.com/admin', + public_endpoint => 'http://dummyhydra.com' + ); + + # Test for 200 OK status code + $mock_api_response = { + issuer => 'http://dummyhydra.com', + authorization_endpoint => 'http://dummyhydra.com/oauth2/auth', + token_endpoint => 'http://dummyhydra.com/oauth2/token', + jwks_uri => 'http://dummyhydra.com/.well-known/jwks.json', + }; + + my $got = $client->oidc_config(); + is_deeply $got, $mock_api_response, 'oidc_config returned correctly'; + + subtest 'test cahcing of oidc_config' => sub { + my $call_count = 0; + $mock_hydra->redefine( + 'fetch_openid_configuration', + sub { + $call_count++; + return $mock_api_response; + }); + + my $client = WebService::Hydra::Client->new( + admin_endpoint => 'http://dummyhydra.com/admin', + public_endpoint => 'http://dummyhydra.com' + ); + $got = $client->oidc_config(); + is $call_count, 1, 'fetch_openid_configuration called only once'; + + $got = $client->oidc_config(); + is $call_count, 1, 'fetch_openid_configuration not called again'; + }; + +}; + done_testing(); 1; From f5290b8e7262afd6007e7df90fff4e4ccd519094 Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Mon, 23 Sep 2024 07:05:23 +0000 Subject: [PATCH 06/16] update changes file --- Changes | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Changes b/Changes index f2b845a..afd40e9 100644 --- a/Changes +++ b/Changes @@ -1,6 +1,4 @@ {{$NEXT}} - -0.001 2024-09-12 11:15:49+00:00 UTC - Initial release - get_login_request - accept_login_request @@ -8,6 +6,8 @@ - accept_logout_request - exchange_token - fetch_jwks + - fetch_openid_configuration + - validate_token - validate_id_token - get_consent_request - accept_consent_request From 4bf1b31867b217c0e4adee90d768009aa548b6f9 Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Mon, 23 Sep 2024 07:35:20 +0000 Subject: [PATCH 07/16] Bug fix --- lib/WebService/Hydra/Client.pm | 4 ++-- t/unit/hydra_client.t | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/WebService/Hydra/Client.pm b/lib/WebService/Hydra/Client.pm index bc9bf86..8133062 100644 --- a/lib/WebService/Hydra/Client.pm +++ b/lib/WebService/Hydra/Client.pm @@ -13,7 +13,7 @@ use WebService::Hydra::Exception; use Syntax::Keyword::Try; use constant OK_STATUS_CODE => 200; -use constant OK_EMPTY_STATUS_CODE => 201; +use constant OK_NO_CONTENT_CODE => 204; use constant BAD_REQUEST_STATUS_CODE => 400; our $VERSION = '0.01'; @@ -455,7 +455,7 @@ method revoke_login_sessions (%args) { $path .= "?$query" if $query; my $result = $self->api_call($method, $path); - if ($result->{code} != OK_EMPTY_STATUS_CODE) { + if ($result->{code} != OK_NO_CONTENT_CODE) { WebService::Hydra::Exception::RevokeLoginSessionsFailed->new( message => "Failed to revoke login sessions", category => "client", diff --git a/t/unit/hydra_client.t b/t/unit/hydra_client.t index 03c781e..6089b94 100644 --- a/t/unit/hydra_client.t +++ b/t/unit/hydra_client.t @@ -406,7 +406,7 @@ subtest 'revoke_login_sessions' => sub { # Test for 200 OK status code $mock_api_response = { - code => 201, + code => 204, data => undef}; my $got = $client->revoke_login_sessions(subject => '1234'); From 81a298fe9a85ae940565df8d50b9ca40e899d309 Mon Sep 17 00:00:00 2001 From: nihal-deriv Date: Mon, 23 Sep 2024 07:38:56 +0000 Subject: [PATCH 08/16] added ci pipeline --- .github/workflows/build-workflow.yml | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 .github/workflows/build-workflow.yml diff --git a/.github/workflows/build-workflow.yml b/.github/workflows/build-workflow.yml new file mode 100644 index 0000000..1ed0501 --- /dev/null +++ b/.github/workflows/build-workflow.yml @@ -0,0 +1,40 @@ +name: build and test +run-name: build and test +on: + push: + branches: + - main + pull_request: +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + perl-version: + - '5.38' + - '5.36' + - '5.34' + - '5.32' + - '5.30' + + container: + image: perldocker/perl-tester:${{ matrix.perl-version }} + + steps: + - uses: actions/checkout@main + - run: perl -V + - run: | + cpm install -g --no-test Dist::Zilla Dist::Zilla::App::Command::cover ExtUtils::MakeMaker + name: Install Dzil + - name: Install dzil author dependencies + run: | + cpm install --no-test -g \ + -w 2 \ + --mirror=http://cpan.cpantesters.org/ $(dzil authordeps --missing) + - name: Install dist deps + run: | + cpanm -n --installdeps . + dzil listdeps --author --missing --cpanm-versions | xargs cpanm -n + + - run: dzil smoke --release --author && dzil cover -test -report codecov && dzil xtest From e69f14cfb376ce8ff37f5ff6f0c9ee29f076d899 Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Tue, 24 Sep 2024 07:16:16 +0000 Subject: [PATCH 09/16] Add field $redirect_to --- lib/WebService/Hydra/Exception/InvalidLoginRequest.pm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/WebService/Hydra/Exception/InvalidLoginRequest.pm b/lib/WebService/Hydra/Exception/InvalidLoginRequest.pm index 709fd9f..d258983 100644 --- a/lib/WebService/Hydra/Exception/InvalidLoginRequest.pm +++ b/lib/WebService/Hydra/Exception/InvalidLoginRequest.pm @@ -6,7 +6,7 @@ use Object::Pad; ## VERSION class WebService::Hydra::Exception::InvalidLoginRequest :isa(WebService::Hydra::Exception) { - + field $redirect_to :param :reader = undef; sub BUILDARGS { my ($class, %args) = @_; From c0cecbd5a5e9e8d7c5cc2c2ec5e8f91db84a9783 Mon Sep 17 00:00:00 2001 From: nihal-deriv Date: Tue, 24 Sep 2024 09:54:25 +0000 Subject: [PATCH 10/16] removed sample workflow --- .github/workflows/build-wokflow.yml | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 .github/workflows/build-wokflow.yml diff --git a/.github/workflows/build-wokflow.yml b/.github/workflows/build-wokflow.yml deleted file mode 100644 index aa2b252..0000000 --- a/.github/workflows/build-wokflow.yml +++ /dev/null @@ -1,8 +0,0 @@ -name: hello-world -on: push -jobs: - my-job: - runs-on: ubuntu-latest - steps: - - name: my-step - run: echo "Hello World!" \ No newline at end of file From 8bff14e304a729dc91f78ca55f2ee67f561096b8 Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Wed, 25 Sep 2024 06:04:23 +0000 Subject: [PATCH 11/16] Add main module to record version --- lib/WebService/Hydra/Hydra.pm | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 lib/WebService/Hydra/Hydra.pm diff --git a/lib/WebService/Hydra/Hydra.pm b/lib/WebService/Hydra/Hydra.pm new file mode 100644 index 0000000..e62989d --- /dev/null +++ b/lib/WebService/Hydra/Hydra.pm @@ -0,0 +1,5 @@ +package WebService::Hydra; + +our $VERSION = "0.001"; + +1; From 1ef78dc52606ba9b3607f603db525582294c87ff Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Wed, 25 Sep 2024 09:51:27 +0000 Subject: [PATCH 12/16] add ABSTRACT --- lib/WebService/{Hydra => }/Hydra.pm | 1 + 1 file changed, 1 insertion(+) rename lib/WebService/{Hydra => }/Hydra.pm (77%) diff --git a/lib/WebService/Hydra/Hydra.pm b/lib/WebService/Hydra.pm similarity index 77% rename from lib/WebService/Hydra/Hydra.pm rename to lib/WebService/Hydra.pm index e62989d..b3190db 100644 --- a/lib/WebService/Hydra/Hydra.pm +++ b/lib/WebService/Hydra.pm @@ -1,4 +1,5 @@ package WebService::Hydra; +# ABSTRACT: ... our $VERSION = "0.001"; From f9b2e9da24909ae447ad88df96fa9e7604f41c30 Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Thu, 26 Sep 2024 03:22:33 +0000 Subject: [PATCH 13/16] Add new exception types --- lib/WebService/Hydra/Exception.pm | 2 ++ .../Hydra/Exception/InvalidClaims.pm | 22 +++++++++++++++++++ .../Hydra/Exception/InvalidToken.pm | 22 +++++++++++++++++++ t/unit/exceptions.t | 2 ++ 4 files changed, 48 insertions(+) create mode 100644 lib/WebService/Hydra/Exception/InvalidClaims.pm create mode 100644 lib/WebService/Hydra/Exception/InvalidToken.pm diff --git a/lib/WebService/Hydra/Exception.pm b/lib/WebService/Hydra/Exception.pm index bd68c50..0ef9469 100644 --- a/lib/WebService/Hydra/Exception.pm +++ b/lib/WebService/Hydra/Exception.pm @@ -106,6 +106,8 @@ my @all_exceptions = qw( InvalidConsentChallenge InternalServerError RevokeLoginSessionsFailed + InvalidToken + InvalidClaims ); =head2 import diff --git a/lib/WebService/Hydra/Exception/InvalidClaims.pm b/lib/WebService/Hydra/Exception/InvalidClaims.pm new file mode 100644 index 0000000..69af8cd --- /dev/null +++ b/lib/WebService/Hydra/Exception/InvalidClaims.pm @@ -0,0 +1,22 @@ +package WebService::Hydra::Exception::InvalidClaims; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::InvalidClaims :isa(WebService::Hydra::Exception) { + + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Invalid claims'; + $args{category} //= 'client'; + + return %args; + } +} + + +1; diff --git a/lib/WebService/Hydra/Exception/InvalidToken.pm b/lib/WebService/Hydra/Exception/InvalidToken.pm new file mode 100644 index 0000000..64eff13 --- /dev/null +++ b/lib/WebService/Hydra/Exception/InvalidToken.pm @@ -0,0 +1,22 @@ +package WebService::Hydra::Exception::InvalidToken; +use strict; +use warnings; +use Object::Pad; + +## VERSION + +class WebService::Hydra::Exception::InvalidToken :isa(WebService::Hydra::Exception) { + + + sub BUILDARGS { + my ($class, %args) = @_; + + $args{message} //= 'Invalid token'; + $args{category} //= 'client'; + + return %args; + } +} + + +1; diff --git a/t/unit/exceptions.t b/t/unit/exceptions.t index 135d40a..e7454d3 100644 --- a/t/unit/exceptions.t +++ b/t/unit/exceptions.t @@ -13,6 +13,8 @@ my @exceptions_to_test = qw( InvalidIdToken InvalidConsentChallenge InternalServerError + InvalidClaims + InvalidToken ); # Test that all exception classes can be loaded successfully From ded6288b9c883daea0d1891d9430f35f62488afd Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Thu, 26 Sep 2024 05:18:44 +0000 Subject: [PATCH 14/16] code tidy --- lib/WebService/Hydra/Client.pm | 10 +-- lib/WebService/Hydra/Exception.pm | 19 +++-- .../Hydra/Exception/FeatureUnavailable.pm | 4 +- .../Hydra/Exception/HydraRequestError.pm | 4 +- .../Exception/HydraServiceUnreachable.pm | 4 +- .../Hydra/Exception/InternalServerError.pm | 6 +- .../Hydra/Exception/InvalidClaims.pm | 4 +- .../Exception/InvalidConsentChallenge.pm | 3 +- .../Hydra/Exception/InvalidIdToken.pm | 2 - .../Hydra/Exception/InvalidLoginChallenge.pm | 5 +- .../Hydra/Exception/InvalidLoginRequest.pm | 5 +- .../Hydra/Exception/InvalidLogoutChallenge.pm | 4 +- .../Hydra/Exception/InvalidToken.pm | 2 - .../Exception/RevokeLoginSessionsFailed.pm | 6 +- .../Hydra/Exception/TokenExchangeFailed.pm | 5 +- t/unit/exception.t | 73 ++++++++++++------- t/unit/exceptions.t | 12 +-- t/unit/hydra_client.t | 16 ++-- 18 files changed, 92 insertions(+), 92 deletions(-) diff --git a/lib/WebService/Hydra/Client.pm b/lib/WebService/Hydra/Client.pm index 8133062..ad22c9b 100644 --- a/lib/WebService/Hydra/Client.pm +++ b/lib/WebService/Hydra/Client.pm @@ -13,7 +13,7 @@ use WebService::Hydra::Exception; use Syntax::Keyword::Try; use constant OK_STATUS_CODE => 200; -use constant OK_NO_CONTENT_CODE => 204; +use constant OK_NO_CONTENT_CODE => 204; use constant BAD_REQUEST_STATUS_CODE => 400; our $VERSION = '0.01'; @@ -438,8 +438,6 @@ method accept_consent_request ($consent_challenge, $params) { return $result->{data}; } - - =head2 revoke_login_sessions This endpoint invalidates authentication sessions. @@ -450,8 +448,8 @@ It expects a user ID (subject) and invalidates all sessions for this user. or se method revoke_login_sessions (%args) { my $method = "DELETE"; my $path = "$admin_endpoint/admin/oauth2/auth/sessions/login"; - - my $query = join('&', map { "$_=$args{$_}" } keys %args); + + my $query = join('&', map { "$_=$args{$_}" } keys %args); $path .= "?$query" if $query; my $result = $self->api_call($method, $path); @@ -463,6 +461,6 @@ method revoke_login_sessions (%args) { )->throw; } return $result->{data}; -} +} 1; diff --git a/lib/WebService/Hydra/Exception.pm b/lib/WebService/Hydra/Exception.pm index 0ef9469..17a0eb8 100644 --- a/lib/WebService/Hydra/Exception.pm +++ b/lib/WebService/Hydra/Exception.pm @@ -7,7 +7,7 @@ use warnings; use File::Spec; use Module::Load; use JSON::MaybeUTF8 qw(encode_json_text); -use Log::Any qw($log); +use Log::Any qw($log); ## VERSION @@ -32,7 +32,6 @@ BUILD { die ref($self) . " is a base class and cannot be instantiated directly." if ref($self) eq __PACKAGE__; } - =head1 Methods =head2 throw @@ -53,11 +52,12 @@ sub throw { Returns a string representation of the exception. =cut + method as_string { - my $string = blessed($self); + my $string = blessed($self); my @substrings = (); - push @substrings, "Category=$category" if $category; - push @substrings, "Message=$message" if $message; + push @substrings, "Category=$category" if $category; + push @substrings, "Message=$message" if $message; push @substrings, "Details=" . encode_json_text($details) if @$details; $string .= "(" . join(", ", @substrings) . ")" if @substrings; @@ -73,9 +73,9 @@ Returns a JSON string representation of the exception. method as_json { my $data = { Exception => blessed($self), - Category => $self->category, - Message => $self->message, - Details => $self->details, + Category => $self->category, + Message => $self->message, + Details => $self->details, }; return encode_json_text($data); } @@ -92,7 +92,6 @@ method log { $stats_name =~ s/::/./g; } - # Exception class names explicitly listed my @all_exceptions = qw( HydraServiceUnreachable @@ -127,7 +126,7 @@ sub import { my $module_name = "WebService::Hydra::Exception::$exception"; eval { - load $module_name; # Load the exception module + load $module_name; # Load the exception module 1; } or warn "Failed to load module $module_name: $@"; } diff --git a/lib/WebService/Hydra/Exception/FeatureUnavailable.pm b/lib/WebService/Hydra/Exception/FeatureUnavailable.pm index a8db777..62ffddb 100644 --- a/lib/WebService/Hydra/Exception/FeatureUnavailable.pm +++ b/lib/WebService/Hydra/Exception/FeatureUnavailable.pm @@ -6,17 +6,15 @@ use Object::Pad; ## VERSION class WebService::Hydra::Exception::FeatureUnavailable :isa(WebService::Hydra::Exception) { - sub BUILDARGS { my ($class, %args) = @_; $args{message} //= 'The feature is currently unavailable'; $args{category} //= 'client'; - + return %args; } } - 1; diff --git a/lib/WebService/Hydra/Exception/HydraRequestError.pm b/lib/WebService/Hydra/Exception/HydraRequestError.pm index a0cfca6..fe7cd32 100644 --- a/lib/WebService/Hydra/Exception/HydraRequestError.pm +++ b/lib/WebService/Hydra/Exception/HydraRequestError.pm @@ -6,17 +6,15 @@ use Object::Pad; ## VERSION class WebService::Hydra::Exception::HydraRequestError :isa(WebService::Hydra::Exception) { - sub BUILDARGS { my ($class, %args) = @_; $args{message} //= 'Sorry, something went wrong while processing your request'; $args{category} //= 'hydra'; - + return %args; } } - 1; diff --git a/lib/WebService/Hydra/Exception/HydraServiceUnreachable.pm b/lib/WebService/Hydra/Exception/HydraServiceUnreachable.pm index 01b0f27..3f1c5a0 100644 --- a/lib/WebService/Hydra/Exception/HydraServiceUnreachable.pm +++ b/lib/WebService/Hydra/Exception/HydraServiceUnreachable.pm @@ -6,17 +6,15 @@ use Object::Pad; ## VERSION class WebService::Hydra::Exception::HydraServiceUnreachable :isa(WebService::Hydra::Exception) { - sub BUILDARGS { my ($class, %args) = @_; $args{message} //= 'Hydra service is unreachable'; $args{category} //= 'hydra'; - + return %args; } } - 1; diff --git a/lib/WebService/Hydra/Exception/InternalServerError.pm b/lib/WebService/Hydra/Exception/InternalServerError.pm index d2a0b55..c612535 100644 --- a/lib/WebService/Hydra/Exception/InternalServerError.pm +++ b/lib/WebService/Hydra/Exception/InternalServerError.pm @@ -6,17 +6,15 @@ use Object::Pad; ## VERSION class WebService::Hydra::Exception::InternalServerError :isa(WebService::Hydra::Exception) { - sub BUILDARGS { my ($class, %args) = @_; - + $args{message} //= 'Internal server error'; $args{category} //= 'server'; - + return %args; } } - 1; diff --git a/lib/WebService/Hydra/Exception/InvalidClaims.pm b/lib/WebService/Hydra/Exception/InvalidClaims.pm index 69af8cd..e24f007 100644 --- a/lib/WebService/Hydra/Exception/InvalidClaims.pm +++ b/lib/WebService/Hydra/Exception/InvalidClaims.pm @@ -6,17 +6,15 @@ use Object::Pad; ## VERSION class WebService::Hydra::Exception::InvalidClaims :isa(WebService::Hydra::Exception) { - sub BUILDARGS { my ($class, %args) = @_; $args{message} //= 'Invalid claims'; $args{category} //= 'client'; - + return %args; } } - 1; diff --git a/lib/WebService/Hydra/Exception/InvalidConsentChallenge.pm b/lib/WebService/Hydra/Exception/InvalidConsentChallenge.pm index 02d433c..04d4ed6 100644 --- a/lib/WebService/Hydra/Exception/InvalidConsentChallenge.pm +++ b/lib/WebService/Hydra/Exception/InvalidConsentChallenge.pm @@ -13,10 +13,9 @@ class WebService::Hydra::Exception::InvalidConsentChallenge :isa(WebService::Hyd $args{message} //= 'Invalid Consent Challenge'; $args{category} //= 'client'; - + return %args; } } - 1; diff --git a/lib/WebService/Hydra/Exception/InvalidIdToken.pm b/lib/WebService/Hydra/Exception/InvalidIdToken.pm index c8ece4b..be207be 100644 --- a/lib/WebService/Hydra/Exception/InvalidIdToken.pm +++ b/lib/WebService/Hydra/Exception/InvalidIdToken.pm @@ -6,7 +6,6 @@ use Object::Pad; ## VERSION class WebService::Hydra::Exception::InvalidIdToken :isa(WebService::Hydra::Exception) { - sub BUILDARGS { my ($class, %args) = @_; @@ -18,5 +17,4 @@ class WebService::Hydra::Exception::InvalidIdToken :isa(WebService::Hydra::Excep } } - 1; diff --git a/lib/WebService/Hydra/Exception/InvalidLoginChallenge.pm b/lib/WebService/Hydra/Exception/InvalidLoginChallenge.pm index 171c04c..0647e5e 100644 --- a/lib/WebService/Hydra/Exception/InvalidLoginChallenge.pm +++ b/lib/WebService/Hydra/Exception/InvalidLoginChallenge.pm @@ -10,13 +10,12 @@ class WebService::Hydra::Exception::InvalidLoginChallenge :isa(WebService::Hydra sub BUILDARGS { my ($class, %args) = @_; - + $args{message} //= 'Invalid Login Challenge'; $args{category} //= 'client'; - + return %args; } } - 1; diff --git a/lib/WebService/Hydra/Exception/InvalidLoginRequest.pm b/lib/WebService/Hydra/Exception/InvalidLoginRequest.pm index d258983..d2e3713 100644 --- a/lib/WebService/Hydra/Exception/InvalidLoginRequest.pm +++ b/lib/WebService/Hydra/Exception/InvalidLoginRequest.pm @@ -10,13 +10,12 @@ class WebService::Hydra::Exception::InvalidLoginRequest :isa(WebService::Hydra:: sub BUILDARGS { my ($class, %args) = @_; - + $args{message} //= 'Invalid Login Request'; $args{category} //= 'client'; - + return %args; } } - 1; diff --git a/lib/WebService/Hydra/Exception/InvalidLogoutChallenge.pm b/lib/WebService/Hydra/Exception/InvalidLogoutChallenge.pm index 9d72ffe..ad72ff8 100644 --- a/lib/WebService/Hydra/Exception/InvalidLogoutChallenge.pm +++ b/lib/WebService/Hydra/Exception/InvalidLogoutChallenge.pm @@ -10,10 +10,10 @@ class WebService::Hydra::Exception::InvalidLogoutChallenge :isa(WebService::Hydr sub BUILDARGS { my ($class, %args) = @_; - + $args{message} //= 'Invalid Logout Challenge'; $args{category} //= 'client'; - + return %args; } } diff --git a/lib/WebService/Hydra/Exception/InvalidToken.pm b/lib/WebService/Hydra/Exception/InvalidToken.pm index 64eff13..92545ff 100644 --- a/lib/WebService/Hydra/Exception/InvalidToken.pm +++ b/lib/WebService/Hydra/Exception/InvalidToken.pm @@ -6,7 +6,6 @@ use Object::Pad; ## VERSION class WebService::Hydra::Exception::InvalidToken :isa(WebService::Hydra::Exception) { - sub BUILDARGS { my ($class, %args) = @_; @@ -18,5 +17,4 @@ class WebService::Hydra::Exception::InvalidToken :isa(WebService::Hydra::Excepti } } - 1; diff --git a/lib/WebService/Hydra/Exception/RevokeLoginSessionsFailed.pm b/lib/WebService/Hydra/Exception/RevokeLoginSessionsFailed.pm index 9ca23d8..f9debf9 100644 --- a/lib/WebService/Hydra/Exception/RevokeLoginSessionsFailed.pm +++ b/lib/WebService/Hydra/Exception/RevokeLoginSessionsFailed.pm @@ -6,13 +6,13 @@ use Object::Pad; ## VERSION class WebService::Hydra::Exception::RevokeLoginSessionsFailed :isa(WebService::Hydra::Exception) { - + sub BUILDARGS { my ($class, %args) = @_; - + $args{message} //= 'Failed to revoke login sessions'; $args{category} //= 'client'; - + return %args; } } diff --git a/lib/WebService/Hydra/Exception/TokenExchangeFailed.pm b/lib/WebService/Hydra/Exception/TokenExchangeFailed.pm index f9f6830..c570bb7 100644 --- a/lib/WebService/Hydra/Exception/TokenExchangeFailed.pm +++ b/lib/WebService/Hydra/Exception/TokenExchangeFailed.pm @@ -6,14 +6,13 @@ use Object::Pad; ## VERSION class WebService::Hydra::Exception::TokenExchangeFailed :isa(WebService::Hydra::Exception) { - sub BUILDARGS { my ($class, %args) = @_; - + $args{message} //= 'Token exchange failed'; $args{category} //= 'client'; - + return %args; } } diff --git a/t/unit/exception.t b/t/unit/exception.t index 6051509..24a9350 100644 --- a/t/unit/exception.t +++ b/t/unit/exception.t @@ -5,7 +5,7 @@ use Test::Exception; use Test::MockModule; use Object::Pad; use Log::Any::Test; -use Log::Any qw($log); +use Log::Any qw($log); use JSON::MaybeUTF8 qw(decode_json_text); BEGIN { @@ -13,22 +13,30 @@ BEGIN { } # Test WebService::Hydra::Exception itself -throws_ok {WebService::Hydra::Exception->new} qr/WebService::Hydra::Exception is a base class and cannot be instantiated directly/, "WebService::Hydra::Exception cannot be instantiated directly"; +throws_ok { WebService::Hydra::Exception->new } qr/WebService::Hydra::Exception is a base class and cannot be instantiated directly/, + "WebService::Hydra::Exception cannot be instantiated directly"; -throws_ok {WebService::Hydra::Exception->throw(message => 'test')} qr/WebService::Hydra::Exception is a base class and cannot be thrown directly/, "WebService::Hydra::Exception cannot be thrown directly"; +throws_ok { WebService::Hydra::Exception->throw(message => 'test') } qr/WebService::Hydra::Exception is a base class and cannot be thrown directly/, + "WebService::Hydra::Exception cannot be thrown directly"; # define a subclass -class Test::Exception :isa(WebService::Hydra::Exception){ +class Test::Exception :isa(WebService::Hydra::Exception) { }; -subtest 'throw' => sub{ - my $e = Test::Exception->new(message => 'this is a test exception', category => 'test'); - throws_ok {$e->throw} $e, "throw from an obj throws exception"; - throws_ok {Test::Exception->throw} 'Test::Exception', "throw from a class throws exception"; +subtest 'throw' => sub { + my $e = Test::Exception->new( + message => 'this is a test exception', + category => 'test' + ); + throws_ok { $e->throw } $e, "throw from an obj throws exception"; + throws_ok { Test::Exception->throw } 'Test::Exception', "throw from a class throws exception"; }; subtest 'log' => sub { - my $e = Test::Exception->new(message => 'this is a test exception', category => 'test'); + my $e = Test::Exception->new( + message => 'this is a test exception', + category => 'test' + ); $log->clear; $e->log; $log->contains_ok(qr/this is a test exception/, "log contains exception message"); @@ -36,26 +44,41 @@ subtest 'log' => sub { }; subtest 'as_string' => sub { - my $e = Test::Exception->new(message => 'this is a test exception', category => 'test'); + my $e = Test::Exception->new( + message => 'this is a test exception', + category => 'test' + ); is $e->as_string, "Test::Exception(Category=test, Message=this is a test exception)", "as_string returns message"; - $e = Test::Exception->new(message => 'this is a test exception', details => ["details 1", "details 2", {context_key => 'context value'}]); - is $e->as_string, 'Test::Exception(Message=this is a test exception, Details=["details 1","details 2",{"context_key":"context value"}])', "details will be encoded as json"; + $e = Test::Exception->new( + message => 'this is a test exception', + details => ["details 1", "details 2", {context_key => 'context value'}]); + is $e->as_string, 'Test::Exception(Message=this is a test exception, Details=["details 1","details 2",{"context_key":"context value"}])', + "details will be encoded as json"; }; -subtest 'as_json' => sub{ - my $e = Test::Exception->new(message => 'this is a test exception', category => 'test'); - is_deeply decode_json_text($e->as_json), { +subtest 'as_json' => sub { + my $e = Test::Exception->new( + message => 'this is a test exception', + category => 'test' + ); + is_deeply decode_json_text($e->as_json), + { Exception => 'Test::Exception', - Details => [], - Message => 'this is a test exception', - Category => 'test', - }, "as_string returns message"; - $e = Test::Exception->new(message => 'this is a test exception', details => ["details 1", "details 2", {context_key => 'context value'}]); - is_deeply decode_json_text($e->as_json), { + Details => [], + Message => 'this is a test exception', + Category => 'test', + }, + "as_string returns message"; + $e = Test::Exception->new( + message => 'this is a test exception', + details => ["details 1", "details 2", {context_key => 'context value'}]); + is_deeply decode_json_text($e->as_json), + { Exception => 'Test::Exception', - Details => ["details 1", "details 2", {"context_key" => "context value"}], - Message => 'this is a test exception', - Category => '', - }, , "details will be encoded as json"; + Details => ["details 1", "details 2", {"context_key" => "context value"}], + Message => 'this is a test exception', + Category => '', + }, + , "details will be encoded as json"; }; done_testing(); diff --git a/t/unit/exceptions.t b/t/unit/exceptions.t index e7454d3..72d1d6d 100644 --- a/t/unit/exceptions.t +++ b/t/unit/exceptions.t @@ -20,18 +20,18 @@ my @exceptions_to_test = qw( # Test that all exception classes can be loaded successfully for my $exception (@exceptions_to_test) { my $full_class = "WebService::Hydra::Exception::$exception"; - + use_ok $full_class; - + is($@, '', "Loaded $full_class successfully"); - + # Test creating an instance with no additional parameters my $instance = $full_class->new(); ok($instance, "$full_class instance created"); - + # Check that required fields like message and category are accessible - ok($instance->message, "$full_class has a message"); + ok($instance->message, "$full_class has a message"); ok($instance->category, "$full_class has a category"); } -done_testing(); \ No newline at end of file +done_testing(); diff --git a/t/unit/hydra_client.t b/t/unit/hydra_client.t index 6089b94..05f4641 100644 --- a/t/unit/hydra_client.t +++ b/t/unit/hydra_client.t @@ -407,20 +407,19 @@ subtest 'revoke_login_sessions' => sub { # Test for 200 OK status code $mock_api_response = { code => 204, - data => undef}; + data => undef + }; my $got = $client->revoke_login_sessions(subject => '1234'); - is $params[1], 'DELETE', 'DELETE request method'; - is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/sessions/login?subject=1234', - 'Request URL built with correct parameters'; + is $params[1], 'DELETE', 'DELETE request method'; + is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/sessions/login?subject=1234', 'Request URL built with correct parameters'; is_deeply $got , $mock_api_response->{data}, 'api_call response correctly parsed'; - + @params = (); my $got = $client->revoke_login_sessions(sid => '1234'); - is $params[1], 'DELETE', 'DELETE request method'; - is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/sessions/login?sid=1234', - 'Request URL built with correct parameters'; + is $params[1], 'DELETE', 'DELETE request method'; + is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/sessions/login?sid=1234', 'Request URL built with correct parameters'; is_deeply $got , $mock_api_response->{data}, 'api_call response correctly parsed'; # Test for other non-200 status codes @@ -449,7 +448,6 @@ subtest 'revoke_login_sessions' => sub { dies_ok { $client->accept_logout_request("VALID_CHALLENGE") } 'Dies if http request fails for some reason'; }; - subtest 'fetch_openid_configuration' => sub { my $mock_hydra = Test::MockModule->new('WebService::Hydra::Client'); my $mock_api_response; From 687021886d348f3f408d88e662c5e505203f605c Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Thu, 26 Sep 2024 05:46:59 +0000 Subject: [PATCH 15/16] minor fixs --- lib/WebService/Hydra.pm | 3 +++ lib/WebService/Hydra/Client.pm | 16 +++++++++------- lib/WebService/Hydra/Exception.pm | 8 +++++--- t/unit/hydra_client.t | 2 +- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/lib/WebService/Hydra.pm b/lib/WebService/Hydra.pm index b3190db..7e84e4b 100644 --- a/lib/WebService/Hydra.pm +++ b/lib/WebService/Hydra.pm @@ -1,6 +1,9 @@ package WebService::Hydra; # ABSTRACT: ... +use strict; +use warnings; + our $VERSION = "0.001"; 1; diff --git a/lib/WebService/Hydra/Client.pm b/lib/WebService/Hydra/Client.pm index ad22c9b..58ed73a 100644 --- a/lib/WebService/Hydra/Client.pm +++ b/lib/WebService/Hydra/Client.pm @@ -1,10 +1,12 @@ -use Object::Pad; - -class WebService::Hydra::Client; +package WebService::Hydra::Client; use strict; use warnings; +use Object::Pad; + +class WebService::Hydra::Client; + use HTTP::Tiny; use Log::Any qw( $log ); use Crypt::JWT qw(decode_jwt); @@ -59,13 +61,13 @@ This is a required parameter when creating Hydra Client Object using new. Returns the base URL for the hydra service. -=cut +=cut =head2 public_endpoint Returns the base URL for the hydra service. -=cut +=cut =head2 http @@ -102,7 +104,7 @@ Takes request method, the endpoint, and the payload. It sends the request to the 1. JSON object of code and data returned from the service. 2. Error string in case an exception is thrown. -=cut +=cut method api_call ($method, $endpoint, $payload = undef, $content_type = 'json') { @@ -159,7 +161,7 @@ Arguments: =item C<$login_challenge> Authentication challenge string that is used to identify and fetch information -about the OAuth2 request from hydra. +about the OAuth2 request from hydra. =back diff --git a/lib/WebService/Hydra/Exception.pm b/lib/WebService/Hydra/Exception.pm index 17a0eb8..5735b78 100644 --- a/lib/WebService/Hydra/Exception.pm +++ b/lib/WebService/Hydra/Exception.pm @@ -1,9 +1,12 @@ +package WebService::Hydra::Exception; + +use strict; +use warnings; + use Object::Pad; class WebService::Hydra::Exception; -use strict; -use warnings; use File::Spec; use Module::Load; use JSON::MaybeUTF8 qw(encode_json_text); @@ -59,7 +62,6 @@ method as_string { push @substrings, "Category=$category" if $category; push @substrings, "Message=$message" if $message; push @substrings, "Details=" . encode_json_text($details) if @$details; - $string .= "(" . join(", ", @substrings) . ")" if @substrings; return $string; } diff --git a/t/unit/hydra_client.t b/t/unit/hydra_client.t index 05f4641..9122e23 100644 --- a/t/unit/hydra_client.t +++ b/t/unit/hydra_client.t @@ -416,7 +416,7 @@ subtest 'revoke_login_sessions' => sub { is_deeply $got , $mock_api_response->{data}, 'api_call response correctly parsed'; @params = (); - my $got = $client->revoke_login_sessions(sid => '1234'); + $got = $client->revoke_login_sessions(sid => '1234'); is $params[1], 'DELETE', 'DELETE request method'; is $params[2], 'http://dummyhydra.com/admin/admin/oauth2/auth/sessions/login?sid=1234', 'Request URL built with correct parameters'; From e15537601a1313bc074bdbdd6ba9fdcdbc7ce97b Mon Sep 17 00:00:00 2001 From: youssef-deriv Date: Mon, 30 Sep 2024 02:35:21 +0000 Subject: [PATCH 16/16] update required package --- cpanfile | 13 +++++++++---- lib/WebService/Hydra/Exception.pm | 1 - 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/cpanfile b/cpanfile index 550d346..693e1a5 100644 --- a/cpanfile +++ b/cpanfile @@ -1,7 +1,12 @@ -requires 'Object::Pad', '0.56'; # Required modules with specific versions -requires 'JSON::MaybeUTF8', '0'; -requires 'Scalar::Util', '0'; -requires 'Log::Any', '0'; +requires 'Object::Pad', '0.56'; # Required modules with specific versions +requires 'JSON::MaybeUTF8', '0'; +requires 'Scalar::Util', '0'; +requires 'Log::Any', '0'; +requires 'Crypt::JWT', '0'; +requires 'JSON::MaybeUTF8', '0'; +requires 'Syntax::Keyword::Try', '0'; +requires 'HTTP::Tiny', '0'; +requires 'Module::Load', '0'; on 'test' => sub { requires 'Test::More', '0'; # Test dependencies diff --git a/lib/WebService/Hydra/Exception.pm b/lib/WebService/Hydra/Exception.pm index 5735b78..9722e0e 100644 --- a/lib/WebService/Hydra/Exception.pm +++ b/lib/WebService/Hydra/Exception.pm @@ -7,7 +7,6 @@ use Object::Pad; class WebService::Hydra::Exception; -use File::Spec; use Module::Load; use JSON::MaybeUTF8 qw(encode_json_text); use Log::Any qw($log);