DEV Community

Cover image for Authentication in Angular, why it is so hard to wrap your head around it
Ayyash
Ayyash

Posted on • Originally published at garage.sekrab.com

Authentication in Angular, why it is so hard to wrap your head around it

By definition of an SPA: single page application, all elements on the screen are no longer part of a global page state, but have their own existence, and lifespan. Authentication, and authorization., affect some or all of the elements on the screen, making it the odd one out.

Authentication ingredients

A full cycle of authentication and authorization in an SPA may go through the following factors:

  • User login: getting an access token, and possibly a refresh token.
  • Storing on client: in local storage along with minimal details
  • Login resolver: to direct away from the login page if the user is already authenticated
  • Router auth guard: to direct to login page if user is not authenticated
  • Logout: remove storage

Use cases:

  • Http interceptor: to use the token for API calls
  • 401 handling: to request a new token
  • User display: getting extra details to display on the screen
  • Redirect URL: an extra piece of information to track

Other concerns:

  • Third-party: how much do we need to change to adapt
  • Server-side rendering: getting the access token by other means

We should always remind ourselves as we build authentication that:

  • Not all routes go through an auth guard
  • Some components display user status anyway
  • There is a sequence of events taking place in an Angular app

Follow along on StackBlitz

The simplest login

Let us begin with the simplest format of authentication, a form with user name and password, that returns a profile with an access token, and possibly a refresh token. Over the years, I have come to terms with a few things and tend to never argue about them:

  • Yes sign the token on the server and return both access token and refresh token to the client
  • Yes, save them both in LocalStorage
  • Yes, extract and save everything you need on the client
  • No, you don't have to have a server for the front end

The API is a simple POST that accepts username and password and returns a dummy access token and a refresh token. First, let's get one thing out of the way. A login button.

Avoiding standalone on app root, and routed components is the best setup, as we previously concluded.

We are creating a route from application root, /public/login



// components login.component.ts
login() {
    // we are going to mimic a click of a login button
    someAuthService.login(username, password).subscribe()
}


Enter fullscreen mode Exit fullscreen mode

Let's create the AuthService to handle the login.

Auth Service

The AuthService has everything that needs to be done on the server. We will send a request to our API, and in future episodes touch on third-party auth servers. The bare minimum is a service that logs user in and returns a typed object of, let's call it, IAuthInfo.



// Auth service
@Injectable({ providedIn: 'root' })
export class AuthService {
  private _loginUrl = '/auth/login';
  constructor(private _http: HttpClient) {}
  // login method
  Login(username: string, password: string): Observable<any> {
    return this._http.post(this._loginUrl, { username, password }).pipe(
      map((response) => {
        // prepare the response to be handled, then return
        return response;
      })
    );
  }
}


Enter fullscreen mode Exit fullscreen mode

Assume the body of the response to be as follows. Notice that detailed user information is usually not the responsibility of an authentication server, so the payload is slim.



{ accessToken: 'some_acces_token',
    refreshToken: 'some_refresh_token',
    payload: {
        "id": "841fca92-eba8-48c8-8c96-92616554c590",
        "email": "aa@gmail.com"
    },
    expiresIn: 3600}


Enter fullscreen mode Exit fullscreen mode

Let us build proper models to set authentication information apart from user information (the payload).



// user model
export interface IUser {
  email: string;
  id: string;
}
// auth model
export interface IAuthInfo {
  payload?: IUser;
  accessToken?: string;
  refreshToken?: string;
  expiresAt?: number
}


Enter fullscreen mode Exit fullscreen mode

Back in our service, when login is successful we need to save information in the localStorage.



Login(username: string, password: string): Observable<any> {
  return this._http.post(this._loginUrl, { username, password }).pipe(
    map((response) => {
      // prepare the response to be handled, then return
      // we'll tidy up later
      const retUser: IAuthInfo = <IAuthInfo>(<any>response).data;
      // save in localStorage
      localStorage.setItem('user', JSON.stringify(retUser));
      return retUser;
    })
  );
}


