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

2

17

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.

Apelles answered 18/2, 2019 at 18:5 Comment(0)
A
20

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.

Apelles answered 18/2, 2019 at 18:17 Comment(3)
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.Roustabout
@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!Apelles
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();Disposure
P
-4

Angular (7) front-end communicates with a REST API on the server

and then:

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

Which tells me that your RESTful API isn't really RESTful.

The reason is that the above GET request is not part of the RESTful API paradigm. It's a basic HTTP GET request that yields a non-JSON content type response, and that response doesn't represent the state of a RESTful resource.

This is all just URL semantics and doesn't really change anything, but you do tend to run into these kinds of issues when you start mixing things into a hybrid API.

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.

No, it's working correctly. It's the server that yields the 401 unauthorized response.

I understand what you're saying. The <a> tag no longer allows you to download a URL, because that URL now requires authentication. With that said, it's kind of strange for the server to require HEADER authentication for a GET request in a context where none can be provided. It's not a problem unique to your experience as I've seen this happen frequently. It's the mindset of switching to a JWT token and thinking this solves everything.

Using createObjectURL() to mutate the response into a new window is kind of a hack that has other side effects. Such as popup blockers, browser history mutation and the user's inability to see the download, abort the download or view it in their browser's download history. You also have to wonder about all of the memory the download is consuming in the browser, and switching to base64 is just going to balloon that memory consumption.

You should fix the issue by fixing the server's authentication.

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

A hybrid RESTful API deserves a hybrid authentication approach.

Paving answered 18/2, 2019 at 20:4 Comment(6)
"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).Apelles
"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.Apelles
"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.Apelles
"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.Apelles
Never expose your JWT Token with query parameter.Chantilly
Really ? jwt token as query parameter ? #643855 https://mcmap.net/q/110055/-how-to-handle-file-downloads-with-jwt-based-authenticationCardinal

© 2022 - 2024 — McMap. All rights reserved.