DEV Community

Cover image for AWS Amplify Auth & Angular RxJS Simple State Management
Michael Gustmann
Michael Gustmann

Posted on

AWS Amplify Auth & Angular RxJS Simple State Management

Recent updates to the aws-amplify hub make it easier to listen to (AWS Cognito) auth state changes without using the aws-amplify-angular package. If we are not using the prebuild UI-components of that package, we might save a few hundred KB by just importing the modules we need.

But even if we use aws-amplify-angular package, we can take a look at how we can manage our authentication and user state in a very Angular'esque way without any state management libraries, like Redux, NGRX, apollo-link-state, MobX, Akita or NGXS

One of the features of Angular is their dependency injection system. We can define tree-shakable injectables to use in any of our components or other injectables. It's a good place to store the state of feature modules in one or more services.

If our app grows more complex there might come a time, where we feel the need to use a single store to manage our state. We might be interested in better developer ergonomics with tools like Redux DevTools or reap the benefits of clearly defined patterns of the library of our choice.

But until this time comes or if we decide, that we want more than one store, this might be a good pattern to use in our Angular apps.

RxJS BehaviorSubject

Angular has a peer dependency on RxJS and uses it in some of their packages. Learning Angular usually entails learning RxJS as well. RxJS offers a Subject, which is an Observable and an Observer in one. The BehaviorSubject additionally stores the last state and can't be created without an initial state. This is pretty ideal for our state management needs, because the subscriber gets the last emitted value immediately after subscribing to the observable. Having an initial state is common to many state management libraries and removes the need to check against null or undefined.

If we want to protect the state against changes from outside, we have a way to make it private and only expose the observable to the public.

Auth-Service

To create a service using the Angular CLI type

$ ng g s auth

This will generate a service auth.service.ts in our app.

We need to import our service somewhere, so the bundler includes it. Since this is a app-wide service that should run all the time, we can simply inject the service in app.module.ts:

@NgModule({
  /*...*/
})
export class AppModule {
  constructor(_auth: AuthService) {
    console.log('starting AppModule');
  }
}

This will instantiate the AuthService as a singleton on the start of the app.

In a real-world app, we would probably want to move the service in a separate module (ie. auth.module.ts) and inject it in its constructor. Importing that module in app.module.ts would have the same effect.

This is our implementation of auth.service.ts:

import { Injectable } from '@angular/core';
import Auth from '@aws-amplify/auth';
import { Hub } from '@aws-amplify/core';
import { BehaviorSubject } from 'rxjs';
import { map } from 'rxjs/operators';

export interface AuthState {
  isLoggedIn: boolean;
  username: string | null;
  id: string | null;
  email: string | null;
}

const initialAuthState = {
  isLoggedIn: false,
  username: null,
  id: null,
  email: null
};

@Injectable({
  providedIn: 'root'
})
export class AuthService {
  private readonly _authState = new BehaviorSubject<AuthState>(
    initialAuthState
  );

  /** AuthState as an Observable */
  readonly auth$ = this._authState.asObservable();

  /** Observe the isLoggedIn slice of the auth state */
  readonly isLoggedIn$ = this.auth$.pipe(map(state => state.isLoggedIn));

  constructor() {
    // Get the user on creation of this service
    Auth.currentAuthenticatedUser().then(
      (user: any) => this.setUser(user),
      _err => this._authState.next(initialAuthState)
    );

    // Use Hub channel 'auth' to get notified on changes
    Hub.listen('auth', ({ payload: { event, data, message } }) => {
      if (event === 'signIn') {
        // On 'signIn' event, the data is a CognitoUser object
        this.setUser(data);
      } else {
        this._authState.next(initialAuthState);
      }
    });
  }

  private setUser(user: any) {
    if (!user) {
      return;
    }

    const {
      attributes: { sub: id, email },
      username
    } = user;

    this._authState.next({ isLoggedIn: true, id, username, email });
  }
}
  1. We define our public interface of our state in AuthState and create an initial object initialAuthState.
  2. We decided to use the default providedIn property to make this service tree-shakable (optional)
  3. We define and initialize a private BehaviorSubject and expose it as an observable
  4. We write a private function setUser(user: any) to take a cognito user object, desctructure it and create the next state out of it
  5. We create an optional way of exposing a slice of the state, the isLoggedIn boolean value of our state object.
  6. In the constructor we request the current user by writing Auth.currentAuthenticatedUser() on instantiation of this injectable and emitting either the user object or the initialAuthState
  7. We start listening on the hub channel named 'auth' and construct the next state on whether the user is signed in or not

That's it! We can subscribe to state changes anywhere in our where we inject this service.

Using our auth state service

To access our state, we inject the service and subscribe to any of the exposed observables.

