Skip to content

Commit

Permalink
Support private key JWT token endpoint authentication (#39)
Browse files Browse the repository at this point in the history
* Initial private key jwt builder

* Set private key jwt generator in service provider

* Use variables for config values

* Use correct config key

* Add signature algorithms config and add JWK loading

* Reformat

* Use JWSSerializer interface

* Add test

* Add jti claim

Used the same jti implementation as jumbojett/OpenID-Connect-PHP

* Configurable expiration in seconds

* Add test for token endpoint request
  • Loading branch information
ricklambrechts authored Jun 10, 2024
1 parent e619f01 commit 058238f
Show file tree
Hide file tree
Showing 5 changed files with 350 additions and 8 deletions.
33 changes: 33 additions & 0 deletions config/oidc.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,39 @@
*/
'client_secret' => env('OIDC_CLIENT_SECRET', ''),

/**
* Configuration for the token authentication.
*/
'client_authentication' => [
/**
* When you want to use `private_key_jwt` client authentication then you can specify the path to the private key.
*/
'signing_private_key_path' => env('OIDC_SIGNING_PRIVATE_KEY_PATH'),

/**
* When you want to use `private_key_jwt` client authentication then you can specify the signing algorithm.
* For a list of supported algorithms see https://tools.ietf.org/html/rfc7518#section-3.1
*/
'signing_algorithm' => env('OIDC_SIGNING_ALGORITHM', 'RS256'),

/**
* When you want to use `private_key_jwt` client authentication then need
* to specify the available signature algorithms.
*
* The input is used for the AlgorithmManager and should be a list of class names.
* See https://web-token.spomky-labs.com/the-components/algorithm-management-jwa
*/
'signature_algorithms' => [
\Jose\Component\Signature\Algorithm\RS256::class,
],

/**
* Token lifetime in seconds, used to set the expiration time of the JWT.
* This is used when you are using `private_key_jwt` client authentication.
*/
'token_lifetime_in_seconds' => 60,
],

/**
* Only needed when response of user info endpoint is encrypted.
* This is the path to the JWE decryption key.
Expand Down
49 changes: 48 additions & 1 deletion src/OpenIDConnectServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@

namespace MinVWS\OpenIDConnectLaravel;

use Illuminate\Contracts\Config\Repository as ConfigRepository;
use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Jose\Component\Core\Algorithm;
use Jose\Component\Core\AlgorithmManager;
use Jose\Component\Core\JWKSet;
use Jose\Component\KeyManagement\JWKFactory;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\Serializer\CompactSerializer;
use MinVWS\OpenIDConnectLaravel\Http\Responses\LoginResponseHandler;
use MinVWS\OpenIDConnectLaravel\Http\Responses\LoginResponseHandlerInterface;
use MinVWS\OpenIDConnectLaravel\OpenIDConfiguration\OpenIDConfigurationLoader;
use MinVWS\OpenIDConnectLaravel\Services\JWE\JweDecryptInterface;
use MinVWS\OpenIDConnectLaravel\Services\JWE\JweDecryptService;
use MinVWS\OpenIDConnectLaravel\Services\ExceptionHandler;
use MinVWS\OpenIDConnectLaravel\Services\ExceptionHandlerInterface;
use MinVWS\OpenIDConnectLaravel\Services\JWS\PrivateKeyJWTBuilder;

class OpenIDConnectServiceProvider extends ServiceProvider
{
Expand Down Expand Up @@ -95,12 +101,14 @@ protected function registerConfigurationLoader(): void
protected function registerClient(): void
{
$this->app->singleton(OpenIDConnectClient::class, function (Application $app) {
$clientId = $app['config']->get('oidc.client_id');

$oidc = new OpenIDConnectClient(
providerUrl: $app['config']->get('oidc.issuer'),
jweDecrypter: $app->make(JweDecryptInterface::class),
openIDConfiguration: $app->make(OpenIDConfigurationLoader::class)->getConfiguration(),
);
$oidc->setClientID($app['config']->get('oidc.client_id'));
$oidc->setClientID($clientId);
if (!empty($app['config']->get('oidc.client_secret'))) {
$oidc->setClientSecret($app['config']->get('oidc.client_secret'));
}
Expand All @@ -115,6 +123,28 @@ protected function registerClient(): void
}

$oidc->setTlsVerify($app['config']->get('oidc.tls_verify'));

$signingPrivateKeyPath = $app['config']->get('oidc.client_authentication.signing_private_key_path');
if (!empty($signingPrivateKeyPath)) {
$algorithms = $this->parseSignatureAlgorithms($app['config']);
$signingPrivateKey = JWKFactory::createFromKeyFile($signingPrivateKeyPath);
$singingAlgorithm = $app['config']->get('oidc.client_authentication.signing_algorithm');
$tokenLifetimeInSeconds = $app['config']->get('oidc.client_authentication.token_lifetime_in_seconds');

$privateKeyJwtBuilder = new PrivateKeyJWTBuilder(
clientId: $clientId,
jwsBuilder: new JWSBuilder(new AlgorithmManager($algorithms)),
signatureKey: $signingPrivateKey,
signatureAlgorithm: $singingAlgorithm,
serializer: new CompactSerializer(),
tokenLifetimeInSeconds: $tokenLifetimeInSeconds,
);

// Set private key JWT generator and explicit allow of private_key_jwt
$oidc->setPrivateKeyJwtGenerator($privateKeyJwtBuilder);
$oidc->setTokenEndpointAuthMethodsSupported(['private_key_jwt']);
}

return $oidc;
});
}
Expand Down Expand Up @@ -160,4 +190,21 @@ protected function parseDecryptionKeySet(): ?JWKSet

return new JWKSet($keys);
}

/**
* @param ConfigRepository $config
* @return array<Algorithm>
*/
protected function parseSignatureAlgorithms(ConfigRepository $config): array
{
/** @var ?array<class-string<Algorithm>> $algorithms */
$algorithms = $config->get('oidc.client_authentication.signature_algorithms');
if (!is_array($algorithms)) {
return [];
}

return array_map(function (string $algorithm) {
return new $algorithm();
}, $algorithms);
}
}
53 changes: 53 additions & 0 deletions src/Services/JWS/PrivateKeyJWTBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace MinVWS\OpenIDConnectLaravel\Services\JWS;

