Using KRL to implement a simple OAuth Client

This is a proof of concept. It is based on the book OAuth in Action by Justin Richer and Antonio Sanso. In particular, what I have done is to work with their exercises for Chapter 3 (or 5), and replace their single page client server and application with one built using Angular and Picos (the "AP" stack).

Picos are available for use by anyone. Download the pico engine and/or study the documentation at the Picolabs website (picolabs.io). Picolabs is an open source project, and your contributions and use are invited.

Caveat

As the authors point out, this is not a real OAuth system, but is designed to help the reader "focus on ... understanding in detail how the OAuth protocol works." In particular, in real usage all communication among the components would be over https and client secrets and access tokens would not be shown openly.

Front end Angular HTML

Where the book used express, and separate template files, I elected to use Angular and multiple ng-template elements.

Lines 13-18 borrow the standard Picolabs branding. The Angular portion is lines 19-37, in which the ui-view in line 20 is replaced by one of the three templates, depending on which of the client views is shown.

<!DOCTYPE html>
<html>
   <head>
      <!-- include our dependency on angular -->
      <script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.4.0/angular.min.js"></script>
      <script src="http://angular-ui.github.io/ui-router/release/angular-ui-router.js"></script>
      <script src="client.js"></script>
      <link rel="stylesheet" type="text/css" href="client.css">
      <meta charset="utf-8">
      <title>OAuth Client</title>
   </head>
   <body>
      <a class="logo" href="http://picolabs.io"><img src="logo.png" alt="logo"></a>
      <h1 class="title">OAuth Client</h1>
      <p class="description">
      Demonstration of a ruleset for authenticating using OAuth2.
      Uses the services in Chapter 3 of <em>OAuth2 in Action</em> by Justin Richer and Antonio Sanso.
      </p>
      <div ng-app='myApp' ng-controller='myCtrl'>
         <ui-view></ui-view>
         <script type="text/ng-template" id="/index.html">
            <p>Pico ECI: {{pico_eci}}</p>
            <p>Authorization Token: {{access_token}}</p>
            <p>Scope: {{scope}}</p>
            <p>Refresh Token: {{refresh_token}}</p>
            <a href="#/index" ng-click="getToken()" class="button">Get OAuth Token</a>
            <a href="#/data" class="button">Get Protected Resource</a>
         </script>
         <script type="text/ng-template" id="/data.html">
            <p>Resource: {{resource}}</p>
            <a class="button" role="button" href="#/index">Back</a>
         </script>
         <script type="text/ng-template" id="/error.html">
            <p>Error: {{error_message}}</p>
            <a class="button" role="button" href="#/index">Back</a>
         </script>
      </div>
   </body>
</html>

The principal view at "#/index" is defined in lines 21-28. As in the book, it displays the information held by the (server of their) OAuth client. It also provides a button for logging in (line 26), and another for obtaining data from the protected resource (line 27). The first of these obligates us to provide a getToken function in the Angular JavaScript code. Note that, although the front end has the access token, it does not use it directly to obtain data from the protected resource.

In this case, the actual information is held in a pico (as seen below), from which it is obtained for display. Again, in practice, a real client would not display the tokens.

The "#/data" view, defined in lines 29-32, is used to display the data from the protected resource, along with a "Back" button (line 31).

Finally, lines 33-36 define a view for error messages, "#/error", which includes a "Back" button (line 35). All of the views are used by the Angular JavaScript code shown later.

Front end CSS

Styling the front end includes lines 1-22 for the standard Picolabs header, and lines 23-30 for the buttons.

body {
  margin: 0 0 0 8px;
}
div p.description {
  display: inline-block;
  position: absolute;
  width: 250px;
  font-size: 120%;
  padding-left: 20px;
}
h1.title {
  display: inline-block;
  font-family: Arial, sans-serif;
  margin: 0 0 0 120px;
  padding-top: 18px;
}
a.logo img {
  vertical-align: top;
}
a.logo {
  cursor: pointer;
}
a.button {
  text-decoration: none;
  display: inline-block;
  margin: 5px;
  padding: 10px;
  border: 1px solid black;
  border-radius: 5px;
}

