DEV Community

Cover image for Authentication in Angular: Part V, Handling SSR
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Authentication in Angular: Part V, Handling SSR

When loading a localStorage-authenticated page, the SSR version kicks in with no access to the token. An easy fix involves setting a cookie on the server of the front end.

Before we move on to further features, let’s test our application in an SSR environment. Let's digress a bit to know which of the scenarios of Angular server side rendering we want to handle authentication for.

Keep up with the code on StackBlitz

To SSR, or not to SSR

There are multiple reasons to do SSR, one of them is performance gain. That reason alone is not enough to go fully fledged SSR as there are usually better ways to load content faster for users, like prerendering static content, or lazy loading different modules.

Another reason to SSR is searchability, or SEO, and shareability. Some bots still do not run JavaScript to load content, thus need a static server-side generated copy. When sharing a link on social networks, the bot that previews the content does not run JavaScript either, so a prerendered static version or SSR is indeed helpful.

Serving limited apps for limited devices is also a use case mentioned on Angular docs website. If the main target audience is of such limited devices, we are better off without Angular. This is a case where the Login feature itself is server-side. I am not going to speak of this case.

Of all those scenarios, content that sits behind authentication wall falls under one of the following shapes:

  • Protected routes
  • Public routes with user context: like displaying the user avatar on public routes.
  • Public routes with API call returning user context. like favored tweets of public accounts.

Protected routes

These routes need not be rendered on server for SEO purposes. For users however, the pages will flicker if not handled on server. When routing to a protected route with server side rendering, it all seems on the surface to be working fine, since the initial server load would deny user entrance and reroute to login page, then hydration occurs, and LoginResolve kicks in with a new value fed from localStorage, and that's when it reroutes back to the protected route. This will look like a flicker.

Public routes with user context

Again, user information need not be rendered on server for SEO purposes. In this case, hydration will display the new value for client, if we properly use async pipe to listen to AuthState Observable stateItem$. This use-case practically does not need intervention.

Public routes with API user related content

If an API request is made on a public route, that returns partial data related to the user logged in, like favored tweets of a public profile, the authentication header needs to be sent along. In most cases the API call happens fast enough on the server, the result is then saved in JavaScript cached object, which is then populated after hydration. This result set is public, and will not contain contextual information, nor will there be another API call. The user will see a list of tweets without knowing which ones he or she favored. This is frustrating for the user.

Solutions

There are multiple ways I am sure, some of them are quite complicated, like calling a different API in JavaScript to handle user related content, I've seen solutions that involve tapping into the TransferState, solutions that isolated the login page on its own project, and other solutions that created a different provider for localStorage. Today, I am going with a very simple solution: save cookie in session.

PS. In order to use async pipe with proper Http requests on SSR, you'd be better avoid standalone provideHttpClient and use regular HttpClientModule

Things to remember as we fix this:

  • The Login form is always client-side
  • The use-cases are when loading route from the server, not when rerouting on client-side
  • We only need to save the access token, but will save other information as well
  • This is not a discussion of cookie security, there are ways to keep the cookie secure, we will try our best and keep an eye on security measures

Working backwards

Let's begin with the end in mind. There are two main places where we need the user info available to fix the flicker, and the API call: AuthGuard, and HttpInterceptor. In the former we need the existence of the user, and later we need the roles if they exist. In the latter we only need the access token, but it must be checked against the expiration date.

Save cookie in session

Let's revisit our AuthState and update our code to manipulate the cookies, right when it touches on localStorage

// services/auth.state
private _SaveUser(user: IAuthInfo) {
  localStorage.setItem('user', JSON.stringify(user));
    // also save cookie here
    this._SetCookie(user);
}
private _RemoveUser() {
  localStorage.removeItem('user');
    // also delete cookie
    this._DeleteCookie();
}

private _GetUser(): IAuthInfo | null {
    // redo this, if on SSR, read cookie
    if (onServer()) {
        // read cookie here from express js
    }
  const _localuser: IAuthInfo = JSON.parse(localStorage.getItem('user'));
  if (_localuser && _localuser.accessToken) {
    return <IAuthInfo>_localuser;
  }
  return null;
}

Enter fullscreen mode Exit fullscreen mode

Let's create those two functions to see what we need