Enter fullscreen mode Exit fullscreen mode

Direct cast to IAuthInfo and using localStorage without a wrapper is not ideal, but we have more important issues to cover.

When the user visits the site again, I need to populate user information from localStorage. If not found, there are two scenarios

  • if private route: return to a safe route
  • if public route: allow user in, but flag user as not logged in

With many hidden caveats and twists and turns, it is easier said than done.

The bottom line

Here is a diagram of different elements of a random app.

Angular application elements

Reading the user state from localStorage needs to happen as early as possible, because the rest of the app depends on it.

Let me demonstrate.

Let's create a couple of routes, and a partial component to display user info in the root application component. I choose the root because it is the most extreme location to display anything. Under private/dashbaord we'll have a component, that login redirects to.



// login component
login() {
  this.authService.Login('username', 'password').subscribe({
    next: (result) => {
      // redirect to dashbaord
      this.router.navigateByUrl('/private/dashboard');
    },
   // ...
  });
}


Enter fullscreen mode Exit fullscreen mode

Our app module now looks like this



// app module
const AppRoutes: Routes = [
    // ...
    // add private path for dashboard
  {
    path: 'private',
    loadChildren: () =>
      import('./routes/dashboard.route').then((m) => m.AccountRoutingModule),
  },
  // and throw in another public route
  {
    path: 'projects',
    loadChildren: () =>
      import('./routes/project.route').then((m) => m.ProjectRoutingModule),
  },
];

@NgModule({
  imports: [...,
    // import standalone component to be used in root
    AccountStatusPartialComponent
  ],
  // ...
})
export class AppModule {}


Enter fullscreen mode Exit fullscreen mode

The status component in its simplest format should read localStorage and decide what to do with it.



// components/private/status.partial
@Component({
  selector: 'cr-account-status',
  template: `
  <div class="box">Email: {{ s.email }}</div>
  `,
  //...
})
export class AccountStatusPartialComponent implements OnInit {
  s: any;
  ngOnInit(): void {
    // the simplest case, get localstroage and parse it, then read payload
    this.s = JSON.parse(localStorage.getItem('user') || '').payload;
  }
}


Enter fullscreen mode Exit fullscreen mode

In our application component, we add our partial component.



// app.component.html
<header>
 // ...
<cr-account-status></cr-account-status>
</header>


Enter fullscreen mode Exit fullscreen mode

After clicking on login, and redirecting to dashboard, this component should reflect the new value. It doesn't. Normally. We need a way to listen and updateState management is in the house.

Auth State

The first issue we are facing is the responsiveness of the status component right after login. To fix that we are going to use RxJS state management: a behavior subject, and an observable. (This is the most repetitive pattern of auth state in Angular). Let's adjust the AuthService to carry the state item.



// update services/auth.service
export class AuthService {

  // create an internal subject and an observable to keep track
  private stateItem: BehaviorSubject<IAuthInfo | null> = new BehaviorSubject(null);
  stateItem$: Observable<IAuthInfo | null> = this.stateItem.asObservable();

  constructor(private _http: HttpClient) {}

  // login method
  Login(username: string, password: string): Observable<any> {
    return this._http.post(this._loginUrl, { username, password }).pipe(
      map((response) => {
        // ...

        // also update state
        this.stateItem.next(retUser);
        return retUser;
      })
    );
  }
}


Enter fullscreen mode Exit fullscreen mode

Our status partial component is fed in two ways:

  • From AuthService stateitem$ observable: after user login
  • From localStorage: after page hard refresh

Our status partial should only read the observable.



// components/account/status.partial
// this is not the place to intialize state, only read state
export class AccountStatusPartialComponent implements OnInit {
    status$: Observable<IUser>;
    constructor(private authService: AuthService) {

    }
    ngOnInit(): void {
        // read only the payload for this component
        this.status$ = this.authService.stateItem$.pipe(map(state => state?.payload));
    }
}


