44
55namespace OpenIDConnectClient ;
66
7+ use CoderCat \JWKToPEM \Exception \Base64DecodeException ;
8+ use CoderCat \JWKToPEM \Exception \JWKConverterException ;
9+ use CoderCat \JWKToPEM \JWKConverter ;
710use Lcobucci \JWT \Signer ;
811use Lcobucci \JWT \Signer \Key ;
912use 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 ;
1016use League \OAuth2 \Client \Provider \GenericProvider ;
17+ use InvalidArgumentException ;
18+ use League \OAuth2 \Client \Token \AccessTokenInterface ;
19+ use OpenIDConnectClient \Exception \InvalidConfigurationException ;
1120use OpenIDConnectClient \Exception \InvalidTokenException ;
1221use OpenIDConnectClient \Validator \EqualsTo ;
1322use 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