use Jose\Component\Core\JWK;
use Jose\Component\Signature\JWSBuilder;
use Jose\Component\Signature\Serializer\JWSSerializer;

class PrivateKeyJWTBuilder
{
public function __construct(
protected string $clientId,
protected JWSBuilder $jwsBuilder,
protected JWK $signatureKey,
protected string $signatureAlgorithm,
protected JWSSerializer $serializer,
protected int $tokenLifetimeInSeconds,
) {
}

public function __invoke(string $audience): string
{
return $this->buildJws($this->getPayload($audience));
}

protected function getPayload(string $audience): string
{
$jti = hash('sha256', bin2hex(random_bytes(64)));
$now = time();

return json_encode([
'iss' => $this->clientId,
'sub' => $this->clientId,
'aud' => $audience,
'jti' => $jti,
'exp' => $now + $this->tokenLifetimeInSeconds,
'iat' => $now,
], JSON_THROW_ON_ERROR);
}

protected function buildJws(string $payload): string
{
$jws = $this->jwsBuilder
->create()
->withPayload($payload)
->addSignature($this->signatureKey, ['alg' => $this->signatureAlgorithm])
->build();

return $this->serializer->serialize($jws, 0);
}
}
93 changes: 86 additions & 7 deletions tests/Feature/Http/Controllers/LoginControllerResponseTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
use MinVWS\OpenIDConnectLaravel\Tests\TestCase;
use Mockery;

use function MinVWS\OpenIDConnectLaravel\Tests\generateJwt;
use function MinVWS\OpenIDConnectLaravel\Tests\{
generateJwt,
generateOpenSSLKey,
};