Front end Angular JavaScript

I used the ui.router add-on to provide separate views and controllers for the main view, the data view and the error view. Lines 2-26 configure this, with the three states defined by lines 6-11, 12-17, and 18-24, respectively.

angular.module("myApp", ['ui.router'])
  .config([
    '$stateProvider',
    '$urlRouterProvider',
    function($stateProvider, $urlRouterProvider) {
      $stateProvider
        .state('index', {
          url: '/index',
          templateUrl: '/index.html',
          controller: 'myCtrl'
        });
      $stateProvider
        .state('data', {
          url: '/data',
          templateUrl: '/data.html',
          controller: 'dataCtrl'
        });
      $stateProvider
        .state('error', {
          url: '/error',
          templateUrl: '/error.html',
          controller: 'errorCtrl',
          params: { error_message: "no error" }
        });
      $urlRouterProvider.otherwise('index');
  }])

The controller for the main view, in lines 28-47, makes a /sky/cloud query to the pico to obtain a copy of the information the pico is holding.

It also includes the getToken function (lines 37-46). This function is invoked when the user clicks on the "Get OAuth Token" button, and sends the oauth:authorize event to the pico. It is then up to the pico to provide the URL needed to complete the front channel communication with the authorization server. This is done by having the browser redirect to that server's consent page. One of the innovations of this work is how this answers the question, "How do we get a pico to initiate a front channel communication?"

  .controller("myCtrl", function($scope,$http,$window) {
     $http.get("sky/cloud/cj1fhcm8e00060cddcvrzbm9p/io.picolabs.oauth_client/status")
     .then(function(resp){
       $scope.pico_eci = "cj1fhcm8e00060cddcvrzbm9p";
       $scope.access_token = resp.data.access_token || "NONE";
       $scope.scope = resp.data.scope || "NONE";
       $scope.refresh_token = resp.data.refresh_token || "NONE";
     }, function(error) {
     });
     $scope.getToken = function(){
       $http.get("sky/event/cj1fhcm8e00060cddcvrzbm9p/oauth/oauth/authorize")
       .then(function(resp){
         var directives = resp.data.directives;
         directives.forEach(function(d){if(d.name === "redirect"){
           $window.location.href = d.options.url;
         }});
       }, function(error){
       });
     };
  })

Lines 49-63 define the controller for the data view. It obtains the data from the protected resource by making a /oauth/resource /sky/cloud query to the pico. See later for why this query is not done in the typical /sky/cloud way. If this query fails for some reason, the UI transitions to the error view to present the error message. In the happy path, the protected data is displayed.

  .controller("dataCtrl", function($scope,$http,$state) {
     var access_token;
     $http.get("oauth/resourcesky/cloud/cj1fhcm8e00060cddcvrzbm9p/io.picolabs.oauth_client/getResource")
     .then(function(resp){
       var data = resp.data;
       if (data.status_code !== 200){
         var error_message = data.status_code + " " + data.status_line;
         $state.transitionTo('error', {error_message: error_message});
       } else {
         var protectedResource = JSON.parse(resp.data.content);
         $scope.resource = JSON.stringify(protectedResource,undefined,2);
       }
     }, function(error){
     });
  })

Lines 65-67 are the controller for the error view.

  .controller("errorCtrl", function($scope,$stateParams) {
     $scope.error_message = $stateParams.error_message;
  });

Back end KRL

The io.picolabs.oauth_client ruleset contains all of the information contained in the book's OAuth Client application's server side. This information corresponds to lines 12-25 of this ruleset. Since the pico engine does not yet have the means to do Base64 encoding, I did this manually and placed the result in line 18.

