As Angular developers flock to the new features of SSR, they'll inevitably run into issues with browser specific APIs trying to run on the server. Just try to render any web components.

But not all is lost if you're strictly using Angular components. However, what about certain services that that require browser specific APIs?

Problem

Imagine there's a ClaimsDirective that reads a JWT token. The token contains role based claims. On the browser, the JWT is read and stored into sessionStorage. When the server tries to read the JWT from sessionStorage the server throws an error.

PLEASE NOTE

This is merely an example using JWT tokens as a way to read claims from different storage. I'm assuming the token has been validated server side before rendering any sort of sensitive information.

Goals

The main goal is to exhibit no differences between the server side rendered markup and the client.

1. Define a contract for the Client and Server

2. Understand which Context the Service is Running

3. Implement the JwtToken Services

4. Configure the Providers

5. Create ClaimsDirective

Before You Begin

A working example exists in the ngserveio-platform-provider-pattern repository

Define the Service Contract

The services will implement the same interface. Given the goal is to create a claims service for JWT tokens, each service includes a getClaims<T extends object>(): Nullable<T> and a setClaims(token: string): void.

import { Nullable } from '@ngserveio/utilities';

export interface IJwtClaimsService {
  getClaims<T extends object>(): Nullable<T>;
  setToken(token: string): void;
}

Depending on the needs of the application using the JWT token, the storage of the token could differ between platforms. So getClaims accepts a generic type T.

With the interface defined, how will the application determine which instance to create to return the claims?

Understand which Context the Service is Running

Angular ships a couple of helper functions and an injection token out of @angular/common to help determine the platform context it's running. The PLATFORM_ID injection token's value can be supplied to the isPlatformBrowser or the isPlatformServer function to determine where the code is executing.

How do we choose the correct service given the platformId and inject the correct service?

Components and services dependent on platform specific services shouldn't be responsible for determining platform context and choosing the correct service to use. It'd muddy the code quickly with platform checks. The platform services they do consume should also adhere to an interface. In this case, the services will adhere to IJwtClaimsService interface.

This is where the power of generics and the factory pattern shine. The platformProviderFactory accepts a generic T. Two Type<T> parameters are supplied for the browser and server. The PLATFORM_ID and isPlatformServer determine the platform context and choose which service to instantiate.

import { isPlatformServer } from "@angular/common";
import { PLATFORM_ID, Type, inject } from "@angular/core";

/**
 * Chooses the service on the isPlatformBrowser check
 * @param browser - The browser based service
 * @param server - Server based service
 * @returns 
 */
export const platformProviderFactory = <T>(browser: Type<T>, server: Type<T>): T => {
  const platformId = inject(PLATFORM_ID);
  const platformType: Type<T> = isPlatformServer(platformId)
    ? server
    : browser;

  return inject(platformType);
}

How do these services differ and for JWT tokens returning claims?

Implement the JWT Token Services

Reading the token is dependent where it's stored. The JwtToken services differ on which platform specific storage service to use.

Parsing the claims from the token should be consistent for both services. The parseClaims function ensures the token is split correctly and returns the payload of the JWT. It can be used by both the server and browser services we define later.

export const parseClaims = <T extends object>(
  token: Nullable<string>
): Nullable<T> => {
  const claims = token?.split('.');

  if (claims?.length !== 3) {
    return null;
  }

  const parsedToken = atob(claims[1]);
  return JSON.parse(parsedToken) as T;
};

The browser JwtToken service stores the JWT into session storage and implements the IJwtClaimsService interface. The claims are read and parsed from the session storage key JWT_TOKEN.

import { Injectable } from '@angular/core';
import { Nullable } from '@ngserveio/utilities';
import { IJwtClaimsService } from './consts';
import { parseClaims } from './token-claims.helper';

@Injectable({
  providedIn: 'root',
})
export class SessionStorageJwtService implements IJwtClaimsService {
  protected readonly JWT_TOKEN = 'JWT_TOKEN';

  protected get storage(): Storage {
    return sessionStorage;
  }

  getClaims<T extends object>(): Nullable<T> {
    const token = this.storage.getItem(this.JWT_TOKEN);
    return parseClaims<T>(token);
  }

  setToken(token: string): void {
    this.storage.setItem(this.JWT_TOKEN, token);
  }
}

The server JwtToken is read from the headers in a cookie. This implementation requires the request to be passed to the service. Injection tokens REQUEST / RESPONSE pass an express request / response to the CookieJwtService.

import { InjectionToken } from "@angular/core";
import { Request, Response } from 'express';

export const REQUEST = new InjectionToken<Request>('REQUEST');
export const RESPONSE = new InjectionToken<Response>('RESPONSE');

The CookieJwtService implements the IJwtClaimsService. The claims are read from the JWT_TOKEN cookie on the request and parsed into the generic T claims object.

