Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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.

like image 630
Gary McGill Avatar asked Feb 18 '19 18:02

Gary McGill


1 Answers

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.

like image 198
Gary McGill Avatar answered Sep 26 '22 17:09

Gary McGill