How can I pass an auth token when downloading a file?

10,982

I've cobbled together something that "works on my machine" based partly on this answer and others like it - though my effort is "Angular-ized" by being packaged as a re-usable directive. There's not much to it (most of the code is doing the grunt-work of figuring out what the filename should be based on the content-disposition header sent by the server).

download-file.directive.ts:

import { Directive, HostListener, Input } from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';

@Directive({
  selector: '[downloadFile]'
})
export class DownloadFileDirective {
  constructor(private readonly httpClient: HttpClient) {}

  private downloadUrl: string;

  @Input('downloadFile')
  public set url(url: string) {
    this.downloadUrl = url;
  };

  @HostListener('click')
  public async onClick(): Promise<void> {

    // Download the document as a blob
    const response = await this.httpClient.get(
      this.downloadUrl,
      { responseType: 'blob', observe: 'response' }
    ).toPromise();

    // Create a URL for the blob
    const url = URL.createObjectURL(response.body);

    // Create an anchor element to "point" to it
    const anchor = document.createElement('a');
    anchor.href = url;

    // Get the suggested filename for the file from the response headers
    anchor.download = this.getFilenameFromHeaders(response.headers) || 'file';

    // Simulate a click on our anchor element
    anchor.click();

    // Discard the object data
    URL.revokeObjectURL(url);
  }

  private getFilenameFromHeaders(headers: HttpHeaders) {
    // The content-disposition header should include a suggested filename for the file
    const contentDisposition = headers.get('Content-Disposition');
    if (!contentDisposition) {
      return null;
    }

    /* StackOverflow is full of RegEx-es for parsing the content-disposition header,
    * but that's overkill for my purposes, since I have a known back-end with
    * predictable behaviour. I can afford to assume that the content-disposition
    * header looks like the example in the docs
    * https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
    *
    * In other words, it'll be something like this:
    *    Content-Disposition: attachment; filename="filename.ext"
    *
    * I probably should allow for single and double quotes (or no quotes) around
    * the filename. I don't need to worry about character-encoding since all of
    * the filenames I generate on the server side should be vanilla ASCII.
    */

    const leadIn = 'filename=';
    const start = contentDisposition.search(leadIn);
    if (start < 0) {
      return null;
    }

    // Get the 'value' after the filename= part (which may be enclosed in quotes)
    const value = contentDisposition.substring(start + leadIn.length).trim();
    if (value.length === 0) {
      return null;
    }

    // If it's not quoted, we can return the whole thing
    const firstCharacter = value[0];
    if (firstCharacter !== '\"' && firstCharacter !== '\'') {
      return value;
    }

    // If it's quoted, it must have a matching end-quote
    if (value.length < 2) {
      return null;
    }

    // The end-quote must match the opening quote
    const lastCharacter = value[value.length - 1];
    if (lastCharacter !== firstCharacter) {
      return null;
    }

    // Return the content of the quotes
    return value.substring(1, value.length - 1);
  }
}

This is used as follows:

<a downloadFile="https://my-server.com/my-api/document?id=3">Download</a>

...or, of course:

<a [downloadFile]="myUrlProperty">Download</a>

Note that I'm not explicitly adding the auth token to the HTTP request in this code, because that's already taken care of for all HttpClient calls by my HttpInterceptor implementation (not shown). To do this without an interceptor is simply a case of adding a header to the request (in my case, an Authorization header).

One more thing worth mentioning, is that, if the web API being called is on a server that uses CORS, it might prevent the client-side code from accessing the content-disposition response header. To allow access to this header, you can have the server send an appropriate access-control-allow-headers header.

Share:
10,982

Related videos on Youtube

Gary McGill
Author by

Gary McGill

Updated on June 04, 2022

Comments

  • Gary McGill
    Gary McGill almost 2 years

    I have a web app where the Angular (7) front-end communicates with a REST API on the server, and uses OpenId Connect (OIDC) for authentication. I'm using an HttpInterceptor that adds an Authorization header to my HTTP requests to provide the auth token to the server. So far, so good.

    However, as well as traditional JSON data, my back-end is also responsible for generating documents on-the-fly. Before I added authentication, I could simply link to these documents, as in:

    <a href="https://my-server.com/my-api/document?id=3">Download</a>
    

    However, now that I've added authentication, this no longer works, because the browser does not include the auth token in the request when fetching the document - and so I get a 401-Unathorized response from the server.

    So, I can no longer rely on a vanilla HTML link - I need to create my own HTTP request, and add the auth token explicitly. But then, how can I ensure that the user experience is the same as if the user had clicked a link? Ideally, I'd like the file to be saved with the filename suggested by the server, rather than a generic filename.

  • Gary McGill
    Gary McGill about 5 years
    "Which tells me that your RESTful API isn't really RESTful" -- actually, what it tells you is that my example URL was poorly chosen :-) It doesn't match my actual API (I don't own my-server.com either, for the record).
  • Gary McGill
    Gary McGill about 5 years
    "No, it's working correctly. It's the server that yields the 401 unauthorized response." -- clearly I should have been more precise in my use of language. I did not mean to imply "does not work" in the sense that the Internet is broken, merely "does not work" in the sense that a 401 isn't useful to the user. I am aware of the split between server and client, but thanks for checking.
  • Gary McGill
    Gary McGill about 5 years
    "t's kind of strange for the server to require HEADER authentication for a GET request in a context where none can be provided" -- I'm in control of both ends of this communication, so I could implement authorisation for this resource using a query parameter as you suggest. However, I'm always loathe to "roll my own" when it comes to security, as often that's the wrong thing to do. Currently, although I've had to mess around a bit to make it "work", I've changed neither the client nor the server mechanism for handling authentication. You'll note there's no security-related code in my answer.
  • Gary McGill
    Gary McGill about 5 years
    "Using createObjectURL() to mutate the response into a new window is kind of a hack that has other side effects." -- OK, this part made me laugh, but only because I couldn't agree more. The whole thing - OIDC client library included - is a mile-high Jenga tower of hacks. To my eyes, this is no worse a hack than all the other hacks on which it sits. Anyway, none of the drawbacks you mention actually happen in my environment (which is internal with known browsers etc.) - the download works exactly the same as a normal file download.
  • ShamPooSham
    ShamPooSham over 3 years
    NIce answer, but won't the document.createElement('a') create a new element each time you click a link? And wouldn't it be better to use an Angular native solution than to mess with the DOM? I'm thinking using an ElementRef or something.
  • Gary McGill
    Gary McGill over 3 years
    @ShamPooSham: The element that's created is never attached to the document, so once it goes out of scope it'll be discarded. If you have an Angular-native solution, I'm all ears!
  • wonsuc
    wonsuc about 3 years
    Never expose your JWT Token with query parameter.
  • show
    show about 3 years
    In my case, the content-disposition was attachment; filename=xy.jpeg; filename*=UTF-8''xy.jpeg, so I had to change return value; to return value.split(';')[0].trim();
  • Anddo
    Anddo over 2 years