Sometimes you are faced with the need to authenticate videos and images in Angular. And yes I know, normally I write about .NET, but this one took my some time - so I thought it might be worth sharing.
The problem
Imagine you have a <video>
or <img>
tag where the src
is a link that is protected (for example with a JWT token). Natively the browser will not send the token with the request (just cookies), so the server will return a 401 (or similar) error. In my case, we have an Azure Function that serves the videos and images, and we only served assets with a valid JWT token.
<video src="https://my-azure-function.com/video.mp4"></video>
The first attempt: Use a pipe
My first thought was to use a pipe and use the HttpClient
to fetch the video and add the token to the request.
Something around:
import { Pipe, PipeTransform } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { catchError, map, Observable, of } from 'rxjs';
@Pipe({
name: 'blobUrlAuthenticated',
})
export class BlobUrlAuthenticatedPipe implements PipeTransform {
constructor(private http: HttpClient) {}
transform(blobUrl: string | null | undefined, fallbackUrl = ''): Observable<string> {
if (!blobUrl) {
return of(fallbackUrl);
}
if (!blobUrl.startsWith('http')) {
return of(blobUrl);
}
return this.http.get(blobUrl, { responseType: 'blob' }).pipe(
catchError(() => {
return of(null);
}),
map(response => {
return response
? URL.createObjectURL(response)
: fallbackUrl;
})
);
}
}
Which could be used like this:
<video [src]="videoUrl | blobUrlAuthenticated | async"></video>
But that had some really ugly side effects.
- This simple implementation would download the whole video into memory before playing it. This is not a good idea for large videos. Also - you should revoke the created URL at some point to free up memory. So you have to handle that case.
- As you download the whole thing, you need a loading spinner or something similar to show the user that something is happening.
Overall - this is not a good solution and I was not happy with it.
The second attempt: Use MediaSource
The next idea was to use the MediaSource
API. This API allows you to create a video source from a stream. This way you can fetch the video in chunks and play it while it is still downloading. Basically you do the stuff as above but you have a flexible buffer you write into and the video plays while you are still downloading it. I don't even want to show the code I did use, as it didn't work out at all!
The biggest issue - You have to have streamable files. So I would have to re-encode a majority of stuff! So no!
The third attempt: Use a web service and proxy every request
My last attempt (and the final one I did use) is to basically intercept every request call and add the token to those requests, that are for videos or images. This way the browser will send the token with the request and the server will be happy.
So at the end I only want to do this:
<video src="https://my-azure-function.com/video.mp4"></video>
Nothing more. No pipes, no nothing. Just the plain old <video>
tag.
The service worker
For that I created a service worker that attaches to the fetch event and adds the token to the request if the request is for a video or image. I will comment my code to guide you through my thoughts.
self.addEventListener('fetch', function (event) {
const url = event.request.url.toLowerCase();
// I don't want to intercept requests that are not for assets
if (!url.includes('api/assets')) {
return;
}
// I don't want to intercept requests that already have an Authorization header
// In my case this isn't needed because none of them would have a token
if (event.request.headers.has('Authorization')) {
return;
}
// This bid is like a HttpMessageHandler in .NET
// This allows us to intercept the fetch call
// And this would include regular fetch calls (like this.http.get in Angular but also the <video> tag)
event.respondWith(
(async function () {
const token = await requestTokenFromMainThread();
const headers = new Headers(event.request.headers);
headers.set('Authorization', 'Bearer ' + token);
// You have to set the mode otherwise chromium based browsers will not send the Authorization header
// The default is no-cors and the browser will not allow any custom header
const modifiedRequestInit = {
headers: headers,
mode: 'cors',
credentials: 'omit',
};
const modifiedRequest = new Request(event.request, modifiedRequestInit);
try {
return fetch(modifiedRequest);
} catch (error) {
console.error('Error fetching resource:', error);
return new Response('Error fetching resource', { status: 500 });
}
})(),
);
});
// The worker has absolutely no access to the Angular app
// And therefore we need a message system to get the token from the main thread
function requestTokenFromMainThread() {
return new Promise((resolve) => {
const channel = new MessageChannel();
channel.port1.onmessage = (event) => {
resolve(event.data);
};
self.clients.matchAll().then((clients) => {
if (clients && clients.length) {
clients[0].postMessage('requestToken', [channel.port2]);
}
});
});
}
The Angular part
So to recap: The service worker will intercept every request to the api/assets
endpoint and add the token to the request. But the service worker has no access to the Angular app. So we need a way of communicating between the service worker and the Angular app.
For that I build a very simple communication system:
import { Injectable } from '@angular/core';
import { AuthService } from '@auth0/auth0-angular';
import { lastValueFrom } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class CommunicationService {
constructor(private readonly authService: AuthService) {
navigator.serviceWorker.addEventListener('message', async (event) => {
if (event.data === 'requestToken') {
const token = await this.getToken();
event.ports[0].postMessage(token);
}
});
}
private async getToken() {
return await lastValueFrom(
this.authService.getAccessTokenSilently({ cacheMode: 'cache-only' }),
);
}
}
Basically this service will life the whole time your angular app is running and listen for messages from the service worker. If the service worker sends a message with the content requestToken
the service will get the token from the AuthService
and send it back to the service worker. I did use cache-only
because I don't want to trigger too many requests to the Auth API (in this case Auth0).
The last bid is missing - that service has run from the start to the end of the application. We basically registered this as a APP_INITIALIZER
in the app.module.ts
:
{
provide: APP_INITIALIZER,
useFactory: (service: CommunicationService) => {
return () => {
return service;
};
},
multi: true,
deps: [CommunicationService],
},
The last part is to start the service worker:
ServiceWorkerModule.register('sw.js'),
as part of your imports
inside your AppModule
.
And that's it. Now you can use the <video>
and <img>
tags as you would normally do and the service worker will add the token to the request. The server will be happy and you can serve your protected assets.
In the network tab of your browser you will also see which requests are intercepted by the service worker and which are not (there is a little cog icon):
But now - there is no need of downloading the whole thing! No worries to cancel the download and free up memory. The video will play while it is still downloading. And if the user navigates away from the page the download will be canceled.
Firefox Bonus Edition!
It seems that Firefox needs the crossorigin="anonymous"
attribute in video
tags, otherwise it will bypass any fetch interceptor:
<video src="..." crossorigin="anonymous">
See the following ticket.
Conclusion
There you have it - a way to authenticate videos and images in Angular. After 3 years in this blog, the first Angular topic!