Don't want to miss out on Elixir news? Subscribe to ElixirPulse!

How to Generate a PDF on a Page Behind Auth in Elixir

The request is inevitable: “The client wants to be able to download a PDF of this page”.

PDF generation woes aside, this particular request is extra problematic because the page the customer wants to download is behind authentication.

What are our options?

  • Create an anonymous viewing URL that the PDF renderer can view?
  • Somehow render the page server-side and feed the HTML to the PDF generator?

The first poses a security risk - Try explaining to the SOC2 auditors that technically auth can be bypassed with a magic URL parameter.

I have yet to find a way to accomplish the second option with Phoenix & LiveView. Where does that leave us?

Generate a cookie for the logged-in user that the PDF generator can use!

Any HTML to PDF converter can take advantage of this approach. The HTML can be fetched with any HTTP client and then fed to the PDF converter.

Some PDF generation engines (e.g. ChromicPDF) can even accept a cookie and use it to access webpages that require a logged-in user.

We’ll look at two examples: ChromicPDF and WeasyPrint

The best part is, it only takes about 10 lines of code:

alias Plug.Crypto.KeyGenerator
alias Plug.Crypto.MessageVerifier

token = Accounts.generate_user_session_token(current_user)
key_base = Application.get_env(:agility, AgilityWeb.Endpoint)[:secret_key_base]
salt = Application.get_env(:agility, :cookie_signing_salt)
key = KeyGenerator.generate(key_base, salt)

signed_token =
  %{"user_token" => token}
  |> :erlang.term_to_binary()
  |> MessageVerifier.sign(term, key)

Now your cookie can be passed to any HTTP request with the name of _your_application_key and the value being signed_token!

Move the Signing Salt

In order for the snippet above to work, the signing salt needs to be moved into config so that the salt can be accessed outside of the Endpoint module.

Replace @session_options with:

# lib/your_app_web/endpoint.ex

@session_options [
  store: :cookie,
  key: "_your_application_key",
  signing_salt: Application.compile_env!(:your_application, :cookie_signing_salt),
  same_site: "Lax"
]

And inside of config/config.exs:

# config/config.exs
config :your_application, :cookie_signing_salt, "YourSigningSalt"

Example - ChromicPDF

In order to use with ChromicPDF, create a map of the cookie and pass it to print_to_pdf with the set_cookie option, like so:

cookie = %{
  name: "_your_app_key",
  value: signed_token,
  domain: "your_domain" # or "localhost" to test on dev
}

{:ok, base64blob} = ChromicPDF.print_to_pdf({:url, "localhost:4000/auth/url"}, set_cookie: cookie)

Example - WeasyPrint

WeasyPrint converts HTML to PDF without the need for a full browser engine. Since it doesn’t have a browser engine, it cannot accept a cookie. What needs to happen instead is writing a custom URL resolver that passes the HTML back to WeasyPrint for processing.

Normally, WeasyPrint documents are fetched like so:

html = HTML("url")

To get around the auth portion, we pass a custom url resolver:

html = HTML("url", url_fetcher=authenticated_fetcher)

Where authenticated_fetcher uses the python lib requests with our cookie:

def authenticated_fetcher(url):
    try:
        # Pass the cookie in from argparse or similar
        cookies = {}
        cookies['_your_app_key'] = cookie

        # Send a request with cookies
        response = requests.get(url, cookies=cookies)
        response.raise_for_status()  # Raises a HTTPError for bad responses
        return dict(string=response.content, mime_type=response.headers['Content-Type'])
    except requests.RequestException as e:
        raise URLFetchingError('Could not fetch URL: {}'.format(e))

Now, WeasyPrint can turn the authenticated HTML into the PDF for the client!