Angular 8 isAuthenticated race condition

Hello, posting here after migrating from Auth0.js to Auth0-spa.js. Everything seems to work fine except there seems to be some kind of race condition when calling handleAuthCallback and having an AuthGuard as suggested in the migration docs and Angular setup docs.

I have lazy-loaded routes/modules. With the App Module splitting auth and home. My redirect_uri is /home/dashboard. I have tried calling handleAuthCallback from AppComponent, my Dashboard component (which the user gets redirected to after login), and the constructor of the AuthService. The most success I’ve seen is AppComponent and adding a delay(500) RxJs Operator to the authComplete$ code. Obviously, that’s a hack and not ideal. Also, I removed the combineLatest part with fetching the UserProfile because I don’t need that information. Although, I tried it initially with it there and the same issue still occurred.

const authComplete$ = this.handleRedirectCallback$.pipe(
    delay(500),
    tap(cbRes => {
      targetRoute = cbRes.appState && cbRes.appState.target ? cbRes.appState.target : '/';
    }),
    concatMap(() => {
      return this.isAuthenticated$;
    })
  );

In the AuthGuard:

canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
return this.authService.isAuthenticated$.pipe(
  tap(loggedIn => {
    if (!loggedIn) {
      this.authService.login(state.url);
    }
  })
);

In auth.service.ts:

login(redirectPath: string = '/home/dashboard') {
 this.auth0Client$.subscribe((client: Auth0Client) => {
  this.loading = true;
  client.loginWithRedirect({
    redirect_uri: environment.auth0.redirect_uri,
    appState: { target: redirectPath }
  });
 }); }

environment.ts:

export const environment = {
  ...,
  auth0: {
    client_id: 'someSuperSecretKey',
    domain: 'fooBar.auth0.com',
    logout_url: 'http://localhost:4200/auth/login',
    redirect_uri: 'http://localhost:4200/home/dashboard',
    audience: 'https://fooBar.auth0.com/api/v2/'
  }};

Some of the things I am trying currently is maybe adding some kind of loading$ observable that the AuthGuard waits on resolving so it knows once the necessary app state has been set before trying to resolve the route with checking if the user is logged in.

Any help would be greatly appreciated!

3 Likes

What appears to be happening according to my debugging:

  1. User clicks Login

  2. taken to Universal Login

  3. User signs in

  4. handleAuthCallback gets called

  5. AuthGuard runs(isAuthenticated$ is false)

  6. handleAuthCallBack async code finishes finally but the user is now already back at the login page
    authComplete$.subscribe(() => {
    console.log(‘auth complete’);
    this.router.navigate([targetRoute]);
    });

  7. User Logs in AGAIN

  8. Now it will work because isAuthenticated$ will finally be true from the previous login

So after taking a step back and examining how the suggested code runs I am kind of curious how this was working as intended for the person who set this up for an Angular application. I noticed a few anti-patterns when it comes to RxJs and some assumptions made around the whole asynchronous process of how these things would happen.

This is what I ended up with:

auth.service.ts:

@Injectable({ providedIn: 'root' })
export class AuthService {
  refreshSub: Subscription;

  auth0Client$: Observable<Auth0Client> = from(
    createAuth0Client({
     domain: environment.auth0.domain,
     client_id: environment.auth0.client_id,
     redirect_uri: environment.auth0.redirect_uri,
     audience: environment.auth0.audience
   })).pipe(shareReplay(1), catchError(err => throwError(err)));

  isAuthenticated$ = this.auth0Client$.pipe(concatMap((client: Auth0Client) => 
    from(client.isAuthenticated())));

  handleRedirectCallback$ = this.auth0Client$.pipe(
    concatMap((client: Auth0Client) => from(client.handleRedirectCallback())));

  constructor(public router: Router) {}

  getTokenSilently$(options?): Observable<string> {
    return this.auth0Client$.pipe(concatMap((client: Auth0Client) => 
      from(client.getTokenSilently(options))));
  }

  login(redirectPath: string = '/home/dashboard'): Observable<void> {
    return this.auth0Client$.pipe(
      concatMap((client: Auth0Client) =>
        client.loginWithRedirect({
        redirect_uri: environment.auth0.redirect_uri,
        appState: { target: redirectPath }
      })));
  }

  handleAuthCallback(): Observable<{ loggedIn: boolean; targetUrl: string }> {
     return of(window.location.search).pipe(
      concatMap(params =>
        iif(() => params.includes('code=') && params.includes('state='),
           this.handleRedirectCallback$.pipe(concatMap(cbRes =>
              this.isAuthenticated$.pipe(take(1),
                map(loggedIn => ({ loggedIn,
              targetUrl: cbRes.appState && cbRes.appState.target ? cbRes.appState.target : '/'
            }))))),
          this.isAuthenticated$.pipe(take(1), map(loggedIn => ({ loggedIn, targetUrl: null }))))));
  }

  logout() {
   this.auth0Client$.subscribe((client: Auth0Client) => {
     client.logout({
       client_id: environment.auth0.client_id,
       returnTo: environment.auth0.logout_url
    });
  });
 }
}

auth.guard.ts

canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean> {
     return this.authService.isAuthenticated$.pipe(
       concatMap(_ => this.authService.handleAuthCallback()),
       concatMap(result => iif(() => result.loggedIn, of(true), this.authService.login(state.url).pipe(map(_ 
=> false)))));
 }

home.component.ts

  ngAfterViewInit(): void {
    this.location.replaceState('/home/dashboard');
  }

This works for me now. I made it this way to enforce the AuthGuard to wait until the checks are complete and not making assumptions that things will complete before the AuthGuard runs. Also, I changed some of the RxJs Code to use concatMap instead of .subscribe and to return Observables instead of subscribing to new ones inside of function calls like login(). In general, you should avoid calling subscribe within subscriptions to avoid memory leak headaches from having long-lived subscriptions that don’t ever complete.

Hope this helps anyone who runs into this issue.

11 Likes

This topic was automatically closed 15 days after the last reply. New replies are no longer allowed.