// auth.state
// I am setting everything in cookie, but I would be selective in real life and save only the need parts:
// accessToken, roles, and expiresAt, and may be refresh token which will make the process easier
private _SetCookie(user: IAuthInfo) {
  // save cookie with user, be selective in real life as to what to save in cookie
  let cookieStr = encodeURIComponent('CrCookie') + '=' + encodeURIComponent(JSON.stringify(user));

  // use expiration tp expire the cookie
  const dtExpires = new Date(user.expiresAt);

  cookieStr += ';expires=' + dtExpires.toUTCString();
  cookieStr += ';path=/';
  // some good security measures:
  cookieStr += ';samesite=lax';
  // when in production
  // cookieStr += ';secure';

  // be strong:
  document.cookie = cookieStr;
}
private _DeleteCookie(): void {
  // void accessToken but more importantly expire
  this._SetCookie({accessToken: '', expiresAt: 0});
}

Enter fullscreen mode Exit fullscreen mode

In order to read the cookie, we need to do that only on server side. Meaning, when Request is injected and exists. To do that, we need to inject the Request that is provided in the ngExpressEngine renderer, according to the documentation:

// this is how we use nguniversal express-engine, we normally provide the server side request
app.get('/**/*', (req: Request, res: Response) => {
  res.render('../dist/index', {
    req,
    res,
  });
});

Enter fullscreen mode Exit fullscreen mode

Then in our AuthState service, let's inject the REQUEST token

// auth.state

import { REQUEST } from '@nguniversal/express-engine/tokens';
import { Request } from 'express';

@Injectable({ providedIn: 'root' })
export class AuthState {
    constructor(
    // ...
    // inject REQUEST token
    @Optional() @Inject(REQUEST) private request: Request
  ) {
    // ...
}

Enter fullscreen mode Exit fullscreen mode

Now we can adapt the _GetUser method to read from cookie if on server:

// auth.state
private _GetUser(): IAuthInfo | null {
  // if on server
  if (this.request) {
    const _serverCookie = this.request.cookies['CrCookie'];
    if (_serverCookie) {
      try {
        return JSON.parse(_serverCookie);
      } catch (e) {
        // silence
      }
    }
  }
    // else read from localStorage
  const _localuser: IAuthInfo = this.localStorage.getItem('user');

  if (_localuser && _localuser.accessToken) {
    return <IAuthInfo>_localuser;
  }
  return null;
}
Enter fullscreen mode Exit fullscreen mode

StackBlitz project is not set up to be a NodeJs application, so you're gonna have to take my word for it. It works. The protected routes loaded without flickering, and the API was properly mounted with the access token. You can add extra features like SameSite:strict and Secure attribute to further secure the token.

Pushing the envelope: set cookie on server

There are quite a lot of good measures to keep the cookie safe with the JavaScript available attributes. But if we can't sleep at night knowing that our cookie can be read via JavaScript, let's try a new way. Let's create another request, to our local front end server, specifically to set the cookie in NodeJs.

Filter out local calls

For that to work, we need to first tap into the HttpInterceptor, and filter out this specific call, since it is not an API call. Then we let it pass with the correct URL.

// services/http
// in interceptor filter out the local call
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  if (req.url.indexOf('localdata') > -1) {
     // this call is local, thus the url is relative to this same server
     // if your server cannot handle relative calls, prefix it with the proper
     // url, like <https://my.domain/> + url
      return next.handle(req);
  }
   // ...
}

Enter fullscreen mode Exit fullscreen mode

Express Route

The NodeJs that fired up our server could be written in server.ts (like they do in Angular docs), or you could have written your own server, like we've learned to do in Isolating the server. In any case, the express route needs to do the following:

// express routes, anywhere they are
app.post('/localdata/setsession', (req, res) => {
  // read req and save cookie
  const body = req.body;
  // notice the HttpOnly
  res.cookie(body.cookieName, JSON.stringify(body.auth),
    expires: new Date(body.auth.expiresAt),
    sameSite: 'lax',
    // when in production set secure
    secure: true,
    httpOnly: true,
  });
  // silence is gold
  res.send(true);
});

Enter fullscreen mode Exit fullscreen mode

Login request

