OIDC-Client

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN


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

README.md  view on Meta::CPAN


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} || {} };

t/client.t  view on Meta::CPAN

    );
  } 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',

t/client.t  view on Meta::CPAN

    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

t/client.t  view on Meta::CPAN

      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 = (

t/client.t  view on Meta::CPAN

    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 = (

t/client.t  view on Meta::CPAN

      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' },
    );



( run in 2.738 seconds using v1.01-cache-2.11-cpan-a49fcb8fa48 )