You are here

SamlService.php in SAML Authentication 4.x

Same filename and directory in other branches
  1. 8.3 src/SamlService.php
  2. 8 src/SamlService.php
  3. 8.2 src/SamlService.php

Namespace

Drupal\samlauth

File

src/SamlService.php
View source
<?php

namespace Drupal\samlauth;

use Drupal\Core\Config\ConfigFactoryInterface;
use Drupal\Core\Config\ImmutableConfig;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Flood\FloodInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\Session\AccountInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\Core\Url;
use Drupal\externalauth\Authmap;
use Drupal\externalauth\ExternalAuth;
use Drupal\key\KeyRepositoryInterface;
use Drupal\samlauth\Event\SamlauthEvents;
use Drupal\samlauth\Event\SamlauthUserLinkEvent;
use Drupal\samlauth\Event\SamlauthUserSyncEvent;
use Drupal\Core\TempStore\PrivateTempStoreFactory;
use Drupal\user\UserInterface;
use OneLogin\Saml2\Auth;
use OneLogin\Saml2\Error as SamlError;
use OneLogin\Saml2\Utils as SamlUtils;
use Psr\Log\LoggerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;

/**
 * Governs communication between the SAML toolkit and the IdP / login behavior.
 *
 * There's no formal interface here, only a promise to not change things in
 * breaking ways in the 3.x releases. The division in responsibilities between
 * this class and SamlController (which calls most of its public methods) is
 * partly arbitrary. It's roughly "Controller contains code dealing with
 * redirects; SamlService contains the other logic". Code will likely be moved
 * around to new classes in 4.x.
 */
class SamlService {
  use StringTranslationTrait;

  /**
   * Auth objects (usually 0 or 1) representing the current request state.
   *
   * @var \OneLogin\Saml2\Auth[]
   */
  protected $samlAuth;

  /**
   * The ExternalAuth service.
   *
   * @var \Drupal\externalauth\ExternalAuth
   */
  protected $externalAuth;

  /**
   * The Authmap service.
   *
   * @var \Drupal\externalauth\Authmap
   */
  protected $authmap;

  /**
   * The config factory.
   *
   * @var \Drupal\Core\Config\ConfigFactoryInterface
   */
  protected $configFactory;

  /**
   * The EntityTypeManager service.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * A logger instance.
   *
   * @var \Psr\Log\LoggerInterface
   */
  protected $logger;

  /**
   * The event dispatcher.
   *
   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
   */
  protected $eventDispatcher;

  /**
   * The request stack.
   *
   * @var \Symfony\Component\HttpFoundation\RequestStack
   */
  protected $requestStack;

  /**
   * Private store for SAML session data.
   *
   * @var \Drupal\Core\TempStore\PrivateTempStore
   */
  protected $privateTempStore;

  /**
   * The flood service.
   *
   * @var \Drupal\Core\Flood\FloodInterface
   */
  protected $flood;

  /**
   * The current user service.
   *
   * @var \Drupal\Core\Session\AccountInterface
   */
  protected $currentUser;

  /**
   * The messenger service.
   *
   * @var \Drupal\Core\Messenger\MessengerInterface
   */
  protected $messenger;

  /**
   * The Key repository service.
   *
   * This is set when the key module is installed. (Not when the key_asymmetric
   * module is installed. The latter is necessary for entering public/private
   * keys but reading them will work fine without it, it seems.)
   *
   * @var \Drupal\key\KeyRepositoryInterface
   */
  protected $keyRepository;

  /**
   * Constructs a new SamlService.
   *
   * @param \Drupal\externalauth\ExternalAuth $external_auth
   *   The ExternalAuth service.
   * @param \Drupal\externalauth\Authmap $authmap
   *   The Authmap service.
   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
   *   The config factory.
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The EntityTypeManager service.
   * @param \Psr\Log\LoggerInterface $logger
   *   A logger instance.
   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
   *   The event dispatcher.
   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
   *   The request stack.
   * @param \Drupal\Core\TempStore\PrivateTempStoreFactory $temp_store_factory
   *   A temp data store factory object.
   * @param \Drupal\Core\Flood\FloodInterface $flood
   *   The flood service.
   * @param \Drupal\Core\Session\AccountInterface $current_user
   *   The current user service.
   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
   *   The messenger service.
   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
   *   The string translation service.
   */
  public function __construct(ExternalAuth $external_auth, Authmap $authmap, ConfigFactoryInterface $config_factory, EntityTypeManagerInterface $entity_type_manager, LoggerInterface $logger, EventDispatcherInterface $event_dispatcher, RequestStack $request_stack, PrivateTempStoreFactory $temp_store_factory, FloodInterface $flood, AccountInterface $current_user, MessengerInterface $messenger, TranslationInterface $translation) {
    $this->externalAuth = $external_auth;
    $this->authmap = $authmap;
    $this->configFactory = $config_factory;
    $this->entityTypeManager = $entity_type_manager;
    $this->logger = $logger;
    $this->eventDispatcher = $event_dispatcher;
    $this->requestStack = $request_stack;
    $this->privateTempStore = $temp_store_factory
      ->get('samlauth');
    $this->flood = $flood;
    $this->currentUser = $current_user;
    $this->messenger = $messenger;
    $this
      ->setStringTranslation($translation);
    $config = $this->configFactory
      ->get('samlauth.authentication');

    // setProxyVars lets the SAML PHP Toolkit use 'X-Forwarded-*' HTTP headers
    // for identifying the SP URL, but we should pass the Drupal/Symfony base
    // URL to into the toolkit instead. That uses headers/trusted values in the
    // same way as the rest of Drupal (as configured in settings.php).
    // @todo remove this in v4.x
    if ($config
      ->get('use_proxy_headers') && !$config
      ->get('use_base_url')) {

      // Use 'X-Forwarded-*' HTTP headers for identifying the SP URL.
      SamlUtils::setProxyVars(TRUE);
    }
  }

  /**
   * Set the Key repository service.
   *
   * This has a separate setter (unlike all other dependent objects mentioned
   * in the constructor) because it's an optional dependency.
   *
   * @param \Drupal\key\KeyRepositoryInterface $key_repository
   *   the Key repository service.
   */
  public function setKeyRepository(KeyRepositoryInterface $key_repository) {
    $this->keyRepository = $key_repository;
  }

