Skip to content

Commit 2e35a66

Browse files
committed
Implement automatic OpenID Connect Discovery
1 parent be1541a commit 2e35a66

3 files changed

Lines changed: 135 additions & 0 deletions

File tree

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
"php": ">= 7.4",
1313
"lcobucci/jwt": "^3.4",
1414
"league/oauth2-client": "^2.0",
15+
"codercat/jwk-to-pem": "^1.1",
1516
"webmozart/assert": "^1.10"
1617
},
1718
"require-dev": {
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?php
2+
3+
namespace OpenIDConnectClient\Exception;
4+
5+
class InvalidConfigurationException extends \Exception
6+
{
7+
8+
}

src/OpenIDConnectProvider.php

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,19 @@
44

55
namespace OpenIDConnectClient;
66

7+
use CoderCat\JWKToPEM\Exception\Base64DecodeException;
8+
use CoderCat\JWKToPEM\Exception\JWKConverterException;
9+
use CoderCat\JWKToPEM\JWKConverter;
710
use Lcobucci\JWT\Signer;
811
use Lcobucci\JWT\Signer\Key;
912
use League\OAuth2\Client\Grant\AbstractGrant;
13+
use Lcobucci\JWT\Token;
14+
use League\OAuth2\Client\Provider\AbstractProvider;
15+
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
1016
use League\OAuth2\Client\Provider\GenericProvider;
17+
use InvalidArgumentException;
18+
use League\OAuth2\Client\Token\AccessTokenInterface;
19+
use OpenIDConnectClient\Exception\InvalidConfigurationException;
1120
use OpenIDConnectClient\Exception\InvalidTokenException;
1221
use OpenIDConnectClient\Validator\EqualsTo;
1322
use OpenIDConnectClient\Validator\EqualsToOrContains;
@@ -32,6 +41,15 @@ final class OpenIDConnectProvider extends GenericProvider
3241
*/
3342
public function __construct(array $options = [], array $collaborators = [])
3443
{
44+
// This is not the most elegant construct, but this will partially setup the current
45+
// class, and specifically the HttpClient, without going through GenericProvider's
46+
// constructor. That constructor performs validation of the $options, but we might want
47+
// to dynamically obtain them in discovery, but for discovery we need the HttpClient.
48+
//
49+
// An alternative solution would be to extend AbstractProvider directly, but that mainly
50+
// brings a lot of "plumbing" for standard properties and methods.
51+
AbstractProvider::__construct($options, $collaborators);
52+
3553
Assert::keyExists($collaborators, 'signer');
3654
Assert::isInstanceOf($collaborators['signer'], Signer::class);
3755

@@ -65,6 +83,11 @@ public function __construct(array $options = [], array $collaborators = [])
6583
$options['scopes'][] = 'openid';
6684
}
6785

86+
// Using discovery
87+
if(isset($options['issuer'])) {
88+
$options = $this->discoverConfiguration($options["issuer"], $options);
89+
}
90+
6891
parent::__construct($options, $collaborators);
6992
}
7093

@@ -218,4 +241,107 @@ protected function createAccessToken(array $response, AbstractGrant $grant): Acc
218241
{
219242
return new AccessToken($response);
220243
}
244+
245+
/**
246+
* Retrieves OpenID Connect configuration from a discovery endpoint
247+
* (<$issuer>/.well-known/openid-configuration) and merges it into
248+
* a given options array
249+
*
250+
* @param string $issuer
251+
* @param array $options
252+
* @return array
253+
* @throws InvalidConfigurationException
254+
* @throws Base64DecodeException
255+
* @throws JWKConverterException
256+
* @throws IdentityProviderException
257+
*/
258+
protected function discoverConfiguration($issuer, $options)
259+
{
260+
$uri = $issuer . '/.well-known/openid-configuration';
261+
$request = $this->getRequest(self::METHOD_GET, $uri);
262+
$response = $this->getParsedResponse($request);
263+
if (false === is_array($response)) {
264+
throw new InvalidConfigurationException(
265+
'Invalid response received from discovery. Expected JSON.'
266+
);
267+
}
268+
269+
// Map configuration to options
270+
$optionMapping = [
271+
'idTokenIssuer' => [
272+
'name' => 'issuer',
273+
'required' => true
274+
],
275+
'urlAuthorize' => [
276+
'name' => 'authorization_endpoint',
277+
'required' => true
278+
],
279+
'urlAccessToken' => [
280+
'name' => 'token_endpoint',
281+
'required' => true
282+
],
283+
'urlResourceOwnerDetails' => [
284+
'name' => 'userinfo_endpoint',
285+
'required' => false
286+
],
287+
];
288+
289+
foreach($optionMapping as $optionKey => $responseKey) {
290+
if($responseKey['required'] && !isset($response[$responseKey['name']])) {
291+
throw new InvalidConfigurationException(
292+
"Parameter {$responseKey['name']} missing in discovery configuration at $uri"
293+
);
294+
}
295+
296+
$options[$optionKey] = $response[$responseKey['name']];
297+
}
298+
299+
// Validate scopes
300+
$scopesSupported = $response["scopes_supported"];
301+
if(isset($scopesSupported)) {
302+
foreach($options['scopes'] as $scope) {
303+
if(!in_array($scope, $scopesSupported)) {
304+
throw new InvalidConfigurationException(
305+
"Scope $scope is not supported in discovery configuration at $uri"
306+
);
307+
}
308+
}
309+
}
310+
311+
// Set public key
312+
if(!isset($response["jwks_uri"])) {
313+
throw new InvalidConfigurationException(
314+
"Parameter jwks_uri missing in discovery configuration at $uri"
315+
);
316+
}
317+
$jwksUri = $response["jwks_uri"];
318+
319+
$jwksRequest = $this->getRequest(self::METHOD_GET, $jwksUri);
320+
$jwksResponse = $this->getParsedResponse($jwksRequest);
321+
if (false === is_array($jwksResponse) || false === is_array($jwksResponse['keys'])) {
322+
throw new InvalidConfigurationException(
323+
'Invalid response received from discovery. Expected JSON.'
324+
);
325+
}
326+
327+
// We will only need signature keys supported by our signer
328+
$jwks = array_filter($jwksResponse['keys'], function($jwk) {
329+
if(!is_array($jwk)) return false;
330+
if(isset($jwk['use']) && $jwk['use'] !== 'sig') return false;
331+
if($jwk['alg'] !== $this->signer->getAlgorithmId()) return false;
332+
333+
return true;
334+
});
335+
336+
if(count($jwks) === 0) {
337+
throw new InvalidConfigurationException(
338+
"No valid signing keys found in discovery at $uri"
339+
);
340+
}
341+
342+
$jwkConverter = new JWKConverter();
343+
$options['publicKey'] = $jwkConverter->toPEM($jwks[0]);
344+
345+
return $options;
346+
}
221347
}

0 commit comments

Comments
 (0)