In our login response, we are going to add another silent http request for our localhost/setsession right after service Login.

// in services/auth.service
SetLocalSession(user: IAuthInfo): Observable<IAuthInfo> {
  // prepare the information to use in the cookie
  // basically the auth info and the cookie name
  const data = PrepSetSession(user);
  // notice the relative url, this is the path you need to setup in your server
  return this.http.post('localdata/setsession', data).pipe(
    map((response) => {
    // return user as is to facilitate chaining
      return user;
    })
  );
}

// switch map Login:
Login(username: string, password: string): Observable<any> {
  return this.http.post(this._loginUrl, { username, password }).pipe(
    map((response) => {
      const retUser: IAuthInfo = NewAuthInfo((<any>response).data);
      return this.authState.SaveSession(retUser);
    }),
    // here switch map to call the local setter
    switchMap((user) => this.SetLocalSession(user))
  );
}

// in services/auth.model, prepare
export const PrepSetSession = (auth: IAuthInfo): any => {
  // in real life, return only information the server might need
  return {
    auth: auth,
    cookieName: 'CrCookie', // this better be saved in external config
  };
};
Enter fullscreen mode Exit fullscreen mode

I am testing this on my localhost, in development it will throw an error, which is okay.

Debugging on Angular universal with a separate express server could drive one crazy, the easiest way is to set optimization to false in angular.json server configuration, and open the generated scripts, to debug directly.

After building, and logging in, I can see the CrCookie being set. The test is whether I can read it with document.cookie. It indeed does not return the cookie information.

Now to see if Angular is reading it when it needs it, let's turn off JavaScript, and refresh a protected route after running the server. It should not redirect to login. Indeed, it does not.

Logout and refresh token

To tighten loose-ends, there are two more places where our local server needs to be called, after a refresh token, and upon logout. The refresh token request can be piped to our newly created method: SetLocalSession, and the logout link is a new silent call. Here they are:

// logout button click
logout() {
   // ...
   this.authService.Logout().subscribe();
}

// services/auth.service
Logout(): Observable<boolean> {
  // logout locally
  const data = PrepLogout();

  return this.http.post('localdata/logout', data).pipe(
    map((response) => {
      return true;
    })
  );
}

// in services/auth.model
export const PrepLogout = (): any => {
  return {
    cookieName: 'CrCookie'
  }
}
Enter fullscreen mode Exit fullscreen mode

The express route for logout would simply clear the cookie with the same options

// on server, express route for logout
app.post('/localdata/logout', (req, res) => {
  // read req and save cookie
  const body = req.body;

  res.clearCookie(body.cookieName, {
    sameSite: 'lax',
    secure: true,
    httpOnly: true,
  });
  res.send(true);
});

Enter fullscreen mode Exit fullscreen mode

This is a silent call. If it fails it is not a big deal because any interactive calls to API will happen on client side, which does not have any access tokens.

As for refresh token, I changed the returned value to be IAuthInfo instead of Boolean to keep things simple.

// services/auth.service
RefreshToken(): Observable<IAuthInfo> {
  return (
    this.http
      .post(this._refreshUrl, { token: this.authState.GetRefreshToken() })
      .pipe(
        map((response) => {
          // ...
          // return user
          return user;
        }),
        // then switch map to set local session
        switchMap((response) => this.SetLocalSession(response))
      )
  );
}

Enter fullscreen mode Exit fullscreen mode

Overkilling it

If you are still concerned, don't use cookies, nor localStorage. Use sessions that invalidate at the end of the browser session. Better yet, don't use Angular for authentication, isolate the login path to be served by the server. But remember, all precautions fall apart in front of a committed hacker, so stay calm and keep walking. Personally, I do not like the extreme nature of the second solution, most web apps are fine with simple cookie settings, coupled with API white-listing.

More features

There are more things to do around authentication, roles, forgot password, and change password are some examples. I will save these for future articles. This article took a little too long to come out, because I was traveling. Forgive me.

Thanks for reading this far though, I hope you forgave me.

Top comments (3)

Collapse
 
ayyash profile image
Ayyash

JS libraries developed by angular or a third part? I want to know more about this. Personally I do the bare minimum for SSR, and I even go with prerendering for static sites, because I agree, it's a beast with 11 eyes :)