  /**
   * Show metadata about the local sp. Use this to configure your saml2 IdP.
   *
   * @param int|null $validity
   *   (Optional) 'validUntil' property of the metadata (which is a date, not
   *   an interval) will be this many seconds into the future. If left empty,
   *   the SAML PHP Toolkit will assign a value.
   * @param int|null $cache_duration
   *   (Optional) number of seconds used for the 'cacheDuration' property of
   *   the metadata. If left empty, the SAML PHP Toolkit will assign a value.
   *
   * @return mixed
   *   XML string representing metadata.
   *
   * @throws \OneLogin\Saml2\Error
   *   If the metatdad is invalid.
   */
  public function getMetadata($validity = NULL, $cache_duration = NULL) {

    // It's actually strange how we need to instantiate an Auth object when
    // we only need the Settings object. We may refactor that when refactoring
    // getSamlAuth().
    $settings = $this
      ->getSamlAuth('metadata')
      ->getSettings();
    $metadata = $settings
      ->getSPMetadata(FALSE, $validity, $cache_duration);
    $errors = $settings
      ->validateMetadata($metadata);
    if (empty($errors)) {
      return $metadata;
    }
    else {
      throw new SamlError('Invalid SP metadata: ' . implode(', ', $errors), SamlError::METADATA_SP_INVALID);
    }
  }

  /**
   * Initiates a SAML2 authentication flow and redirects to the IdP.
   *
   * @param string $return_to
   *   (optional) The path to return the user to after successful processing by
   *   the IdP.
   * @param array $parameters
   *   (optional) Extra query parameters to add to the returned redirect URL.
   *
   * @return string
   *   The URL of the single sign-on service to redirect to, including query
   *   parameters.
   */
  public function login($return_to = NULL, array $parameters = []) {
    $config = $this->configFactory
      ->get('samlauth.authentication');
    $url = $this
      ->getSamlAuth('login')
      ->login($return_to, $parameters, FALSE, FALSE, TRUE, $config
      ->get('request_set_name_id_policy') ?? TRUE);
    if ($config
      ->get('debug_log_saml_out')) {
      $this->logger
        ->debug('Sending SAML authentication request: <pre>@message</pre>', [
        '@message' => $this
          ->getSamlAuth('login')
          ->getLastRequestXML(),
      ]);
    }
    return $url;
  }

  /**
   * Processes a SAML response (Assertion Consumer Service).
   *
   * First checks whether the SAML request is OK, then takes action on the
   * Drupal user (logs in / maps existing / create new) depending on attributes
   * sent in the request and our module configuration.
   *
   * @return bool
   *   TRUE if the response was correctly processed; FALSE if an error was
   *   encountered while processing but there's a currently logged-in user and
   *   we decided not to throw an exception for this case.
   *
   * @throws \Exception
   */
  public function acs() {
    $config = $this->configFactory
      ->get('samlauth.authentication');
    if ($config
      ->get('debug_log_in')) {
      if (isset($_POST['SAMLResponse'])) {
        $response = base64_decode($_POST['SAMLResponse']);
        if ($response) {
          $this->logger
            ->debug("ACS received 'SAMLResponse' in POST request (base64 decoded): <pre>@message</pre>", [
            '@message' => $response,
          ]);
        }
        else {
          $this->logger
            ->warning("ACS received 'SAMLResponse' in POST request which could not be base64 decoded: <pre>@message</pre>", [
            '@message' => $_POST['SAMLResponse'],
          ]);
        }
      }
      else {

        // Not sure if we should be more detailed...
        $this->logger
          ->warning("HTTP request to ACS is not a POST request, or contains no 'SAMLResponse' parameter.");
      }
    }

    // Perform flood control. This is not to guard against failed login
    // attempts per se; that is the IdP's job. It's just protection against
    // a flood of bogus (DDoS-like) requests because this route performs
    // computationally expensive operations. So: just IP based flood control,
    // using the limit / window values that Core uses for regular login.
    $flood_config = $this->configFactory
      ->get('user.flood');
    if (!$this->flood
      ->isAllowed('samlauth.failed_login_ip', $flood_config
      ->get('ip_limit'), $flood_config
      ->get('ip_window'))) {
      throw new TooManyRequestsHttpException(NULL, 'Access is blocked because of IP based flood prevention.');
    }

    // Process the ACS response message and check if we can derive a linked
    // account, but don't process errors yet. (The following code is a kludge
    // because we may need the linked account / may ignore errors later.)
    try {
      $this
        ->processLoginResponse();
    } catch (\Exception $acs_exception) {
    }
    if (!isset($acs_exception)) {
      $unique_id = $this
        ->getAttributeByConfig('unique_id_attribute');
      if ($unique_id) {
        $account = $this->externalAuth
          ->load($unique_id, 'samlauth') ?: NULL;
      }
    }
    $logout_different_user = $config
      ->get('logout_different_user');
    if ($this->currentUser
      ->isAuthenticated()) {

      // Either redirect or log out so that we can log a different user in.
      // 'Redirecting' is done by the caller - so we can just return from here.
      if (isset($account) && $account
        ->id() === $this->currentUser
        ->id()) {

        // Noting that we were already logged in probably isn't useful. (Core's
        // user/reset link isn't a good case to compare: it always logs the
        // user out and presents the "Reset password" form with a login button.
        // 'drush uli' links, at least on D7, display an info message "please
        // reset your password" because they land on the user edit form.)
        return !isset($acs_exception);
      }
      if (!$logout_different_user) {

        // Message similar to when a user/reset link is followed.
        $this->messenger
          ->addWarning($this
          ->t('Another user (%other_user) is already logged into the site on this computer, but you tried to log in as user %login_user through an external authentication provider. Please <a href=":logout">log out</a> and try again.', [
          '%other_user' => $this->currentUser
            ->getAccountName(),
          '%login_user' => $account ? $account
            ->getAccountName() : '?',
          // Point to /user/logout rather than /saml/logout because we don't
          // want to make people log out from all their logged-in sites.
          ':logout' => Url::fromRoute('user.logout')
            ->toString(),
        ]));
        return !isset($acs_exception);
      }

      // If the SAML response indicates (/ if the processing generated) an
      // error, we don't want to log the current user out but we want to
      // clearly indicate that someone else is still logged in.
      if (isset($acs_exception)) {
        $this->messenger
          ->addWarning($this
          ->t('Another user (%other_user) is already logged into the site on this computer. You tried to log in through an external authentication provider, which failed, so the user is still logged in.', [
          '%other_user' => $this->currentUser
            ->getAccountName(),
        ]));
      }
      else {
        $this
          ->drupalLogoutHelper();
        $this->messenger
          ->addStatus($this
          ->t('Another user (%other_user) was already logged into the site on this computer, and has now been logged out.', [
          '%other_user' => $this->currentUser
            ->getAccountName(),
        ]));
      }
    }
    if (isset($acs_exception)) {
      $this->flood
        ->register('samlauth.failed_login_ip', $flood_config
        ->get('ip_window'));
      throw $acs_exception;
    }
    if (!$unique_id) {
      throw new \RuntimeException('Configured unique ID is not present in SAML response.');
    }
    $this
      ->doLogin($unique_id, $account);

    // Remember SAML session values that may be necessary for logout.
    $auth = $this
      ->getSamlAuth('acs');
    $values = [
      'session_index' => $auth
        ->getSessionIndex(),
      'session_expiration' => $auth
        ->getSessionExpiration(),
      'name_id' => $auth
        ->getNameId(),
      'name_id_format' => $auth
        ->getNameIdFormat(),
    ];
    foreach ($values as $key => $value) {
      if (isset($value)) {
        $this->privateTempStore
          ->set($key, $value);
      }
      else {
        $this->privateTempStore
          ->delete($key);
      }
    }
    return TRUE;
  }