ruleset io.picolabs.oauth_client {
  meta {
    use module io.picolabs.pico alias wrangler
    shares __testing, status, getResource
  }
  global {
    __testing = { "queries": [ { "name": "__testing" },
                               { "name": "status" },
                               { "name": "getResource" } ],
                  "events": [ { "domain": "oauth", "type": "access_token_expired" } ]
                }
    authorizationEndpoint = "http://localhost:9001/authorize"
    tokenEndpoint = "http://localhost:9001/token"
    client_id = "oauth-client-1"
    client_secret = "oauth-client-secret-1"
    encodeClientCredentials = function(username,password) {
      // Base64(client_id + ":" + client_secret)
      "b2F1dGgtY2xpZW50LTE6b2F1dGgtY2xpZW50LXNlY3JldC0x"
    }
    redirect_uris = ["http://localhost:9000/callback"]
    application_home = "client.html#/index"
    protectedResource = "http://localhost:9002/resource"
    refreshToken = function() {
      ent:refresh_token || "j2r3oj32r23rmasd98uhjrk2o3i"
    }
    status = function() {
      { "access_token": ent:access_token,
        "scope": ent:scope,
        "refresh_token": refreshToken() }
    }
    getResource = function() {
      resource = http:post(protectedResource) with
        headers = { "Authorization": "Bearer " + ent:access_token };
      resource
    }
  }

The status function, defined in lines 26-30, provides most of the information to be displayed in the main view of the front end.

The getResource function is defined in lines 31-35, and uses the access token to request data from the protected resource. Noticeably absent from this function is any checking of the response to its http:post request. This will be handled by the /oauth/resource /sky/cloud route used by the front end, which is defined modified as shown below.

The oauth_authorize rule, which is used directly by the application, is defined in lines 38-51. It creates a new channel for the pico, which will be used just to obtain an authorization code from the authorization server. The id of this new channel will serve as the event channel identifier (ECI) of the pico for this purpose. This new ECI is given as the value of the "state" parameter in the URL which will be sent to the authorization server (and returned by it later). The rule passes this URL back to the front end in a directive, and forgets any previous access token it might have had, but remembers the new channel in its ent:state entity variable.

  rule oauth_authorize {
    select when oauth authorize
    pre {
      state = engine:newChannel(
        { "name": "oauth", "type": "nonce", "pico_id": wrangler:myself().id })
      authorizeUrl = <<#{authorizationEndpoint}?response_type=code&client_id=#{client_id}>>
                   + <<&state=#{state.id}&redirect_uri=#{redirect_uris[0]}>>
    }
    send_directive("redirect") with url = authorizeUrl
    fired {
      ent:access_token := null;
      ent:state := state
    }
  }

As we will see below, the pico engine responds to the callback from the authorization server by sending the oauth:callback event to the pico, using the "state" value as its ECI. This triggers the oauth_callback rule, defined in lines 53-78. The rule checks to ensure that the state matches, then trades the code for an access token in the back channel by issuing the http:post of lines 60-66. In the happy path, it raises the oauth event "access_token" so that the pico will record the new access token. In all cases, it discards the channel (lines 72-75) and forgets it (from the ent:status entity variable (line 76)).

  rule oauth_callback {
    select when oauth callback code re#(.*)# setting(code)
    pre {
      state = event:attr("state")
      stateMatches = ent:state{"id"} == state
    }
    if stateMatches then 
      http:post(tokenEndpoint) setting(tokRes) with
        headers = {
          "Authorization": "Basic " + encodeClientCredentials(client_id,client_secret) }
        form = {
          "grant_type": "authorization_code",
          "code": code,
          "redirect_uri": redirect_uris[0] }
    fired {
      raise oauth event "access_token" attributes tokRes
    } else {
      log error "State DOES NOT MATCH: expected "+ent:state{"id"}+" got "+state
    } finally {
      engine:removeChannel({
        "pico_id": wrangler:myself().id,
        "eci": state
      });
      ent:state := null
    }
  }

The oauth_access_token rule is defined in lines 80-96. It will only be selected after a successful ("200 OK") back channel communication. Other outcomes will need to be handled before this code could be used in production. The main purpose of the rule is to remember the access token, its type and scope, and any refresh token provided. The book's authorization server for Chapter 3 has only a hard-coded refresh token, which this code accomodates. The other purpose of the rule is to provide a redirection URL for the front end.

  rule oauth_access_token {
    select when oauth access_token status_code re#200#
    pre {
      content = event:attr("content").decode()
      access_token = content{"access_token"}
      token_type = content{"token_type"}
      scope = content{"scope"}
      refresh_token = content{"refresh_token"}.defaultsTo(refreshToken())
    }
    send_directive("redirect") with url = application_home
    fired {
      ent:access_token := access_token;
      ent:token_type := token_type;
      ent:scope := scope;
      ent:refresh_token := refresh_token
    }
  }

If obtaining data from the protected resource fails, the pico engine's /oauth/resource /sky/cloud route will (as we'll see below) send the oauth:access_token_expired event to the pico. This will cause the oauth_access_token_expired rule (defined in lines 98-115) to be selected. Assuming (as in the happy path) that it has a refresh token, it will use it to obtain a new access token using the http:post of lines 104-109. It will return the response to the pico engine itself, after raising the oauth event "access_token" (which will be handled by the rule in lines 80-96).

  rule oauth_access_token_expired {
    select when oauth access_token_expired
    pre {
      refresh_token = refreshToken()
    }
    if refresh_token then
      http:post(tokenEndpoint) setting(tokRes) with
        headers = {
          "Authorization": "Basic " + encodeClientCredentials(client_id,client_secret) }
        form = {
          "grant_type": "refresh_token",
          "refresh_token": refresh_token }
      send_directive("refresh_response") with response = tokRes
    fired {
      raise oauth event "access_token" attributes tokRes
    }
  }
}