Enter fullscreen mode Exit fullscreen mode

We need to think of another place to initialize state from localStorage. This place must be on the root of all evil, I mean, application. We need to make sure the state is initialized immediately before any other checks. And what better place to do that than APP_INITIALIZER token provider?

Auth State in APP_INITIALIZER

In our app module, we provide a service for this token:



// app.module

@NgModule({
 // ...
// add a provider to array of providers
  providers: [
        {
        provide: APP_INITIALIZER,
        useFactory: authFactory,
        multi: true,
        deps: [AuthService],
      }
    ],
})
export class AppModule {}


Enter fullscreen mode Exit fullscreen mode

And for now, let's dump everything in the AuthService (this soon shall move to its own state service)



const CheckAuth = (auth: IAuthInfo): boolean => {
  // check if accesstoken exists and that it has not expired yet
    // ...
  // return if profile is valid
  return true;
};

export const authFactory = (authService: AuthService) => () => {
  // initialize auth state

  // check item validity
  const _localuser: IAuthInfo = JSON.parse(localStorage.getItem('user'));
  if (CheckAuth(_localuser)) {
    authService.SetState(_localuser);
  } else {
    // remove leftover
    authService.RemoveState();
    // and clean localstroage
    localStorage.removeItem('user');
  }
};

export class AuthService {
  // ... add two more methods that shall move soon to auth state service
  SetState(item: IAuthInfo) {
    this.stateItem.next(item);
  }
  RemoveState() {
    this.stateItem.next(null);
  }
}


Enter fullscreen mode Exit fullscreen mode

For the sole purpose of demonstrating that we got the sequence right, this should be enough. Now testing in our app, with no login, then login, then refresh. It works. The authentication state is always up to date.

Await remote configuration

There could be another initializer in line, let's imagine the key of the localStorage is stored in a remote configuration file, which we load via an Http request (read about loading external configurations via http using APP_INITIALIZER). Not only is the sequence of initializers important, but the async nature of the Http request adds a slight complexity, let's fix it. We should wait for the configuration to be ready before we attempt to use it.



// auth factory change
// add second attribute for config service which has the config$ observable
export const authFactory = (authService: AuthService, configService: ConfigService) => () => {
  // wait for config to be ready
   configService.config$.pipe(
      filter(config => config.isServed)
   ).subscribe({
      next: config => {
         // do the rest using config key
         const _localuser: IAuthInfo = JSON.parse(
            localStorage.getItem(config.Auth.userAccessKey)
         );
         if (CheckAuth(_localuser)) {
            authService.SetState(_localuser);
         } else {
            // remove leftover
            authService.RemoveState();
            // and clean localstroage
            localStorage.removeItem(config.Auth.userAccessKey);
         }
      }
   }
   );
}


Enter fullscreen mode Exit fullscreen mode

In our provider, we add ConfigService to the deps array. We also can make this initializer above the configuration initializer, since it has to wait for it anyway.

We can enhance this a little: move everything into the AuthService (or future AuthState service), and clean up our code. The token in app module would look this dumb:



// app.module, providers
{
  provide: APP_INITIALIZER,
  // dummy factory
  useFactory: () => () => {},
  multi: true,
  // injected depdencies, this will be constructed immidiately
  deps: [AuthService]
},


Enter fullscreen mode Exit fullscreen mode

Now we can move the logic to AuthService (or our future AuthState)