  /**
   * Processes a SAML authentication response; throws an exception if invalid.
   *
   * The mechanics of checking whether there are any errors are not so
   * straightforward, so this helper function hopes to abstract that away.
   *
   * @todo should we also check a Response against the ID of the request we
   *   sent earlier? Seems to be not absolutely required on top of the validity
   *   / signature checks which the library already does - but every extra
   *   check is good. Maybe make it optional.
   */
  protected function processLoginResponse() {
    $config = $this->configFactory
      ->get('samlauth.authentication');
    $auth = $this
      ->getSamlAuth('acs');

    // This call can throw various kinds of exceptions if the 'SAMLResponse'
    // request parameter is not present or cannot be decoded into a valid SAML
    // (XML) message, and can also set error conditions instead - if the XML
    // contains data that is not considered valid. We should likely treat all
    // error conditions the same.
    $auth
      ->processResponse();
    if ($config
      ->get('debug_log_saml_in')) {
      $this->logger
        ->debug('ACS received SAML response: <pre>@message</pre>', [
        '@message' => $auth
          ->getLastResponseXML(),
      ]);
    }
    $errors = $auth
      ->getErrors();
    if ($errors) {

      // We have one or multiple error types / short descriptions, and one
      // 'reason' for the last error.
      throw new \RuntimeException('Error(s) encountered during processing of authentication response. Type(s): ' . implode(', ', array_unique($errors)) . '; reason given for last error: ' . $auth
        ->getLastErrorReason());
    }
    if (!$auth
      ->isAuthenticated()) {

      // Looking at the current code, isAuthenticated() just means "response
      // is valid" because it is mutually exclusive with $errors and exceptions
      // being thrown. So we should never get here. We're just checking it in
      // case the library code changes - in which case we should reevaluate.
      throw new \RuntimeException('SAML authentication response was apparently not fully validated even when no error was provided.');
    }
  }

  /**
   * Logs a user in, creating / linking an account; synchronizes attributes.
   *
   * Split off from acs() to... have at least some kind of split.
   *
   * @param string $unique_id
   *   The unique ID (attribute value) contained in the SAML response.
   * @param \Drupal\Core\Session\AccountInterface|null $account
   *   The existing user account derived from the unique ID, if any.
   */
  protected function doLogin($unique_id, AccountInterface $account = NULL) {
    $config = $this->configFactory
      ->get('samlauth.authentication');
    $first_saml_login = FALSE;
    if (!$account) {
      $this->logger
        ->debug('No matching local users found for unique SAML ID @saml_id.', [
        '@saml_id' => $unique_id,
      ]);

      // Try to link an existing user: first through a custom event handler,
      // then by name, then by email.
      if ($config
        ->get('map_users')) {
        $event = new SamlauthUserLinkEvent($this
          ->getAttributes());
        $this->eventDispatcher
          ->dispatch(SamlauthEvents::USER_LINK, $event);
        $account = $event
          ->getLinkedAccount();
        if ($account) {
          $this->logger
            ->info('Existing user @name (@uid) was newly matched to SAML login attributes; linking user and logging in.', [
            '@name' => $account
              ->getAccountName(),
            '@uid' => $account
              ->id(),
          ]);
        }
      }

      // Linking by name / email: we also select accounts if they are blocked
      // (and throw an exception later on) because 1) we don't want the
      // selection to be dependent on the current account's state; 2) name and
      // email are unique and would otherwise lead to another error while
      // trying to create a new account with duplicate values.
      if (!$account) {
        $name = $this
          ->getAttributeByConfig('user_name_attribute');
        if ($name && ($account_search = $this->entityTypeManager
          ->getStorage('user')
          ->loadByProperties([
          'name' => $name,
        ]))) {
          $account = current($account_search);
          if ($config
            ->get('map_users_name')) {
            $this->logger
              ->info('SAML login for name @name (as provided in a SAML attribute) matches existing Drupal account @uid; linking account and logging in.', [
              '@name' => $name,
              '@uid' => $account
                ->id(),
            ]);
          }
          else {

            // We're not configured to link the account by name, but we still
            // looked it up by name so we can give a better error message than
            // the one caused by trying to save a new account with a duplicate
            // name, later.
            $this->logger
              ->warning('Denying login: SAML login for unique ID @saml_id matches existing Drupal account name @name and we are not configured to automatically link accounts.', [
              '@saml_id' => $unique_id,
              '@name' => $account
                ->getAccountName(),
            ]);
            throw new UserVisibleException('A local user account with your login name already exists, and we are disallowed from linking it.');
          }
        }
      }
      if (!$account) {
        $mail = $this
          ->getAttributeByConfig('user_mail_attribute');
        if ($mail && ($account_search = $this->entityTypeManager
          ->getStorage('user')
          ->loadByProperties([
          'mail' => $mail,
        ]))) {
          $account = current($account_search);
          if ($config
            ->get('map_users_mail')) {
            $this->logger
              ->info('SAML login for email @mail (as provided in a SAML attribute) matches existing Drupal account @uid; linking account and logging in.', [
              '@mail' => $mail,
              '@uid' => $account
                ->id(),
            ]);
          }
          else {

            // Treat duplicate email same as duplicate name above.
            $this->logger
              ->warning('Denying login: SAML login for unique ID @saml_id matches existing Drupal account email @mail and we are not configured to automatically link the account.', [
              '@saml_id' => $unique_id,
              '@mail' => $account
                ->getEmail(),
            ]);
            throw new UserVisibleException('A local user account with your login email address name already exists, and we are disallowed from linking it.');
          }
        }
      }
      if ($account) {
        $this
          ->linkExistingAccount($unique_id, $account);
        $first_saml_login = TRUE;
      }
    }

    // If we haven't found an account to link, create one from the SAML
    // attributes.
    if (!$account) {
      if ($config
        ->get('create_users')) {

        // The register() call will save the account. We want to:
        // - add values from the SAML response into the user account;
        // - not save the account twice (because if the second save fails we do
        //   not want to end up with a user account in an undetermined state);
        // - reuse code (i.e. call synchronizeUserAttributes() with its current
        //   signature, which is also done when an existing user logs in).
        // Because of the third point, we are not passing the necessary SAML
        // attributes into register()'s $account_data parameter, but we want to
        // hook into the save operation of the user account object that is
        // created by register(). It seems we can only do this by implementing
        // hook_user_presave() - which calls our synchronizeUserAttributes().
        $account_data = [
          'name' => $this
            ->getAttributeByConfig('user_name_attribute'),
        ];
        $account = $this->externalAuth
          ->register($unique_id, 'samlauth', $account_data);
        $this->externalAuth
          ->userLoginFinalize($account, $unique_id, 'samlauth');
      }
      else {
        throw new UserVisibleException('No existing user account matches the SAML ID provided. This authentication service is not configured to create new accounts.');
      }
    }
    elseif ($account
      ->isBlocked()) {
      throw new UserVisibleException('Requested account is blocked.');
    }
    else {

      // Synchronize the user account with SAML attributes if needed.
      $this
        ->synchronizeUserAttributes($account, FALSE, $first_saml_login);
      $this->externalAuth
        ->userLoginFinalize($account, $unique_id, 'samlauth');
    }
  }

