Google analytics script

Latest jQuery CDN with code tiggling.

Friday, 9 October 2015

Angular ngRoute routing authorization with asychronous promises

AngularJS routing doesn't support route authorization out of the box and nor does its popular cousin ui-router even though the latter supports state change cancellation with later continuation (using $urlRouter.sync(); check documentation). But in non-trivial Angular SPAs this feature is regularly needed. So I went off implementing it.

Work of others

It is possible to find several working examples for this problem on the internet that do route authorization, but I haven't come across an elegant example that wouldn't authorize synchronously. With this I mean requesting server for authorization info (be it general per-user or granular per-route). This means that an asynchronous request has to be sent to server during routing phase. And that's exactly what said examples most commonly don't implement. They also don't reveal how they initially obtain user roles? Whether they're obtained before SPA bootstraping (this would require manual bootstraping) or after.

What's already supported by ngRoute

The first thing we think of when we mention promises (which an anyc server request is) and routing are route resolves (check resolve property of the route parameter object in Angular documentation). This parameter defines any promises that need to be resolved before your routed view is being processed. Documentation states that this is a mapping of dependencies that will be injected into controller some of which may be promises that will first get resolved before controller is instantiated. Ok, so there is some already supported mechanism in ngRoute that can get us closer to route authorization. The problem is that we should be manually adding resolves to all routes that require some form of authorization. That's quite a bit of code duplication on the routing configuration as we'd have to be doing something along these lines:
// some route definition object
{
  templateUrl: "viewTemplate.html",
  controller: "SomeController",
  controllerAs: "vm",
  resolve: {
    authorize: ["userService", "$location", function(userService, $location) {
      return userService.getUserInfo()
                        .then(function(userInfo) {
                          if (userInfo.isAnonymous)
                          {
                            $location.path("/");
                          }
                          return userInfo;
                        });
    }]
  }
}
...for every route. Not to mention if we wanted to implement role based authorization. And if we wanted to introduce some change to this process we'd have to change all routes that apply. Maintainability nightmare!

There's also one much greater problem with upper technique that you should be aware of. Authorized route resolution is taking place even on unauthorized route requests. And that is particularly problematic, because authorization-secured route controller gets instantiated possibly making some server resource requests in the process. Not to mention that authorization-secured view also gets processed along the way. But at least this major problem can be solved by throwing an error in our resolve which basically stops route from processing all the way to success rather ending up in a route change error state.

// some route definition object
{
  templateUrl: "viewTemplate.html",
  controller: "SomeController",
  controllerAs: "vm",
  resolve: {
    authorize: ["userService", "$location", function(userService, $location) {
      return userService.getUserInfo()
                        .then(function(userInfo) {
                          if (userInfo.isAnonymous)
                          {
                            $location.path("/");
                            throw 302; // THIS ERROR as HTTP 302 Found
                          }
                          return userInfo;
                        });
    }]
  }
}

Which gets us to centralized solution

Specifically that route change error makes us think of centralized set it and forget it solution to this problem with minor code additions to routing configuration and no code duplication. There are three routing events in ngRoute that we can add listeners to and interfere with the process to our requirements.

  • $routeChangeStart which fires before route starts processing its resolves
  • $routeChangeSuccess which fires right after all route resolves are successfully resolved and
  • $routeChangeError which fires after resolving route resolves but at least one failed resolution
