Reputation: 27516
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.
Upvotes: 17
Views: 17169
Reputation: 27516
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.
Upvotes: 21
Reputation: 54771
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.
Upvotes: -4