view release on metacpan or search on metacpan
1.06 2025-12-23
- Added support for the 'after_touching_session' callback (needed for Dancer2)
- Claim mapping configuration : added the ability to walk down the data tree of a claim using the dot character
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
All security-sensitive random values generated by this module â `state`, `nonce`, `jti`, and PKCE `code_verifier` â are generated using a cryptographically secure pseudo-random number generator (CSPRNG). The generated values provide at least 128 ...
### Client Authentication
The following OAuth 2.0 client authentication methods are supported:
- `client_secret_basic`
- `client_secret_post`
- `client_secret_jwt`
- `private_key_jwt`
- `tls_client_auth`
- `none`
### Token Validation
ID Tokens and Access Tokens can be validated according to the OpenID Connect and OAuth 2.0 specifications, including support for JWT signature verification and token introspection.
## Documentation Index
- [Client module documentation](https://metacpan.org/pod/OIDC::Client)
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 tls_client_auth none/];
enum 'TokenValidationMethod' => [qw/jwt introspection/];
enum 'CodeChallengeMethod' => [qw/plain S256/];
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 *
tls_client_auth
=item *
none
=back
lib/OIDC/Client.pm view on Meta::CPAN
=item *
client_secret_post
=item *
client_secret_jwt
=item *
private_key_jwt
=item *
tls_client_auth
=item *
none
=back
lib/OIDC/Client.pm view on Meta::CPAN
=item *
client_secret_post
=item *
client_secret_jwt
=item *
private_key_jwt
=item *
tls_client_auth
=item *
none
=back
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 tls_client_cert_file
Path to the PEM-encoded client certificate to use for mutual TLS (mTLS) client authentication.
This option is used only when C<client_auth_method> is set to C<tls_client_auth>.
The certificate must correspond to the private key configured with C<tls_client_key_file>.
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 :
=over
=item private_jwk_file
=item private_jwk
=item private_key_file
=item private_key
=back
=item tls_client_auth
Mutual TLS (mTLS) client authentication using an X.509 certificate during
the TLS handshake itself.
=item none
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
tls_client_key_file tls_client_cert_file tls_ca_file
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_secret',
);
has 'tls_client_key_passphrase' => (
is => 'rw',
isa => 'Maybe[Str]',
lazy => 1,
builder => '_build_tls_client_key_passphrase',
);
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->_get_value_from_config_or_envvar('secret')
or croak("OIDC: no secret configured or set up in environment");
return $secret;
}
sub _build_tls_client_key_passphrase ($self) {
return $self->_get_value_from_config_or_envvar('tls_client_key_passphrase');
}
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
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 'tls_client_auth' || $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 = OIDC::Client::Utils::generate_jti();
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
provider => { isa => 'Str', optional => 1 },
store_mode => { isa => 'StoreMode', optional => 1 },
proxy_detect => { isa => 'Bool', optional => 1 },
user_agent => { isa => 'Str', optional => 1 },
use_pkce => { isa => 'Bool', optional => 1 },
pkce_code_challenge_method => { 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 },
tls_client_key_file => { isa => 'Str', optional => 1 },
tls_client_key_passphrase => { isa => 'Str', optional => 1 },
tls_client_cert_file => { isa => 'Str', optional => 1 },
tls_ca_file => { 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 },
lib/OIDC/Client/Role/ConfigurationChecker.pm view on Meta::CPAN
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 },
lib/OIDC/Client/Role/ConfigurationChecker.pm view on Meta::CPAN
my @auth_methods = uniq($self->token_endpoint_auth_method,
$self->introspection_endpoint_auth_method);
if (any { $_ eq 'tls_client_auth' } @auth_methods) {
unless ($self->tls_client_cert_file && $self->tls_client_key_file) {
croak("OIDC: 'tls_client_cert_file' and 'tls_client_key_file' attributes "
. "are required when tls_client_auth method is used");
}
}
if (any { $_ eq 'private_key_jwt' } @auth_methods) {
$self->private_key;
}
# client_secret_* methods : we can't check the presence of the secret here because a Resource Server
# doesn't need any secret to verify an access token (without introspection).
# An exception is thrown when attempting to access the 'secret' attribute if it is missing.
}
sub _check_audiences_configuration ($self) {
my %config_audience_alias = %{ $self->config->{audience_alias} || {} };
);
} qr/required when tls_client_auth method is used/,
'tls_client_auth method without cert file or key file';
throws_ok {
$class->new(
log => $log,
config => {
provider => 'my_provider',
id => 'my_client_id',
token_endpoint_auth_method => 'private_key_jwt',
},
);
} qr/no private_jwk_file, private_jwk, private_key_file or private_key has been configured/,
'private_key_jwt method without private key';
}
sub test_secret_attribute {
subtest "secret from config" => sub {
# Given
my %config = (
id => 'my_client_id',
secret => 'my_client_secret',
provider => 'my_provider',
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 => 'fake_jti',
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 => 'fake_jti',
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' },
);