Last week I got a small task for a project my team is working on. I don’t usually code that much lately, but this one seemed like a simple and straightforward one.

The problem was straightforward, allow clients to download an on-the-fly generated file (a report) from our React frontend. As trivial as it may sound some interesting caveats made this problem not trivial at all.

As you know, SPAs communicate with the backend using AJAX and most of the time authentication/authorization is done via some kind of header information. At Ingenious we use JWT a lot, and we love it.

For our app, users need to be authorized and authenticated to get the report but streaming a file as a response of an AJAX request only works for Chrome, all the other browsers ignore the response and don't pop up the save file dialog.

The problem is now evident:

How to stream contents to the client with AJAX when there are authentication headers involved.

Start digging, stop digging

As usual, I googled "js generate file from stream" because I thought it was the easiest solution, just grab what I already have working for Chrome and do the extra mile to make it work for all the other browsers.

Maybe there’s even an npm package for that, but I quickly realized that this was not the right choice and that throwing npm packages to the problem won’t solve it, quite the opposite, it will make it difficult to read and error-prone.

Rethinking the problem

My problem was not to stream contents via AJAX and generate a file out of that content but actually allow clients to download a file (which happens to be created on the fly) without compromising the app security, i.e., without opening a resource to the whole internet.

With this new objective in mind, I re-imagined the file download as a two-step process.

What if the client app request the document to be created and as the response, it gets a short-living URL for that resource.

The idea was to:

  1. Request a file "creation" from React and get back a signed short-living URL. This is an authenticated request.
  2. Using this URL, I can request the report in a new window without any extra headers (and thus without the usual authorization I use for my web app).

The key was to generate a URL on step 1 that carries a token on the query string with an expiration date I can check on the “open” endpoint (step 2). So I looked for a solution that allows me to sign data and make it expire after X amount of time and guess what, JWT does precisely that.

The only key difference is that I had to create a token and send it on a query string due to the impossibility to send headers when doing a window.open.

Show me the code

The previous code the app had was quite simple, we made an AJAX request and streamed with send_data the contents of the file. Authorization / Authentication is done via Pundit / Knock on a before_action hook.

My old rails code

This wasn't working for browsers other than Chrome so I split the process, first create a report URL that will live for 30 seconds and serve the file on that new URL.

1_3O8Oig25GO1K_HmEePTMMQ

We added a create method to the controller. This method will be in charge of creating a short living URL using a signed JWT token that will expire in 30 seconds from now, I also encode the user id that's requesting the resource.

The client will get a JSON object similar to this:{url: "https://domain.com/reports/report_type?token=encryptedtoken"}.

1_qDZwbFKHupObMgsvt2KICA

The show changes a bit, it skips the authentication and, the first thing it does is to decode the JWT token with JWT.decode. JWT.decode would throw a JWT::ExpiredSignature if the token expired. I can then rescue from that error and return a 403 to my users if needed. I can also rescue from JWT::DecodeError in case no token is given for example.

If everything passes then, I know the URL was signed by me and that it's within the exp time I set on the create method. I can later override the pundit_user and call my authorization method for an extra security layer.

Conclusion

With this simple idea we can have authenticated, short-living URLs with an approach that's flexible enough to avoid rewrite huge parts of our client app. I hope you like the idea.