Routes added to the node pico engine

One of the innovations of this work is answering the question, "How can a pico receive the callback from an authorization server?" The answer is to send one of the pico's ECI's as the value of the (optional) "state" parameter in the initial authorization request. Then, the engine can handle the callback request directly, and be able to find the pico using that "state" value as its ECI.

This is done by adding a /callback route to the engine. For production work, we would want to name the route /oauth/callback, but doing so in this case would require modifications to the book's authorization server, and I wished to drop in a replacement for just it's client's server component.

The new route is shown below, in lines 1-20. Notice how in line 3, it uses the request "state" value as the event ECI. The event domain and type are hard-coded in lines 5-6, and the event is sent and handled in lines 9-19. The engine is functioning here as an endpoint, an unusual role for the engine, so it responds to the first "redirect" directive produced by the rule(s) triggered by the event, thus completing this front channel communication.

    app.get("/callback", function(req,res){
        var event = {
            eci: req.query.state,
            eid: "callback",
            domain: "oauth",
            type: "callback",
            attrs: req.query
        };
        pe.signalEvent(event, function(err, response){
            if(err) return errResp(res, err);
            var directives = response.directives;
            var d_redirect = _.find(directives,
                function(d){return d.name==="redirect"});
            if(d_redirect){
                res.redirect(d_redirect.options.url);
            } else {
                res.redirect("client.html#/index");
            }
        });
    });

Another innovation of this work is answering the question, "How could we record the new access token when we need to refresh it?" We would detect the need for a new access token in the KRL function used to request data from the protected resource, and could code up the http:post to refresh the token in that same function. However, a KRL function is not allowed to mutate entity variables, and thus could not record the new access token.

