WebService-Hydra/lib/WebService/Hydra/Client.pm
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);
use JSON::MaybeUTF8;
use WebService::Hydra::Exception;
use Syntax::Keyword::Try;
use constant OK_STATUS_CODE => 200;
use constant OK_NO_CONTENT_CODE => 204;
use constant BAD_REQUEST_STATUS_CODE => 400;
use constant HTTP_TIMEOUT_SECONDS => 10;
our $VERSION = '0.005';
field $http;
field $jwks;
field $oidc_config;
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>
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>
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(timeout => HTTP_TIMEOUT_SECONDS);
}
=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:
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 reject_login_request
Rejects 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<$reject_payload>
Payload to be sent to the Hydra service to reject the login request.
=back
=cut
method reject_login_request ($login_challenge, $reject_payload) {
my $method = "PUT";
my $path = "$admin_endpoint/admin/oauth2/auth/requests/login/reject?challenge=$login_challenge";
my $result = $self->api_call($method, $path, $reject_payload);
if ($result->{code} != OK_STATUS_CODE) {
WebService::Hydra::Exception::InvalidLoginRequest->new(
message => "Failed to reject 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 ($exchange_payload) {
my $method = "POST";
my $path = "$public_endpoint/oauth2/token";
my $payload = {
grant_type => 'authorization_code',
$exchange_payload->%*
};
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 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.
=cut
method validate_id_token ($id_token) {
try {
my $payload = decode_jwt(
token => $id_token,
kid_keys => $self->jwks
);
return $payload;
} catch ($e) {
WebService::Hydra::Exception::InvalidIdToken->new(
message => "Failed to validate id token",
category => "client",
details => $e
)->throw;
}
}
=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 => $self->oidc_config->{issuer},
kid_keys => $self->jwks
);
return $payload;
}
=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};
}
=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_NO_CONTENT_CODE) {
WebService::Hydra::Exception::RevokeLoginSessionsFailed->new(
message => "Failed to revoke login sessions",
category => "client",
details => $result
)->throw;
}
return $result->{data};
}
1;