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.