Authenticate videos and images in Angular

12/23/2024
7 minute read

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.

  1. 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.
  2. 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):

Network tab

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!

Infographics Compendium I - Generators, pure functions and more

Sometimes I publish parts of my infographics I publish on various channels with more explanation.

And then sometimes I don't. This time I just put some of my (hopefully self-explanatory) infographics here.

  • Pure functions
  • Generator functions
  • Cost of anonymous types

My first Post

Welcome to my blog and my first post

In this article I will show you why I created my own blog-software from scratch and why I have a blog in the first place. Short answer: I like to know how to create a blog and I like blazor. Long answer you will find in the post.

Fullstack Web Applications with .NET, Nx and Angular Seminar

Join us for an exciting seminar with Fabian Gosebrink about .NET 8, Nx, and Angular! Learn the latest trends and best practices. More details: here

An error has occurred. This application may no longer respond until reloaded. Reload x