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.
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.
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.
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;
}
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;
});
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
}
}
}
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).
© CC-BY Bruce Conrad April 19, 2017app.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);
}
});
});
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