import { Injectable, inject } from '@angular/core';
import { Nullable } from '@ngserveio/utilities';
import { parseClaims } from './token-claims.helper';
import { IJwtClaimsService } from './jwt-service.interface';
import { REQUEST, RESPONSE } from './request.token';
import { JWT_COOKIE_NAME } from './consts';

@Injectable({
  providedIn: 'root',
})
export class CookieJwtService implements IJwtClaimsService {
  protected readonly request = inject(REQUEST, {
    optional: true,
  });

  protected readonly response = inject(RESPONSE, {
    optional: true
  });

  getClaims<T extends object>(): Nullable<T> {
    const token = this.request?.cookies[JWT_COOKIE_NAME];
    return parseClaims<T>(token);
  }

  setToken(token: string): void {
    this.response?.cookie(JWT_COOKIE_NAME, token);
  }
}

Configure the Providers

Using the platformFactoryProvider function created earlier, the jwtClaimsService function defines the browser and server claims services. This is the only function exported out of this library. Developers won't need to worry about which service to choose. The jwtClaimsService can be invoked in any injection context.

import { SessionStorageJwtService } from './session-storage-jwt.service';
import { CookieJwtService } from './cookie-jwt.service';
import { platformProviderFactory } from './platform-provider.factory';
import { IJwtClaimsService } from './jwt-service.interface';

export const jwtClaimsService = () => platformProviderFactory<IJwtClaimsService>(
  LocalStorageJwtService,
  CookieJwtService
);

The server context needs to pass along the express request object. Using the REQUEST / RESPONSE injection tokens, the CommonEngine requires these providers be injected into CookieJwtClaimsService.

// All regular routes use the Angular engine
  server.get('*', (req, res, next) => {
    const { protocol, originalUrl, baseUrl, headers } = req;

    commonEngine
      .render({
        bootstrap,
        documentFilePath: indexHtml,
        url: `${protocol}://${headers.host}${originalUrl}`,
        publicPath: browserDistFolder,
        providers: [
          { provide: APP_BASE_HREF, useValue: baseUrl },
          { provide: REQUEST, useValue: req }, // ADD THIS
          { provide: RESPONSE: useValue: res } // ADD THIS
        ],
      })
      .then((html) => res.send(html))
      .catch((err) => next(err));
  });

The jwtClaimsService can be injected into any component or service requiring claims from the JWT.

Create the ClaimsDirective

It could be argued the app doesn’t need to be server side rendered behind an authorized route. But why make the browser do the work the server can do from the beginning? Even authorized routes that are server side rendered make a better more performant experience.

The ClaimsDirective accepts a role checking if the role exists in the claims. The jwtClaimsService function provides us the correct service to use to retrieve the claims. Using the getClaims method, the claims are returned and the role is compared against the directive's input.

import { Directive, Input, TemplateRef, ViewContainerRef, inject } from '@angular/core';
import { jwtClaimsService } from './jwt.service';
import { RolesEnum } from './role.type';
import { Claims } from './claims.type';

@Directive({
  selector: '[orgClaims]',
  standalone: true,
})
export class ClaimsDirective {
  protected jwtClaimsService = jwtClaimsService();
  protected templateRef = inject(TemplateRef<unknown>);
  protected viewContainer = inject(ViewContainerRef);

  @Input({ required: true }) public set orgClaims(value: RolesEnum) {
    const userRole = this.jwtClaimsService.getClaims<Claims>();
    
    // This is only an example permissions would have a hierarchy of access
    // and your logic would reflect that better here
    if (userRole?.role === value) {
      this.viewContainer.createEmbeddedView(this.templateRef);
      return;
    }

    this.viewContainer.clear();
  }
}

Determining which block of code to show the user, the ClaimsDirective is applied showing which role the user belongs.

<p>Name: {{ claims?.name || 'No Name provided' }}</p>
<p>Email: {{ claims?.email || 'No Email Provided' }}</p>

<p *orgClaims="roles.ADMIN">I Am an Admin</p>
<p *orgClaims="roles.EDITOR">I Am an Editor</p>
<p *orgClaims="roles.VIEWER">I Am an Viewer</p>

If working in the ngserveio-platform-provider-pattern repository, start the sample application by running the following commands.

ng build
node ./dist/ngserveio-platform-provider-pattern/server/server.mjs

A login page shows initially. Click the login button as this will generate a sample token with claims and add the token to session storage. The page shows the claims in the page including the I am an Admin text per the role.

Page showing the name and I Am an Admin
Showing the rendered HTML from SSR

Conclusion

The global api surface will differ base on the platform the code runs. Server side render relies on the request headers for handling requests. In this case, a HttpOnly cookie is unavailable for the browser to read. The sessionStorage is unavailable for Node to read. Our code needs to understand where to read the JWT token.

Determining storage mechanism to be used in the platform can done through using the isPlatformBrowser function. Without littering the code with this function, the platformServiceProvider registers and chooses which service to use with a common interface.


Tagged in:

Angular 17, Articles, SSR

Last Update: July 09, 2024