class LoginControllerResponseTest extends TestCase
{
Expand Down Expand Up @@ -90,7 +93,9 @@ public function testCodeChallengeIsSetWhenSupported(
array $codeChallengesSupportedAtProvider,
bool $codeChallengeShouldBeSet,
): void {
$this->mockOpenIDConfigurationLoader($codeChallengesSupportedAtProvider);
$this->mockOpenIDConfigurationLoader(
codeChallengeMethodsSupported: $codeChallengesSupportedAtProvider,
);
Config::set('oidc.code_challenge_method', $requestedCodeChallengeMethod);

// Check if code verified is not set in cache.
Expand Down Expand Up @@ -207,21 +212,95 @@ public function testTokenSignedWithClientSecret(): void
});
}

protected function mockOpenIDConfigurationLoader(array $codeChallengeMethodsSupported = []): void
public function testTokenSignedWithPrivateKey(): void
{
Http::fake([
// Token requested by OpenIDConnectClient::authenticate() function.
'https://provider.rdobeheer.nl/token' => Http::response([
'access_token' => 'access-token-from-token-endpoint',
'id_token' => 'does-not-matter-not-testing-id-token',
'token_type' => 'Bearer',
'expires_in' => 3600,
]),
]);

// Set OIDC provider configuration
$this->mockOpenIDConfigurationLoader(tokenEndpointAuthMethodsSupported: ['private_key_jwt']);

Config::set('oidc.issuer', 'https://provider.rdobeheer.nl');
Config::set('oidc.client_id', 'test-client-id');

// Set client private key
[$key, $keyResource] = generateOpenSSLKey();
Config::set('oidc.client_authentication.signing_private_key_path', stream_get_meta_data($keyResource)['uri']);

// Set current state, normally this is generated before logging in and send
// to the issuer, when the user is redirected for login.
Session::put('openid_connect_state', 'some-state');

// We simulate here that the user now comes back after successful login at issuer.
$this->getRoute('oidc.login', ['code' => 'some-code', 'state' => 'some-state']);

// Check if state and nonce are removed from session.
$this->assertEmpty(session('openid_connect_state'));
$this->assertEmpty(session('openid_connect_nonce'));

Http::assertSentCount(1);
Http::assertSentInOrder([
'https://provider.rdobeheer.nl/token',
]);
Http::assertSent(function (Request $request) {
if (!in_array($request->url(), ['https://provider.rdobeheer.nl/token'], true)) {
return false;
}

if ($request->url() === 'https://provider.rdobeheer.nl/token') {
$this->assertSame(
expected: 'POST',
actual: $request->method(),
);

// We only check if the client_assertion is set in the request body.
// The JWT of the PrivateKeyJWTBuilder is tested in a separate test.
$this->assertStringStartsWith(
prefix: 'grant_type=authorization_code'
. '&code=some-code'
. '&redirect_uri=http%3A%2F%2Flocalhost%2Foidc%2Flogin'
. '&client_id=test-client-id'
. '&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer'
. '&client_assertion=eyJhbGciOiJSUzI1NiJ9.',
string: $request->body(),
);

return true;
}

return true;
});
}

protected function mockOpenIDConfigurationLoader(
array $tokenEndpointAuthMethodsSupported = ["none"],
array $codeChallengeMethodsSupported = [],
): void {
$mock = Mockery::mock(OpenIDConfigurationLoader::class);
$mock
->shouldReceive('getConfiguration')
->andReturn($this->exampleOpenIDConfiguration($codeChallengeMethodsSupported));
->andReturn($this->exampleOpenIDConfiguration(
tokenEndpointAuthMethodsSupported: $tokenEndpointAuthMethodsSupported,
codeChallengeMethodsSupported: $codeChallengeMethodsSupported,
));

$this->app->instance(OpenIDConfigurationLoader::class, $mock);
}

protected function exampleOpenIDConfiguration(array $codeChallengeMethodsSupported = []): OpenIDConfiguration
{
protected function exampleOpenIDConfiguration(
array $tokenEndpointAuthMethodsSupported = ["none"],
array $codeChallengeMethodsSupported = [],
): OpenIDConfiguration {
return new OpenIDConfiguration(
version: "3.0",
tokenEndpointAuthMethodsSupported: ["none"],
tokenEndpointAuthMethodsSupported: $tokenEndpointAuthMethodsSupported,
claimsParameterSupported: true,
requestParameterSupported: false,
requestUriParameterSupported: true,
Expand Down
Loading

0 comments on commit 058238f

Please sign in to comment.