Preserving Cookies in Pantheon

Posted February 18th, 2020

Migrating legacy applications can be quite a challenge. Quite often they are very nuanced and go well beyond "how do we get content from here to there?". There are many aspects to consider.

One such example could be user cookies. On a large project I am working on, we have to preserve the legacy application cookies for thousands of site visitors. The cookies are set when a user logs into an authentication system on a Perl server. The phase of this project did not include implementing a login authentication in Drupal that authenticates with that system, because users rarely ever need to re-authenticate. For this clients needs, these cookies were set with very long lifetimes, and are often refreshed or identified by a users IP or encrypted cookie value. These cookies are passed on in an AJAX request to various backend systems who respond with if said user is allowed to view an academic page in the system or not, so they are mission critical. Drupal makes the request on behalf of the front-end AJAX code for a handful of reasons, so its important that PHP can also read from these cookies.

On Pantheon, if you want to interact or read the users cookies, you will quickly find that you cannot. That is because Pantheon requires you to prefix cookie names in order to persist and view them in the backend code. While the Perl application has been updated to create cookies with this new prefix and our Drupal 8 module looks for them, we have to account for the thousands of users out there with active cookies and preserve them without forcing a login.

Requiring frequent logins is not something this organization does for its subscribers, and it's not a ball we want to drop on launch day. We want a seamless experience. 

Copying Existing Cookies to Pantheon Compliant Cookies

First, we need to create a custom module that will help us accomplish this task. Inside, we create a folder named "js" and place a script inside (called cookies.js):

/**
 * On window load, copy legacy cookies to Pantheon compliant cookies.
 */
(function () {
  const pantheon_prefix = 'STYXKEY_';

  const legacy_cookies = [
    'username_ticket_cookie',
    'security_tickets',
  ];

  let reload = false;

  legacy_cookies.forEach(function(cookie_name) {
    if (cookieExists(cookie_name) && !cookieExists(pantheon_prefix + cookie_name)) {
      const domain = getCookieDomain();
      const cookie_value = getCookieValue(cookie_name);
      document.cookie = (pantheon_prefix + cookie_name) + '=' + cookie_value + ';domain=' + domain + ';expires=Tue, 19 Jan 2038 03:14:07 UTC; path=/;';
      reload = true;
    }
  });

  if (reload) {
    location.reload();
  }
})();

/**
 * Checks the document cookies for a specific cookie.
 * Adapted from MDN example.
 *
 * @param cookie_name
 * @returns {boolean}
 */
function cookieExists(cookie_name) {
  return Boolean(document.cookie.split(';').filter((item) => item.trim().startsWith(cookie_name + '=')).length);
}

/**
 * @see https://stackoverflow.com/a/59603055/295112
 * @param cookie_name
 * @returns {string}
 */
function getCookieValue(cookie_name) {
  return ("; " + document.cookie).split("; " + cookie_name + "=").pop().split(";").shift();
}

/**
 * Returns the top level domain to set a cookie, or FALSE if criteria for
 * domain requirement is not met.
 *
 * Otherwise, passing an invalid domain to document.cookie will fail.
 *
 * @returns {*}
 */
function getCookieDomain() {
  return ('.' + location.hostname.split('.').slice(1).slice(-2).join('.'));
}

The script is straightforward. We create a variable for the prefix string, and an array with all the cookies we want to carry over. The cookies are then looped over, if they exist and the new one does not, it writes a new cookie with a long expiration time. cookieExists was based on an example found in MDN, and getCookieValue was taken from a StackOverflow answer. I did not really see a simple way to get and read user cookies in Javascript without adding additional script dependencies, and I don't want to add those in just for this simple script.

You'll notice we are not passing in jQuery or Drupal either to our function - having no dependencies means we don't need to load either one for anonymous users, making the page size lighter.

The one downside here that you may have noticed is that if we do write a new cookie, the script ends with a browser refresh. This is unfortunately necessary due to the number of pages in the site (roughly 60,000 out of 100,000) that use the aforementioned AJAX to determine how much content access a user has to the current page.

So if they open a bookmark and hit the page, if its their first visit post launch, they could see the page load and then load again.

