OIDC-Client

 view release on metacpan or  search on metacpan

Changes  view on Meta::CPAN

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 },

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 => re('\w+'),
        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 => re('\w+'),
        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 0.506 second using v1.01-cache-2.11-cpan-62ea2d55848 )