Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for scopes #149

Closed
wants to merge 23 commits into from
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8ebe219
Implements scope support
lfk Jan 19, 2015
c199992
Scoping is now implemented using middleware
lfk Jan 23, 2015
ddac89f
The checkScope method now receives the entire bearerToken object
lfk Jan 26, 2015
4e281d6
Updated Readme and postgresql example
lfk Jan 28, 2015
fd69c79
Documentation fixes and checkScope parameter order changed
lfk Jan 29, 2015
01450c3
Added optional scope argument to authorise middleware
lfk Feb 4, 2015
eec2eed
Fix parameter order in postgres model
nunofgs Feb 10, 2015
ceb8fab
Improve scope support in authorise middleware
nunofgs Feb 10, 2015
2aeffb0
Improve scope support in grant middleware
nunofgs Feb 10, 2015
97e17cb
Improve scope middleware and add tests
nunofgs Mar 11, 2015
28b55ac
Merge branch 'seegno-forks-feature-scope' into feature-scope
lfk Mar 12, 2015
0726291
Scope improvements
lfk Mar 13, 2015
92d4ea7
Updated tests
lfk Mar 13, 2015
bb1206c
Updated postgresql reference implementation
lfk Mar 19, 2015
036440f
Added missing callback parameter to `model.validateScope`
lfk Mar 19, 2015
eef7652
added user-scope filtering support for password grant type
ccamarat Apr 20, 2015
ed89b4e
Prevented scope from being overwritten
ccamarat Apr 21, 2015
715f4f7
Updated tests with proposed `validateScope` signature
ccamarat Apr 21, 2015
0f4c59e
updated doc. Made example more accurate.
ccamarat Apr 21, 2015
7512224
Pass scope to `model.saveRefreshToken`.
ccamarat May 13, 2015
f04d32b
Corrected client credentials flow per review
ccamarat May 19, 2015
a073262
Merge branch 'ccamarat-feature/filter-scope' into feature-scope
lfk May 20, 2015
4cb4d8a
Replaced tabs with blankspaces
lfk May 20, 2015
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 21 additions & 4 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,20 @@ Note: see https://github.com/thomseddon/node-oauth2-server/tree/master/examples/
- *mixed* **error**
- Truthy to indicate an error

#### checkScope (scope, accessToken, callback)
- *mixed* **scope**
- String, array, or object indicating which scope(s) a token must possess
- *string* **accessToken**
- *function* **callback (error)**
- *mixed* **error**
- Truthy to indicate an error

#### saveScope (scope, accessToken, callback)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason why the order of parameters differs from checkScope()?

- *string* **scope**
- *object* **accessToken**
- *function* **callback (error)**
- *mixed* **error**
- Truthy to indicate an error

### Required for `authorization_code` grant type

Expand All @@ -151,12 +165,13 @@ Note: see https://github.com/thomseddon/node-oauth2-server/tree/master/examples/
- *string|number* **userId**
- The userId

#### saveAuthCode (authCode, clientId, expires, user, callback)
#### saveAuthCode (authCode, clientId, expires, user, scope, callback)
- *string* **authCode**
- *string* **clientId**
- *date* **expires**
- *mixed* **user**
- Whatever was passed as `user` to the codeGrant function (see example)
- *string* **scope**
- *function* **callback (error)**
- *mixed* **error**
- Truthy to indicate an error
Expand Down Expand Up @@ -275,22 +290,23 @@ First you must insert client id/secret and user into storage. This is out of the

To obtain a token you should POST to `/oauth/token`. You should include your client credentials in
the Authorization header ("Basic " + client_id:client_secret base64'd), and then grant_type ("password"),
username and password in the request body, for example:
username, password, and optionally a scope in the request body, for example:

```
POST /oauth/token HTTP/1.1
Host: server.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=password&username=johndoe&password=A3ddj3w
grant_type=password&username=johndoe&password=A3ddj3w&scope=readonly
```
This will then call the following on your model (in this order):
- getClient (clientId, clientSecret, callback)
- grantTypeAllowed (clientId, grantType, callback)
- getUser (username, password, callback)
- saveAccessToken (accessToken, clientId, expires, user, callback)
- saveRefreshToken (refreshToken, clientId, expires, user, callback) **(if using)**
- saveScope (scope, accessToken, callback)

Provided there weren't any errors, this will return the following (excluding the `refresh_token` if you've not enabled the refresh_token grant type):

