Skip to content

Commit 058238f

Browse files
Support private key JWT token endpoint authentication (#39)
* 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
1 parent e619f01 commit 058238f

File tree

5 files changed

+350
-8
lines changed

5 files changed

+350
-8
lines changed

config/oidc.php

+33
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,39 @@
2020
*/
2121
'client_secret' => env('OIDC_CLIENT_SECRET', ''),
2222

23+
/**
24+
* Configuration for the token authentication.
25+
*/
26+
'client_authentication' => [
27+
/**
28+
* When you want to use `private_key_jwt` client authentication then you can specify the path to the private key.
29+
*/
30+
'signing_private_key_path' => env('OIDC_SIGNING_PRIVATE_KEY_PATH'),
31+
32+
/**
33+
* When you want to use `private_key_jwt` client authentication then you can specify the signing algorithm.
34+
* For a list of supported algorithms see https://tools.ietf.org/html/rfc7518#section-3.1
35+
*/
36+
'signing_algorithm' => env('OIDC_SIGNING_ALGORITHM', 'RS256'),
37+
38+
/**
39+
* When you want to use `private_key_jwt` client authentication then need
40+
* to specify the available signature algorithms.
41+
*
42+
* The input is used for the AlgorithmManager and should be a list of class names.
43+
* See https://web-token.spomky-labs.com/the-components/algorithm-management-jwa
44+
*/
45+
'signature_algorithms' => [
46+
\Jose\Component\Signature\Algorithm\RS256::class,
47+
],
48+
49+
/**
50+
* Token lifetime in seconds, used to set the expiration time of the JWT.
51+
* This is used when you are using `private_key_jwt` client authentication.
52+
*/
53+
'token_lifetime_in_seconds' => 60,
54+
],
55+
2356
/**
2457
* Only needed when response of user info endpoint is encrypted.
2558
* This is the path to the JWE decryption key.

src/OpenIDConnectServiceProvider.php

+48-1
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,24 @@
44

55
namespace MinVWS\OpenIDConnectLaravel;
66

7+
use Illuminate\Contracts\Config\Repository as ConfigRepository;
78
use Illuminate\Foundation\Application;
89
use Illuminate\Support\Facades\Route;
910
use Illuminate\Support\ServiceProvider;
11+
use Jose\Component\Core\Algorithm;
12+
use Jose\Component\Core\AlgorithmManager;
1013
use Jose\Component\Core\JWKSet;
1114
use Jose\Component\KeyManagement\JWKFactory;
15+
use Jose\Component\Signature\JWSBuilder;
16+
use Jose\Component\Signature\Serializer\CompactSerializer;
1217
use MinVWS\OpenIDConnectLaravel\Http\Responses\LoginResponseHandler;
1318
use MinVWS\OpenIDConnectLaravel\Http\Responses\LoginResponseHandlerInterface;
1419
use MinVWS\OpenIDConnectLaravel\OpenIDConfiguration\OpenIDConfigurationLoader;
1520
use MinVWS\OpenIDConnectLaravel\Services\JWE\JweDecryptInterface;
1621
use MinVWS\OpenIDConnectLaravel\Services\JWE\JweDecryptService;
1722
use MinVWS\OpenIDConnectLaravel\Services\ExceptionHandler;
1823
use MinVWS\OpenIDConnectLaravel\Services\ExceptionHandlerInterface;
24+
use MinVWS\OpenIDConnectLaravel\Services\JWS\PrivateKeyJWTBuilder;
1925

2026
class OpenIDConnectServiceProvider extends ServiceProvider
2127
{
@@ -95,12 +101,14 @@ protected function registerConfigurationLoader(): void
95101
protected function registerClient(): void
96102
{
97103
$this->app->singleton(OpenIDConnectClient::class, function (Application $app) {
104+
$clientId = $app['config']->get('oidc.client_id');
105+
98106
$oidc = new OpenIDConnectClient(
99107
providerUrl: $app['config']->get('oidc.issuer'),
100108
jweDecrypter: $app->make(JweDecryptInterface::class),
101109
openIDConfiguration: $app->make(OpenIDConfigurationLoader::class)->getConfiguration(),
102110
);
103-
$oidc->setClientID($app['config']->get('oidc.client_id'));
111+
$oidc->setClientID($clientId);
104112
if (!empty($app['config']->get('oidc.client_secret'))) {
105113
$oidc->setClientSecret($app['config']->get('oidc.client_secret'));
106114
}
@@ -115,6 +123,28 @@ protected function registerClient(): void
115123
}
116124

117125
$oidc->setTlsVerify($app['config']->get('oidc.tls_verify'));
126+
127+
$signingPrivateKeyPath = $app['config']->get('oidc.client_authentication.signing_private_key_path');
128+
if (!empty($signingPrivateKeyPath)) {
129+
$algorithms = $this->parseSignatureAlgorithms($app['config']);
130+
$signingPrivateKey = JWKFactory::createFromKeyFile($signingPrivateKeyPath);
131+
$singingAlgorithm = $app['config']->get('oidc.client_authentication.signing_algorithm');
132+
$tokenLifetimeInSeconds = $app['config']->get('oidc.client_authentication.token_lifetime_in_seconds');
133+
134+
$privateKeyJwtBuilder = new PrivateKeyJWTBuilder(
135+
clientId: $clientId,
136+
jwsBuilder: new JWSBuilder(new AlgorithmManager($algorithms)),
137+
signatureKey: $signingPrivateKey,
138+
signatureAlgorithm: $singingAlgorithm,
139+
serializer: new CompactSerializer(),
140+
tokenLifetimeInSeconds: $tokenLifetimeInSeconds,
141+
);
142+
143+
// Set private key JWT generator and explicit allow of private_key_jwt
144+
$oidc->setPrivateKeyJwtGenerator($privateKeyJwtBuilder);
145+
$oidc->setTokenEndpointAuthMethodsSupported(['private_key_jwt']);
146+
}
147+
118148
return $oidc;
119149
});
120150
}
@@ -160,4 +190,21 @@ protected function parseDecryptionKeySet(): ?JWKSet
160190

161191
return new JWKSet($keys);
162192
}
193+
194+
/**
195+
* @param ConfigRepository $config
196+
* @return array<Algorithm>
197+
*/
198+
protected function parseSignatureAlgorithms(ConfigRepository $config): array
199+
{
200+
/** @var ?array<class-string<Algorithm>> $algorithms */
201+
$algorithms = $config->get('oidc.client_authentication.signature_algorithms');
202+
if (!is_array($algorithms)) {
203+
return [];
204+
}
205+
206+
return array_map(function (string $algorithm) {
207+
return new $algorithm();
208+
}, $algorithms);
209+
}
163210
}
+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace MinVWS\OpenIDConnectLaravel\Services\JWS;
6+
7+
use Jose\Component\Core\JWK;
8+
use Jose\Component\Signature\JWSBuilder;
9+
use Jose\Component\Signature\Serializer\JWSSerializer;
10+
11+
class PrivateKeyJWTBuilder
12+
{
13+
public function __construct(
14+
protected string $clientId,
15+
protected JWSBuilder $jwsBuilder,
16+
protected JWK $signatureKey,
17+
protected string $signatureAlgorithm,
18+
protected JWSSerializer $serializer,
19+
protected int $tokenLifetimeInSeconds,
20+
) {
21+
}
22+
23+
public function __invoke(string $audience): string
24+
{
25+
return $this->buildJws($this->getPayload($audience));
26+
}
27+
28+
protected function getPayload(string $audience): string
29+
{
30+
$jti = hash('sha256', bin2hex(random_bytes(64)));
31+
$now = time();
32+
33+
return json_encode([
34+
'iss' => $this->clientId,
35+
'sub' => $this->clientId,
36+
'aud' => $audience,
37+
'jti' => $jti,
38+
'exp' => $now + $this->tokenLifetimeInSeconds,
39+
'iat' => $now,
40+
], JSON_THROW_ON_ERROR);
41+
}
42+
43+
protected function buildJws(string $payload): string
44+
{
45+
$jws = $this->jwsBuilder
46+
->create()
47+
->withPayload($payload)
48+
->addSignature($this->signatureKey, ['alg' => $this->signatureAlgorithm])
49+
->build();
50+
51+
return $this->serializer->serialize($jws, 0);
52+
}
53+
}

