Protecting Routes with Guards in Angular
The last feature we are going to add is to prevent unauthenticated users from getting to the home page, and to auto redirect logged in users to the home page. Firebase will remember users automatically, so if we are already logged in we should go directly to the home page.
Redirecting the User
Before we create our guard which will help keep our user where they are supposed to be, we are going to implement some redirects that react to the user’s auth state changing.
The problem in our application right now is that when we try to log in or create an account… nothing happens.
These operations are actually happening successfully, it’s just that our
application doesn’t care. What we need to do is have our components react to the
authState from Firebase changing by triggering a navigation. We already have
a convenient way to do this. Whenever our authState observable emits we set
our user state in our AuthService. This results in the following
possibilities:
- If we do not yet know if the user is authenticated, the
user()signal will beundefined - If the user is unauthenticated the
user()signal will benull - If the user is authenticated the
user()signal will be theUserfrom Firebase
This user() signal updates automatically whenever we change the user’s authState in any way — whether that happens because of a login, logout, create account, or anything else.
We also have a convenient way to trigger running some code, like a navigation, by using the effect Signal API. That means we can just add some effects to each of our components to handle the navigation:
- The
LoginComponentshould react by navigating to thehomeroute when theuser()signal becomes truthy (i.e. notnullorundefined) - The
RegisterComponentshould do the same - The
HomeComponentshould react by navigating to theauth/loginroute when theuser()signal becomesnull
See if you can implement this before continuing.
Click here to reveal solution
Solution
private router = inject(Router);
constructor() { effect(() => { if (this.authService.user()) { this.router.navigate(['home']); } }); } private router = inject(Router);
constructor() { effect(() => { if (this.authService.user()) { this.router.navigate(['home']); } }); } private router = inject(Router);
constructor() { effect(() => { if (!this.authService.user()) { this.router.navigate(['auth', 'login']); } }); }Now if you try to use the application it should actually appear to work
correctly (although it will still look ugly). However, if you happen to have
logged in before you will automatically be taken to the home route. This is
good, but we don’t actually have a way to trigger a log out, so you will be
stuck there.
<div class="container"> <mat-toolbar color="primary"> <span class="spacer"></span> <button mat-icon-button (click)="authService.logout()"> <mat-icon>logout</mat-icon> </button> </mat-toolbar>
<app-message-list [messages]="messageService.messages()" /> <app-message-input (send)="messageService.add$.next($event)" /> </div> imports: [ MessageListComponent, MessageInputComponent, MatIconModule, MatButtonModule, MatToolbarModule, ],Handling Re-authentication
If you use the application now it should generally work — as in you should be able to create an account, log in, and log out. All of the redirects should happen as we expect now.
However, you might notice that if you log out and back in the messages will no
longer work. That is because of the way our messages$ source is set up in the
MessageService:
messages$ = this.getMessages();Looks innocent enough, but the problem here is that when we log out we will no
longer have permission to read the messages in the messages collection in the
database. This means that the collectionData stream that getMessages returns
is going to error:
ERROR FirebaseError:false for 'list' @ L6, false for 'list' @ L12When an observable errors it will no longer emit any more values. That means that even when we log in again and we have permission to access the database again, the stream will still broken and it won’t emit any more values.
To fix this, we can modify our messages$ source slightly.
private authUser$ = toObservable(this.authService.user);
// sources messages$ = this.getMessages().pipe( // restart stream when user reauthenticates retry({ delay: () => this.authUser$.pipe(filter((user) => !!user)), }) );The idea here is that we use the retry operator — the purpose of this operator
is to retry subscribing to an observable stream after it errors. There are
various ways this retry can behave, but one way it can be used is by providing
a “notifier” observable stream to delay to tell it when to retry. When this
stream emits, it will retry subscribing to the observable stream.
We want the retry to happen when our user becomes authenticated again. We
convert our user signal from the AuthService into an observable stream by
using the toObservable function from the @angular/core/rxjs-interop package.
This is basically the opposite of the toSignal function we have used
occasionally.
This authUser$ stream will emit whenever the auth state changes. But we don’t
want to retry the stream whenever the auth state changes, we specifically only
want to retry when the auth state changes to a valid user. That is why we
add the filter to filter out any null values for the authUser$ stream.
This means that now it will only emit when the user becomes authenticated again, and it is then that we want to retry the stream.
After making this change, you should see that the messages work fine again.
Create a Guard for the Home Route
A route guard is probably easier to implement than it sounds — essentially, it is a function that determines whether a user can activate a particular route or not. Let’s just create it and see how it works.
import { inject } from '@angular/core';import { CanActivateFn, Router } from '@angular/router';import { AuthService } from '../data-access/auth.service';
export const isAuthenticatedGuard = (): CanActivateFn => { return () => { const authService = inject(AuthService); const router = inject(Router);
if (authService.user()) { return true; }
return router.parseUrl('auth/login'); };};The basic idea here is that if this function returns true the user will be
able to activate this route. If it returns false they can not.
All we do is check our user() signal from the AuthService to determine this.
But, we are doing something a little bit interesting here. Rather than just
returning false if they can not access this route, we do this:
return router.parseUrl('auth/login');We don’t want to just prevent them from accessing the home route, we want to
redirect them to the login page. Using parseUrl here is a way for us to
directly tell Angular where the resulting navigation should be. We could
technically use a normal navigate call in here, but this is a side effect that
will then trigger the routing process for Angular again — parseUrl is just
a more direct way to get the result we want in the route guard.
To use our route guard, we can just attach it to any route we want to protect with this function.
import { Routes } from '@angular/router';import { isAuthenticatedGuard } from './shared/guards/auth.guard';
export const routes: Routes = [ { path: 'auth', loadChildren: () => import('./auth/auth.routes').then((m) => m.AUTH_ROUTES), }, { path: 'home', canActivate: [isAuthenticatedGuard()], loadComponent: () => import('./home/home.component'), }, { path: '', redirectTo: 'auth', pathMatch: 'full', },];If you make sure you are logged out now, and then try to directly access the
/home route by entering it into the URL bar, you will see the login page gets
loaded instead.
Special Handling for the Login Route
Now we need to handle what happens if the user is logged in and they try to access the login page (which will happen by default when they load the application).
We could do essentially the same thing here with another route guard. Create
a route guard for the login route, but just have the opposite check — if
a user is already authenticated then they get “kicked” to the logged in view.
That would be a worthwhile exercise actually if you want to try and implement that yourself.
But, we are ultimately going to go with a slightly different approach for the login page.
If you make sure you are logged in and then try to access the root route / you
will see that it actually already does redirect. However, unlike when we used
the route guard, we still see the login page momentarily.
We are going to keep things this way because the downside of the route guard is
that nothing can happen until our authState observable from Firebase emits
— this is what tells us if the user is authenticated or not. So, there is going
to be some delay, and if we use a route guard for our login page it means the
user will see nothing until that stream emits its value.
To improve the perceived performance here, we are going to immediately display the login page, and redirect automatically once the check finishes (again, this is already happening).
We can improve the UX a bit here though by displaying a loading spinner whilst this check happens. It would be a bit annoying to see the login form, go to fill it in thinking you aren’t authenticated, and then all of a sudden you get thrown into a different page.
<div class="container gradient-bg"> @if(authService.user() === null){ <app-login-form [loginStatus]="loginService.status()" (login)="loginService.login$.next($event)" /> <a routerLink="/auth/register">Create account</a> } @else { <mat-spinner diameter="50" /> } </div>Now if the user is already logged in they will see a brief loading spinner and then be taken to the logged in view. If they are not authenticated, they will briefly see a spinner and then see the login form.
This doesn’t change the overall performance, but if things are happening on screen it can make the application feel a lot faster and smoother.
And that’s it! Our application is mostly completed at this point… it just looks terrible. In the next lesson, we are going to add a few final touches.