view release on metacpan or search on metacpan
Changes for OIDC-Client distribution
1.05 2025-10-28
- Possible configuration breaking change : 'post' and 'basic' auth methods are renamed to 'client_secret_basic' and 'client_secret_post'
- Possible configuration breaking change : 'client_secret_basic' becomes the default client authentication method
- OIDC::Client::verify_token() is deprecated in favor of OIDC::Client::verify_jwt_token()
- Added support for 'introspection' token validation method
- Added support for 'client_secret_jwt', 'private_key_jwt' and 'none' client authentication methods
- Added support for the 'cache' store (only for 'client_credentials' and 'password' grant types)
- Added check of the access token's 'at_hash' against the ID token's 'at_hash' claim if present
- Renewed ID token : no nonce from provider is accepted
- Renewed ID token : 'sub' claim must be the same as in the original ID token
- JWT validation : 'exp' and 'iat' claims must be present (and valid)
- Fix token exchange without a refresh token in the response
- Explicitly accepts 'application/json' for all requests to the provider
1.04 2025-09-12
- Fix for token refresh : the 'refresh_scope' config should only be used for the current audience
lib/OIDC/Client.pm view on Meta::CPAN
=item * L<Dancer2::Plugin::OIDC>
=back
=cut
enum 'StoreMode' => [qw/session stash cache/];
enum 'ResponseMode' => [qw/query form_post/];
enum 'GrantType' => [qw/authorization_code client_credentials password refresh_token/];
enum 'ClientAuthMethod' => [qw/client_secret_basic client_secret_post client_secret_jwt private_key_jwt none/];
enum 'TokenValidationMethod' => [qw/jwt introspection/];
with 'OIDC::Client::Role::LoggerWrapper';
with 'OIDC::Client::Role::AttributesManager';
with 'OIDC::Client::Role::ConfigurationChecker';
with 'OIDC::Client::Role::ClaimsValidator';
with 'OIDC::Client::Role::ClientAuthenticationHelper';
=head1 METHODS
lib/OIDC/Client.pm view on Meta::CPAN
=item *
client_secret_post
=item *
client_secret_jwt
=item *
private_key_jwt
=item *
none
=back
Can also be specified in the C<token_endpoint_auth_method> configuration entry
or the global C<client_auth_method> configuration entry.
Default to C<client_secret_basic>.
lib/OIDC/Client.pm view on Meta::CPAN
=item *
client_secret_post
=item *
client_secret_jwt
=item *
private_key_jwt
=item *
none
=back
Can also be specified in the C<introspection_endpoint_auth_method> configuration entry
or the global C<client_auth_method> configuration entry.
Default to C<client_secret_basic>.
lib/OIDC/Client.pm view on Meta::CPAN
=item *
client_secret_post
=item *
client_secret_jwt
=item *
private_key_jwt
=item *
none
=back
Can also be specified in the C<token_endpoint_auth_method> configuration entry
or the global C<client_auth_method> configuration entry.
Default to C<client_secret_basic>.
lib/OIDC/Client/Config.pod view on Meta::CPAN
=head2 provider."provider".id
OIDC client ID supplied by your provider. Mandatory
=head2 provider."provider".secret
OIDC client secret supplied by your provider.
If not present, the secret must be defined in the C<OIDC_${provider}_SECRET>
environment variable unless the authentication method is C<none> or C<private_key_jwt>.
=head2 provider."provider".private_jwk_file
Path to the private JWK file, used when using the C<private_key_jwt> client
authentication method.
=head2 provider."provider".private_jwk
Perl HASH ref with JWK key structure, used when using the C<private_key_jwt> client
authentication method.
=head2 provider."provider".private_key_file
Path to the private RSA key file when using the C<private_key_jwt> client
authentication method.
=head2 provider."provider".private_key
String of the private RSA key file when using the C<private_key_jwt> client
authentication method.
=head2 provider."provider".audience
Specifies the provider for whom the access token is intended.
If this parameter is omitted, the access token returned by the provider is intended
for your OIDC client (useful for making token exchanges).
For an application, it's better to leave this parameter out and make token exchanges
lib/OIDC/Client/Config.pod view on Meta::CPAN
By default, the transmitted options are :
=over
=item alg: 'HS256'
Encoding algorithm used
=back
=head2 provider."provider".private_key_jwt_encoding_options
Options to be transferred to the
L<Crypt::JWT::encode_jwt()|https://metacpan.org/pod/Crypt::JWT#encode_jwt>
function called to encode a JWT token when using the C<private_key_jwt>
authentication method.
By default, the transmitted options are :
=over
=item alg: 'RS256'
Encoding algorithm used
lib/OIDC/Client/Config.pod view on Meta::CPAN
=item client_secret_post
The client id and secret are sent in the POST body.
=item client_secret_jwt
A JWT assertion, signed with the client secret using an HMAC SHA algorithm,
is generated and sent in the POST body.
=item private_key_jwt
A JWT assertion, signed using a private key in asymmetric cryptography,
is generated and sent in the POST body.
The private key can be defined with the C<private_key> attribute of the L<OIDC::Client>
object instance or with one of the following configuration entries :
=item none
The Client does not authenticate itself.
=over
=item private_jwk_file
=item private_jwk
=item private_key_file
=item private_key
=back
=back
You can also redefines the authentication method to be used for each endpoint
with the C<token_endpoint_auth_method> and C<introspection_endpoint_auth_method>
configuration entries.
=head2 provider."provider".token_endpoint_auth_method
lib/OIDC/Client/Config.pod view on Meta::CPAN
=head2 provider."provider".introspection_endpoint_auth_method
Defines the authentication method to be used when calling the C<token> endpoint.
Same list of possible values as for the C<client_auth_method> configuration entry.
=head2 provider."provider".client_assertion_lifetime
Specifies the lifetime, in seconds, of the client assertion JWT generated
for client authentication methods such as C<client_secret_jwt> and
C<private_key_jwt>.
120 seconds by default.
=head2 provider."provider".client_assertion_audience
Defines the audience (C<aud>) claim to include in the client assertion JWT
used for authentication.
Default: the URL of the endpoint being called.
lib/OIDC/Client/Role/AttributesManager.pm view on Meta::CPAN
Readonly my $DEFAULT_TOKEN_VALIDATION_METHOD => 'jwt';
Readonly my $DEFAULT_CLIENT_ASSERTION_LIFETIME => 120;
Readonly my $DEFAULT_MAX_ID_TOKEN_AGE => 30; # in addition to the leeway to account for clock skew
has 'config' => (
is => 'ro',
isa => 'HashRef',
default => sub { {} },
);
foreach my $attr_name (qw( private_key_file private_jwk_file role_prefix client_assertion_audience
signin_redirect_path signin_redirect_uri logout_redirect_path post_logout_redirect_uri
scope refresh_scope well_known_url )) {
has $attr_name => (
is => 'ro',
isa => 'Maybe[Str]',
lazy => 1,
default => sub { shift->config->{$attr_name} },
);
}
lib/OIDC/Client/Role/AttributesManager.pm view on Meta::CPAN
builder => '_build_id',
);
has 'secret' => (
is => 'rw',
isa => 'Maybe[Str]',
lazy => 1,
builder => '_build_secret',
);
has 'private_key' => (
is => 'rw',
isa => 'HashRef|ScalarRef',
lazy => 1,
builder => '_build_private_key',
);
has 'audience' => (
is => 'ro',
isa => 'Str',
lazy => 1,
builder => '_build_audience',
);
has 'user_agent' => (
lib/OIDC/Client/Role/AttributesManager.pm view on Meta::CPAN
);
has 'client_secret_jwt_encoding_options' => (
is => 'rw',
isa => 'HashRef',
lazy => 1,
default => sub { shift->config->{client_secret_jwt_encoding_options}
|| \%DEFAULT_CLIENT_SECRET_JWT_ENCODING_OPTIONS },
);
has 'private_key_jwt_encoding_options' => (
is => 'rw',
isa => 'HashRef',
lazy => 1,
default => sub { shift->config->{private_key_jwt_encoding_options}
|| \%DEFAULT_PRIVATE_KEY_JWT_ENCODING_OPTIONS },
);
has 'token_endpoint_grant_type' => (
is => 'ro',
isa => 'GrantType',
lazy => 1,
default => sub { shift->config->{token_endpoint_grant_type}
|| $DEFAULT_GRANT_TYPE },
);
lib/OIDC/Client/Role/AttributesManager.pm view on Meta::CPAN
sub _build_secret ($self) {
my $secret = $self->config->{secret};
unless ($secret) {
my $provider = $self->provider;
$secret = $ENV{uc "OIDC_${provider}_SECRET"};
}
$secret or croak("OIDC: no secret configured or set up in environment");
return $secret;
}
sub _build_private_key ($self) {
if (my $private_jwk_file = $self->private_jwk_file) {
my $private_jwk = decode_json(Mojo::File->new($private_jwk_file)->slurp);
return $private_jwk;
}
elsif (my $private_jwk = $self->private_jwk) {
return $private_jwk;
}
elsif (my $private_key_file = $self->private_key_file) {
my $private_key = Mojo::File->new($private_key_file)->slurp;
return \$private_key;
}
elsif (my $private_key = $self->config->{private_key}) {
return \$private_key;
}
else {
croak('OIDC: no private_jwk_file, private_jwk, private_key_file or private_key has been configured');
}
}
sub _build_audience ($self) {
return $self->config->{audience} || $self->id;
}
sub _build_user_agent ($self) {
my $ua = Mojo::UserAgent->new();
lib/OIDC/Client/Role/ClientAuthenticationHelper.pm view on Meta::CPAN
=head1 DESCRIPTION
This Moose role covers private methods for building client authentication data.
=cut
requires qw(log_msg
id
secret
private_key
client_assertion_lifetime
client_assertion_audience
generate_uuid_string
private_key_jwt_encoding_options
client_secret_jwt_encoding_options);
Readonly my $CLIENT_ASSERTION_TYPE => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer';
sub _build_client_auth_arguments ($self, $method, $url) {
my (%headers, %form);
lib/OIDC/Client/Role/ClientAuthenticationHelper.pm view on Meta::CPAN
}
elsif ($method eq 'client_secret_post') {
$form{client_id} = $self->id;
$form{client_secret} = $self->secret;
}
elsif ($method eq 'client_secret_jwt') {
$form{client_id} = $self->id;
$form{client_assertion_type} = $CLIENT_ASSERTION_TYPE;
$form{client_assertion} = $self->_build_client_assertion(0, $url);
}
elsif ($method eq 'private_key_jwt') {
$form{client_id} = $self->id;
$form{client_assertion_type} = $CLIENT_ASSERTION_TYPE;
$form{client_assertion} = $self->_build_client_assertion(1, $url);
}
elsif ($method eq 'none') {
$form{client_id} = $self->id;
}
else {
croak("Unsupported client auth method: $method");
}
return (\%headers, \%form);
}
sub _build_client_assertion ($self, $use_private_key, $url) {
$self->log_msg(debug => 'OIDC: building client assertion');
my $now = time;
my $exp = $now + $self->client_assertion_lifetime;
my $aud = $self->client_assertion_audience // $url;
my $jti = $self->generate_uuid_string();
my %claims = (
iss => $self->id,
sub => $self->id,
aud => $aud,
jti => $jti,
iat => $now,
exp => $exp,
);
my $jwt_encoding_options = $use_private_key ? $self->private_key_jwt_encoding_options
: $self->client_secret_jwt_encoding_options;
return Crypt::JWT::encode_jwt(
%$jwt_encoding_options,
payload => \%claims,
key => $use_private_key ? $self->private_key : $self->secret,
);
}
1;
lib/OIDC/Client/Role/ConfigurationChecker.pm view on Meta::CPAN
validated_hash(
\@config,
provider => { isa => 'Str', optional => 1 },
store_mode => { isa => 'StoreMode', optional => 1 },
proxy_detect => { isa => 'Bool', optional => 1 },
user_agent => { isa => 'Str', optional => 1 },
id => { isa => 'Str', optional => 1 },
secret => { isa => 'Str', optional => 1 },
private_jwk_file => { isa => 'Str', optional => 1 },
private_jwk => { isa => 'HashRef', optional => 1 },
private_key_file => { isa => 'Str', optional => 1 },
private_key => { isa => 'Str', optional => 1 },
audience => { isa => 'Str', optional => 1 },
role_prefix => { isa => 'Str', optional => 1 },
well_known_url => { isa => 'Str', optional => 1 },
issuer => { isa => 'Str', optional => 1 },
jwks_url => { isa => 'Str', optional => 1 },
authorize_url => { isa => 'Str', optional => 1 },
token_url => { isa => 'Str', optional => 1 },
introspection_url => { isa => 'Str', optional => 1 },
userinfo_url => { isa => 'Str', optional => 1 },
end_session_url => { isa => 'Str', optional => 1 },
signin_redirect_path => { isa => 'Str', optional => 1 },
signin_redirect_uri => { isa => 'Str', optional => 1 },
scope => { isa => 'Str', optional => 1 },
refresh_scope => { isa => 'Str', optional => 1 },
identity_expires_in => { isa => 'Int', optional => 1 },
expiration_leeway => { isa => 'Int', optional => 1 },
max_id_token_age => { isa => 'Int', optional => 1 },
jwt_decoding_options => { isa => 'HashRef', optional => 1 },
client_secret_jwt_encoding_options => { isa => 'HashRef', optional => 1 },
private_key_jwt_encoding_options => { isa => 'HashRef', optional => 1 },
claim_mapping => { isa => 'HashRef[Str]', optional => 1 },
audience_alias => { isa => 'HashRef[HashRef]', optional => 1 },
authorize_endpoint_response_mode => { isa => 'ResponseMode', optional => 1 },
authorize_endpoint_extra_params => { isa => 'HashRef', optional => 1 },
token_validation_method => { isa => 'TokenValidationMethod', optional => 1 },
token_endpoint_grant_type => { isa => 'GrantType', optional => 1 },
client_auth_method => { isa => 'ClientAuthMethod', optional => 1 },
token_endpoint_auth_method => { isa => 'ClientAuthMethod', optional => 1 },
introspection_endpoint_auth_method => { isa => 'ClientAuthMethod', optional => 1 },
client_assertion_lifetime => { isa => 'Int', optional => 1 },
my %expected_headers = ();
my @user_agent_sended_args = $test->mocked_user_agent->next_call();
cmp_deeply(\@user_agent_sended_args,
[ 'post', [ $test->mocked_user_agent, 'https://my-provider/token', \%expected_headers, 'form', \%expected_args ] ],
'expected call to user agent');
my $client_assertion_sended_claims = $user_agent_sended_args[1][4]{client_assertion}{payload};
is($client_assertion_sended_claims->{exp}, $client_assertion_sended_claims->{iat} + 120,
'expected exp claim value');
};
subtest "get_token() authorization_code - private_key_jwt auth method" => sub {
# Given
$test->mock_encode_jwt(); # encode_jwt() args are placed directly into 'client_assertion'
my $private_jwk = { kty => 'FAKE' };
my $client = $class->new(
log => $log,
user_agent => $test->mocked_user_agent,
token_response_parser => $test->mocked_token_response_parser,
kid_keys => {},
config => {
provider => 'my_provider',
id => 'my_client_id',
private_jwk => $private_jwk,
signin_redirect_uri => 'my_signin_redirect_uri',
client_auth_method => 'private_key_jwt',
},
provider_metadata => { token_url => 'https://my-provider/token' },
);
# When
my $token_response = $client->get_token(
code => 'my_code',
);
# Then
refresh_token => 'my_refresh_token',
);
my %expected_headers = (
Authorization => 'Basic bXlfY2xpZW50X2lkOm15X2NsaWVudF9zZWNyZXQ=',
);
cmp_deeply([ $test->mocked_user_agent->next_call() ],
[ 'post', [ $test->mocked_user_agent, 'https://my-provider/token', \%expected_headers, 'form', \%expected_args ] ],
'expected call to user agent');
};
subtest "get_token() refresh_token grant type with private_key_jwt auth method" => sub {
# Given
$test->mock_encode_jwt(); # encode_jwt() args are placed directly into 'client_assertion'
my $client = $class->new(
log => $log,
user_agent => $test->mocked_user_agent,
token_response_parser => $test->mocked_token_response_parser,
kid_keys => {},
config => {
provider => 'my_provider',
id => 'my_client_id',
private_key_file => "$Bin/resources/client.key",
token_endpoint_grant_type => 'client_credentials',
token_endpoint_auth_method => 'private_key_jwt',
scope => 'my_scope',
},
provider_metadata => { token_url => 'https://my-provider/token' },
);
# When
my $token_response = $client->get_token(
grant_type => 'refresh_token',
refresh_token => 'my_refresh_token',
);
# Then
is($token_response->access_token, 'my_access_token',
'expected access token');
my $expected_private_key = "FAKE PRIVATE KEY\n";
my %expected_encode_jwt_args = (
alg => 'RS256',
key => \$expected_private_key,
payload => {
iss => 'my_client_id',
sub => 'my_client_id',
aud => 'https://my-provider/token',
jti => re('\w+'),
iat => re('\d+'),
exp => re('\d+'),
},
);
my %expected_args = (
my %expected_headers = ();
my @user_agent_sended_args = $test->mocked_user_agent->next_call();
cmp_deeply(\@user_agent_sended_args,
[ 'post', [ $test->mocked_user_agent, 'https://my-provider/introspect', \%expected_headers, 'form', \%expected_args ] ],
'expected call to user agent');
my $client_assertion_sended_claims = $user_agent_sended_args[1][4]{client_assertion}{payload};
is($client_assertion_sended_claims->{exp}, $client_assertion_sended_claims->{iat} + 40,
'expected exp claim value');
};
subtest "introspect_token() - 'private_key_jwt' auth method" => sub {
# Given
my %returned_claims = (
active => 1,
);
$test->mock_user_agent(to_mock => { post => \%returned_claims });
$test->mock_response_parser();
$test->mock_encode_jwt(); # encode_jwt() args are placed directly into 'client_assertion'
my $private_key = 'FAKE_PRIVATE_KEY';
my $client = $class->new(
log => $log,
user_agent => $test->mocked_user_agent,
response_parser => $test->mocked_response_parser,
config => {
provider => 'my_provider',
id => 'my_client_id',
private_key => $private_key,
client_auth_method => 'private_key_jwt',
},
provider_metadata => { issuer => 'my_issuer',
token_url => 'https://my-provider/token',
introspection_url => 'https://my-provider/introspect' },
);
# When
my $claims = $client->introspect_token(
token => 'opaque_token',
);
# Then
cmp_deeply($claims, \%returned_claims,
'expected claims');
my %expected_encode_jwt_args = (
alg => 'RS256',
key => \$private_key,
payload => {
iss => 'my_client_id',
sub => 'my_client_id',
aud => 'https://my-provider/introspect',
jti => re('\w+'),
iat => re('\d+'),
exp => re('\d+'),
},
);
my %expected_args = (
client_assertion_type => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion => \%expected_encode_jwt_args,
);
my %expected_headers = ();
my @user_agent_sended_args = $test->mocked_user_agent->next_call();
cmp_deeply(\@user_agent_sended_args,
[ 'post', [ $test->mocked_user_agent, 'https://my-provider/token', \%expected_headers, 'form', \%expected_args ] ],
'expected call to user agent');
};
subtest "exchange_token() - private_key_jwt auth method" => sub {
# Given
$test->mock_encode_jwt(); # encode_jwt() args are placed directly into 'client_assertion'
my $client = $class->new(
log => $log,
user_agent => $test->mocked_user_agent,
token_response_parser => $test->mocked_token_response_parser,
config => {
provider => 'my_provider',
id => 'my_client_id',
private_jwk_file => "$Bin/resources/client.jwk",
client_auth_method => 'private_key_jwt',
client_assertion_audience => 'my_client_assertion_audience',
audience_alias => {
my_alias => {
audience => 'my_audience',
},
},
},
provider_metadata => { token_url => 'https://my-provider/token' },
);