  /**
   * Link a pre-existing Drupal user to a given authname.
   *
   * @param string $unique_id
   *   The unique ID (attribute value) contained in the SAML response.
   * @param \Drupal\Core\Session\AccountInterface|null $account
   *   The existing user account derived from the unique ID, if any.
   *
   * @throws \Drupal\samlauth\UserVisibleException
   *   If linking fails or is denied.
   */
  protected function linkExistingAccount($unique_id, UserInterface $account) {
    $allowed_roles = $this->configFactory
      ->get('samlauth.authentication')
      ->get('map_users_roles');
    $disallowed_roles = array_diff($account
      ->getRoles(), $allowed_roles, [
      AccountInterface::AUTHENTICATED_ROLE,
    ]);
    if ($disallowed_roles) {
      $this->logger
        ->warning('Denying login: SAML login for unique ID @saml_id matches existing Drupal account @uid which we are not allowed to link because it has roles @roles.', [
        '@saml_id' => $unique_id,
        '@uid' => $account
          ->id(),
        '@roles' => implode(', ', $disallowed_roles),
      ]);
      throw new UserVisibleException('A local user account matching your login already exists, and we are disallowed from linking it.');
    }
    $this->externalAuth
      ->linkExistingAccount($unique_id, 'samlauth', $account);

    // linkExistingAccount() does not tell us whether the link was actually
    // successful; it silently continues if the account was already linked
    // to a different unique ID. This would mean a user who has the power
    // to change their user name / email on the IdP side, potentially has
    // the power to log into different accounts (as long as they only log
    // into accounts that already are linked to a different IdP user).
    $linked_id = $this->authmap
      ->get($account
      ->id(), 'samlauth');
    if ($linked_id != $unique_id) {
      $this->logger
        ->warning('Denying login: existing Drupal account @uid matches SAML login for unique ID @saml_id, but the account is already linked to SAML login ID @linked_id. If a new account should be created despite the earlier match, temporarily turn off matching. If this login should be linked to user @uid, remove the earlier link.', [
        '@uid' => $account
          ->id(),
        '@saml_id' => $unique_id,
        '@linked_id' => $linked_id,
      ]);
      throw new UserVisibleException('Your login data match an earlier login by a different SAML user.');
    }
  }

  /**
   * Initiates a SAML2 logout flow and redirects to the IdP.
   *
   * @param string $return_to
   *   (optional) The path to return the user to after successful processing by
   *   the IdP.
   * @param array $parameters
   *   (optional) Extra query parameters to add to the returned redirect URL.
   *
   * @return string
   *   The URL of the single logout service to redirect to, including query
   *   parameters.
   */
  public function logout($return_to = NULL, array $parameters = []) {

    // Log the Drupal user out at the start of the process if they were still
    // logged in. Official SAML documentation usually specifies (as far as it
    // does) that we should log the user out after getting redirected from the
    // IdP instead, at /saml/sls. However
    // - Between calling logout() and all those redirects there is a lot that
    //   could go wrong which would then influence users' ability to log out of
    //   Drupal.
    // - There's no real downside to doing it now, either for the user or for
    //   our code (which already explicitly supports handling users who were
    //   previously logged out of Drupal).
    // - Site administrators may also want this endpoint to work for logging
    //   out non-SAML users. (Otherwise how are they going to display
    //   different login links for different users?) PLEASE NOTE however, that
    //   this is not the primary purpose of this method; it is to enable both
    //   logged-in and already-logged-out Drupal users to start a SAML logout
    //   process - i.e. to be redirected to the IdP. So a side effect is that
    //   non-SAML users are also redirected to the IdP unnecessarily. It may be
    //   possible to prevent this - but that will need to be tested carefully.
    $saml_session_data = $this
      ->drupalLogoutHelper();

    // Start the SAML logout process. If the user was already logged out before
    // this method was called, we won't have any SAML session data so won't be
    // able to tell the IdP which session should be logging out. Even so, the
    // SAML Toolkit is able to create a generic LogoutRequest, and for at least
    // some IdPs that's enough to log the user out from the IdP if applicable
    // (because they have their own browser/cookie based session handling) and
    // return a SAMLResponse indicating success. (Maybe there's some way to
    // modify the Drupal logout process to keep the SAML session data available
    // but we won't explore that until there's a practical situation where
    // that's clearly needed.)
    // @todo should we check session expiration time before sending a logout
    //   request to the IdP? (What would an IdP do if it received an old
    //   session index? Is it better to not redirect, and throw an error on
    //   our side?)
    // @todo include nameId(SP)NameQualifier?
    $url = $this
      ->getSamlAuth('logout')
      ->logout($return_to, $parameters, $saml_session_data['name_id'] ?? NULL, $saml_session_data['session_index'] ?? NULL, TRUE, $saml_session_data['name_id_format'] ?? NULL);
    if ($this->configFactory
      ->get('samlauth.authentication')
      ->get('debug_log_saml_out')) {
      $this->logger
        ->debug('Sending SAML logout request: <pre>@message</pre>', [
        '@message' => $this
          ->getSamlAuth('logout')
          ->getLastRequestXML(),
      ]);
    }
    return $url;
  }

