Configurable class-based guard
When building web applications, there are times when routes need protection from unauthorized access. In Angular, router guards handle this responsibility.
This isn't an introduction to Angular guards — for a primer, check the official documentation.
This article demonstrates how to build configurable Angular guards. The example guard checks whether the logged-in user possesses a specific role, redirecting to an unauthorized page if not.
A mock AuthService is used, which exposes a method for verifying a user's role.
// auth.service.ts
export const ROLES = {
ADMIN: 'ADMIN',
MANAGER: 'MANAGER',
};
@Injectable({ providedIn: 'root' })
export class AuthService {
userRole = ROLES.ADMIN;
hasRole(role: string): boolean {
return this.userRole === role;
}
}
Prior to Angular 14, guards were class-based. A straightforward approach for our scenario involved creating a dedicated class-based guard for each role and attaching it to the relevant routes.
However, this strategy lacks flexibility. Each new role necessitates a new guard class, which must then be applied to the intended routes.
This becomes cumbersome and leads to duplicated logic.
The pattern looked like this:
// admin.guard.ts
@Injectable({ providedIn: 'root' })
export class AdminGuard implements CanActivate {
authService = inject(AuthService);
router = inject(Router);
canActivate(): boolean | UrlTree {
const hasAccess = this.authService.hasRole(ROLES.ADMIN);
return hasAccess ? true : this.router.createUrlTree(['/unauthorized']);
}
}
// manager.guard.ts
@Injectable({ providedIn: 'root' })
export class ManagerGuard implements CanActivate {
authService = inject(AuthService);
router = inject(Router);
canActivate(): boolean | UrlTree {
const hasAccess = this.authService.hasRole(ROLES.MANAGER);
return hasAccess ? true : this.router.createUrlTree(['/unauthorized']);
}
}
These guards would then be attached to routes as shown:
// routes.ts
export const routes: Routes = [
{ path: 'home', component: HomeComponent },
{ path: 'admin', component: AdminComponent, canActivate: [AdminGuard] },
{ path: 'manager', component: ManagerComponent, canActivate: [ManagerGuard] },
{ path: 'unauthorized', component: NotAuthorizedComponent },
];
The resulting code duplication is clear. A configurable guard offers a better solution.
Configurable class-based guard
The Angular Router provides a data property on routes, which can carry information to guards. This property is perfect for specifying the required role. By leveraging it, a single guard can handle role checks based on the provided data.
// role.guard.ts
@Injectable({ providedIn: 'root' })
export class RoleGuard implements CanActivate {
authService = inject(AuthService);
router = inject(Router);
canActivate(route: ActivatedRouteSnapshot): boolean | UrlTree {
// Get the role from the route data
const role = route.data.role;
const hasAccess = this.authService.hasRole(role);
return hasAccess ? true : this.router.createUrlTree(['/unauthorized']);
}
}
Applying this guard to routes is straightforward:
// routes.ts
export const routes: Routes = [
{ path: 'home', component: HomeComponent },
{
path: 'admin',
component: AdminComponent,
canActivate: [RoleGuard],
data: { role: ROLES.ADMIN },
},
{
path: 'manager',
component: ManagerComponent,
canActivate: [RoleGuard],
data: { role: ROLES.MANAGER },
},
{ path: 'unauthorized', component: NotAuthorizedComponent },
];
This approach is markedly cleaner and eliminates duplication. One drawback is the lack of type safety; any string could be passed as data, and the guard wouldn't complain. Defining an interface for the data shape and using it for typing solves that issue.
interface RoleGuardData {
role: 'ADMIN' | 'MANAGER'; // We can add more roles here or infer them from the AuthService
}
// And then we can use this interface to type the data field:
export const routes: Routes = [
{
// ...
data: { role: ROLES.MANAGER } as RoleGuardData,
},
];
Now, passing an invalid role string results in a type error.
Configurable function-based guard
Angular 14 introduced function-based guards. This allows for a function that creates and returns a guard. This is ideal for our scenario, as we can define a function that produces a guard for a specific role and then apply it where needed.
// role.guard.ts
export const roleGuard = (role: 'MANAGER' | 'ADMIN'): CanActivateFn => {
const guard: CanActivateFn = () => {
const authService = inject(AuthService);
const router = inject(Router);
const hasAccess = authService.hasRole(role);
return hasAccess ? true : router.createUrlTree(['/unauthorized']);
};
return guard;
};
The function is then applied to routes like this:
// routes.ts
export const routes: Routes = [
{
path: 'admin',
component: AdminComponent,
canActivate: [roleGuard(ROLES.ADMIN)],
},
{
path: 'manager',
component: ManagerComponent,
canActivate: [roleGuard(ROLES.MANAGER)],
},
];
Function-based guards simplify typing concerns. The role is inferred from the function's argument and passed directly to the AuthService.
How to test the guards
Testing typically involves a mock AuthService. Here, the actual AuthService can be used since it's already fake.
// role.guard.spec.ts
import { TestBed } from '@angular/core/testing';
import { RouterTestingModule } from '@angular/router/testing';
import { ActivatedRouteSnapshot, Router } from '@angular/router';
import { ROLES, AuthService } from './auth.service';
import { RoleGuard } from './role.guard';
describe('RoleGuard', () => {
let router: Router;
let guard: RoleGuard;
let authService: AuthService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [RouterTestingModule],
providers: [RoleGuard, AuthService],
});
router = TestBed.inject(Router);
guard = TestBed.inject(RoleGuard);
authService = TestBed.inject(AuthService);
});
it('should be created', () => {
expect(guard).toBeTruthy();
});
it('should return true if the user has the role', () => {
authService.userRole = ROLES.ADMIN; // Set the user role
const route = { data: { role: ROLES.ADMIN } } as unknown as ActivatedRouteSnapshot;
expect(guard.canActivate(route)).toBeTrue();
});
it('should return /unauthorized if the user does not have the role', () => {
authService.userRole = ROLES.ADMIN; // Set the user role
const route = { data: { role: ROLES.MANAGER } } as unknown as ActivatedRouteSnapshot;
const unauthorizedUrlTree = router.createUrlTree(['/unauthorized']);
expect(guard.canActivate(route)).toEqual(unauthorizedUrlTree);
});
});
The test for the function-based guard uses the RouterTestingHarness, which makes the test's intent clearer in my opinion.
// role.guard.spec.ts
describe('RoleGuard', () => {
it('allows user to navigate to route if he has access', async () => {
TestBed.configureTestingModule({
providers: [
AuthService,
provideRouter([
{path: 'admin', component: AdminComponent, canActivate: [roleGuard(ROLES.ADMIN)]},
]),
],
});
const authService = TestBed.inject(AuthService);
const harness = await RouterTestingHarness.create();
authService.userRole = ROLES.ADMIN;
let instance = await harness.navigateByUrl('/admin');
expect(instance).toBeInstanceOf(AdminComponent);
});
it('redirects to unauthorized if the user doesnt have access', async () => {
TestBed.configureTestingModule({
providers: [
AuthService,
provideRouter([
{path: 'manager', component: ManagerComponent, canActivate: [roleGuard(ROLES.MANAGER)]},
{path: 'unauthorized', component: NotAuthorizedComponent},
]),
],
});
const authService = TestBed.inject(AuthService);
const harness = await RouterTestingHarness.create();
authService.userRole = ROLES.ADMIN;
let instance = await harness.navigateByUrl('/manager');
expect(instance).toBeInstanceOf(NotAuthorizedComponent);
});
});
That covers the essentials!
Thank you for reading.
I regularly tweet about Angular — covering news, videos, podcasts, updates, RFCs, pull requests, and more. For that content, follow me at @Enea_Jahollari. If this article was useful and you'd like to see similar posts, follow me on dev.to.