// ... imports
@Component({
  selector: 'my-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent implements OnInit {
  isLoggedIn = false;
  user: { id: string; username: string; email: string };

  constructor(private authService: AuthService) {}

  ngOnInit(): void {
    this.authService.isLoggedIn$.subscribe(
      isLoggedIn => (this.isLoggedIn = isLoggedIn)
    );

    this.authService.auth$.subscribe(({ id, username, email }) => {
      this.user = { id, username, email };
    });
  }
}

I hope this helps to see how to handle state in Angular in a modular and simple way by moving it into a service and informing anyone interested of changes with the help of a RxJS BehaviorSubject.

Top comments (11)

Collapse
 
bhill9270 profile image
Ben

Great post! I've implemented this for my Angular app, but I'm using a third party identity provider and the hosted UI and I'm having an issue getting the authenticated state set correctly on the first load.

The issue is with the Authenticated Code Grant. When the app loads initially, it passes the code to Amplify which then makes calls out to Cognito to obtain the tokens and user information. The time delay causes all the isLoggedIn states to be marked as false. A page refresh then shows the user as logged in correctly as the tokens are all in local storage now.

Any advice?

Collapse
 
beavearony profile image
Michael Gustmann

Thanks,
it's hard to see the error without code, but I would suggest trying these steps:

  • use 'tap(val => console.log(val))' statements in pipe() to see if the value actually changes
  • if using onPush() components, make sure to mark for changes in subscribe callback
  • try using the async pipe and get rid of the subscribe calls

it sounds to me like it might be an Angular changeDetection problem.

Collapse
 
bhill9270 profile image
Ben

Apologies! Below is my basic AuthGuard.

export class AccessGuard implements CanActivate {

isLoggedIn = false;

constructor(private router: Router,
          private authService: AuthService) {}

  canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): 
Observable<boolean> | boolean {

  this.authService.isLoggedIn$
  .subscribe(
    isLoggedIn => (this.isLoggedIn = isLoggedIn)
  );

  if (!this.isLoggedIn) {
    return false;
  } else {
  return true;
   }
 }
}
Thread Thread
 
beavearony profile image
Michael Gustmann

You can just do

canActivate() {
    return this.authService.isLoggedIn$;
}

Do not subscribe here! In your case it will resolve to false immediately and never actually care about the subscribe callback. Also it will create a leak, if you do not unsubscribe. When returning an Observable, Angular will do it for you!

What I actually do is talk to the Amplify Auth class directly:

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
  constructor(
    private router: Router,
    @Inject(PLATFORM_ID) private platformId
  ) {}

  canActivate() {
    if (isPlatformBrowser(this.platformId)) {
      return Auth.currentAuthenticatedUser()
        .then(_user => {
          return true;
        })
        .catch(_err => {
          this.router.navigate(['/auth']);
          return false;
        });
    } else {
      return true;
    }
  }
}

I have to check for the Browser platform, because Amplify is not SSR compatible. But this will directly look at the saved value in localStorage.

Thread Thread
 
bhill9270 profile image
Ben • Edited

Thanks for the suggestions!!

I've tried both, and still get 'not authenticated' on the first load/login of the app. The only thing I've been able to get to work is adding a timeout in the auth service to give it time to populate the localstorage. Here is a related github issue: github.com/aws-amplify/amplify-js/...

When the ADFS system redirects the user to the site with a auth code (instead of token) Amplify has to make multiple calls out to Cognito, and the canActivate is not waiting for the calls Amplify is making to return.

I'm going to try adding a loop in the auth service that checks for the localstorage before performing any .next actions. I'll let you know how it turns out and post my solution!

Edit: This seems to do the trick:

     private getAuthToken(i: number) {
      setTimeout(() => {
        const ampToken = localStorage.getItem('amplify-signin-with-hostedUI');
         if (!ampToken && i < 500) { //stopping the loop eventually if the user isn't logged in
           i++
           this.getAuthToken(i);
        }
     return this.checkAuthorization();
   }, 20); //Loops every 20 ms
 }

 private checkAuthorization() {
  Auth.currentAuthenticatedUser().then(
  (user: any) => { 
    this.setUser(user) },
  _err => console.log(_err)
  );
 }

Would love to hear any other ideas on waiting on the tokens to populate.

Thread Thread
 
beavearony profile image
Michael Gustmann

One note about my previous comment. If you return an observable in a guard, you need to make sure it is completed. To do that, you can just add take(1):

return this.auth.isLoggedIn$.pipe(take(1));

What you can think about as well is to wait for rendering the app until the Auth.currentAuthenticatedUser() resolves.

Wrap the AppComponent's HTML with a

<ng-container *ngIf="authChecked">
   ... app-component.html stuff
</ng-container>
export class AppComponent {
  authChecked = false;
  constructor() {
    Auth.currentAuthenticatedUser().then(
      _user => (this.authChecked = true),
      _err => (this.authChecked = true)
    );
  }
}
Collapse
 
jasonbbelcher profile image
Jason Belcher

BehaviourSubjects are so powerful for state management. I find that in most cases I never need Redux or NgRx when utilizing behaviorSubjects to implement dataStorage services. Cool article. I found it searching for tips on using Amplify with Angular. Cheers!

Collapse
 
hannabecker profile image
Hanna Becker

Thanks for sharing this awesome solution!

2 things I needed to change:

  • We use social providers on top of the native Cognito auth. In that case the 'signIn' event does not include the data needed for setting the user, and we instead need to make another call to Auth.currentAuthenticatedUser() to retrieve it.
  • Treating every event besides 'signIn' as if it were a logout is something we tripped over in the past, as we saw an occasional token refresh event submitted on that channel, as well. We therefore specificallty handle 'signIn', 'signOut', and 'oAuthSignOut' events, and ignore all other events.
Collapse
 
ig33kmor3 profile image
Freddie

This post was amazing! It really helped me get my feet underneath me with the AWS library. I do have a simple question. Are you getting errors with executing code in the Hub.Listen() callback using THIS? I nested Auth.CurrentAuthenticatedUser into that callback block and that code never gets executed if I use THIS in the Auth.CurrentAuthenticatedUser. Thanks!

Collapse
 
ig33kmor3 profile image
Freddie

Figured it out for anyone whoever discovers this comment later ..... at the time of this post, executing a behavior subject inside the Hub.Listen callback, you must use NgZone so angular can detect there is a change.

Collapse
 
harmohana profile image
harmohan-a

this is so good! its everything the way i wanted to implement laid out in a platter.
going through amplify docs wasnt fun.

thanks