tests/Feature/Http/Controllers/LoginControllerResponseTest.php

+86-7
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@
1414
use MinVWS\OpenIDConnectLaravel\Tests\TestCase;
1515
use Mockery;
1616

17-
use function MinVWS\OpenIDConnectLaravel\Tests\generateJwt;
17+
use function MinVWS\OpenIDConnectLaravel\Tests\{
18+
generateJwt,
19+
generateOpenSSLKey,
20+
};
1821

1922
class LoginControllerResponseTest extends TestCase
2023
{
@@ -90,7 +93,9 @@ public function testCodeChallengeIsSetWhenSupported(
9093
array $codeChallengesSupportedAtProvider,
9194
bool $codeChallengeShouldBeSet,
9295
): void {
93-
$this->mockOpenIDConfigurationLoader($codeChallengesSupportedAtProvider);
96+
$this->mockOpenIDConfigurationLoader(
97+
codeChallengeMethodsSupported: $codeChallengesSupportedAtProvider,
98+
);
9499
Config::set('oidc.code_challenge_method', $requestedCodeChallengeMethod);
95100

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

210-
protected function mockOpenIDConfigurationLoader(array $codeChallengeMethodsSupported = []): void
215+
public function testTokenSignedWithPrivateKey(): void
211216
{
217+
Http::fake([
218+
// Token requested by OpenIDConnectClient::authenticate() function.
219+
'https://provider.rdobeheer.nl/token' => Http::response([
220+
'access_token' => 'access-token-from-token-endpoint',
221+
'id_token' => 'does-not-matter-not-testing-id-token',
222+
'token_type' => 'Bearer',
223+
'expires_in' => 3600,
224+
]),
225+
]);
226+
227+
// Set OIDC provider configuration
228+
$this->mockOpenIDConfigurationLoader(tokenEndpointAuthMethodsSupported: ['private_key_jwt']);
229+
230+
Config::set('oidc.issuer', 'https://provider.rdobeheer.nl');
231+
Config::set('oidc.client_id', 'test-client-id');
232+
233+
// Set client private key
234+
[$key, $keyResource] = generateOpenSSLKey();
235+
Config::set('oidc.client_authentication.signing_private_key_path', stream_get_meta_data($keyResource)['uri']);
236+
237+
// Set current state, normally this is generated before logging in and send
238+
// to the issuer, when the user is redirected for login.
239+
Session::put('openid_connect_state', 'some-state');
240+
241+
// We simulate here that the user now comes back after successful login at issuer.
242+
$this->getRoute('oidc.login', ['code' => 'some-code', 'state' => 'some-state']);
243+
244+
// Check if state and nonce are removed from session.
245+
$this->assertEmpty(session('openid_connect_state'));
246+
$this->assertEmpty(session('openid_connect_nonce'));
247+
248+
Http::assertSentCount(1);
249+
Http::assertSentInOrder([
250+
'https://provider.rdobeheer.nl/token',
251+
]);
252+
Http::assertSent(function (Request $request) {
253+
if (!in_array($request->url(), ['https://provider.rdobeheer.nl/token'], true)) {
254+
return false;
255+
}
256+
257+
if ($request->url() === 'https://provider.rdobeheer.nl/token') {
258+
$this->assertSame(
259+
expected: 'POST',
260+
actual: $request->method(),
261+
);
262+
263+
// We only check if the client_assertion is set in the request body.
264+
// The JWT of the PrivateKeyJWTBuilder is tested in a separate test.
265+
$this->assertStringStartsWith(
266+
prefix: 'grant_type=authorization_code'
267+
. '&code=some-code'
268+
. '&redirect_uri=http%3A%2F%2Flocalhost%2Foidc%2Flogin'
269+
. '&client_id=test-client-id'
270+
. '&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer'
271+
. '&client_assertion=eyJhbGciOiJSUzI1NiJ9.',
272+
string: $request->body(),
273+
);
274+
275+
return true;
276+
}
277+
278+
return true;
279+
});
280+
}
281+
282+
protected function mockOpenIDConfigurationLoader(
283+
array $tokenEndpointAuthMethodsSupported = ["none"],
284+
array $codeChallengeMethodsSupported = [],
285+
): void {
212286
$mock = Mockery::mock(OpenIDConfigurationLoader::class);
213287
$mock
214288
->shouldReceive('getConfiguration')
215-
->andReturn($this->exampleOpenIDConfiguration($codeChallengeMethodsSupported));
289+
->andReturn($this->exampleOpenIDConfiguration(
290+
tokenEndpointAuthMethodsSupported: $tokenEndpointAuthMethodsSupported,
291+
codeChallengeMethodsSupported: $codeChallengeMethodsSupported,
292+
));
216293

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

220-
protected function exampleOpenIDConfiguration(array $codeChallengeMethodsSupported = []): OpenIDConfiguration
221-
{
297+
protected function exampleOpenIDConfiguration(
298+
array $tokenEndpointAuthMethodsSupported = ["none"],
299+
array $codeChallengeMethodsSupported = [],
300+
): OpenIDConfiguration {
222301
return new OpenIDConfiguration(
223302
version: "3.0",
224-
tokenEndpointAuthMethodsSupported: ["none"],
303+
tokenEndpointAuthMethodsSupported: $tokenEndpointAuthMethodsSupported,
225304
claimsParameterSupported: true,
226305
requestParameterSupported: false,
227306
requestUriParameterSupported: true,

0 commit comments

Comments
 (0)