vendor/drenso/symfony-oidc-bundle/src/OidcClient.php line 113

Open in your IDE?
  1. <?php
  2. namespace Drenso\OidcBundle;
  3. use Drenso\OidcBundle\Exception\OidcCodeChallengeMethodNotSupportedException;
  4. use Drenso\OidcBundle\Exception\OidcConfigurationException;
  5. use Drenso\OidcBundle\Exception\OidcConfigurationResolveException;
  6. use Drenso\OidcBundle\Exception\OidcException;
  7. use Drenso\OidcBundle\Model\OidcTokens;
  8. use Drenso\OidcBundle\Model\OidcUserData;
  9. use Drenso\OidcBundle\Security\Exception\OidcAuthenticationException;
  10. use Exception;
  11. use InvalidArgumentException;
  12. use LogicException;
  13. use phpseclib3\Crypt\RSA;
  14. use RuntimeException;
  15. use Symfony\Component\HttpFoundation\RedirectResponse;
  16. use Symfony\Component\HttpFoundation\Request;
  17. use Symfony\Component\HttpFoundation\RequestStack;
  18. use Symfony\Component\Security\Http\HttpUtils;
  19. use Symfony\Component\String\Slugger\AsciiSlugger;
  20. use Symfony\Contracts\Cache\CacheInterface;
  21. use Symfony\Contracts\Cache\ItemInterface;
  22. /**
  23.  * This class implements the Oidc protocol.
  24.  */
  25. class OidcClient implements OidcClientInterface
  26. {
  27.   /** OIDC configuration values */
  28.   protected ?array $configuration null;
  29.   private ?string $cacheKey       null;
  30.   private const PKCE_ALGORITHMS   = [
  31.     'S256'  => 'sha256',
  32.     'plain' => false,
  33.   ];
  34.   public function __construct(
  35.     protected RequestStack $requestStack,
  36.     protected HttpUtils $httpUtils,
  37.     protected ?CacheInterface $wellKnownCache,
  38.     protected OidcUrlFetcher $urlFetcher,
  39.     protected OidcSessionStorage $sessionStorage,
  40.     protected OidcJwtHelper $jwtHelper,
  41.     protected string $wellKnownUrl,
  42.     private readonly ?int $wellKnownCacheTime,
  43.     private readonly string $clientId,
  44.     private readonly string $clientSecret,
  45.     private readonly string $redirectRoute,
  46.     private readonly string $rememberMeParameter,
  47.     protected ?OidcWellKnownParserInterface $wellKnownParser null,
  48.     private readonly ?string $codeChallengeMethod null,
  49.     private readonly bool $disableNonce false)
  50.   {
  51.     // Check for required phpseclib classes
  52.     if (!class_exists('\phpseclib\Crypt\RSA') && !class_exists(RSA::class)) {
  53.       throw new RuntimeException('Unable to find phpseclib Crypt/RSA.php.  Ensure phpseclib/phpseclib is installed.');
  54.     }
  55.     if (!$this->wellKnownUrl || filter_var($this->wellKnownUrlFILTER_VALIDATE_URL) === false) {
  56.       throw new LogicException(sprintf('Invalid well known url (%s) for OIDC'$this->wellKnownUrl));
  57.     }
  58.     if ($this->codeChallengeMethod && !array_key_exists($this->codeChallengeMethodself::PKCE_ALGORITHMS)) {
  59.       throw new LogicException(sprintf('Invalid PKCE algorithm (%s) for code challenge method'$this->codeChallengeMethod));
  60.     }
  61.   }
  62.   public function authenticate(Request $request): OidcTokens
  63.   {
  64.     // Check whether the request has an error state
  65.     if ($request->request->has('error')) {
  66.       throw new OidcAuthenticationException(sprintf('OIDC error: %s. Description: %s.',
  67.         $request->request->get('error'''), $request->request->get('error_description''')));
  68.     }
  69.     // Check whether the request contains the required state and code keys
  70.     if (!$code $request->query->get('code')) {
  71.       throw new OidcAuthenticationException('Missing code in query');
  72.     }
  73.     if (!$state $request->query->get('state')) {
  74.       throw new OidcAuthenticationException('Missing state in query');
  75.     }
  76.     // Do a session check
  77.     if ($state != $this->sessionStorage->getState()) {
  78.       // Fail silently
  79.       throw new OidcAuthenticationException('Invalid session state');
  80.     }
  81.     // Clear session after check
  82.     $this->sessionStorage->clearState();
  83.     // Request and verify the tokens
  84.     return $this->verifyTokens(
  85.       $this->requestTokens('authorization_code'$code$this->getRedirectUrl()),
  86.       !$this->disableNonce
  87.     );
  88.   }
  89.   public function refreshTokens(string $refreshToken): OidcTokens
  90.   {
  91.     // Clear session after check
  92.     $this->sessionStorage->clearState();
  93.     // Request and verify the tokens
  94.     return $this->verifyTokens(
  95.       $this->requestTokens('refresh_token'nullnull$refreshToken),
  96.       verifyNoncefalse
  97.     );
  98.   }
  99.   public function generateAuthorizationRedirect(
  100.     ?string $prompt null,
  101.     array $scopes = ['openid'],
  102.     bool $forceRememberMe false,
  103.     array $additionalQueryParams = []): RedirectResponse
  104.   {
  105.     $data array_merge($additionalQueryParams, [
  106.       'client_id'     => $this->clientId,
  107.       'response_type' => 'code',
  108.       'redirect_uri'  => $this->getRedirectUrl(),
  109.       'scope'         => implode(' '$scopes),
  110.       'state'         => $this->generateState(),
  111.     ]);
  112.     if (!$this->disableNonce) {
  113.       $data['nonce'] = $this->generateNonce();
  114.     }
  115.     if ($prompt) {
  116.       $validPrompts = ['none''login''consent''select_account''create'];
  117.       if (!in_array($prompt$validPrompts)) {
  118.         throw new InvalidArgumentException(sprintf(
  119.           'The prompt parameter need to be one of ("%s"), but "%s" given',
  120.           implode('", "'$validPrompts),
  121.           $prompt
  122.         ));
  123.       }
  124.       $data['prompt'] = $prompt;
  125.     }
  126.     if ($this->codeChallengeMethod) {
  127.       $data array_merge($data, [
  128.         'code_challenge'        => $this->generateCodeChallenge(),
  129.         'code_challenge_method' => $this->codeChallengeMethod,
  130.       ]);
  131.     }
  132.     // Store remember me state
  133.     /** @phan-suppress-next-line PhanAccessMethodInternal */
  134.     $parameter $this->requestStack->getCurrentRequest()->get($this->rememberMeParameter);
  135.     $this->sessionStorage->storeRememberMe($forceRememberMe || 'true' === $parameter || 'on' === $parameter || '1' === $parameter || 'yes' === $parameter || true === $parameter);
  136.     // Remove security session state
  137.     $session $this->requestStack->getSession();
  138.     // BC for attribute definition
  139.     $session->remove(match (true) {
  140.       // Symfony 7
  141.       defined('\Symfony\Component\Security\Http\SecurityRequestAttributes::AUTHENTICATION_ERROR') => \Symfony\Component\Security\Http\SecurityRequestAttributes::AUTHENTICATION_ERROR,
  142.       // Symfony 6
  143.       /* @phan-suppress-next-line PhanUndeclaredConstantOfClass */
  144.       defined('\Symfony\Bundle\SecurityBundle\Security::AUTHENTICATION_ERROR') => \Symfony\Bundle\SecurityBundle\Security::AUTHENTICATION_ERROR,
  145.       // Symfony 5
  146.       /* @phan-suppress-next-line PhanUndeclaredClassConstant */
  147.       default => \Symfony\Component\Security\Core\Security::AUTHENTICATION_ERROR,
  148.     });
  149.     $session->remove(match (true) {
  150.       // Symfony 7
  151.       defined('\Symfony\Component\Security\Http\SecurityRequestAttributes::LAST_USERNAME') => \Symfony\Component\Security\Http\SecurityRequestAttributes::LAST_USERNAME,
  152.       // Symfony 6
  153.       /* @phan-suppress-next-line PhanUndeclaredConstantOfClass */
  154.       defined('\Symfony\Bundle\SecurityBundle\Security::LAST_USERNAME') => \Symfony\Bundle\SecurityBundle\Security::LAST_USERNAME,
  155.       // Symfony 5
  156.       /* @phan-suppress-next-line PhanUndeclaredClassConstant */
  157.       default => \Symfony\Component\Security\Core\Security::LAST_USERNAME,
  158.     });
  159.     $endpointHasQuery parse_url($this->getAuthorizationEndpoint(), PHP_URL_QUERY);
  160.     return new RedirectResponse(sprintf('%s%s%s'$this->getAuthorizationEndpoint(), $endpointHasQuery '&' '?'http_build_query($data)));
  161.   }
  162.   public function generateEndSessionEndpointRedirect(
  163.     OidcTokens $tokens,
  164.     ?string $postLogoutRedirectUrl null,
  165.     array $additionalQueryParams = []): RedirectResponse
  166.   {
  167.     $data array_merge($additionalQueryParams, [
  168.       'client_id'     => $this->clientId,
  169.       'id_token_hint' => $tokens->getIdToken(),
  170.     ]);
  171.     if (null !== $postLogoutRedirectUrl) {
  172.       $data array_merge($data, [
  173.         'post_logout_redirect_uri' => $postLogoutRedirectUrl,
  174.       ]);
  175.     }
  176.     $endpointHasQuery parse_url($this->getEndSessionEndpoint(), PHP_URL_QUERY);
  177.     return new RedirectResponse(sprintf('%s%s%s'$this->getEndSessionEndpoint(), $endpointHasQuery '&' '?'http_build_query($data)));
  178.   }
  179.   public function retrieveUserInfo(OidcTokens $tokens): OidcUserData
  180.   {
  181.     // Set the authorization header
  182.     $headers = ["Authorization: Bearer {$tokens->getAccessToken()}"];
  183.     // Retrieve the user information and convert the encoding to UTF-8 to harden for surfconext UTF-8 bug
  184.     $jsonData $this->urlFetcher->fetchUrl($this->getUserinfoEndpoint(), null$headers);
  185.     $jsonData mb_convert_encoding($jsonData'UTF-8');
  186.     // Read the data
  187.     $data json_decode($jsonDatatrue);
  188.     // Check data due
  189.     if (!is_array($data)) {
  190.       throw new OidcException('Error retrieving the user info from the endpoint.');
  191.     }
  192.     return new OidcUserData($data);
  193.   }
  194.   /**
  195.    * @throws OidcConfigurationException
  196.    * @throws OidcConfigurationResolveException
  197.    */
  198.   protected function getAuthorizationEndpoint(): string
  199.   {
  200.     return $this->getConfigurationValue('authorization_endpoint');
  201.   }
  202.   /**
  203.    * @throws OidcConfigurationException
  204.    * @throws OidcConfigurationResolveException
  205.    */
  206.   protected function getEndSessionEndpoint(): string
  207.   {
  208.     return $this->getConfigurationValue('end_session_endpoint');
  209.   }
  210.   /**
  211.    * @throws OidcConfigurationException
  212.    * @throws OidcConfigurationResolveException
  213.    */
  214.   protected function getIssuer(): string
  215.   {
  216.     return $this->getConfigurationValue('issuer');
  217.   }
  218.   /**
  219.    * @throws OidcConfigurationException
  220.    * @throws OidcConfigurationResolveException
  221.    */
  222.   protected function getJwktUri(): string
  223.   {
  224.     return $this->getConfigurationValue('jwks_uri');
  225.   }
  226.   protected function getRedirectUrl(): string
  227.   {
  228.     return $this->httpUtils->generateUri($this->requestStack->getCurrentRequest(), $this->redirectRoute);
  229.   }
  230.   /**
  231.    * @throws OidcConfigurationException
  232.    * @throws OidcConfigurationResolveException
  233.    */
  234.   protected function getTokenEndpoint(): string
  235.   {
  236.     return $this->getConfigurationValue('token_endpoint');
  237.   }
  238.   /**
  239.    * @throws OidcConfigurationException
  240.    * @throws OidcConfigurationResolveException
  241.    */
  242.   protected function getTokenEndpointAuthMethods(): array
  243.   {
  244.     return $this->getConfigurationValue('token_endpoint_auth_methods_supported', ['client_secret_basic']);
  245.   }
  246.   /**
  247.    * @throws OidcConfigurationException
  248.    * @throws OidcConfigurationResolveException
  249.    */
  250.   protected function getCodeChallengeMethodsSupported(): array
  251.   {
  252.     $value $this->getConfigurationValue('code_challenge_methods_supported');
  253.     if (!is_array($value)) {
  254.       return [];
  255.     }
  256.     return $value;
  257.   }
  258.   /**
  259.    * @throws OidcConfigurationException
  260.    * @throws OidcConfigurationResolveException
  261.    */
  262.   protected function getUserinfoEndpoint(): string
  263.   {
  264.     return $this->getConfigurationValue('userinfo_endpoint');
  265.   }
  266.   /** Generate a nonce to verify the response */
  267.   private function generateNonce(): string
  268.   {
  269.     $value $this->generateRandomString();
  270.     $this->sessionStorage->storeNonce($value);
  271.     return $value;
  272.   }
  273.   /**
  274.    * Generate a code challenge based on the code verifier and PKCE Algorithm.
  275.    *
  276.    * @throws OidcConfigurationException
  277.    * @throws OidcConfigurationResolveException
  278.    * @throws OidcCodeChallengeMethodNotSupportedException
  279.    */
  280.   private function generateCodeChallenge(): string
  281.   {
  282.     if (null === $this->codeChallengeMethod) {
  283.       throw new RuntimeException('Method should not called when a code challenge method isn\'t conmfigured');
  284.     }
  285.     if (!in_array($this->codeChallengeMethod$this->getCodeChallengeMethodsSupported(), true)) {
  286.       throw new OidcCodeChallengeMethodNotSupportedException($this->codeChallengeMethod);
  287.     }
  288.     $codeVerifier bin2hex(random_bytes(64));
  289.     // Save the code verifier for later use in token verification
  290.     $this->sessionStorage->storeCodeVerifier($codeVerifier);
  291.     $pkceAlgorithm self::PKCE_ALGORITHMS[$this->codeChallengeMethod];
  292.     // if $pkceAlgorithm is false handle it as plain
  293.     if (!$pkceAlgorithm) {
  294.       $codeChallenge $codeVerifier;
  295.     } else {
  296.       $codeChallenge rtrim(strtr(base64_encode(hash(self::PKCE_ALGORITHMS[$this->codeChallengeMethod], $codeVerifiertrue)), '+/''-_'), '=');
  297.     }
  298.     return $codeChallenge;
  299.   }
  300.   /** Generate a secure random string for usage as state */
  301.   private function generateRandomString(): string
  302.   {
  303.     return md5(random_bytes(25));
  304.   }
  305.   /** Generate a state to identify the request */
  306.   private function generateState(): string
  307.   {
  308.     $value $this->generateRandomString();
  309.     $this->sessionStorage->storeState($value);
  310.     return $value;
  311.   }
  312.   /**
  313.    * Retrieve a configuration value from the provider well-known configuration.
  314.    *
  315.    * @throws OidcConfigurationException
  316.    * @throws OidcConfigurationResolveException
  317.    */
  318.   private function getConfigurationValue(string $keymixed $default null): mixed
  319.   {
  320.     // Resolve the configuration
  321.     $this->resolveConfiguration();
  322.     if (!array_key_exists($key$this->configuration)) {
  323.       return $default ?? throw new OidcConfigurationException($key);
  324.     }
  325.     return $this->configuration[$key];
  326.   }
  327.   /**
  328.    * Request the tokens from the OIDC provider.
  329.    *
  330.    * @throws OidcException
  331.    */
  332.   private function requestTokens(
  333.     string $grantType,
  334.     ?string $code null,
  335.     ?string $redirectUrl null,
  336.     ?string $refreshToken null): OidcTokens
  337.   {
  338.     $params = [
  339.       'grant_type'    => $grantType,
  340.       'client_id'     => $this->clientId,
  341.       'client_secret' => $this->clientSecret,
  342.     ];
  343.     if (null !== $code) {
  344.       $params['code'] = $code;
  345.     }
  346.     if (null !== $redirectUrl) {
  347.       $params['redirect_uri'] = $redirectUrl;
  348.     }
  349.     if (null !== $refreshToken) {
  350.       $params['refresh_token'] = $refreshToken;
  351.     }
  352.     // Use basic auth if offered
  353.     $headers = [];
  354.     if (in_array('client_secret_basic'$this->getTokenEndpointAuthMethods())) {
  355.       $headers = ['Authorization: Basic ' base64_encode(urlencode($this->clientId) . ':' urlencode($this->clientSecret))];
  356.       unset($params['client_id']);
  357.       unset($params['client_secret']);
  358.     }
  359.     if ($codeVerifier $this->sessionStorage->getCodeVerifier()) {
  360.       unset($params['client_secret']);
  361.       $params array_merge($params, [
  362.         'code_verifier' => $codeVerifier,
  363.       ]);
  364.     }
  365.     $jsonToken json_decode($this->urlFetcher->fetchUrl($this->getTokenEndpoint(), $params$headers));
  366.     // Throw an error if the server returns one
  367.     if (isset($jsonToken->error)) {
  368.       if (isset($jsonToken->error_description)) {
  369.         throw new OidcAuthenticationException($jsonToken->error_description);
  370.       }
  371.       throw new OidcAuthenticationException(sprintf('Got response: %s'$jsonToken->error));
  372.     }
  373.     // Clear code verifier from session after check
  374.     $this->sessionStorage->clearCodeVerifier();
  375.     return new OidcTokens($jsonToken);
  376.   }
  377.   /** @throws OidcException */
  378.   private function verifyTokens(OidcTokens $tokens$verifyNonce true): OidcTokens
  379.   {
  380.     // Retrieve the claims
  381.     $claims $this->jwtHelper->getIdTokenClaims($tokens);
  382.     // Verify the token
  383.     if (!$this->jwtHelper->verifyJwtSignature($this->getJwktUri(), $tokens)) {
  384.       throw new OidcAuthenticationException('Unable to verify signature');
  385.     }
  386.     // If this is a valid claim
  387.     if ($this->jwtHelper->verifyJwtClaims($this->getIssuer(), $claims$tokens$verifyNonce)) {
  388.       return $tokens;
  389.     } else {
  390.       throw new OidcAuthenticationException('Unable to verify JWT claims');
  391.     }
  392.   }
  393.   /**
  394.    * Retrieves the well-known configuration and saves it in the class.
  395.    *
  396.    * @phan-suppress PhanTypeInvalidThrowsIsInterface
  397.    *
  398.    * @throws OidcConfigurationResolveException
  399.    */
  400.   private function resolveConfiguration(): void
  401.   {
  402.     // Check whether the configuration is already available
  403.     if ($this->configuration !== null) {
  404.       return;
  405.     }
  406.     if ($this->wellKnownCache && $this->wellKnownCacheTime !== null) {
  407.       try {
  408.         $this->cacheKey ??= '_drenso_oidc_client__' . (new AsciiSlugger('en'))->slug($this->wellKnownUrl);
  409.         $config         $this->wellKnownCache->get($this->cacheKey, function (ItemInterface $item) {
  410.           $item->expiresAfter($this->wellKnownCacheTime);
  411.           return $this->retrieveWellKnownConfiguration();
  412.         });
  413.       } catch (\Psr\Cache\InvalidArgumentException $e) {
  414.         throw new OidcConfigurationResolveException('Cache failed: ' $e->getMessage(), previous$e);
  415.       }
  416.     } else {
  417.       $config $this->retrieveWellKnownConfiguration();
  418.     }
  419.     // Set the configuration
  420.     $this->configuration $config;
  421.   }
  422.   /**
  423.    * Retrieves the well-known configuration from the configured url.
  424.    *
  425.    * @throws OidcConfigurationResolveException
  426.    */
  427.   private function retrieveWellKnownConfiguration(): array
  428.   {
  429.     try {
  430.       $wellKnown $this->urlFetcher->fetchUrl($this->wellKnownUrl);
  431.     } catch (Exception $e) {
  432.       throw new OidcConfigurationResolveException(sprintf('Could not retrieve OIDC configuration from "%s".'$this->wellKnownUrl), 0$e);
  433.     }
  434.     // Parse the configuration
  435.     if (($config json_decode($wellKnowntrue)) === null) {
  436.       throw new OidcConfigurationResolveException(sprintf('Could not parse OIDC configuration. Response data: "%s"'$wellKnown));
  437.     }
  438.     return $this->wellKnownParser?->parseWellKnown($config) ?? $config;
  439.   }
  440. }