163

I'm writing a webapp in Angular where authentication is handled by a JWT token, meaning that every request has an "Authentication" header with all the necessary information.

This works nicely for REST calls, but I don't understand how I should handle download links for files hosted on the backend (the files reside on the same server where the webservices are hosted).

I can't use regular <a href='...'/> links since they won't carry any header and the authentication will fail. Same for the various incantations of window.open(...).

Some solutions I thought of:

  1. Generate a temporary unsecured download link on the server
  2. Pass the authentication information as an url parameter and manually handle the case
  3. Get the data through XHR and save the file client side.

All of the above are less than satisfactory.

1 is the solution I am using right now. I don't like it for two reasons: first it is not ideal security-wise, second it works but it requires quite a lot of work especially on the server: to download something I need to call a service that generates a new "random" url, stores it somewhere (possibly on the DB) for a some time, and returns it to the client. The client gets the url, and use window.open or similar with it. When requested, the new url should check if it is still valid, and then return the data.

2 seems at least as much work.

3 seems a lot of work, even using available libraries, and lot of potential issues. (I would need to provide my own download status bar, load the whole file in memory and then ask the user to save the file locally).

The task seems a pretty basic one though, so I'm wondering if there is anything much simpler that I can use.

I'm not necessarily looking for a solution "the Angular way". Regular Javascript would be fine.

