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

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