Load the cookies.js script as soon as possible

The only mitigation we can do to prevent seeing a double page load is to get our script to load as soon as possible and execute. The typical way of adding a Javascript file to Drupal won't do, for a few reasons:

  1. We don't want it to aggregate with the other files.
  2. We don't want it to load in the footer.
  3. It absolutely has to load as soon as possible for the user.

Savvy developers out there know that you can add a header: true; key to a library definition in your module/themes .libraries.yml file, and this will indeed put the script in the head of the page. But there could also be other scripts in the page in the head too, and we want to load ahead of them.

Fortunately, Drupal has a hook we can use to accomplish all 3 points rather easily: hook_page_attachments. So lets implement that in our module:

<?php

/**
 * Implements hook_page_attachments().
 */
function mymodule_page_attachments(array &$page) {
  if (\Drupal::service('router.admin_context')->isAdminRoute()) {
    return;
  }

  $query_string = \Drupal::state()->get('system.css_js_query_string') ?: '0';

  $page['#attached']['html_head'][] = [
    [
      '#type' => 'html_tag',
      '#tag' => 'script',
      '#attributes' => ['src' => '/' . drupal_get_path('module', 'mymodule') . '/js/cookies.js?' . $query_string, 'defer' => FALSE],
      '#weight' => -1000,
    ],
  ];
}

Now we're almost there. With this in place, we are creating a script tag in the head area of the page, and setting its weight high enough to beat out anything else. Taking a cue from Google Tag Manager module, I am appending the current query string cache busting value that the system is tracking. If there are any updates made to this file, clearing the cache will have browsers fetch a new version.

The head of our page now looks like this:

<!DOCTYPE html>
<html lang="en" dir="ltr" prefix="(truncated...)">
  <head>
    <script src="/modules/custom/mymodule/js/cookies.js?abcdef123"></script>
    <meta charset="utf-8" />

Well, we are certainly loading first now! So, is it working? How do we test cookies? Can we? Yes, we can. We can write a browser test using WebDriverTestBase and write a test scenario:

<?php

namespace Drupal\Tests\mymodule\FunctionalJavascript;

use Drupal\FunctionalJavascriptTests\WebDriverTestBase;

/**
 * Class CookieScriptTest
 *
 * @group mymodule
 * @package Drupal\Tests\mymodule\FunctionalJavascript
 */
class CookieScriptTest extends WebDriverTestBase {

  /**
   * {@inheritdoc}
   */
  protected $strictConfigSchema = FALSE;

  /**
   * {@inheritdoc}
   */
  protected static $modules = ['node', 'user', 'mymodule', 'system'];

  /**
   * {@inheritdoc}
   */
  protected function setUp() {
    parent::setUp();
    $this->container->get('module_installer')->uninstall(['page_cache']);
    $this->container->get('theme_installer')->install(['mytheme']);
    $this->container->get('config.factory')->getEditable('system.theme')->set('default', 'mytheme')->save();
  }

  /**
   * Test that mymodule/js/cookies.js copies legacy cookies.
   */
  public function testOldCookiesAreCopied() {
    $cookies = [
      'COOKIE_ONE' => 'foo',
      'COOKIE_TWO' => 'bar',
    ];

    $this->prepareCookies($cookies);

    $cookie_one = $this->getSession()->getCookie('STYXKEY_COOKIE_ONE');
    $cookie_two = $this->getSession()->getCookie('STYXKEY_COOKIE_TWO');
    $this->assertEqual($cookie_one, 'foo');
    $this->assertEqual($cookie_two, 'bar');
  }

  /**
   * Helper method to set a cookie value for testing.
   *
   * @param string $cookie_name
   * @param null $cookie_value
   */
  protected function prepareCookies(array $cookies) : void {
    $session = $this->getSession();

    foreach ($cookies as $name => $value) {
      $session->setCookie($name, $value);
    }

    // This is required to "set" the old cookies in the request and have the test browser
    // add them for the next request. Essentially, "click the site logo".
    $this->click('.header__branding a');
  }

}

With a passing test, we can be confident that user cookies will be preserved through the launch of the site on a new hosting platform with Pantheon and continue operating without killing thousands of user sessions.