Marco Righele
  • 2,702
  • 3
  • 23
  • 23
  • By remote do you mean that the downloadable files are on a different domain than the Angular app? Do you control the remote (have access to modify it's backend) or not? – robertjd Apr 13 '15 at 22:33
  • I mean that the file data is not on the client (browser); the file is hosted on the same domain and I have control of the backend. I will update the question to make it less ambiguous. – Marco Righele Apr 14 '15 at 09:52
  • The difficulty of option 2 is dependent on your backend. If you can tell your backend to check the query string in addition the the authorization header for the JWT when it goes through the authentication layer, you're done. Which backend are you using? – Technetium Mar 31 '17 at 21:49

5 Answers5

78

Here's a way to download it on the client using the download attribute, the fetch API, and URL.createObjectURL. You would fetch the file using your JWT, convert the payload into a blob, put the blob into an objectURL, set the source of an anchor tag to that objectURL, and click that objectURL in javascript.

let anchor = document.createElement("a");
document.body.appendChild(anchor);
let file = 'https://www.example.com/some-file.pdf';

let headers = new Headers();
headers.append('Authorization', 'Bearer MY-TOKEN');

fetch(file, { headers })
    .then(response => response.blob())
    .then(blobby => {
        let objectUrl = window.URL.createObjectURL(blobby);

        anchor.href = objectUrl;
        anchor.download = 'some-file.pdf';
        anchor.click();

        window.URL.revokeObjectURL(objectUrl);
    });

The value of the download attribute will be the eventual file name. If desired, you can mine an intended filename out of the content disposition response header as described in other answers.

Dave
  • 645
  • 7
  • 8
Technetium
  • 5,902
  • 2
  • 43
  • 54
  • 1
    I keep wondering why no one considers this response. It's simple and since we're living in 2017, the platform support is fairly good. – Rafal Pastuszak Sep 18 '17 at 16:36
  • 1
    But iosSafari support for the download attribute looks pretty red :( – Martin Cremer Mar 07 '18 at 17:37
  • what if the filename doesn't have any extension? .txt is always appended, which is not the desired behavior. – Nicolas Mar 20 '18 at 20:40
  • 1
    This worked fine for me in chrome. For firefox it worked after I added the anchor to the document: document.body.appendChild(anchor); Did not find any solution for Edge... – Tompi Sep 12 '18 at 13:46
  • 38
    This solution works but does this solution handle UX concerns with large files? If I need to sometimes download a 300MB file it could take some time to download before clicking the link and sending it to the brower's download manager. We could spend the effort use the fetch-progress api and build out our own download progress UI.. but then there's also the questionable practice of loading a 300mb file into js-land (in memory?) to merely hand it off to the download manager. – scvnc Sep 13 '18 at 21:21
  • @scvnc I would support putting the `id_token` as a query string parameter in addition to the authorization header in that case so you can have the browser manage the download. That's actually what we did. – Technetium Sep 24 '18 at 19:00
  • 1
    @Tompi i too could not make this work for Edge and IE – zappa Feb 28 '19 at 13:29
  • I have tested it on Edge Version 87.0.664.60 (Official build) (64-bit) and it works!. Regarding downloading large files, from UX point of view we see the download after a delay. What happens during this delay ? But what about the memory consuption at the browser level ? – Ida Amit Jan 10 '21 at 16:42
  • @IdaAmit Because basically, you are creating a very heavy HTML element for the current page. That's the reason why you see a delay. Think about if the user downloads from mobile devices, the delay can be over a minute. – wonsuc Mar 02 '21 at 05:48
  • It works, I can pass the token to the request. But do you have any idea to make browser open the download url in a new tab? – anhtv13 Mar 16 '23 at 03:11
57

Technique

Based on this advice of Matias Woloski from Auth0, known JWT evangelist, I solved it by generating a signed request with Hawk.

Quoting Woloski:

The way you solve this is by generating a signed request like AWS does, for example.

Here you have an example of this technique, used for activation links.

backend

I created an API to sign my download urls:

Request:

POST /api/sign
Content-Type: application/json
Authorization: Bearer...
{"url": "https://path.to/protected.file"}

Response:

{"url": "https://path.to/protected.file?bewit=NTUzMDYzZTQ2NDYxNzQwMGFlMDMwMDAwXDE0NTU2MzU5OThcZDBIeEplRHJLVVFRWTY0OWFFZUVEaGpMOWJlVTk2czA0cmN6UU4zZndTOD1c"}

With a signed URL, we can get the file

Request:

GET https://path.to/protected.file?bewit=NTUzMDYzZTQ2NDYxNzQwMGFlMDMwMDAwXDE0NTU2MzU5OThcZDBIeEplRHJLVVFRWTY0OWFFZUVEaGpMOWJlVTk2czA0cmN6UU4zZndTOD1c

Response:

Content-Type: multipart/mixed; charset="UTF-8"
Content-Disposition': attachment; filename=protected.file
{BLOB}

frontend (by jojoyuji)

This way you can do it all on a single user click:

function clickedOnDownloadButton() {

  postToSignWithAuthorizationHeader({
    url: 'https://path.to/protected.file'
  }).then(function(signed) {
    window.location = signed.url;
  });

}
Ezequias Dinella
  • 1,278
  • 9
  • 12
  • 4
    This is cool but I don't understand how it's different, from a security perspective, than the OP's option #2 (token as query string parameter). Actually, I can imagine that the signed request could be more restrictive, i.e. just allowed to access a particular endpoint. But the OP's #2 seems easier / fewer steps, what's wrong with that? – Tyler Collier Jul 17 '16 at 21:21
  • 6
    Depending on your web server the full URL might get logged in its log files. You might not want your IT people having access to all the tokens. – Ezequias Dinella Jul 21 '16 at 21:14
  • 3
    Additionally the URL with the query string would be saved in your user's history, allowing other users of the same machine to access the URL. – Ezequias Dinella Jul 21 '16 at 21:14
  • 1
    Finally and what makes this very insecure is, the URL is sent in the Referer header of all requests for any resource, even third party resources. So if your using Google Analytics for example, you will send Google the URL token in and all to them. – Ezequias Dinella Jul 21 '16 at 21:14
  • 1
    This text was taken from here: http://stackoverflow.com/questions/643355/https-url-with-token-parameter-how-secure-is-it – Ezequias Dinella Jul 21 '16 at 21:15
  • 3
    in my implementations of the web api for this pattern, the signed.url is only good for 1 access – bkwdesign Mar 27 '18 at 16:33
  • 1
    link to the "advice" doesn't seem to work, or it redirects to some other page – gaurav5430 Jun 10 '19 at 18:27
  • 1
    Great solution and the best IMHO! If you're not using Hapi, you might want to try this small lib for signing URLs: https://github.com/smbwain/signed – Tony O'Hagan Oct 03 '20 at 13:28
  • I guess this is safe when the signed URL is for one-time only. – wonsuc Feb 17 '21 at 00:36
  • 1
    Thank you @mohitesachin217. I fixed it using the Web Archive Wayback Machine. – Ezequias Dinella Feb 06 '23 at 15:26
41

An alternative to the existing "fetch/createObjectURL" and "download-token" approaches already mentioned is a standard Form POST that targets a new window. Once the browser reads the attachment header on the server response, it will close the new tab and begin the download. This same approach also happens to work nicely for displaying a resource like a PDF in a new tab.

This has better support for older browsers and avoids having to manage a new type of token. This will also have better long-term support than basic auth on the URL, since support for username/password on the url is being removed by browsers.

On the client-side we use target="_blank" to avoid navigation even in failure cases, which is particularly important for SPAs (single page apps).

The major caveat is that the server-side JWT validation has to get the token from the POST data and not from the header. If your framework manages access to route handlers automatically using the Authentication header, you may need to mark your handler as unauthenticated/anonymous so that you can manually validate the JWT to ensure proper authorization.

The form can be dynamically created and immediately destroyed so that it is properly cleaned up (note: this can be done in plain JS, but JQuery is used here for clarity) -

function DownloadWithJwtViaFormPost(url, id, token) {
    var jwtInput = $('<input type="hidden" name="jwtToken">').val(token);
    var idInput = $('<input type="hidden" name="id">').val(id);
    $('<form method="post" target="_blank"></form>')
                .attr("action", url)
                .append(jwtInput)
                .append(idInput)
                .appendTo('body')
                .submit()
                .remove();
}

Just add any extra data you need to submit as hidden inputs and make sure they are appended to the form.

James
  • 1,305
  • 11
  • 20
  • 2
    I believe this solution is greatly undervoted. It's easy, clean, and works perfectly. – Yura Fedoriv Feb 05 '20 at 22:32
  • This solution works the only concern I have is from security point of view. The service might be attach by huge amount of calls , although they all have invalid jwt token. It makes the service busy. – Ida Amit Jan 07 '21 at 14:06
  • @IdaAmit I can appreciate your concern. As long as JWT validation is the first thing done, I'm not sure how this is more exposed to a DoS attack than any of the previously mentioned approaches, all of which have to validate a JWT token to download (or to get a download token). Although there are differences between server technologies, usually a public route is fairly light-weight. As long as the same validation code is used, the difference in overhead should be minimal. Just because a framework hides the JWT validation code doesn't mean it won't have that overhead. – James Jan 08 '21 at 21:53
  • What if `access_token` is expired for `download file` request? How are you going to refresh `access_token`? – wonsuc Mar 02 '21 at 05:29
  • @wonsuc The lifetime of the token is the same as the rest of your application. For example, if you let your access_token expire and then try to access some other resource such as a list of items, you will also have similar issues. Usually one will use a refresh_token to periodically request a fresh access_token, but that depends on your application and is not specific to this scenario. You could use the expiration information to check if the JWT is still valid before doing the post, but again, that's a design choice that's well outside the scope of this question. – James Mar 03 '21 at 16:27
  • Thanks! I can confirm that this works... moreover since I use flask-praetorian for server-side JWT validation I've went ahead and forked it adding automatic support for this feature (i.e. taking a token from a POST param). This example would work as-is provided the correct token and using my fork, which can be added to a requirements.txt file using this line (a PR was also opened): github.com/shaioz/flask-praetorian@master#egg=flask-praetorian (NOTE: add git+https:// to start of line, this does not format well inside this github comment) – shaioz Jul 28 '22 at 10:40
9

Pure JS version of James' answer

function downloadFile (url, token) {
    let form = document.createElement('form')
    form.method = 'post'
    form.target = '_blank'
    form.action = url
    form.innerHTML = '<input type="hidden" name="jwtToken" value="' + token + '">'

    console.log('form:', form)

    document.body.appendChild(form)
    form.submit()
    document.body.removeChild(form)
}
Joren Van Severen
  • 2,269
  • 2
  • 24
  • 30
  • Thanks this is working for me! please see https://stackoverflow.com/questions/29452031/how-to-handle-file-downloads-with-jwt-based-authentication#comment129194968_59363326 for a flask-praetorian fork that supports this at the server side automatically (a PR was created also). – shaioz Jul 28 '22 at 10:48
7

I would generate tokens for download.

Within angular make an authenticated request to obtain a temporary token (say an hour) then add it to the url as a get parameter. This way you can download files in any way you like (window.open ...)

Fred
  • 484
  • 8
  • 16
  • 2
    This is the solution I'm using for now, but I'm not satisfied with it because it's quite a lot of work and I'm hoping there is a better solution "out there" ... – Marco Righele Jun 07 '15 at 23:30
  • 4
    I think this is the cleanest solution available and i can't see a lot of work there. But I would either choose a smaller validity time of token (e.g. 3 minutes) or make it a one-time-token by keeping a list of the tokens on the server and delete used tokens (not accepting tokens that aren't on my list). – nabinca Nov 24 '15 at 14:57
  • I have a binary (static file) to be protected this manner. Can I host this static file in Webserver and access it with the JWT ? In such case, what happens if user tries to hit the file URL without the JWT ? – yathirigan May 05 '21 at 08:15
  • @yathirigan the downside to this aproach is that if you do a window.open or a direct link to the file, you cant pass the jwt token as header. – Fred May 06 '21 at 06:11