The answer I selected, with thanks to both Adam Burdett and Matthew Wright, who independently suggested it, is to have a route in the pico engine which wraps a query in a special way. This new route (/oauth/resource) is shown below. Lines 2-9 (and 26) are exactly the same as the corresponding lines in the route which handles a /sky/cloud query. and to Phil Windley for pointing out the leakage problem, is to modify the /sky/cloud route as shown in the postscript. The line numbers in the next two paragraphs apply to the modified route.

For this to work, the KRL function called using this route must for now (see below*) return the complete result of the http request and lines 11-24 handle the case where it fails with a "401 Unauthorized" response. In any other case, it proceeds exactly like an ordinary query, with line 26.

When authorization is required, this solution assumes that it is because an access token has expired, so the engine sends an oauth:access_token_expired event to the pico (lines 11-24), and then re-tries the original query (lines 20-23).

    app.get("/oauth/resource/:eci/:rid/:function", function(req, res){
        var query = {
            eci: req.params.eci,
            rid: req.params.rid,
            name: req.params["function"],
            args: req.query
        };
        pe.runQuery(query, function(err, data){
            if(err) return errResp(res, err);
            if(data && data.status_code===401){
                var event = {
                    eci: query.eci,
                    eid: "refresh-token",
                    domain: "oauth",
                    type: "access_token_expired",
                    attrs: {}
                };
                pe.signalEvent(event, function(err, response){
                    if(err) return errResp(res, err);
                    pe.runQuery(query, function(err,data2){
                        if(err) return errResp(res,err);
                        res.json(data2);
                   });
                });
            } else {
                res.json(data);
            }
        });
    });
© CC-BY Bruce Conrad
April 19, 2017

Postscript

During discussions with Phil Windley and the team during our Picolabs meeting, it became clear that this solution is flawed. The OAuth ruleset needs to be packaged as a KRL module for use by KRL developers. Those developers will write rulesets for use by developers of single page applications, and we are leaking information by requiring the use of /oauth/resource for certain functions and /sky/cloud for others. Furthermore, if a KRL developer needed to modify a function to use OAuth, all of that function's customers would need to change their code. This is an un-due burden.

The solution that I propose is to extend KRL by giving functions the ability to report an error instead of (or in addition to) returning a value. Error reporting is already available for rules, and is done by raising an event, but functions cannot raise events. Then, the standard /sky/cloud route will be able to respond to an error thrown by a function.

To simulate this, as a proof of concept, I completely removed the /oauth/resource route and instead modified the /sky/cloud route as follows. The original code of this route is shown in blue.

    app.all("/sky/cloud/:eci/:rid/:function", function(req, res){
        var query = {
            eci: req.params.eci,
            rid: req.params.rid,
            name: req.params["function"],
            args: mergeGetPost(req)
        };
        pe.runQuery(query, function(err, data){
            if(err) return errResp(res, err);
            if(data && data.status_code===401){
                var event = {
                    eci: query.eci,
                    eid: "refresh-token",
                    domain: "oauth",
                    type: "access_token_expired",
                    attrs: {}
                };
                pe.signalEvent(event, function(err, response){
                    if(err) return errResp(res, err);
                    pe.runQuery(query, function(err,data2){
                        if(err) return errResp(res,err);
                        res.json(data2);
                   });
                });
            } else {
                res.json(data);
            }
        });
    });

* In production, instead of consulting the data returned by the function for its status_code we would react instead to the err object (which has yet to be defined) and try to repair the problem by sending an event and retrying if that object directed the engine to do so. The great thing about this is that there will be no need to hard-code the recovery event's domain and type in the engine. Instead, these are under the control of the KRL developer!

For the proof of concept, besides modifying the existing /sky/cloud route instead of creating an additional one, we of course replaced all mentions of /oauth/resource with /sky/cloud. There is only one, in the "client.js" file.

Our KRL OAuth Client works exactly the same, thus demonstrating the concept without leaking information.

This postscript would like to edit the earlier version of this page. Use this button to then scroll through the page to see them.

added April 21, 2017