// auth.service constructor can have everything we need
constructor(private configService: ConfigService) {
  this.configService.config$.pipe(
     filter(config => config.isServed)
  ).subscribe({
     next: config => {
        // ...
    }
  );
}


Enter fullscreen mode Exit fullscreen mode

Login Resolve

A login resolve is to redirect user away from the login page, if user is already logged in.



// services/login.resolve
@Injectable({ providedIn: 'root' })
export class LoginResolve implements Resolve<boolean> {
 // bring in Auth future State service
 constructor(private authState: AuthService, private router: Router) { }
 resolve(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
    // listen to state changes
    return this.authState.stateItem$.pipe(
      // if item exists redirect to default
      // later we will enhance this with a redirect url
       map(user => {
          if (user) {
             this.router.navigateByUrl('/private/dashboard');
          }
          // does not really matter, I either go in or navigate away
          return true;
       })
    );
 }
}

// in public route:
{
  path: 'public/login',
  component: PublicLoginComponent,
  resolve: {
    ready: LoginResolve,
  },
},


Enter fullscreen mode Exit fullscreen mode

There is no magic there, since the AuthService used is ready at this point.

PS: I am using AuthService and calling it authState on purpose, we'll touch on it later

Auth Guard

This is to guard private routes and redirect back to login if user is not logged in.



// services/auth.guard
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate, CanActivateChild {
    // bring in our Auth future State
    constructor(private authState: AuthService, private _router: Router) {}

    canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
      return this.secure(route);
    }

    canActivateChild(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
      return this.secure(route);
    }

    private secure(route: ActivatedRouteSnapshot | Route): Observable<boolean> {
        // listen to auth state
      return this.authState.stateItem$.pipe(
         map(user => {
            // if user exists let them in, else redirect to login
            if (!user) {
               this._router.navigateByUrl('/login');
               return false;
            }
            // user exists
            return true;
         })
      );
    }
}


Enter fullscreen mode Exit fullscreen mode

Before we go on, let me inspect another choice that would have been tempting, just to demonstrate how terrible it is.

Inject in root component

There is a slight difference for this one, it really is late enough the configuration is definitely be loaded by then. But the login resolve and auth guard are usually faster than root component, so this might be a bit too late. Let's inspect.



// app.component
 export class AppComponent {
    constructor(
        private authService: AuthService
    ) {

      // initiate state of auth
      const _localuser: IAuthInfo = JSON.parse(
         localStorage.getItem('key here from config')
      );
      if (this.authService.CheckAuth(_localuser)) {
         this.authService.SetState(_localuser);
      } else {
         this.authService.RemoveState();
         localStorage.removeItem('key here from config');
      }
    }
}


Enter fullscreen mode Exit fullscreen mode

The configuration is guaranteed to be loaded at this point. We can also place this logic in the AuthService constructor, but we still need to inject it in the app root component. I don't know about you, but I don't like injecting something I am not going to use!

Be careful as you test different settings, the authService injection may happen elsewhere and ruin your conclusions, always make sure there is no other injection for most guaranteed results.

Now running with LoginResolve, and AuthGuard, I can immediately spot the problem. They are too early. Refreshing the URL /login will not redirect to dashboard even if the user is found. To fix that, the logic must be in the AuthService constructor, which is --- messy.

Logout

Logout is as simple as removing state, and localStorage. The Logout feature itself can be placed anywhere it is needed, but the true user-triggered logout, needs an extra redirect to close the cycle. We already have the logic in AuthService we just need to move it to its own method.



// services/auth.service
// add another method, and use it when needed
Logout() {
  // remove leftover
  this.RemoveState();
  // and clean localstroage, you can use static configuration at this point
  localStorage.removeItem(ConfigService.Config.Auth.userAccessKey);
}


Enter fullscreen mode Exit fullscreen mode

PS: at this point, the configuration is loaded, you can use static configuration for the key.

In our root component I will add a link that calls a logout function, which uses the AuthService Logout, and then reroutes:



// app.component
export class AppComponent {
constructor(private authService: AuthService, private router: Router) {}
Logout() {
this.authService.Logout();
// also reroute to login
this.router.navigateByUrl('/public/login');
}
}
// in HTML templte: (click)="Logout()"

Enter fullscreen mode Exit fullscreen mode




Use cases

Let us add the access token to our API Http calls, and deal with a 401. That and a few other use cases will have to wait till next week. 😴

Thank you for reading this far, did you check your injections?

RELATED POSTS

Top comments (0)