  /**
   * Does processing for the Single Logout Service.
   *
   * @return null|string
   *   Usually returns nothing. May return a URL to redirect to.
   */
  public function sls() {
    $config = $this->configFactory
      ->get('samlauth.authentication');

    // We might at some point check if this code can be abstracted a bit...
    if ($config
      ->get('debug_log_in')) {
      if (isset($_GET['SAMLResponse'])) {
        $response = base64_decode($_GET['SAMLResponse']);
        if ($response) {
          $this->logger
            ->debug("SLS received 'SAMLResponse' in GET request (base64 decoded): <pre>@message</pre>", [
            '@message' => $response,
          ]);
        }
        else {
          $this->logger
            ->warning("SLS received 'SAMLResponse' in GET request which could not be base64 decoded: <pre>@message</pre>", [
            '@message' => $_POST['SAMLResponse'],
          ]);
        }
      }
      elseif (isset($_GET['SAMLRequest'])) {
        $response = base64_decode($_GET['SAMLRequest']);
        if ($response) {
          $this->logger
            ->debug("SLS received 'SAMLRequest' in GET request (base64 decoded): <pre>@message</pre>", [
            '@message' => $response,
          ]);
        }
        else {
          $this->logger
            ->warning("SLS received 'SAMLRequest' in GET request which could not be base64 decoded: <pre>@message</pre>", [
            '@message' => $_POST['SAMLRequest'],
          ]);
        }
      }
      else {

        // Not sure if we should be more detailed...
        $this->logger
          ->warning("HTTP request to SLS is not a GET request, or contains no 'SAMLResponse'/'SAMLRequest' parameters.");
      }
    }

    // Perform flood control; see acs().
    $flood_config = $this->configFactory
      ->get('user.flood');
    if (!$this->flood
      ->isAllowed('samlauth.failed_logout_ip', $flood_config
      ->get('ip_limit'), $flood_config
      ->get('ip_window'))) {
      throw new TooManyRequestsHttpException(NULL, 'Access is blocked because of IP based flood prevention.');
    }
    try {

      // This line means we're extracting logic previously encapsulated inside
      // the Auth class. That's slightly unfortunate but doesn't compare to all
      // other considerations still needing to be made re. refactoring logic.
      $purpose = isset($_GET['SAMLResponse']) ? 'sls-response' : 'sls-request';

      // Unlike the 'logout()' route, we only log the user out if we have a
      // valid request/response, so first have the SAML Toolkit check things.
      // Don't have it do any session actions, because nothing is needed
      // besides our own logout actions (if any). This call can either set an
      // error condition or throw a \OneLogin_Saml2_Error, depending on whether
      // we are processing a POST request; don't catch anything.
      // @todo should we check a LogoutResponse against the ID of the
      //   LogoutRequest we sent earlier? Seems to be not absolutely required on
      //   top of the validity / signature checks which the library already does
      //   - but every extra check is good. Maybe make it optional.
      $url = $this
        ->getSamlAuth($purpose)
        ->processSLO(TRUE, NULL, (bool) $config
        ->get('security_logout_reuse_sigs'), NULL, TRUE);
    } catch (\Exception $e) {
      $this->flood
        ->register('samlauth.failed_logout_ip', $flood_config
        ->get('ip_window'));
      throw $e;
    }
    if ($config
      ->get('debug_log_saml_in')) {

      // There should be no way we can get here if neither GET parameter is set;
      // if nothing gets logged, that's a bug.
      if (isset($_GET['SAMLResponse'])) {
        $this->logger
          ->debug('SLS received SAML response: <pre>@message</pre>', [
          '@message' => $this
            ->getSamlAuth($purpose)
            ->getLastResponseXML(),
        ]);
      }
      elseif (isset($_GET['SAMLRequest'])) {
        $this->logger
          ->debug('SLS received SAML request: <pre>@message</pre>', [
          '@message' => $this
            ->getSamlAuth($purpose)
            ->getLastRequestXML(),
        ]);
      }
    }

    // Now look if there were any errors and also throw.
    $errors = $this
      ->getSamlAuth($purpose)
      ->getErrors();
    if (!empty($errors)) {

      // We have one or multiple error types / short descriptions, and one
      // 'reason' for the last error.
      throw new \RuntimeException('Error(s) encountered during processing of SLS response. Type(s): ' . implode(', ', array_unique($errors)) . '; reason given for last error: ' . $this
        ->getSamlAuth($purpose)
        ->getLastErrorReason());
    }

    // Remove SAML session data, log the user out of Drupal, and return a
    // redirect URL if we got any. Usually,
    // - a LogoutRequest means we need to log out and redirect back to the IdP,
    //   for which the SAML Toolkit returned a URL.
    // - after a LogoutResponse we don't need to log out because we already did
    //   that at the start of the process, in logout() - but there's nothing
    //   against checking. We did not get an URL returned and our caller can
    //   decide what to do next.
    $this
      ->drupalLogoutHelper();
    return $url;
  }

  /**
   * Synchronizes user data with attributes in the SAML request.
   *
   * @param \Drupal\user\UserInterface $account
   *   The Drupal user to synchronize attributes into.
   * @param bool $skip_save
   *   (optional) If TRUE, skip saving the user account.
   * @param bool $first_saml_login
   *   (optional) Indicator of whether the account is newly registered/linked.
   */
  public function synchronizeUserAttributes(UserInterface $account, $skip_save = FALSE, $first_saml_login = FALSE) {

    // Dispatch a user_sync event.
    $event = new SamlauthUserSyncEvent($account, $this
      ->getAttributes(), $first_saml_login);
    $this->eventDispatcher
      ->dispatch(SamlauthEvents::USER_SYNC, $event);
    if (!$skip_save && $event
      ->isAccountChanged()) {
      $account
        ->save();
    }
  }

  /**
   * Returns all attributes in a SAML response.
   *
   * This method will return valid data after a response is processed (i.e.
   * after samlAuth->processResponse() is called).
   *
   * @return array
   *   An array with all returned SAML attributes..
   */
  public function getAttributes() {
    $attributes = $this
      ->getSamlAuth('acs')
      ->getAttributes();
    $friendly_attributes = $this
      ->getSamlAuth('acs')
      ->getAttributesWithFriendlyName();
    return $attributes + $friendly_attributes;
  }

  /**
   * Returns value from a SAML attribute whose name is configured in our module.
   *
   * This method will return valid data after a response is processed (i.e.
   * after samlAuth->processResponse() is called).
   *
   * @param string $config_key
   *   A key in the module's configuration, containing the name of a SAML
   *   attribute.
   *
   * @return mixed|null
   *   The SAML attribute value; NULL if the attribute value, or configuration
   *   key, was not found.
   */
  public function getAttributeByConfig($config_key) {
    $attribute_name = $this->configFactory
      ->get('samlauth.authentication')
      ->get($config_key);
    if ($attribute_name) {
      $attribute = $this
        ->getSamlAuth('acs')
        ->getAttribute($attribute_name);
      if (!empty($attribute[0])) {
        return $attribute[0];
      }
      $friendly_attribute = $this
        ->getSamlAuth('acs')
        ->getAttributeWithFriendlyName($attribute_name);
      if (!empty($friendly_attribute[0])) {
        return $friendly_attribute[0];
      }
    }
  }

  /**
   * Returns an initialized Auth class from the SAML Toolkit.
   *
   * @param string $purpose
   *   (Optional) purpose for the config: 'metadata' / 'login' / 'acs' /
   *   'logout' / 'sls-request' / 'sls-response'. Empty string means 'any', but
   *   likely shouldn't be used anywhere. (The way many callers hardcode this
   *   argument may seem strange, until you realize that _these callers_ only
   *   have one possible purpose too, in practice. This is almost sure to be
   *   refactored away in a future version.)
   */
  protected function getSamlAuth($purpose = '') {
    if (!isset($this->samlAuth[$purpose])) {
      $base_url = '';
      $config = $this->configFactory
        ->get('samlauth.authentication');
      if ($config
        ->get('use_base_url')) {
        $request = $this->requestStack
          ->getCurrentRequest();

        // The 'base url' for the SAML Toolkit is apparently 'all except the
        // last part of the endpoint URLs'. (Whoever wants a better explanation
        // can try to extract it from e.g. Utils::getSelfRoutedURLNoQuery().)
        $base_url = $request
          ->getSchemeAndHttpHost() . $request
          ->getBaseUrl() . '/saml';
      }
      $this->samlAuth[$purpose] = new Auth(static::reformatConfig($config, $base_url, $purpose, $this->keyRepository));
    }
    return $this->samlAuth[$purpose];
  }

