use
5.014;
enum
'RedirectType'
=> [
qw/login logout/
];
enum
'StoreMode'
=> [
qw/session stash/
];
has
'store_mode'
=> (
is
=>
'ro'
,
isa
=>
'StoreMode'
,
required
=> 1,
);
has
'request_params'
=> (
is
=>
'ro'
,
isa
=>
'HashRef'
,
required
=> 1,
);
has
'request_headers'
=> (
is
=>
'ro'
,
isa
=>
'HashRef'
,
required
=> 1,
);
has
'session'
=> (
is
=>
'ro'
,
isa
=>
'HashRef'
,
required
=> 1,
);
has
'stash'
=> (
is
=>
'ro'
,
isa
=>
'HashRef'
,
required
=> 1,
);
has
'get_flash'
=> (
is
=>
'ro'
,
isa
=>
'CodeRef'
,
required
=> 1,
);
has
'set_flash'
=> (
is
=>
'ro'
,
isa
=>
'CodeRef'
,
required
=> 1,
);
has
'redirect'
=> (
is
=>
'ro'
,
isa
=>
'CodeRef'
,
required
=> 1,
);
has
'client'
=> (
is
=>
'ro'
,
isa
=>
'OIDC::Client'
,
required
=> 1,
);
has
'base_url'
=> (
is
=>
'ro'
,
isa
=> subtype(as
'Str'
, where { /^http/ }),
required
=> 1,
);
has
'current_url'
=> (
is
=>
'ro'
,
isa
=>
'Str'
,
required
=> 1,
);
has
'uuid_generator'
=> (
is
=>
'ro'
,
isa
=>
'Data::UUID'
,
default
=>
sub
{ Data::UUID->new() },
);
has
'login_redirect_uri'
=> (
is
=>
'ro'
,
isa
=>
'Maybe[Str]'
,
lazy
=> 1,
builder
=>
'_build_login_redirect_uri'
,
);
has
'logout_redirect_uri'
=> (
is
=>
'ro'
,
isa
=>
'Maybe[Str]'
,
lazy
=> 1,
builder
=>
'_build_logout_redirect_uri'
,
);
has
'is_base_url_local'
=> (
is
=>
'ro'
,
isa
=>
'Bool'
,
lazy
=> 1,
builder
=>
'_build_is_base_url_local'
,
);
sub
_build_is_base_url_local {
return
shift
->base_url =~ m[^http://localhost\b] }
sub
_build_login_redirect_uri {
return
shift
->_build_redirect_uri_from_path(
'login'
) }
sub
_build_logout_redirect_uri {
return
shift
->_build_redirect_uri_from_path(
'logout'
) }
sub
_build_redirect_uri_from_path {
my
$self
=
shift
;
my
(
$redirect_type
) = pos_validated_list(\
@_
, {
isa
=>
'RedirectType'
,
optional
=> 0 });
my
$config_entry
=
$redirect_type
eq
'login'
?
'signin_redirect_path'
:
'logout_redirect_path'
;
my
$redirect_path
=
$self
->client->config->{
$config_entry
}
or
return
;
my
$base
= Mojo::URL->new(
$self
->base_url);
return
Mojo::URL->new(
$redirect_path
)->base(
$base
)->to_abs()->to_string();
}
sub
redirect_to_authorize {
my
$self
=
shift
;
my
%params
= validated_hash(
\
@_
,
target_url
=> {
isa
=>
'Str'
,
optional
=> 1 },
redirect_uri
=> {
isa
=>
'Str'
,
optional
=> 1 },
extra_params
=> {
isa
=>
'HashRef'
,
optional
=> 1 },
other_state_params
=> {
isa
=>
'ArrayRef[Str]'
,
optional
=> 1 },
);
my
$nonce
=
$self
->_generate_uuid_string();
my
$state
=
join
','
, (@{
$params
{other_state_params} || []},
$self
->_generate_uuid_string());
my
%args
= (
nonce
=>
$nonce
,
state
=>
$state
,
);
if
(
my
$redirect_uri
=
$params
{redirect_uri} ||
$self
->login_redirect_uri) {
$args
{redirect_uri} =
$redirect_uri
;
}
if
(
my
$extra_params
=
$params
{extra_params}) {
$args
{extra_params} =
$extra_params
;
}
my
$authorize_url
=
$self
->client->auth_url(
%args
);
$self
->set_flash->(
oidc_nonce
=>
$nonce
);
$self
->set_flash->(
oidc_state
=>
$state
);
$self
->set_flash->(
oidc_provider
=>
$self
->client->provider);
$self
->set_flash->(
oidc_target_url
=>
$params
{target_url} ?
$params
{target_url}
:
$self
->current_url);
$self
->log_msg(
debug
=>
"OIDC: redirecting to provider : $authorize_url"
);
$self
->redirect->(
$authorize_url
);
}
sub
get_token {
my
$self
=
shift
;
my
%params
= validated_hash(
\
@_
,
redirect_uri
=> {
isa
=>
'Str'
,
optional
=> 1 },
);
$self
->log_msg(
debug
=>
'OIDC: getting token'
);
if
(
$self
->request_params->{error}) {
OIDC::Client::Error::Provider->throw({
response_parameters
=>
$self
->request_params});
}
$self
->_check_state_parameter();
my
$redirect_uri
=
$params
{redirect_uri} ||
$self
->login_redirect_uri;
my
$token_response
=
$self
->client->get_token(
code
=>
$self
->request_params->{code},
$redirect_uri
? (
redirect_uri
=>
$redirect_uri
) : (),
);
if
(
my
$id_token
=
$token_response
->id_token) {
my
$claims_id_token
=
$self
->client->verify_token(
token
=>
$id_token
,
expected_audience
=>
$self
->client->id,
expected_nonce
=>
$self
->get_flash->(
'oidc_nonce'
),
);
$self
->_store_identity(
id_token
=>
$id_token
,
claims
=>
$claims_id_token
,
);
$self
->log_msg(
debug
=>
"OIDC: identity has been stored"
);
}
elsif
((
$self
->client->config->{scope} ||
''
) =~ /\bopenid\b/) {
OIDC::Client::Error::Authentication->throw(
"OIDC: no ID token returned by the provider ?"
);
}
if
(
my
$access_token
=
$token_response
->access_token) {
my
$expires_at
=
$self
->_get_expiration_time(
token_response
=>
$token_response
);
$self
->_store_access_token(
audience
=>
$self
->client->audience,
access_token
=>
$access_token
,
refresh_token
=>
$token_response
->refresh_token,
token_type
=>
$token_response
->token_type,
expires_at
=>
$expires_at
,
);
$self
->log_msg(
debug
=>
"OIDC: access token has been stored"
);
}
return
$self
->get_stored_identity();
}
sub
refresh_token {
my
$self
=
shift
;
my
(
$audience_alias
) = pos_validated_list(\
@_
, {
isa
=>
'Maybe[Str]'
,
optional
=> 1 });
my
$audience
=
$audience_alias
?
$self
->client->get_audience_for_alias(
$audience_alias
)
:
$self
->client->audience
or croak(
"OIDC: no audience for alias '$audience_alias'"
);
$self
->log_msg(
debug
=>
"OIDC: refreshing access token for audience $audience"
);
my
$stored_token
=
$self
->_get_stored_access_token(
$audience
)
or croak(
"OIDC: no access token has been stored"
);
my
$refresh_token
=
$stored_token
->{refresh_token};
unless
(
$refresh_token
) {
$self
->log_msg(
debug
=>
"OIDC: no refresh token has been stored"
);
return
;
}
my
$token_response
=
$self
->client->get_token(
grant_type
=>
'refresh_token'
,
refresh_token
=>
$refresh_token
,
);
my
$expires_at
=
$self
->_get_expiration_time(
token_response
=>
$token_response
);
$self
->_store_access_token(
audience
=>
$audience
,
access_token
=>
$token_response
->access_token,
refresh_token
=>
$token_response
->refresh_token,
token_type
=>
$token_response
->token_type,
expires_at
=>
$expires_at
,
);
$self
->log_msg(
debug
=>
"OIDC: token has been refreshed and stored"
);
return
$self
->_get_stored_access_token(
$audience
);
}
sub
exchange_token {
my
$self
=
shift
;
my
(
$audience_alias
) = pos_validated_list(\
@_
, {
isa
=>
'Str'
,
optional
=> 0 });
$self
->log_msg(
debug
=>
'OIDC: exchanging token'
);
my
$audience
=
$self
->client->get_audience_for_alias(
$audience_alias
)
or croak(
"OIDC: no audience for alias '$audience_alias'"
);
my
$access_token
=
$self
->get_valid_access_token()
or croak(
"OIDC: cannot retrieve the access token"
);
my
$exchanged_token_response
=
$self
->client->exchange_token(
token
=>
$access_token
->{token},
audience
=>
$audience
,
);
my
$expires_at
=
$self
->_get_expiration_time(
token_response
=>
$exchanged_token_response
);
$self
->_store_access_token(
audience
=>
$audience
,
access_token
=>
$exchanged_token_response
->access_token,
refresh_token
=>
$exchanged_token_response
->refresh_token,
token_type
=>
$exchanged_token_response
->token_type,
expires_at
=>
$expires_at
,
);
$self
->log_msg(
debug
=>
"OIDC: token has been exchanged and stored"
);
return
$self
->_get_stored_access_token(
$audience
);
}
sub
verify_token {
my
$self
=
shift
;
if
(
$self
->is_base_url_local and
my
$mocked_claims
=
$self
->client->config->{mocked_claims}) {
return
$mocked_claims
;
}
my
$token
=
$self
->get_token_from_authorization_header()
or croak(
"OIDC: no token in authorization header"
);
my
$claims
=
$self
->client->verify_token(
token
=>
$token
);
my
$expires_at
=
$self
->_get_expiration_time(
claims
=>
$claims
);
my
$scopes
=
$self
->_get_scopes_from_claims(
$claims
);
$self
->_store_access_token(
audience
=>
$self
->client->audience,
access_token
=>
$token
,
expires_at
=>
$expires_at
,
scopes
=>
$scopes
,
);
return
$claims
;
}
sub
get_token_from_authorization_header {
my
$self
=
shift
;
my
$authorization
=
$self
->request_headers->{Authorization}
or
return
;
my
$token_type
=
$self
->client->default_token_type;
my
(
$token
) =
$authorization
=~ /^
$token_type
\s+([^\s]+)/i;
return
$token
;
}
sub
has_scope {
my
$self
=
shift
;
my
(
$expected_scope
) = pos_validated_list(\
@_
, {
isa
=>
'Str'
,
optional
=> 0 });
my
$stored_token
=
$self
->get_valid_access_token()
or croak(
"OIDC: cannot retrieve the access token"
);
my
$scopes
=
$stored_token
->{scopes}
or
return
0;
return
any {
$_
eq
$expected_scope
}
@$scopes
;
}
sub
get_userinfo {
my
$self
=
shift
;
if
(
$self
->is_base_url_local and
my
$mocked_userinfo
=
$self
->client->config->{mocked_userinfo}) {
return
$mocked_userinfo
;
}
my
$stored_token
=
$self
->get_valid_access_token()
or croak(
"OIDC: cannot retrieve the access token"
);
return
$self
->client->get_userinfo(
access_token
=>
$stored_token
->{token},
token_type
=>
$stored_token
->{token_type},
);
}
sub
build_user_from_userinfo {
my
$self
=
shift
;
my
(
$user_class
) = pos_validated_list(\
@_
, {
isa
=>
'Str'
,
default
=>
'OIDC::Client::User'
});
load(
$user_class
);
my
$userinfo
=
$self
->get_userinfo();
my
$mapping
=
$self
->client->claim_mapping;
my
$role_prefix
=
$self
->client->role_prefix;
return
$user_class
->new(
(
map
{
$_
=>
$userinfo
->{
$mapping
->{
$_
} } }
grep
{
exists
$userinfo
->{
$mapping
->{
$_
} } }
keys
%$mapping
),
defined
$role_prefix
? (
role_prefix
=>
$role_prefix
) : (),
);
}
sub
build_user_from_identity {
my
$self
=
shift
;
my
(
$user_class
) = pos_validated_list(\
@_
, {
isa
=>
'Str'
,
default
=>
'OIDC::Client::User'
});
load(
$user_class
);
my
$identity
=
$self
->get_stored_identity()
or croak(
"OIDC: no identity has been stored"
);
my
$role_prefix
=
$self
->client->role_prefix;
return
$user_class
->new(
%$identity
,
defined
$role_prefix
? (
role_prefix
=>
$role_prefix
) : (),
);
}
sub
build_api_useragent {
my
$self
=
shift
;
my
(
$audience_alias
) = pos_validated_list(\
@_
, {
isa
=>
'Str'
,
optional
=> 0 });
my
$exchanged_token
=
try
{
return
$self
->get_valid_access_token(
$audience_alias
);
}
catch
{
$self
->log_msg(
warning
=>
"OIDC: error getting valid access token : $_"
);
return
;
};
$exchanged_token
||=
$self
->exchange_token(
$audience_alias
);
return
$self
->client->build_api_useragent(
token
=>
$exchanged_token
->{token},
token_type
=>
$exchanged_token
->{token_type},
);
}
sub
redirect_to_logout {
my
$self
=
shift
;
my
%params
= validated_hash(
\
@_
,
with_id_token
=> {
isa
=>
'Bool'
,
default
=> 1 },
target_url
=> {
isa
=>
'Str'
,
optional
=> 1 },
post_logout_redirect_uri
=> {
isa
=>
'Str'
,
optional
=> 1 },
extra_params
=> {
isa
=>
'HashRef'
,
optional
=> 1 },
state
=> {
isa
=>
'Str'
,
optional
=> 1 },
);
my
%args
;
if
(
$params
{with_id_token} //
$self
->client->config->{logout_with_id_token}) {
my
$identity
=
$self
->get_stored_identity()
or croak(
"OIDC: no identity has been stored"
);
$args
{id_token} =
$identity
->{token};
}
if
(
my
$redirect_uri
=
$params
{post_logout_redirect_uri} ||
$self
->logout_redirect_uri) {
$args
{post_logout_redirect_uri} =
$redirect_uri
;
}
$args
{state} =
$params
{state}
if
defined
$params
{state};
if
(
my
$extra_params
=
$params
{extra_params}) {
$args
{extra_params} =
$extra_params
;
}
my
$logout_url
=
$self
->client->logout_url(
%args
);
if
(
my
$target_url
=
$params
{target_url}) {
$self
->set_flash->(
oidc_target_url
=>
$target_url
);
}
$self
->log_msg(
debug
=>
"OIDC: redirecting to idp for log out : $logout_url"
);
$self
->redirect->(
$logout_url
);
}
sub
has_access_token_expired {
my
$self
=
shift
;
my
(
$audience_alias
) = pos_validated_list(\
@_
, {
isa
=>
'Maybe[Str]'
,
optional
=> 1 });
my
$audience
=
$audience_alias
?
$self
->client->get_audience_for_alias(
$audience_alias
)
:
$self
->client->audience
or croak(
"OIDC: no audience for alias '$audience_alias'"
);
my
$stored_token
=
$self
->_get_stored_access_token(
$audience
)
or croak(
"OIDC: no access token has been stored for audience '$audience'"
);
if
(
$self
->client->has_expired(
$stored_token
->{expires_at})) {
$self
->log_msg(
debug
=>
"OIDC: access token has expired for audience '$audience'"
);
return
1;
}
return
0;
}
sub
get_valid_access_token {
my
$self
=
shift
;
my
(
$audience_alias
) = pos_validated_list(\
@_
, {
isa
=>
'Maybe[Str]'
,
optional
=> 1 });
my
$audience
=
$audience_alias
?
$self
->client->get_audience_for_alias(
$audience_alias
)
:
$self
->client->audience
or croak(
"OIDC: no audience for alias '$audience_alias'"
);
if
(
$self
->is_base_url_local and
my
$mocked_claims
=
$self
->client->config->{mocked_claims}) {
my
$scopes
=
$self
->_get_scopes_from_claims(
$mocked_claims
);
return
{
token
=>
"mocked token for audience '$audience'"
,
scopes
=>
$scopes
};
}
my
$stored_token
=
$self
->_get_stored_access_token(
$audience
);
unless
(
$stored_token
) {
$self
->log_msg(
debug
=>
"OIDC: no access token has been stored for audience '$audience'"
);
return
;
}
if
(
$self
->client->has_expired(
$stored_token
->{expires_at})) {
$self
->log_msg(
debug
=>
"OIDC: access token has expired for audience '$audience'"
);
return
$self
->refresh_token(
$audience_alias
);
}
else
{
$self
->log_msg(
debug
=>
"OIDC: access token for audience '$audience' has been retrieved from store"
);
return
$stored_token
;
}
}
sub
get_stored_identity {
my
$self
=
shift
;
if
(
$self
->is_base_url_local and
my
$mocked_identity
=
$self
->client->config->{mocked_identity}) {
return
$mocked_identity
;
}
my
$provider
=
$self
->client->provider;
return
$self
->_store->{oidc}{provider}{
$provider
}{identity};
}
sub
_store_identity {
my
$self
=
shift
;
my
%params
= validated_hash(
\
@_
,
id_token
=> {
isa
=>
'Str'
,
optional
=> 0 },
claims
=> {
isa
=>
'HashRef'
,
optional
=> 0 },
);
my
$subject
=
$params
{claims}->{
sub
};
defined
$subject
or croak(
"OIDC: the 'sub' claim is not defined"
);
my
%identity
= (
token
=>
$params
{id_token},
subject
=>
$subject
,
);
foreach
my
$claim_name
(
keys
%{
$self
->client->claim_mapping }) {
$identity
{
$claim_name
} //=
$self
->client->get_claim_value(
name
=>
$claim_name
,
claims
=>
$params
{claims},
optional
=> 1,
);
}
my
$provider
=
$self
->client->provider;
$self
->_store->{oidc}{provider}{
$provider
}{identity} = \
%identity
;
}
sub
_get_stored_access_token {
my
$self
=
shift
;
my
(
$audience
) = pos_validated_list(\
@_
, {
isa
=>
'Str'
,
optional
=> 0 });
my
$provider
=
$self
->client->provider;
return
$self
->_store->{oidc}{provider}{
$provider
}{access_token}{audience}{
$audience
};
}
sub
_store_access_token {
my
$self
=
shift
;
my
%params
= validated_hash(
\
@_
,
audience
=> {
isa
=>
'Str'
,
optional
=> 0 },
access_token
=> {
isa
=>
'Str'
,
optional
=> 0 },
expires_at
=> {
isa
=>
'Maybe[Int]'
,
optional
=> 1 },
refresh_token
=> {
isa
=>
'Maybe[Str]'
,
optional
=> 1 },
token_type
=> {
isa
=>
'Maybe[Str]'
,
optional
=> 1 },
scopes
=> {
isa
=>
'ArrayRef[Str]'
,
optional
=> 1 },
);
my
$provider
=
$self
->client->provider;
my
%to_store
= (
token
=>
$params
{access_token},
);
for
(
qw/ expires_at refresh_token token_type scopes /
) {
$to_store
{
$_
} =
$params
{
$_
}
if
defined
$params
{
$_
};
}
$self
->_store->{oidc}{provider}{
$provider
}{access_token}{audience}{
$params
{audience}} = \
%to_store
;
}
sub
_get_expiration_time {
my
$self
=
shift
;
my
%params
= validated_hash(
\
@_
,
token_response
=> {
isa
=>
'OIDC::Client::TokenResponse'
,
optional
=> 1 },
claims
=> {
isa
=>
'HashRef'
,
optional
=> 1 },
);
if
(
my
$claims
=
$params
{claims}) {
my
$expiration_time
=
$claims
->{
exp
};
return
$expiration_time
if
defined
$expiration_time
;
}
if
(
my
$token_response
=
$params
{token_response}) {
return
time
+
$token_response
->expires_in
if
defined
$token_response
->expires_in;
}
return
;
}
sub
_get_scopes_from_claims {
my
$self
=
shift
;
my
(
$claims
) = pos_validated_list(\
@_
, {
isa
=>
'HashRef'
,
optional
=> 0 });
return
exists
$claims
->{scp} ? (
$claims
->{scp} // [])
:
exists
$claims
->{scope} ? [
split
(/\s+/, (
$claims
->{scope} //
''
))]
: [];
}
sub
_generate_uuid_string {
my
$self
=
shift
;
return
$self
->uuid_generator->to_string(
$self
->uuid_generator->create());
}
sub
_check_state_parameter {
my
$self
=
shift
;
my
$state
=
$self
->request_params->{state} ||
''
;
my
$expected_state
=
$self
->get_flash->(
'oidc_state'
) ||
''
;
if
(!
$state
|| !
$expected_state
||
$state
ne
$expected_state
) {
OIDC::Client::Error::Authentication->throw(
"OIDC: invalid state parameter (got '$state' but expected '$expected_state')"
);
}
}
sub
_store {
my
$self
=
shift
;
return
$self
->store_mode eq
'session'
?
$self
->session
:
$self
->stash;
}
__PACKAGE__->meta->make_immutable;
1;