If we think of server authorization process it works like this:
  1. client issues an HTTP request for some authorization-secured resource
  2. server processes authorization
  3. if authorization succeeds authorization-secured resource is returned
    if authorization fails an HTTP 302 Found error is being returned instead with Location header pointing to redirection resource - usually login (although this may vary on the server and platform we're using)
A similar process should be utilized in the case of client-side routing authorization.

Final solution process

Following solution shows simple authenticated flag authorization but it could easily be changed to support role-based authentication as well. I'll explain how to do it later on. In any way the process works like this:

  1. client side routing starts for authorized route (authorized routes have an additional custom property authorize: true)
  2. $routeChangeStart event fires where we inject authorization resolve making sure we only inject it once
  3. authorization resolve makes a server request getting authorization information and checks its result with authorization requirements
  4. if authorization succeeds, nothing is particularly done so routing will continue to execute
    if authorization fails, we throw a specific error type (similar to server responding with 302 and not doing anything further)
  5. on fail $routeChangeError event handler executes where error type is being checked (don't assume it's always authorization problem as other resolves may fail for other reasons) and if it matches our custom AuthorizationError a redirect is being done to login route
And that's basically how it works. now let's see the code.

Routing configuration

Routing still needs some sort of configuration so we define somehow which routes require authorization. But since we're building a centralized solution to this problem, we want to simplify this as much as possible. So we only add a simple route configuration property.

// some route definition object
{
  templateUrl: "viewTemplate.html",
  controller: "SomeController",
  controllerAs: "vm",
  authorize: true
}
Now this is a lot less code than previously with route resolves. We only mark those routes that require authorization and keep others as they are.

Route change events

Main logic is part of these events. We have to inject a missing resolve in routes that require it and then handle authorization errors by redirecting to login route. Nothing particularly complicated.

   1:  angular
   2:      .module("ModuleName", ["ngRoute"])
   3:      .config(/* routing configuration */)
   4:      .run(["$rootScope", "$location", function($rootScope, $location) {
   5:          $rootScope.$on("$routeChangeStart", function(evt, to, from) {
   6:              // requires authorization?
   7:              if (to.authorize === true)
   8:              {
   9:                  to.resolve = to.resolve || {};
  10:                  if (!to.resolve.authorizationResolver)
  11:                  {
  12:                      // inject resolver
  13:                      to.resolve.authorizationResolver = ["authService", function(authService) {
  14:                          return authService.authorize();
  15:                      }];
  16:                  }
  17:              }
  18:          });
  19:   
  20:          $rootScope.$on("$routeChangeError", function(evt, to, from, error) {
  21:              if (error instanceof AuthorizationError)
  22:              {
  23:                  // redirect to login with original path we'll be returning back to
  24:                  $location
  25:                      .path("/login")
  26:                      .search("returnTo", to.originalPath);
  27:              }
  28:          });
  29:      }]);

Other minor things

There are other minor things that need implementation like AuthorizationError type or AuthorizationService implementation, but especially the latter is completely up to you.

Complete code of a running example

I've created a plnkr example that you can play with and see how it works. If you want to see how individual parts execute and in what order, make sure you open developer console and observe logs created by the code. Below code is without the additional logging but would run just the same.

   1:  <!DOCTYPE html>
   2:  <html ng-app="Test">
   3:    <head>
   4:      <meta charset="utf-8" />
   5:      <title>Angular routing authentication example</title>
   6:      <style>
   7:          body {
   8:              font-family: Sans-serif;
   9:          }
  10:          section {
  11:              margin-top: 2em;
  12:              border: 1px solid #ccc;
  13:              padding: 0 2em 1em;
  14:          }
  15:          .important {
  16:              color: #e00;
  17:          }
  18:      </style>
  19:    </head>
  20:   
  21:    <body>
  22:      <h3>Angular routing authorization implementation</h3>
  23:      <p>
  24:          Home and login are public views and load immediately because they don't have
  25:          any promises to resolve (set by <code>route.resolve</code>).
  26:          Authorized view is only accessible after user is authenticated.
  27:          Authorization promise resolves in 1 second.
  28:      </p>
  29:      <p>
  30:          Anonymous users are <strong>auto-redirected to login</strong> when trying to
  31:          access Authorized view. After login they're auto-redirected back to where they
  32:          were before redirection to login view. If users manually access login view
  33:          (clicking Login link) they're redirected to Home after login/logout.
  34:      </p>
  35:      <p class="important">Open <strong>development console</strong> to observe execution log.</p>
  36:      <a href="#/home">Home</a> |
  37:      <a href="#/private">Authorized</a> |
  38:      <a href="#/login">Login</a>
  39:      
  40:      <section>
  41:          <ng-view></ng-view>
  42:      </section>
  43:      
  44:      <script type="text/ng-template" id="login">
  45:          <h1>Login view</h1>
  46:          <p>This is a publicly accessible login view</p>
  47:          <p><a href="" ng-click="context.login()">click here</a> to auto-authenticate</p>
  48:          <p><a href="" ng-click="context.logout()">logout</a> to prevent authorized view access.</p>
  49:      </script>
  50:   
  51:      <script type="text/ng-template" id="public">
  52:          <h1>Public view</h1>
  53:          <p>This is a publicly accessible view</p>
  54:      </script>
  55:   
  56:      <script type="text/ng-template" id="private">
  57:          <h1>Authorized view</h1>
  58:          <p>This is an authorized view</p>
  59:      </script>
  60:   
  61:      <script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.6/angular.min.js"></script>
  62:      <script src="https://code.angularjs.org/1.4.3/angular-route.js"></script>
  63:      <script type="text/javascript">
  64:          (function(angular){
  65:   
  66:              "use strict";
  67:   
  68:              angular
  69:                  .module("Test", ["ngRoute"])
  70:                  
  71:                  .config(function($routeProvider){
  72:                      $routeProvider
  73:                          .when("/", {
  74:                              templateUrl: "public",
  75:                              controller: "GeneralController",
  76:                              controllerAs: "context"
  77:                          })
  78:                          .when("/login", {
  79:                              templateUrl: "login",
  80:                              controller: "LoginController",
  81:                              controllerAs: "context"
  82:                          })
  83:                          .when("/private", {
  84:                              templateUrl: "private",
  85:                              controller: "GeneralController",
  86:                              controllerAs: "context",
  87:                              authorize: true
  88:                          })
  89:                          .otherwise({
  90:                              redirectTo: "/"
  91:                          });
  92:                  })
  93:          
  94:                  .run(function($rootScope, $location){
  95:                      $rootScope.$on("$routeChangeStart", function(evt, to, from){
  96:                          if (to.authorize === true)
  97:                          {
  98:                              to.resolve = to.resolve || {};
  99:                              if (!to.resolve.authorizationResolver)
 100:                              {
 101:                                  to.resolve.authorizationResolver = function(authService) {
 102:                                      return authService.authorize();
 103:                                  };
 104:                              }
 105:                          }
 106:                      });
 107:                      
 108:                      $rootScope.$on("$routeChangeError", function(evt, to, from, error){
 109:                          if (error instanceof AuthorizationError)
 110:                          {
 111:                              $location.path("/login").search("returnTo", to.originalPath);
 112:                          }
 113:                      });
 114:                  })
 115:                  
 116:                  .controller("LoginController", function($location, authService){
 117:                      this.login = login(true, $location.search().returnTo);
 118:                      this.logout = login(false, "/");
 119:                      // DRY helper
 120:                      function login(doWhat, whereTo){
 121:                          return function() {
 122:                              authService.authenticated = doWhat;
 123:                              $location.path(whereTo && whereTo || "/");
 124:                          };
 125:                      }
 126:                  })
 127:                  
 128:                  .controller("GeneralController", function(){
 129:                  })
 130:                  
 131:                  .service("authService", function($q, $timeout){
 132:                      var self = this;
 133:                      this.authenticated = false;
 134:                      this.authorize = function() {
 135:                          return this
 136:                              .getInfo()
 137:                              .then(function(info){
 138:                                  if (info.authenticated === true)
 139:                                      return true;
 140:                                  // anonymous
 141:                                  throw new AuthorizationError();
 142:                              });
 143:                      };
 144:                      this.getInfo = function() {
 145:                          return $timeout(function(){
 146:                              return self;
 147:                          }, 1000);
 148:                      };
 149:                  });
 150:                  
 151:                  // Custom error type
 152:                  function AuthorizationError(description) {
 153:                      this.message = "Forbidden";
 154:                      this.description = description || "User authentication required.";
 155:                  }
 156:                  
 157:                  AuthorizationError.prototype = Object.create(Error.prototype);
 158:                  AuthorizationError.prototype.constructor = AuthorizationError;
 159:          
 160:          })(angular);
 161:      </script>
 162:   
 163:    </body>
 164:   
 165:  </html>

How about those user roles

The same code with minor modifications can also be used for role-based authorization. Depending on how your role security should work you'd mainly have to make changes below. By how I mean how authorization should be. Whether you would be granting permission to single role or a combination. Suppose we define three distinct roles: anonymous, authenticated and administrator.

  1. routing configuration should replace authorize: true to one of the these:
    • authorize: "authenticated" when you'd like to authorise against single roles
    • authorize: ["authenticated", "administrator"] or authorize: "authenticated|administrator" when you'd like to authorize against several roles and depending whether role matching would be done using array's .indexOf() or string's regular expression .test() matching
  2. authorization service's .authorize(roles) should now accept a parameter with route roles which you'd use to decide on the outcome of authorization execution
  3. $routeChangeStart should provide roles when injecting authorization resolve so that authorization service would work as per previous alinee
Anything else? I don't think so. That should enable you to implement role-base route authorization in your Angular SPA.

One warning though

I should warn you about one last thing. Some very old AngularJS bug that's been around since 2013 or at least that was the time it was reported. The bug is about browser history. When routing starts resolving an and even if it ends up in the $routeChangeError state, your browser's location (URL) would still be changed as if routing took place successfully. This is particularly problematic in our case where we deliberately fail route changes due to failed authorization checks.

Don't say I didn't warn you. You can mitigate this problem somehow by rewriting URL using $location.path(fromRouteURL).rewrite() which would replace failed route's URL to previous one. You can also call $window.histroy.back() after it, so you don't end up with two identical states in your history if you'd be clicking back button in your browser. Until your next navigation you'd have a forward state defined, but users are a lot more likely to click back than forward.

If you have any suggestions of your own, you're welcome to participate on GitHub's issue or comment on this post and if I find your idea particularly interesting I'll pass it forward to develpers on GitHub.

7 comments:

  1. Hey going to give this a shot with UI-Router, just wanted to thank you in advance incase I forget to show my results.

    ReplyDelete
  2. Why is it necessary to call getInfo with a 1s delay instead of evaluating authenticated right away? Can't quite figure that out. Thanks for this: simple, centralized, and effective. Was able to extend it for authorizing elements, too.

    ReplyDelete
    Replies
    1. getInfo() in this case is just a simulation of an async server call. It's a proof of concept of asynchronous authentication during routing phase. Hence I've written it as a 1s async timeout. Async call should return much quicker though in order for your app to keep performance. :)

      Delete
  3. Thank you, Robert!!
    Your post helped me solve the issue i had with authorization.

    ReplyDelete
  4. Thanks for the writeup, it helped me out a bunch!

    ReplyDelete
  5. thank you! it was very useful!

    ReplyDelete