Skip to content

File Download with Token Authentication and Javascript

March 24, 2021Antoine Apollis6 min read

Screenshot of the end result

Recently on a project of mine, I was presented with a problem: I needed to allow our users to download a ZIP archive of files from our back-end, based on complex authorisation rules, following a four-step process:

  1. The front-end would call the back-end with a list of file names to download.
  2. The back-end would authenticate the user with their JWT token.
  3. It would then check if the user was allowed to retrieve those files.
  4. Finally, it would send them the archive back.

Up until now, we were relying on ‘security through obscurity’, meaning we were trusting the uniquely generated file names to prevent users from snooping around and downloading files they were not supposed to retrieve.

But this was not enough: how could we revoke the rights of the user on a file? How could we be certain that a user would not be accessing a file they were not supposed to access? We needed to add authentication to the route.

The Unauthenticated Case

First, let me show you the code we were using until now. In this case, the back-end is a Spring Boot microservice, and the front-end is an Angular SPA.

Below is a simplified version of the unauthenticated case. What this does is define a GET route /files/download taking a set of file IDs as parameters, and retrieving, zipping and sending the files back to the client.

@GetMapping("/files/download")
public ResponseEntity<ByteArrayResource> download(
    @RequestParam Set<UUID> fileIds
) {
    ByteArrayResource zippedFiles = new ByteArrayResource(
        fileService.getZippedFiles(fileIds)
    );

    return ResponseEntity
        .ok()
        .contentLength(zippedFiles.contentLength())
        .header(HttpHeaders.CONTENT_TYPE, "application/zip")
        .header(
            HttpHeaders.CONTENT_DISPOSITION,
            ContentDisposition
                .builder("attachment")
                .filename("archive.zip")
                .build()
                .toString()
        )
        .body(zippedFiles)
    ;
}

So now, if you make a GET request to /files/download?fileIds=UUID, you’ll receive the file with name UUID zipped. This allows you to keep your front-end code extremely simple because a simple anchor element pointing to the URL to the back-end route will be enough to trigger a download pop-up in the client’s browser, thanks to the Content-Disposition header.

So your front-end component could retrieve the required file names, concatenate them somehow, and then simply display a link to the back-end URL, like so:

<a
  href="{{ backendUrl }}/files/download?fileIds={{ fileNames$ | async }}"
>Download your archive</a>

This is all good and well, but when you do this, you cannot forward JWT tokens, so you cannot authenticate your user: you need to download the files some other way.

Adding Authentication to the Route

So first, you need to add some kind of authentication and authorization. Though my project required complex authorization rules, let us say you only want to be sure the user is logged in before sending them the files. This is only a matter of adding a decorator to your back-end route:

@GetMapping("/files/download")
@PreAuthorize("hasRole(T(fr.theodo.security.Roles).USER)")
public ResponseEntity<ByteArrayResource> download() {}

This is the easy part. Now, if you were to attempt using the aforementioned link, you would get an error: as no authentication token is provided, the back-end responds with a 401 Unauthorized response. How do we fix that?

Retrieving the File with JavaScript

Downloading the file will be done in two steps: first, you will download the file using JavaScript, allowing you to set the authentication token, then, you will ‘forward’ the file to your user.

Alternatively, note that you could set up a different authentication method specifically for this route, such as a cookie-based one, though this would mean having two different authentication methods on your project, with one being used by a single route… so I would advise against it.

Downloading the File

Assuming you already perform authenticated calls to your back-end using some kind of API client, downloading the file will be straightforward: you will instantiate your client in your component, and call your back-end (here, it is done with this.apiClient.downloadZipFile).

The switchMap RxJS operator allows you to discard any previous calls if this.project$ changes; you can read more about it on Learn RxJS.

export class MainPanelComponent {
  @Input() project$!: Observable<Project>;

  constructor(private apiClient: ApiClientService) {}

  private getZip(): Observable<string> {
    return this.project$.pipe(
      switchMap(project => this.apiClient.downloadZipFile(project.files)),
    );
  }
}

‘Forwarding’ the File to Your User

Now that you have retrieved your file, returned as an Observable by switchMap, you need to trigger the save on your user’s computer. This is only a matter of chaining two new operators in the RxJS pipe:

return this.project$.pipe(
  switchMap(project => this.apiClient.downloadZipFile(project.files)),
  map(data => window.URL.createObjectURL(data)),
  tap(url => {
    window.open(url, '_blank');
    window.URL.revokeObjectURL(url);
  })
);

First, you map the retrieved data to an object URL (a simple string). MDN will explain them in greater details, but the main things you need to know are that they are a way to create local URLs pointing to the browser memory… and that they are not supported by Internet Explorer, so you may want to keep that in mind depending on your target audience.

Finally, you tap into this new Observable to expose the file to your user (simply opening a new window with this local URL will work) and clean up behind yourself by removing the object URL (this allows the browser to free up some memory once the file is downloaded).

Triggering the Download

You may have noticed that for now, you have a getZip method, but you do not call it. To remedy this, you only need two new lines of code in your component and one in its template:

export class MainPanelComponent {
  @Input() project$!: Observable<Project>;

  zipDownloadTrigger = new EventEmitter<void>();

  constructor(private apiClient: ApiClientService) {
    this.zipDownloadTrigger.pipe(exhaustMap(this.getZip)).subscribe();
  }}
<button (click)="zipDownloadEmitter.emit()">Download your archive</button>

This defines an event emitter, which emits after each click on a button. You chain this emitter with an exhaustMap RxJS operator, which will call the getZip method, and wait for it to complete before resuming listening to the event emitter (hence the ‘exhaust’; again, refer to Learn RxJS for more details).

This means that all clicks on the button are ignored while the component is downloading the file. Note that you could do without this, in which case you could simply pass the getZip method as a click callback on the button.

Note that for the sake of simplicity, I use a button here, but this is a bad practice in terms of accessibility: you should always use a download link to download a file. Here you could have a button to trigger the download, then display the link with the local URL as its href attribute.

UX Niceties and the Final Code

If you put all this together and add a simple loading toggle, you get the following code:

export class MainPanelComponent {
  @Input() project$!: Observable<Project>;

  isTheArchiveLoading: boolean = false;
  zipDownloadTrigger = new EventEmitter<void>();

  constructor(private apiClient: ApiClientService) {
    this.zipDownloadTrigger.pipe(exhaustMap(this.getZip)).subscribe();
  }

  private getZip(): Observable<string> {
    this.isTheArchiveLoading = true;

    return this.project$.pipe(
      switchMap(project =>
        this.apiClient
          .downloadZipFile(project.files)
          .pipe(finalize(() => { this.isTheArchiveLoading = false; }))
      ),
      map(data => window.URL.createObjectURL(data)),
      tap(url => {
        window.open(url, '_blank');
        window.URL.revokeObjectURL(url);
      })
    );
  }
}

This ensures that you can show a loader to your user if isTheArchiveLoading is true. The finalize operator in the pipe on downloadZipFile is called when downloadZipFile resolves, much like the Promise.finally() method.

And here you go, you have a fully functional downloading method, with an authenticated route, called from your front-end!

Antoine Apollis

Antoine Apollis

Developer & Next.js lover