  /**
   * Ensures the user is logged out from Drupal; returns SAML session data.
   *
   * @param bool $delete_saml_session_data
   *   (optional) whether to delete the SAML session data. This depends on:
   *   - how bad (privacy sensitive) it is to keep around? Answer: not.
   *   - whether we expect the data to ever be reused. That is: could a SAML
   *     logout attempt be done for the same SAML session multiple times?
   *     Answer: we don't know. Unlikely, because it is not accessible anymore
   *     after logout, so the user would need to log in to Drupal locally again
   *     before anything could be done with it.
   *
   * @return array
   *   Array of data about the 'SAML session' that we stored at login. (The
   *   SAML toolkit itself does not store any data / implement the concept of a
   *   session.)
   */
  protected function drupalLogoutHelper($delete_saml_session_data = TRUE) {
    $data = [];
    if ($this->currentUser
      ->isAuthenticated()) {

      // Get data from our temp store which is not accessible after logout.
      // DEVELOPER NOTE: It depends on our session storage, whether we want to
      // try this for unauthenticated users too. At the moment, we are sure
      // only authenticated users have any SAML session data - and trying to
      // get() a value from our privateTempStore can unnecessarily start a new
      // PHP session for unauthenticated users.
      $keys = [
        'session_index',
        'session_expiration',
        'name_id',
        'name_id_format',
      ];
      foreach ($keys as $key) {
        $data[$key] = $this->privateTempStore
          ->get($key);
        if ($delete_saml_session_data) {
          $this->privateTempStore
            ->delete($key);
        }
      }

      // @todo properly inject this... after #2012976 lands.
      user_logout();
    }
    return $data;
  }