Expand All @@ -304,7 +320,8 @@ Pragma: no-cache
"access_token":"2YotnFZFEjr1zCsicMWpAA",
"token_type":"bearer",
"expires_in":3600,
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA"
"refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA",
"scope": "readonly"
}
```

Expand Down
6 changes: 6 additions & 0 deletions examples/postgresql/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,12 @@ app.get('/secret', app.oauth.authorise(), function (req, res) {
res.send('Secret area');
});

app.get('/scoped', app.oauth.authorise(), app.oauth.scope('demo'),
function (req, res) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just personal preference but I would prefer removing the newline before function here, to match the rest of the examples.

// Will require that the access_token possesses the 'demo' scope key
res.send('Secret and scope-controlled area');
});

app.get('/public', function (req, res) {
// Does not require an access_token
res.send('Public area');
Expand Down
47 changes: 39 additions & 8 deletions examples/postgresql/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ var pg = require('pg'),
model.getAccessToken = function (bearerToken, callback) {
pg.connect(connString, function (err, client, done) {
if (err) return callback(err);
client.query('SELECT access_token, client_id, expires, user_id FROM oauth_access_tokens ' +
client.query('SELECT access_token, scope, client_id, expires, user_id FROM oauth_access_tokens ' +
'WHERE access_token = $1', [bearerToken], function (err, result) {
if (err || !result.rowCount) return callback(err);
// This object will be exposed in req.oauth.token
Expand All @@ -37,7 +37,8 @@ model.getAccessToken = function (bearerToken, callback) {
accessToken: token.access_token,
clientId: token.client_id,
expires: token.expires,
userId: token.userId
userId: token.userId,
scope: token.scope.split(' ') // Assumes a flat, space-separated scope string
});
done();
});
Expand Down Expand Up @@ -69,12 +70,14 @@ model.getClient = function (clientId, clientSecret, callback) {
model.getRefreshToken = function (bearerToken, callback) {
pg.connect(connString, function (err, client, done) {
if (err) return callback(err);
client.query('SELECT refresh_token, client_id, expires, user_id FROM oauth_refresh_tokens ' +
'WHERE refresh_token = $1', [bearerToken], function (err, result) {
// The returned user_id will be exposed in req.user.id
callback(err, result.rowCount ? result.rows[0] : false);
done();
});
// Note: To avoid replicating the scope string in both token tables, the old
// access token's scope string must be retrieved and passed along from here.
client.query('SELECT rt.refresh_token, rt.client_id, rt.expires, rt.user_id, at.scope FROM ' +
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes that expired accessToken's are not deleted. This takes away control from the developer since he'll be forced to keep all of the access tokens around.

I see two possible solutions for this:

  1. We can add the scopes column to the oauth_refresh_tokens table.
  2. We can merge the two tables into one: oauth_access_tokens and oauth_refresh_tokens would become oauth_tokens with a type column. There really isn't any difference between these tables right now.

@thomseddon Maybe I'm missing something. What is the reason for having two tables right now? Is it for the purpose of this example or is it recommended by the spec?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One more note. The OAuth RFC 6749 states in section 6 that when using the refresh_token grant_type, the client can send a "scope" and the server MUST validate that the scope cannot include any scopes that were not part of the original access_token. Also, if the scope is omitted, it must be inherited from the last access_token.

However, that implementation has been left to the model in this PR. Should this be something that node-oauth2-server is responsible for handling?

'oauth_refresh_tokens AS rt, oauth_access_tokens AS at WHERE rt.user_id = ' +
'at.user_id AND rt.client_id = at.client_id AND rt.refresh_token = $',
[bearerToken], function (err, result) {
callback(err, result.rowCount ? result.rows[0] : false);
});
});
};

Expand Down Expand Up @@ -113,6 +116,34 @@ model.saveRefreshToken = function (refreshToken, clientId, expires, userId, call
});
};

model.saveScope = function (scope, accessToken, callback) {
// Here you will want to validate that what the client is soliciting
// makes sense. You might then proceed by storing the validated scope.
// In this example, the scope is simply stored as a string in the
// oauth_access_tokens table, but you could also handle them as entries
// in a connection table.
var acceptedScope = scope;

pg.connect(connString, function (err, client, done) {
if (err) return callback(err);
client.query('UPDATE oauth_access_tokens SET scope=$1 WHERE access_token = $2',
[acceptedScope, accessToken], function (err, result) {
callback(err, acceptedScope);
done();
});
};

model.checkScope = function (accessToken, requiredScope, callback)
// requiredScope is set through the scope middleware.
// You may pass anything from a simple string, as this example illustrates,
// to representations including scopes and subscopes such as
// { "account": [ "edit" ] }
if(accessToken.scope.indexOf(requiredScope) === -1) {
return callback('Required scope: ' + requiredScope);
}
callback();
};

/*
* Required to support password grant type
*/
Expand Down
1 change: 1 addition & 0 deletions examples/postgresql/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ SET default_with_oids = false;

CREATE TABLE oauth_access_tokens (
access_token text NOT NULL,
scope text NOT NULL,
client_id text NOT NULL,
user_id uuid NOT NULL,
expires timestamp without time zone NOT NULL
Expand Down
6 changes: 4 additions & 2 deletions lib/authCodeGrant.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ function checkClient (done) {
*/
function checkUserApproved (done) {
var self = this;
this.check(this.req, function (err, allowed, user) {
this.check(this.req, function (err, allowed, user, scope) {
if (err) return done(error('server_error', false, err));

if (!allowed) {
Expand All @@ -146,6 +146,8 @@ function checkUserApproved (done) {
}

self.user = user;
self.scope = scope;

done();
});
}
Expand Down Expand Up @@ -175,7 +177,7 @@ function saveAuthCode (done) {
expires.setSeconds(expires.getSeconds() + this.config.authCodeLifetime);

this.model.saveAuthCode(this.authCode, this.client.clientId, expires,
this.user, function (err) {
this.user, this.scope, function (err) {
if (err) return done(error('server_error', false, err));
done();
});
Expand Down
15 changes: 12 additions & 3 deletions lib/authorise.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,15 +32,17 @@ var fns = [
/**
* Authorise
*
* @param {Object} config Instance of OAuth object
* @param {Object} config Instance of OAuth object
* @param {Object} req
* @param {Object} res
* @param {Object} options May indicate required scope(s)
* @param {Function} next
*/
function Authorise (config, req, next) {
function Authorise (config, req, scope, next) {
this.config = config;
this.model = config.model;
this.req = req;
this.scope = scope;

runner(fns, this, next);
}
Expand Down Expand Up @@ -125,6 +127,13 @@ function checkToken (done) {
self.req.oauth = { bearerToken: token };
self.req.user = token.user ? token.user : { id: token.userId };

done();
if(self.scope) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: change this to match the (proposed) saveScope early return:

if (!scope) return done();

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this is definitely cleaner.

self.model.checkScope(self.scope, token, function (err) {
if (err) { return done(new error('invalid_scope', err)); }
done();
});
} else {
done();
}
});
}
1 change: 1 addition & 0 deletions lib/error.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ function OAuth2Error (error, description, err) {
'WWW-Authenticate': 'Basic realm="Service"'
};
/* falls through */
case 'invalid_scope':
case 'invalid_grant':
case 'invalid_request':
this.code = 400;
Expand Down
23 changes: 22 additions & 1 deletion lib/grant.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ var fns = [
saveAccessToken,
generateRefreshToken,
saveRefreshToken,
saveScope,
sendResponse
];

Expand Down Expand Up @@ -193,6 +194,7 @@ function useAuthCodeGrant (done) {
}

self.user = authCode.user || { id: authCode.userId };
self.scope = authCode.scope || '';
if (!self.user.id) {
return done(error('server_error', false,
'No user/userId parameter returned from getauthCode'));
Expand Down Expand Up @@ -257,6 +259,7 @@ function useRefreshTokenGrant (done) {
}

self.user = refreshToken.user || { id: refreshToken.userId };
self.scope = refreshToken.scope || '';

if (self.model.revokeRefreshToken) {
return self.model.revokeRefreshToken(token, function (err) {
Expand Down Expand Up @@ -449,14 +452,32 @@ function saveRefreshToken (done) {
}

/**
* Create an access token and save it with the model
* Pass the scope string to the model for saving
*
* @param {Function} done
* @this OAuth
*/
function saveScope (done) {
var scope = this.scope || this.req.body.scope;

var self = this;
this.model.saveScope(scope, self.accessToken, function (err, acceptedScope) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The saveScope function will be called on the model even if the user did not send a scope. In fact, scope here will be undefined.

I suggest following the pattern set by the saveRefreshToken function (and even your checkScope):

if (!scope) return done();

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the appropiate course of action if no scope is solicited by the client though? Either the system could assign a default scope and indicate that in the returned payload, or an error message could be displayed. Currently, the decision-making is delegated to the model's saveScope method. (Note, also, that this whole method might well come to be merged into saveAccessToken),

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a very good point. I agree.

Also, 👍 for merging this into saveAccessToken.

if (err) return done(error('server_error', false, err));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lfk: I was in the middle of writing a PR (to your PR 😄) and came upon a snag. In the saveScope implementation you are returning a server_error instead of invalid_scope.

This means that when requesting a token, if you pass an invalid scope, the model has no chance to return a 400 error. It can only return an error which eventually gets mapped as a 503 server error. The only thing the model can do is ignore the invalid scope which will then generate a token anyway.

Also, the AuthCodeGrant middleware also passes the scope parameter to the saveAuthCode model function but it too can only cause a 503 server error.

So, right now I don't see a quick way of validating whether the scope is invalid or not, other than using the scope middleware.

Do you have any suggestions on how to proceed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nunofgs: Yeah, I encountered the same issue when I first wrote that part, and I couldn't really find a clean workaround. Nowhere else in the library are we handling "cases" of returned errors, so it would feel awkward to introduce something of the sort here.

The only thing I can think of is to have a dedicated model method, such as validateScope. I'm writing some more notes on this topic in the PR #149 discussion in a moment, including the saveAuthCode matter.

The scope middleware is utilized for access validation once a token has already been created, it does not influence how scopes are first processed and stored.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, I felt the same pain. I had your PR ready to go but I'm holding off on submitting because I felt the need to create a new error architecture, exactly as you described.

I incorporated almost every change we discussed in this PR, with the exception of passing scope to the saveAccessToken() function since it prevents me from throwing an "invalid scope" error, as I described previously. I also added tests for all of the new functionality.

You can take a look at my commits here: https://github.com/seegno-forks/node-oauth2-server/commits/feature-scope but I'll hold off on submitting it until @thomseddon weighs in on this.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you solved the aforementioned issue in a very neat fashion here. 👍
Regardless of how the scope middleware matter turns out, I'd definitely like to incorporate these fixes into the PR.

self.scope = acceptedScope;
done();
});
}

/**
* Sends the resulting token(s) and related information to the client
*
* @param {Function} done
* @this OAuth
*/
function sendResponse (done) {
var response = {
token_type: 'bearer',
scope: this.scope,
access_token: this.accessToken
};

Expand Down
25 changes: 22 additions & 3 deletions lib/oauth2server.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
var error = require('./error'),
AuthCodeGrant = require('./authCodeGrant'),
Authorise = require('./authorise'),
Grant = require('./grant');
Grant = require('./grant'),
Scope = require('./scope');

module.exports = OAuth2Server;

Expand Down Expand Up @@ -63,11 +64,11 @@ function OAuth2Server (config) {
*
* @return {Function} middleware
*/
OAuth2Server.prototype.authorise = function () {
OAuth2Server.prototype.authorise = function (scope) {
var self = this;

return function (req, res, next) {
return new Authorise(self, req, next);
return new Authorise(self, req, scope, next);
};
};

Expand Down Expand Up @@ -104,6 +105,24 @@ OAuth2Server.prototype.authCodeGrant = function (check) {
};
};

/**
* Scope Check Middleware
*
* Returns middleware that allows the specification of required
* scope(s) for routers and/or routes, which is validated by the model.
*
* @param {Mixed} requiredScope String or list of scope keys
* required to access the route.
* @return {Function}
*/
OAuth2Server.prototype.scope = function(requiredScope) {
var self = this;

return function(req, res, next) {
return new Scope(self, req, next, requiredScope);
};
};

/**
* OAuth Error Middleware
*
Expand Down
Loading