Angular Dynamic Routes

Home / Angular Dynamic Routes

Within a new project I’ve been working on, I needed to be able to handle Routes dynamically in Angular. Primarily, this was driven on authorization. Due to this aspect, authenticaiton had to occur first and that’s where the fun begain.

The authentication piece for this application is using MSAL along with the MSAL Angular npm packages. It’s interesting that if you view the demo projects/code, there is no code for dynamically defining routes and then enforcing authentication. The naive approach is solely relying on the MsalGuard (route guard) to trigger authentication.

This becomes a cart before the horse issue since the routes aren’t static and don’t exist during the APP_INITIALIZER phase of the bootstrap. A second piece that is missing is that the MsalConfig has to be loaded first. This can be handled in APP_INITIALIZER, though.

The first step in the process is to pull down our config needed for authentication. I like to use a provider to handle this.

@NgModule({
  imports: [RouterModule.forRoot([])],
  exports: [RouterModule],
  providers: [
    {
      provide: APP_INITIALIZER, useFactory: configServiceFactory,
      deps: [Injector, HttpBackend, ConfigService, BroadcastService], multi: true
    }
  ]
})

Angular relies on APP_INITIALIZER to return a promise so that bootstrapping can commence once the service provider is finished. This is where we can load the MsalConfig and then add some subscribers to listen for login/token acquisition to be completed. The goal here would be to setup our dynamic routes once the authentication has succeeded. Our “ConfigService” will contain methods to load necessary configs. Suffice it to say these will be pretty basic Api calls. The only catch is that we want to ensure we bypass the MsalInterceptor to get our MsalConfig since that aspect of the config has to support anonymous retrieval.

export function configServiceFactory(injector: Injector, httpBackend: HttpBackend,
  configSvc: ConfigService, broadcastService: BroadcastService): () => Promise<any> {
  // Load and set the MsalConfig - must construct a separate client 
  // to avoid cyclic injection issues
  // Also, subscribe to the Msal BroadcastService events so that we know
  // when the user authentication is complete 
  let isRoutingConfigured = false;

  return (): Promise<any> => {
    return new Promise((resolve, reject) => {
      let httpClient = new HttpClient(httpBackend);
      configSvc
        .loadMsalConfig(httpClient)
        .then((value: any) => {
          let loginSubscription = broadcastService
            .subscribe('msal:loginSuccess', (payload) => {
              console.log('Getting config in routing module');
              if (!isRoutingConfigured) {
                isRoutingConfigured = true;
                setupRoutes(injector, configSvc, broadcastService);
              }
              loginSubscription.unsubscribe();
            });

          let acquireTokenSubscription =
            broadcastService
              .subscribe('msal:acquireTokenSuccess', (payload) => {
                if (!isRoutingConfigured) {
                  isRoutingConfigured = true;
                  console.log('Getting config in routing module');
                  setupRoutes(injector, configSvc, broadcastService);
                }
                acquireTokenSubscription.unsubscribe();
              });

          resolve();
        })
        .catch(err => {
          reject();
          throw new Error(err);
        });
    });
  };
}

Note that we wait for either hte loginSuccess or acquireTokenSuccess events. We really don’t care which once, but we do use a boolean flag to track the completion. This will eliminate double work from our app retrieving config settings. Note also that we resolve the promise as soon as the MsalConfig is acquired. This is a nuance related to simply cascading timing of the authentication flow as you’ll see.

The next step will be to call our “setupRoutes” function. It will call our ConfigService (loadConfig) to get the roles/authorization that the user has relative to our Routes and it will call our Api to get a list of routes. The loadConfig method returns a promise and it’s pretty ordinary HttpClient get requests, so I won’t go into the details for those particular calls. Suffice it to say there are many calls made that are grouped via a forkjoin.

For determining access to the Routes, the data element has an array of roles (roles: []) which we will use to match the user’s roles we retrieve via our ConfigService like this:

// Create our routes with a single not-found wildcard route
export const APP_ROUTES: Routes = [
  {
    path: '**', component: NotFoundComponent,
    data: { name: 'not-found', title: 'Page Not Found', isInMenu: false, roles: [] }
  }
];

With the routes and data retrieve for the user in-hand, we can pretty easily filter out the routes to which the user doesn’t have access and utilize the Router’s resetConfig method. If the Roles for the Route is empty, or the user has one of the Roles in their list of Roles, the Route will be kept. Otherwise, it is removed (“filtered”) from the list of routes.

export function setupRoutes(injector: Injector, configSvc: ConfigService, broadcastService: BroadcastService): void {
  let promise: Promise<void> = configSvc
    .loadConfig()
    .then(res => {
      let router: Router = injector.get(Router);
      let userSvc: UserService = injector.get(UserService);
      var routes: configSvc.routes;

      // The last route is not found route.  Pop it for now.
      let notFoundRoute = APP_ROUTES.pop();

      // Push all of our routes and set the guards
      routes.forEach((appRoute: Route) => {
        appRoute.canActivate = [MsalGuard, AuthGuardService];
        APP_ROUTES.push(appRoute);
      });

      // Add the notFoundRoute last
      APP_ROUTES.push(notFoundRoute);


      var filteredRoutes = APP_ROUTES.filter(route => {
        let isIncluded: Boolean = true;

        if ((route?.data?.roles?.length ?? 0) === 0) {
          isIncluded = true;
        } else {
          // String based roles

          // Intersect the arrays ...
          isIncluded = route.data.roles.filter((role: string) => {
            let isIncluded = configSvc.configData.userRoles.map(x => x.userRoleType).includes(role);
            return isIncluded;
          }).length > 0;
        }

        return isIncluded;
      });

      router.resetConfig(filteredRoutes);
      configSvc.routeConfigComplete();
    });
}

With these things in place, everything looked good. But, I ran into one small issue. Msal enforces authenticaiton via MsalGuard. If a Route doesn’t exist, initially once the the Router triggers navigation, which it does right after APP_INITIALIZER(s) finish, everything breaks. Angular’s Router will throw errors that the route doesn’t exist. Back to the cart and the horse.

To remember this conundrum, we can tell the Router to not navigate initially. This overcomes the one obstalce that I found in dealing with dynamic routes. This also requires that our bootstrapped “app.component” has the be the one to trigger authenticaiton since MsalGuards don’t come into play. To tell Angular’s route to sit back and not navigate until we want it to requires two small changes passing in “initialNavigation” and letting it know when to navigate.

The first change is in the initial module defn:

@NgModule({
  imports: [RouterModule.forRoot([], { initialNavigation: false })],

The second change is at the end of our setupRoutes function:

router.initialNavigation();

With these mechanisms in place, we can dynamically load all of our configuration data, routes, authenticate the user, and everything else that is necessary.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.