  /**
   * Returns a configuration array as used by the external library.
   *
   * Some of these arguments are just added because the method is static (which
   * will change in v4.x).
   *
   * @param \Drupal\Core\Config\ImmutableConfig $config
   *   The module configuration.
   * @param string $base_url
   *   (Optional) base URL to set.
   * @param string $purpose
   *   (Optional) purpose for the config: 'metadata' / 'login' / 'acs' /
   *   'logout' / 'sls-request' / 'sls-response'.
   * @param \Drupal\key\KeyRepositoryInterface $key_repository
   *   (Optional) the service's Key repository.
   *
   * @return array
   *   The library configuration array.
   */
  protected static function reformatConfig(ImmutableConfig $config, $base_url = '', $purpose = '', KeyRepositoryInterface $key_repository = NULL) {
    $library_config = [
      'debug' => (bool) $config
        ->get('debug_phpsaml'),
      'sp' => [
        'entityId' => $config
          ->get('sp_entity_id'),
        'assertionConsumerService' => [
          // See ExecuteInRenderContextTrait if curious why the long chained
          // call is necessary.
          'url' => Url::fromRoute('samlauth.saml_controller_acs', [], [
            'absolute' => TRUE,
          ])
            ->toString(TRUE)
            ->getGeneratedUrl(),
        ],
        'singleLogoutService' => [
          'url' => Url::fromRoute('samlauth.saml_controller_sls', [], [
            'absolute' => TRUE,
          ])
            ->toString(TRUE)
            ->getGeneratedUrl(),
        ],
        'NameIDFormat' => $config
          ->get('sp_name_id_format') ?: NULL,
      ],
      'idp' => [
        'entityId' => $config
          ->get('idp_entity_id'),
        'singleSignOnService' => [
          'url' => $config
            ->get('idp_single_sign_on_service'),
        ],
        'singleLogoutService' => [
          'url' => $config
            ->get('idp_single_log_out_service'),
        ],
      ],
      'security' => [
        // Used for metadata (adds a whole signature section):
        'signMetadata' => (bool) $config
          ->get('security_metadata_sign'),
        // Used for metadata (value goes into attribute
        // AuthnRequestsSigned="true/false" of SPSSODescriptor) / login(*):
        'authnRequestsSigned' => (bool) $config
          ->get('security_authn_requests_sign'),
        // Used for logout(*):
        'logoutRequestSigned' => (bool) $config
          ->get('security_logout_requests_sign'),
        // Used for SLO response, sent after processing incoming SLO request(*):
        'logoutResponseSigned' => (bool) $config
          ->get('security_logout_responses_sign'),
        // Used for logout; also influences Settings:__construct() checks for
        // presence of IDP cert:
        'nameIdEncrypted' => (bool) $config
          ->get('security_nameid_encrypt'),
        // Used for acs:
        // TRUE by default AND must be TRUE on existing installations that
        // didn't have the setting before, so it's the first one to get a
        // default value. (If we didn't have the (bool) operator, we wouldn't
        // necessarily need the default - but leaving it out would just invite
        // a bug later on.)
        'wantNameId' => (bool) ($config
          ->get('security_want_name_id') ?? TRUE),
        // Used for login / acs(*); indirectly influences metadata(**):
        'wantNameIdEncrypted' => (bool) $config
          ->get('security_nameid_encrypted'),
        // Used for acs(*); indirectly influences metadata(**):
        'wantAssertionsEncrypted' => (bool) $config
          ->get('security_assertions_encrypt'),
        // Used for metadata (value goes into attribute
        // WantAssertionsSigned="true/false" of SPSSODescriptor) / acs(*):
        'wantAssertionsSigned' => (bool) $config
          ->get('security_assertions_signed'),
        // Used for acs / sls (processing incoming SLO responses and requests):
        'wantMessagesSigned' => (bool) $config
          ->get('security_messages_sign'),
        // Used for login:
        'requestedAuthnContext' => (bool) $config
          ->get('security_request_authn_context'),
        // Used for login / logout / SLO response, sent after processing
        // incoming SLO request; should be deprecated:
        'lowercaseUrlencoding' => (bool) $config
          ->get('security_lowercase_url_encoding'),
      ],
      'strict' => (bool) $config
        ->get('strict'),
    ];
    $sig_alg = $config
      ->get('security_signature_algorithm');
    if ($sig_alg) {
      $library_config['security']['signatureAlgorithm'] = $sig_alg;
    }
    $enc_alg = $config
      ->get('security_encryption_algorithm');
    if ($enc_alg) {

      // Not a typo; this is snake_case in the library too.
      $library_config['security']['encryption_algorithm'] = $enc_alg;
    }
    if ($base_url) {
      $library_config['baseurl'] = $base_url;
    }

    // We want to read cert/key values from whereever they are stored, only
    // when we actually need them. This may lead to us creating a custom
    // \OneLogin\Saml2\Settings child class that contains the logic of 'just in
    // time' reading key/cert values from storage. However (leaving aside the
    // fact that we can't do that in v3.x because it will break compatibility),
    // - the Auth class cannot use Settings subclasses: its constructor only
    //   accepts an array and its $_settings variable is private (so we cannot
    //   get around this by subclassing Auth).
    // - while the design of Settings supports 'just in time reading', (and the
    //   Settings class even does it, in some cases), its setup _in principle_
    //   isn't well suited for handling that. So if we end up doing this, we'll
    //   need to add good comments about e.g. getSPData() not returning
    //   complete data, format*() not working, ... (Basically, my conclusion is
    //   like my conclusion elsewhere about the Auth object: from the design of
    //   the Settings object it isn't clear whether it wants to be a value
    //   object or whether it wants to contain the logic of how to read the
    //   key/cert values. It would be nice if the code was... clearer.)
    // So for now, we are adding logic to this method that 'knows' when the key
    // / certs are used.
    $add_key = $add_cert = $add_idp_cert = TRUE;
    $add_new_cert = in_array($purpose, [
      'metadata',
      '',
    ]);
    $add_idp_encryption_cert = FALSE;
    switch ($purpose) {
      case 'metadata':

        // signMetadata / wantNameIdEncrypted are not implemented yet but that
        // doesn't mean we can't already use them here: they potentially
        // influence metadata but then we can't turn off $add_key.
        $add_key = !empty($library_config['security']['signMetadata']) || !empty($library_config['security']['wantNameIdEncrypted']) || !empty($library_config['security']['wantAssertionsEncrypted']);

        // We cannot prevent Settings::checkIdPSettings() from being called
        // while using the standard Auth class, so cannot do this:
        // $add_idp_cert = FALSE;.
        // @todo this is a good enough reason for opening a PR to amend
        //   Auth::construct() (to accept a Settings that was constructed with
        //   (, TRUE)), regardless of considerations outlined in AuthVolatile.
        break;
      case 'login':

        // We don't need a cert for operation; we just need to set it to a
        // value if certain security settings are set (which need the private
        // key), or otherwise checkSPCerts() will freak out.
        $add_cert = !empty($library_config['security']['authnRequestsSigned']) || !empty($library_config['security']['wantNameIdEncrypted']) ? 'FAKE' : FALSE;

        // We cannot prevent Settings::checkIdPSettings() from being called
        // while using the standard Auth class, so cannot do this:
        // $add_idp_cert = FALSE;.
        break;
      case 'logout':

        // We don't need a cert for operation; we just need to set it to a
        // value if certain security settings are set (which need the private
        // key), or otherwise checkSPCerts() will freak out.
        $add_cert = !empty($library_config['security']['logoutRequestSigned']) ? 'FAKE' : FALSE;

        // We cannot prevent Settings::checkIdPSettings() from being called
        // while using the standard Auth class, so cannot do this:
        // $add_idp_cert=!empty($library_config['security']['nameIdEncrypted']);
        // ^ This would also need the 2nd parameter to Settings::__construct()
        // to be !$add_idp_cert.
        // That just means we should generally add at least 1 IdP cert, though.
        // We don't need to add the encryption cert specifically - only if:
        $add_idp_encryption_cert = !empty($library_config['security']['nameIdEncrypted']);
        break;
      case 'acs':

        // $add_key depends on the presence of any EncryptedAssertion elements,
        // which is too bothersome for us to check (and which is a reason to
        // have the key reading inside a child Settings object instead) so
        // we'll assume it's TRUE. Same with $add_idp_cert which depends on
        // the presence of various signed elements.
        $add_cert = !empty($library_config['security']['wantAssertionsEncrypted']) || !empty($library_config['security']['wantAssertionsSigned']) || !empty($library_config['security']['wantNameIdEncrypted']) ? 'FAKE' : FALSE;
        break;
      case 'sls-request':

        // We need to both interpret the incoming request and probably create a
        // new outgoing one, so we need key and cert.
        // We cannot prevent Settings::checkIdPSettings() from being called
        // while using the standard Auth class, so cannot do this:
        // $add_idp_cert = isset($_GET['Signature']); // This would also need
        // the 2nd parameter to Settings::__construct() to be !$add_idp_cert.
        break;
      case 'sls-response':
        $add_key = $add_cert = FALSE;
    }
    if (!$add_key || !$add_cert) {

      // If these are set, checkSPCerts() will get called, which requires key +
      // cert presence. Neither of these two variables are (should have been)
      // set to FALSE if any of these security settings is both true and
      // relevant for our $purpose. (The full logic including the switch{}
      // implies the library default is FALSE for all these security settings.)
      unset($library_config['security']['authnRequestsSigned']);
      unset($library_config['security']['logoutRequestSigned']);
      unset($library_config['security']['logoutResponseSigned']);
      unset($library_config['security']['wantAssertionsEncrypted']);
      unset($library_config['security']['wantNameIdEncrypted']);
    }
    if (!$add_idp_cert) {

      // Same for IdP cert.
      unset($library_config['security']['nameIdEncrypted']);
    }

    // Check if we want to load the certificates from a folder. Either folder
    // or cert+key settings should be defined. If both are defined, "folder" is
    // the preferred method and we ignore cert/path values; we don't do more
    // complicated validation like checking whether the cert/key files exist.
    // Initializing cert/key properties to '' (rather than not setting them at
    // all) should be fine, because the standard Settings also does that.
    $cert_folder = $config
      ->get('sp_cert_folder');
    if ($cert_folder) {

      // Set the folder so the SAML toolkit knows where to look. This is the
      // old method which only reads key/cert when we actually need it, because
      // the logic for it is inside the Settings class. We're phasing it out in
      // favor of the hardcoded above $add_* logic and reading the key/cert
      // files inside this function, because this old method relies on a
      // specific hardcoded folder name / file names in the Settings class.
      // @todo remove in 4.x: not applicable after samlauth_update_8304().
      if (!defined('ONELOGIN_CUSTOMPATH')) {
        define('ONELOGIN_CUSTOMPATH', "{$cert_folder}/");
      }
    }
    else {
      if ($add_key) {
        $key = $config
          ->get('sp_private_key');
        if (isset($key) && !is_string($key)) {
          throw new SamlError('SP private key setting is not a string.', SamlError::SETTINGS_INVALID);
        }
        $type = strstr($key, ':', TRUE);
        if ($type === 'key') {
          if ($key_repository) {
            $key = substr($key, 4);
            $key_entity = $key_repository
              ->getKey($key);
            if (!$key_entity) {
              throw new SamlError("SP private key '{$key}' not found.", SamlError::SETTINGS_INVALID);
            }
            $key = $key_entity
              ->getKeyValue();
          }
          else {
            throw new SamlError('SP private key setting is of type "key" but the Key module is not installed.', SamlError::SETTINGS_INVALID);
          }
        }
        elseif ($type === 'file') {
          $key = file_get_contents(substr($key, 5));
          if ($key === FALSE) {
            throw new SamlError('SP private key not found.', SamlError::PRIVATE_KEY_FILE_NOT_FOUND);
          }
        }
        $library_config['sp']['privateKey'] = $key;
      }
      if ($add_cert) {
        $cert = $add_cert === 'FAKE' ? 'dummy-value-to-subvert-validation' : $config
          ->get('sp_x509_certificate');
        if (isset($cert) && !is_string($cert)) {
          throw new SamlError('SP public cert setting is not a string.', SamlError::SETTINGS_INVALID);
        }
        if ($cert) {
          $type = strstr($cert, ':', TRUE);
          if ($type === 'key') {
            if ($key_repository) {
              $cert = substr($cert, 4);
              $key_entity = $key_repository
                ->getKey($cert);
              if (!$key_entity) {
                throw new SamlError("SP public cert '{$cert}' not found.", SamlError::SETTINGS_INVALID);
              }
              $cert = $key_entity
                ->getKeyValue();
            }
            else {
              throw new SamlError('SP public cert setting is of type "key" but the Key module is not installed.', SamlError::SETTINGS_INVALID);
            }
          }
          elseif ($type === 'file') {
            $cert = file_get_contents(substr($cert, 5));
            if ($cert === FALSE) {
              throw new SamlError('SP public cert not found.', SamlError::PUBLIC_CERT_FILE_NOT_FOUND);
            }
          }
          $library_config['sp']['x509cert'] = $cert;
        }
      }
      if ($add_new_cert) {
        $cert = $config
          ->get('sp_new_certificate');
        if (isset($cert) && !is_string($cert)) {
          throw new SamlError('SP new public cert setting is not a string.', SamlError::SETTINGS_INVALID);
        }
        if ($cert) {
          $type = strstr($cert, ':', TRUE);
          if ($type === 'key') {
            if ($key_repository) {
              $cert = substr($cert, 4);
              $key_entity = $key_repository
                ->getKey($cert);
              if (!$key_entity) {
                throw new SamlError("SP new public cert '{$cert}' not found.", SamlError::SETTINGS_INVALID);
              }
              $cert = $key_entity
                ->getKeyValue();
            }
            else {
              throw new SamlError('SP new public cert setting is of type "key" but the Key module is not installed.', SamlError::SETTINGS_INVALID);
            }
          }
          elseif ($type === 'file') {
            $cert = file_get_contents(substr($cert, 5));
            if ($cert === FALSE) {
              throw new SamlError('SP new public cert not found.', SamlError::PUBLIC_CERT_FILE_NOT_FOUND);
            }
          }
          $library_config['sp']['x509certNew'] = $cert;
        }
      }
    }
    $encryption_cert = '';
    $certs = [];
    if ($add_idp_encryption_cert) {
      $encryption_cert = $config
        ->get('idp_cert_encryption');
      if (isset($encryption_cert) && !is_string($encryption_cert)) {
        throw new SamlError('IdP encryption cert setting is not a string.', SamlError::SETTINGS_INVALID);
      }
      $type = strstr($encryption_cert, ':', TRUE);
      if ($type === 'key') {
        if ($key_repository) {
          $encryption_cert = substr($encryption_cert, 4);
          $key_entity = $key_repository
            ->getKey($encryption_cert);
          if (!$key_entity) {
            throw new SamlError("IdP encryption cert '{$encryption_cert}' not found.", SamlError::SETTINGS_INVALID);
          }
          $encryption_cert = $key_entity
            ->getKeyValue();
        }
        else {
          throw new SamlError('IdP encryption cert setting is of type "key" but the Key module is not installed.', SamlError::SETTINGS_INVALID);
        }
      }
      elseif ($type === 'file') {
        $encryption_cert = file_get_contents(substr($encryption_cert, 5));
        if ($encryption_cert === FALSE) {
          throw new SamlError('IdP encryption cert not found.', SamlError::PRIVATE_KEY_FILE_NOT_FOUND);
        }
      }
    }
    if ($add_idp_cert || $add_idp_encryption_cert && !$encryption_cert) {
      $certs = $config
        ->get('idp_certs');
      foreach ($certs as $i => $cert) {
        if (isset($certs[$i]) && !is_string($certs[$i])) {
          $nr = $i ? " {$i}" : '';
          throw new SamlError("IdP cert setting{$nr} is not a string.", SamlError::SETTINGS_INVALID);
        }
        $type = strstr($cert, ':', TRUE);
        if ($type === 'key') {
          if ($key_repository) {
            $cert = substr($cert, 4);
            $key_entity = $key_repository
              ->getKey($cert);
            if (!$key_entity) {
              throw new SamlError("IdP cert '{$cert}' not found.", SamlError::SETTINGS_INVALID);
            }
            $certs[$i] = $key_entity
              ->getKeyValue();
          }
          else {
            throw new SamlError('IdP cert setting is of type "key" but the Key module is not installed.', SamlError::SETTINGS_INVALID);
          }
        }
        elseif ($type === 'file') {
          $certs[$i] = file_get_contents(substr($cert, 5));
          if ($certs[$i] === FALSE) {
            $nr = $i ? " {$i}" : '';
            throw new SamlError("IdP cert{$nr} not found.", SamlError::PRIVATE_KEY_FILE_NOT_FOUND);
          }
        }
      }
    }

    // @todo remove in 4.x: not applicable after samlauth_update_8304().
    // @todo at the same time as removing this, uncomment samlauth_update_8400.
    if (!$certs && !$encryption_cert) {
      $old_cert = $config
        ->get('idp_x509_certificate');
      $old_cert_multi = $config
        ->get('idp_x509_certificate_multi');
      if ($old_cert || $old_cert_multi) {
        $certs = $old_cert ? [
          $old_cert,
        ] : [];
        if ($old_cert_multi) {
          if ($config
            ->get('idp_cert_type') === 'encryption') {
            $encryption_cert = $old_cert_multi;
          }
          else {
            $certs[] = $old_cert_multi;
          }
        }
      }
    }

    // If we don't set a separate 'x509certMulti > encryption' cert, the
    // 'main' cert (not 'x509certMulti > signing') is used for encryption so
    // it must be set. If we have a single 'main/signing' cert, we can set it
    // in either 'x509certMulti > signing' or as the main cert - both is not
    // necessary. This can be encoded in several arbitrary ways, e.g.:
    if ($encryption_cert) {
      $library_config['idp']['x509certMulti'] = [
        // This is an array, but the library never uses anything but the
        // first value.
        'encryption' => [
          $encryption_cert,
        ],
        'signing' => $certs,
      ];
    }
    elseif ($certs) {
      if (count($certs) == 1 || $add_idp_encryption_cert) {
        $library_config['idp']['x509cert'] = reset($certs);
      }
      if (count($certs) > 1) {
        $library_config['idp']['x509certMulti']['signing'] = $certs;
      }
    }
    return $library_config;
  }

}

Classes

Namesort descending Description
SamlService Governs communication between the SAML toolkit and the IdP / login behavior.