Search code examples
angularrxjs6angular-guards

Navigation from Angular Guard not working when using Observables


I've run into an interesting issue where my Angular Guard doesn't seem to do anything when I try to redirect in cases where the user is trying to access routes that they shouldn't be.

As you can see, I've placed console.log calls throughout the process and I see the messages I'd expect, but the navigation never occurs and the originally requested page loads like normal. This has really got me stumped - any help would be much appreciated!

The Guard

// angular dependencies
import { Injectable } from '@angular/core';
import { CanActivate, ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree, Router } from '@angular/router';

// rxjs utils
import { Observable } from 'rxjs';
import { take, map, tap } from 'rxjs/operators';

// service(s)
import { AuthService } from './services/auth/auth.service';

// interface(s)
import User from './interfaces/user.interface';

@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {

    constructor(private _authService: AuthService, private _router: Router) { }

    canActivate(next: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {
        const url = state.url;
        console.log('Auth Guard hit', { url: url });

        return this._authService.user.pipe(
            take(1),
            map(user => {
                console.log(user);
                return !!user;
            }),
            tap(authenticated => {
                console.log(`Authenticated: ${authenticated}`);
                // not authenticated
                if (!authenticated) {
                    // accessing sign in or sign up pages
                    if (['/login', '/register'].includes(url)) {
                        console.log('Allow through');
                        return true;
                    }
                    // accessing application
                    else {
                        console.log('Should bounce to login');
                        return this._router.createUrlTree(['/login']);
                    }
                }
                // authenticated
                else {
                    // accessing sign in or sign up pages
                    if (['/login', '/register'].includes(url)) {
                        console.log('Should bounce to dashboard');
                        return this._router.createUrlTree(['/dashboard']);
                    }
                    // accessing application
                    else {
                        console.log('Allow through');
                        return true;
                    }
                }
            })
        );
    }

}

AuthService

// angular dependencies
import { Injectable } from '@angular/core';

// firebase dependencies
import { AngularFireAuth } from '@angular/fire/auth';
import { AngularFirestore } from '@angular/fire/firestore';

// RXJS helpers
import { Observable, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';

// interfaces
import User from '../../interfaces/user.interface';

@Injectable({ providedIn: 'root' })
export class AuthService {
    public user: Observable<User> = null;

    constructor(private _auth: AngularFireAuth, private _firestore: AngularFirestore) {
        this.user = this._auth.authState.pipe(
            switchMap(({ uid }) => {
                if (uid) {
                    return this._firestore.doc(`users/${uid}`).valueChanges();
                } else {
                    return of(null);
                }
            })
        );
    }

    public async createAccount(email: string, password: string, forename: string, surname: string, relevantTags: string[] = []) {
        // create the user in the auth system
        let authUser;
        try {
            authUser = await this._auth.auth.createUserWithEmailAndPassword(email, password);
        } catch (error) {
            console.error('Failed to create user in auth system', { reason: error.message });
            throw error;
        }

        // flesh out the user data
        const data: User = {
            uid: authUser.user.uid,
            forename: forename,
            surname: surname,
            displayName: `${forename} ${surname}`,
            roles: ['user'],   // everyone has user role, can be promoted by admin as required
            relevantTags: relevantTags,
            emailVerified: false
        };

        // create the user in the database
        try {
            this._firestore.doc(`users/${data.uid}`).set(data);
        } catch (error) {
            console.error('Failed to create user in database', { reason: error.message });
            throw error;
        }

        // attempt to send the initial verification email
        try {
            this.sendVerification();
        } catch (error) {
            console.warn('Failed to send verification email', { reason: error.message });
            throw error;
        }
    }

    public async signIn(email: string, password: string) {
        // attempt to sign in
        let result: firebase.auth.UserCredential;
        try {
            result = await this._auth.auth.signInWithEmailAndPassword(email, password);
        } catch (error) {
            console.error('Failed to log in', { reason: error.message });
            throw error;
        }

        // store the user data for access by controllers
        try {
            this.user = this._firestore.doc<User>(`users/${result.user.uid}`).valueChanges();
        } catch (error) {
            console.error('Failed to set user on service', { reason: error.message });
            this._auth.auth.signOut();
            throw new Error('Failed to log in');
        }
    }

    public async signOut() {
        // attempt to sign out
        try {
            await this._auth.auth.signOut();
        } catch (error) {
            console.error('Failed to log out', { reason: error.message });
            throw new Error('Failed to log out');
        }
    }

    public async sendVerification() {
        // attempt to send verification email
        try {
            this._auth.auth.currentUser.sendEmailVerification();
        } catch (error) {
            console.error('Failed to send verification', { reason: error.message });
            throw new Error('Failed to send verification');
        }
    }
}

The App As you can see from the screenshot below, the logs are correct, and we should be redirected to the dashboard, yet the Login component and route are still activated. Screenshot containing the console logs and showing the login route is still active


Solution

  • Angular Guard should resolve with a boolean. So instead of using tap operator, use map operator and always return a boolean, even when you're redirecting. Try changing that code to below:

    return this._authService.user.pipe(
                take(1),
                map(user => {
                    console.log(user);
                    return !!user;
                }),
                map(authenticated => {
                    console.log(`Authenticated: ${authenticated}`);
                    // not authenticated
                    if (!authenticated) {
                        // accessing sign in or sign up pages
                        if (['/login', '/register'].includes(url)) {
                            console.log('Allow through');
                            return true;
                        }
                        // accessing application
                        else {
                            console.log('Should bounce to login');
                            this._router.navigateByUrl('/login');
                            return false;
                        }
                    }
                    // authenticated
                    else {
                        // accessing sign in or sign up pages
                        if (['/login', '/register'].includes(url)) {
                            console.log('Should bounce to dashboard');
                            this._router.navigateByUrl('/dashboard');
                            return false;
                        }
                        // accessing application
                        else {
                            console.log('Allow through');
                            return true;
                        }
                    }
                })
            );
    

    Update: Since v7.1, the guard should resolve to either a boolean or UrlTree object. If a UrlTree object is returned, the guard will route to that Url instead. Router.parseUrl or Router.createUrlTree methods can be used to create UrlTree object.

        return this._authService.user.pipe(
                take(1),
                map(user => {
                    console.log(user);
                    return !!user;
                }),
                map(authenticated => {
                    console.log(`Authenticated: ${authenticated}`);
                    // not authenticated
                    if (!authenticated) {
                        // accessing sign in or sign up pages
                        if (['/login', '/register'].includes(url)) {
                            console.log('Allow through');
                            return true;
                        }
                        // accessing application
                        else {
                            console.log('Should bounce to login');
                            return this._router.parseUrl('/login');
                        }
                    }
                    // authenticated
                    else {
                        // accessing sign in or sign up pages
                        if (['/login', '/register'].includes(url)) {
                            console.log('Should bounce to dashboard');
                            this._router.parseUrl('/dashboard');
                        }
                        // accessing application
                        else {
                            console.log('Allow through');
                            return true;
                        }
                    }
                })
            );