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:
- Request a file "creation" from React and get back a signed short-living URL. This is an authenticated request.
- 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
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
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.
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:
The show changes a bit, it skips the authentication and, the first thing it does is to decode the JWT token with
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.
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.