diff --git a/Readme.md b/Readme.md index 37352ec8f..424a1e947 100644 --- a/Readme.md +++ b/Readme.md @@ -124,15 +124,36 @@ Note: see https://github.com/thomseddon/node-oauth2-server/tree/master/examples/ - *boolean* **allowed** - Indicates whether the grantType is allowed for this clientId -#### saveAccessToken (accessToken, clientId, expires, user, callback) +#### saveAccessToken (accessToken, clientId, expires, user, scope, callback) - *string* **accessToken** - *string* **clientId** - *date* **expires** - *object* **user** +- *string* **scope** - *function* **callback (error)** - *mixed* **error** - Truthy to indicate an error +#### authoriseScope (accessToken, scope, callback) +- *string* **accessToken** +- *mixed* **scope** +- *function* **callback (error, invalid)** + - *mixed* **error** + - Truthy to indicate an error + - *boolean|string* **invalid** + - Falsey to indicate token possesses required scope; truthy (boolean or string) as invalid scope error message + +### validateScope (scope, client, user, callback) +- *string* **scope** +- *object* **client** +- *object* **user** +- *function* **callback (error, validScope, invalid)** + - *mixed* **error** + - Truthy to indicate an error + - *mixed* **validScope** + - Validated/sanitized scope string + - *boolean|string* **invalid** + - Falsey to indicate solicited scope was granted; truthy (boolean or string) as invalid scope error message ### Required for `authorization_code` grant type @@ -151,12 +172,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 @@ -178,11 +200,12 @@ Note: see https://github.com/thomseddon/node-oauth2-server/tree/master/examples/ ### Required for `refresh_token` grant type -#### saveRefreshToken (refreshToken, clientId, expires, user, callback) +#### saveRefreshToken (refreshToken, clientId, expires, user, scope, callback) - *string* **refreshToken** - *string* **clientId** - *date* **expires** - *object* **user** +- *string* **scope** - *function* **callback (error)** - *mixed* **error** - Truthy to indicate an error @@ -275,7 +298,7 @@ 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 @@ -283,13 +306,13 @@ 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) + - saveAccessToken (accessToken, clientId, expires, user, scope, callback) - saveRefreshToken (refreshToken, clientId, expires, user, callback) **(if using)** 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): @@ -304,7 +327,8 @@ Pragma: no-cache "access_token":"2YotnFZFEjr1zCsicMWpAA", "token_type":"bearer", "expires_in":3600, - "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA" + "refresh_token":"tGzv3JOkF0XG5Qx2TlKWIA", + "scope": "readonly" } ``` diff --git a/examples/postgresql/index.js b/examples/postgresql/index.js index e1cf18e5e..f1816be2e 100644 --- a/examples/postgresql/index.js +++ b/examples/postgresql/index.js @@ -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) { + // 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'); diff --git a/examples/postgresql/model.js b/examples/postgresql/model.js index 31df5369c..217b2386e 100644 --- a/examples/postgresql/model.js +++ b/examples/postgresql/model.js @@ -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 @@ -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-delimited scope string }); done(); }); @@ -48,7 +49,7 @@ model.getClient = function (clientId, clientSecret, callback) { pg.connect(connString, function (err, client, done) { if (err) return callback(err); - client.query('SELECT client_id, client_secret, redirect_uri FROM oauth_clients WHERE ' + + client.query('SELECT client_id, client_secret, redirect_uri, valid_scopes, default_scope FROM oauth_clients WHERE ' + 'client_id = $1', [clientId], function (err, result) { if (err || !result.rowCount) return callback(err); @@ -59,7 +60,9 @@ model.getClient = function (clientId, clientSecret, callback) { // This object will be exposed in req.oauth.client callback(null, { clientId: client.client_id, - clientSecret: client.client_secret + clientSecret: client.client_secret, + validScopes: client.valid_scopes, + defaultScope: client.default_scope }); done(); }); @@ -69,12 +72,11 @@ 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); + client.query('SELECT refresh_token, scope, client_id, expires, user_id FROM oauth_refresh_tokens ' + + 'WHERE refresh_token = $1', [bearerToken], function(err, result) { + callback(err, result.rowCount ? result.rows[0] : false); done(); - }); + }); }); }; @@ -89,11 +91,11 @@ model.grantTypeAllowed = function (clientId, grantType, callback) { callback(false, true); }; -model.saveAccessToken = function (accessToken, clientId, expires, userId, callback) { +model.saveAccessToken = function (accessToken, clientId, expires, userId, scope, callback) { pg.connect(connString, function (err, client, done) { if (err) return callback(err); - client.query('INSERT INTO oauth_access_tokens(access_token, client_id, user_id, expires) ' + - 'VALUES ($1, $2, $3, $4)', [accessToken, clientId, userId, expires], + client.query('INSERT INTO oauth_access_tokens(access_token, client_id, user_id, scope, expires) ' + + 'VALUES ($1, $2, $3, $4, $5)', [accessToken, clientId, userId, scope, expires], function (err, result) { callback(err); done(); @@ -101,25 +103,58 @@ model.saveAccessToken = function (accessToken, clientId, expires, userId, callba }); }; -model.saveRefreshToken = function (refreshToken, clientId, expires, userId, callback) { +model.saveRefreshToken = function (refreshToken, clientId, expires, userId, scope, callback) { pg.connect(connString, function (err, client, done) { if (err) return callback(err); - client.query('INSERT INTO oauth_refresh_tokens(refresh_token, client_id, user_id, ' + - 'expires) VALUES ($1, $2, $3, $4)', [refreshToken, clientId, userId, expires], + + client.query('INSERT INTO oauth_refresh_tokens(refresh_token, client_id, ' + + 'user_id, scope, expires) VALUES ($1, $2, $3, $4, $5)', + [refreshToken, clientId, userId, scope, expires], function (err, result) { - callback(err); - done(); - }); + callback(err); + done(); + }); }); }; +model.authoriseScope = function (accessToken, scope, callback) { + var hasScope = accessToken.scope.indexOf(scope) !== -1; + + // You may pass anything from a simple string, as this example illustrates, + // to representations including scopes and subscopes such as + // { "account": [ "edit" ] } + return callback(false, hasScope ? false : 'Missing scope: ' + scope); +}; + +model.validateScope = function (scope, client, user, callback) { + // Sanitize the requested scope string against a client-specific set of valid scope keys + // and the scopes the user actually is allowed to use (if any). + // You could choose to strip invalid keys, or return an error message + var requestedScope = scope || client.defaultScope || ''; + var requestedScopes = requestedScope.split(' '); + var validScopes = client.validScopes.split(' '); + var isValid = !requestedScope || requestedScopes.every(function(key) { + return validScopes.indexOf(key) !== -1; + }); + + if (user.allowedScopes) { + var userAllowedScopes = user.allowedScopes.split(' '); + var userScopes = validScopes.filter(function(key) { + return (!scope || requestedScopes.indexOf(key) !== -1) && userAllowedScopes.indexOf(key) !== -1; + }); + requestedScope = userScopes.join(' '); + } + + return callback(false, requestedScope, isValid ? false : 'Invalid scope request'); +}; + /* * Required to support password grant type */ model.getUser = function (username, password, callback) { pg.connect(connString, function (err, client, done) { if (err) return callback(err); - client.query('SELECT id FROM users WHERE username = $1 AND password = $2', [username, + client.query('SELECT id, allowed_scopes AS allowedScopes FROM users WHERE username = $1 AND password = $2', [username, password], function (err, result) { callback(err, result.rowCount ? result.rows[0] : false); done(); diff --git a/examples/postgresql/schema.sql b/examples/postgresql/schema.sql index d31212a8a..39e3fcaef 100644 --- a/examples/postgresql/schema.sql +++ b/examples/postgresql/schema.sql @@ -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 @@ -47,7 +48,9 @@ CREATE TABLE oauth_access_tokens ( CREATE TABLE oauth_clients ( client_id text NOT NULL, client_secret text NOT NULL, - redirect_uri text NOT NULL + redirect_uri text NOT NULL, + valid_scopes text NULL, + default_scope text NULL ); @@ -57,6 +60,7 @@ CREATE TABLE oauth_clients ( CREATE TABLE oauth_refresh_tokens ( refresh_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 @@ -70,7 +74,8 @@ CREATE TABLE oauth_refresh_tokens ( CREATE TABLE users ( id uuid NOT NULL, username text NOT NULL, - password text NOT NULL + password text NOT NULL, + allowed_scopes text NULL ); diff --git a/lib/authCodeGrant.js b/lib/authCodeGrant.js index 616bfbdeb..cd5a33710 100644 --- a/lib/authCodeGrant.js +++ b/lib/authCodeGrant.js @@ -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) { @@ -146,6 +146,8 @@ function checkUserApproved (done) { } self.user = user; + self.scope = scope; + done(); }); } @@ -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(); }); diff --git a/lib/authorise.js b/lib/authorise.js index 2b8296019..e9d32414c 100644 --- a/lib/authorise.js +++ b/lib/authorise.js @@ -26,21 +26,26 @@ module.exports = Authorise; */ var fns = [ getBearerToken, - checkToken + checkToken, + checkScope ]; /** * Authorise * - * @param {Object} config Instance of OAuth object + * @param {Object} config Instance of OAuth object * @param {Object} req * @param {Object} res + * @param {Object} options * @param {Function} next */ -function Authorise (config, req, next) { +function Authorise (config, req, options, next) { + options = options || {}; + this.config = config; this.model = config.model; this.req = req; + this.options = options; runner(fns, this, next); } @@ -128,3 +133,22 @@ function checkToken (done) { done(); }); } + +/** + * Check scope + * + * @param {Function} done + * @this OAuth + */ + +function checkScope (done) { + if (!this.options.scope) return done(); + + this.model.authoriseScope(this.req.oauth.bearerToken, this.options.scope, + function (err, invalid) { + if (err) return done(error('server_error', false, err)); + if (invalid) return done(error('invalid_scope', invalid)); + + done(); + }); +} diff --git a/lib/error.js b/lib/error.js index 04d181ee8..299b1ebfc 100644 --- a/lib/error.js +++ b/lib/error.js @@ -46,6 +46,10 @@ function OAuth2Error (error, description, err) { 'WWW-Authenticate': 'Basic realm="Service"' }; /* falls through */ + case 'invalid_scope': + if (typeof this.message === 'boolean') { + this.message = 'Invalid scope'; + } case 'invalid_grant': case 'invalid_request': this.code = 400; diff --git a/lib/grant.js b/lib/grant.js index 7ceb6c640..07e453777 100644 --- a/lib/grant.js +++ b/lib/grant.js @@ -31,6 +31,7 @@ var fns = [ checkClient, checkGrantTypeAllowed, checkGrantType, + checkScope, exposeParams, generateAccessToken, saveAccessToken, @@ -132,6 +133,8 @@ function credsFromBody (req) { * @this OAuth */ function checkClient (done) { + var self = this; + this.model.getClient(this.client.clientId, this.client.clientSecret, function (err, client) { if (err) return done(error('server_error', false, err)); @@ -140,6 +143,10 @@ function checkClient (done) { return done(error('invalid_client', 'Client credentials are invalid')); } + // Expose validated client scope information + self.client.validScopes = client.validScopes; + self.client.defaultScope = client.defaultScope; + done(); }); } @@ -193,6 +200,8 @@ 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')); @@ -224,6 +233,8 @@ function usePasswordGrant (done) { } self.user = user; + self.scope = self.req.body.scope; + done(); }); } @@ -257,6 +268,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) { @@ -285,14 +297,16 @@ function useClientCredentialsGrant (done) { } var self = this; - return this.model.getUserFromClient(clientId, clientSecret, - function (err, user) { + return this.model.getUserFromClient(clientId, clientSecret, function (err, user) { if (err) return done(error('server_error', false, err)); + if (!user) { return done(error('invalid_grant', 'Client credentials are invalid')); } self.user = user; + self.scope = self.req.body.scope || self.client.defaultScope; + done(); }); } @@ -343,6 +357,25 @@ function checkGrantTypeAllowed (done) { }); } +/** + * Validate the scope request + * + * @param {Function} done + * @this OAuth + */ +function checkScope (done) { + var self = this; + this.model.validateScope(this.scope, this.client, this.user, + function(err, scope, invalid) { + if (err) return done(error('server_error', false, err)); + if (invalid) return done(error('invalid_scope', invalid)); + + self.scope = scope; + + done(); + }); +} + /** * Expose user and client params * @@ -396,7 +429,7 @@ function saveAccessToken (done) { } this.model.saveAccessToken(accessToken, this.client.clientId, expires, - this.user, function (err) { + this.user, this.scope, function (err) { if (err) return done(error('server_error', false, err)); done(); }); @@ -442,14 +475,14 @@ function saveRefreshToken (done) { } this.model.saveRefreshToken(refreshToken, this.client.clientId, expires, - this.user, function (err) { + this.user, this.scope, function (err) { if (err) return done(error('server_error', false, err)); done(); }); } /** - * Create an access token and save it with the model + * Sends the resulting token(s) and related information to the client * * @param {Function} done * @this OAuth @@ -464,7 +497,13 @@ function sendResponse (done) { response.expires_in = this.config.accessTokenLifetime; } - if (this.refreshToken) response.refresh_token = this.refreshToken; + if (this.refreshToken) { + response.refresh_token = this.refreshToken; + } + + if (this.scope) { + response.scope = this.scope; + } this.res.jsonp(response); diff --git a/lib/oauth2server.js b/lib/oauth2server.js index 2726043b6..0aaee110b 100644 --- a/lib/oauth2server.js +++ b/lib/oauth2server.js @@ -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; @@ -63,11 +64,11 @@ function OAuth2Server (config) { * * @return {Function} middleware */ -OAuth2Server.prototype.authorise = function () { +OAuth2Server.prototype.authorise = function (options) { var self = this; return function (req, res, next) { - return new Authorise(self, req, next); + return new Authorise(self, req, options, next); }; }; @@ -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 * diff --git a/lib/scope.js b/lib/scope.js new file mode 100644 index 000000000..e83a43cc4 --- /dev/null +++ b/lib/scope.js @@ -0,0 +1,67 @@ +/** + * Copyright 2013-present NightWorld. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var error = require('./error'), + runner = require('./runner'); + +module.exports = Scope; + +/** + * This is the function order used by the runner + * + * @type {Array} + */ +var fns = [ + checkScope +]; + +/** + * Scope + * + * @param {Object} config Instance of OAuth object + * @param {Object} req + * @param {Function} next + * @param {Mixed} scope String or list of required scopes + */ +function Scope (config, req, next, scope) { + this.config = config; + this.model = config.model; + this.req = req; + this.scope = scope; + + runner(fns, this, next); +} + +/** + * Check scope + * + * Passes the access token and required scope key(s) to the model + * for access validation. Bad requests are rejected with an + * invalid_scope error. + * + * @param {Function} done + * + */ +function checkScope (done) { + if (!this.req.oauth) return done(error('invalid_request', 'Request not authenticated')); + + this.model.authoriseScope(this.req.oauth.bearerToken, this.scope, function (err, invalid) { + if (err) return done(error('server_error', false, err)); + if (invalid) return done(error('invalid_scope', invalid)); + + done(); + }); +} diff --git a/test/authCodeGrant.js b/test/authCodeGrant.js index 1ccecfdf3..8b0cc3b5c 100644 --- a/test/authCodeGrant.js +++ b/test/authCodeGrant.js @@ -211,7 +211,7 @@ describe('AuthCodeGrant', function() { redirectUri: 'http://nightworld.com' }); }, - saveAuthCode: function (authCode, clientId, expires, user, callback) { + saveAuthCode: function (authCode, clientId, expires, user, scope, callback) { should.exist(authCode); authCode.should.have.lengthOf(40); clientId.should.equal('thom'); @@ -230,6 +230,30 @@ describe('AuthCodeGrant', function() { .end(); }); + it('should return a scope', function (done) { + var app = bootstrap({ + getClient: function (clientId, clientSecret, callback) { + callback(false, { + clientId: 'thom', + redirectUri: 'http://nightworld.com' + }); + }, + saveAuthCode: function (authCode, clientId, expires, user, scope, callback) { + scope.should.equal('foobar'); + done(); + } + }, [false, true, false, 'foobar']); + + request(app) + .post('/authorise') + .send({ + response_type: 'code', + client_id: 'thom', + redirect_uri: 'http://nightworld.com' + }) + .end(); + }); + it('should accept valid request and return code using POST', function (done) { var code; @@ -240,7 +264,7 @@ describe('AuthCodeGrant', function() { redirectUri: 'http://nightworld.com' }); }, - saveAuthCode: function (authCode, clientId, expires, user, callback) { + saveAuthCode: function (authCode, clientId, expires, user, scope, callback) { should.exist(authCode); code = authCode; callback(); @@ -270,7 +294,7 @@ describe('AuthCodeGrant', function() { redirectUri: 'http://nightworld.com' }); }, - saveAuthCode: function (authCode, clientId, expires, user, callback) { + saveAuthCode: function (authCode, clientId, expires, user, scope, callback) { should.exist(authCode); code = authCode; callback(); @@ -300,7 +324,7 @@ describe('AuthCodeGrant', function() { redirectUri: 'http://nightworld.com' }); }, - saveAuthCode: function (authCode, clientId, expires, user, callback) { + saveAuthCode: function (authCode, clientId, expires, user, scope, callback) { should.exist(authCode); code = authCode; callback(); @@ -329,7 +353,7 @@ describe('AuthCodeGrant', function() { redirectUri: 'http://nightworld.com' }); }, - saveAuthCode: function (authCode, clientId, expires, user, callback) { + saveAuthCode: function (authCode, clientId, expires, user, scope, callback) { callback(); } }, [false, true], true); diff --git a/test/authorise.js b/test/authorise.js index 0b33f564c..33b0906fd 100644 --- a/test/authorise.js +++ b/test/authorise.js @@ -21,28 +21,32 @@ var express = require('express'), var oauth2server = require('../'); -var bootstrap = function (oauthConfig) { - if (oauthConfig === 'mockValid') { - oauthConfig = { - model: { - getAccessToken: function (token, callback) { - token.should.equal('thom'); - var expires = new Date(); - expires.setSeconds(expires.getSeconds() + 20); - callback(false, { expires: expires }); - } - } - }; - } - +var bootstrap = function (model, options, continueAfterResponse) { var app = express(); - app.oauth = oauth2server(oauthConfig || { model: {} }); + + model = model || { + getAccessToken: function (token, callback) { + var expires = new Date(Date.now() * 2); + + callback(false, { expires: expires }); + }, + authoriseScope: function (accessToken, scope, cb) { + cb(false, false); + } + }; + + app.oauth = oauth2server({ + model: model || {}, + continueAfterResponse: continueAfterResponse + }); app.use(bodyParser()); - app.all('/', app.oauth.authorise()); + app.get('/', app.oauth.authorise(options), function (req, res) { + res.send('nightworld'); + }); - app.all('/', function (req, res) { + app.post('/', app.oauth.authorise(options), function (req, res) { res.send('nightworld'); }); @@ -51,10 +55,10 @@ var bootstrap = function (oauthConfig) { return app; }; -describe('Authorise', function() { +describe('Authorise', function () { it('should detect no access token', function (done) { - var app = bootstrap('mockValid'); + var app = bootstrap(); request(app) .get('/') @@ -62,7 +66,7 @@ describe('Authorise', function() { }); it('should allow valid token as query param', function (done){ - var app = bootstrap('mockValid'); + var app = bootstrap(); request(app) .get('/?access_token=thom') @@ -71,7 +75,7 @@ describe('Authorise', function() { it('should require application/x-www-form-urlencoded when access token is ' + 'in body', function (done) { - var app = bootstrap('mockValid'); + var app = bootstrap(); request(app) .post('/') @@ -81,7 +85,7 @@ describe('Authorise', function() { }); it('should not allow GET when access token in body', function (done) { - var app = bootstrap('mockValid'); + var app = bootstrap(); request(app) .get('/') @@ -91,7 +95,7 @@ describe('Authorise', function() { }); it('should allow valid token in body', function (done){ - var app = bootstrap('mockValid'); + var app = bootstrap(); request(app) .post('/') @@ -100,8 +104,33 @@ describe('Authorise', function() { .expect(200, /nightworld/, done); }); + it('should allow if scope is valid for the token', function (done) { + var app = bootstrap(null, { scope: 'foobar' }); + + request(app) + .get('/?access_token=thom') + .expect(200, /nightworld/, done); + }); + + it('should not allow if scope is invalid for the token', function (done) { + var app = bootstrap({ + getAccessToken: function (token, callback) { + callback(false, { expires: null }); + }, + authoriseScope: function (accessToken, scope, cb) { + cb(false, true); + } + }, { scope: 'foobar' }); + + app.use(app.oauth.errorHandler()); + + request(app) + .get('/?access_token=thom') + .expect(400, /invalid_scope/, done); + }); + it('should detect malformed header', function (done) { - var app = bootstrap('mockValid'); + var app = bootstrap(); request(app) .get('/') @@ -110,7 +139,7 @@ describe('Authorise', function() { }); it('should allow valid token in header', function (done){ - var app = bootstrap('mockValid'); + var app = bootstrap(); request(app) .get('/') @@ -119,7 +148,7 @@ describe('Authorise', function() { }); it('should allow exactly one method (get: query + auth)', function (done) { - var app = bootstrap('mockValid'); + var app = bootstrap(); request(app) .get('/?access_token=thom') @@ -128,7 +157,7 @@ describe('Authorise', function() { }); it('should allow exactly one method (post: query + body)', function (done) { - var app = bootstrap('mockValid'); + var app = bootstrap(); request(app) .post('/?access_token=thom') @@ -139,7 +168,7 @@ describe('Authorise', function() { }); it('should allow exactly one method (post: query + empty body)', function (done) { - var app = bootstrap('mockValid'); + var app = bootstrap(); request(app) .post('/?access_token=thom') @@ -151,10 +180,8 @@ describe('Authorise', function() { it('should detect expired token', function (done){ var app = bootstrap({ - model: { - getAccessToken: function (token, callback) { - callback(false, { expires: 0 }); // Fake expires - } + getAccessToken: function (token, callback) { + callback(false, { expires: 0 }); // Fake expires } }); @@ -163,23 +190,13 @@ describe('Authorise', function() { .expect(401, /the access token provided has expired/i, done); }); - it('should passthrough with valid, non-expiring token (token = null)', - function (done) { + it('should passthrough with valid, non-expiring token (token = null)', function (done) { var app = bootstrap({ - model: { - getAccessToken: function (token, callback) { - token.should.equal('thom'); - callback(false, { expires: null }); - } + getAccessToken: function (token, callback) { + callback(false, { expires: null }); } }, false); - app.get('/', app.oauth.authorise(), function (req, res) { - res.send('nightworld'); - }); - - app.use(app.oauth.errorHandler()); - request(app) .get('/?access_token=thom') .expect(200, /nightworld/, done); @@ -187,24 +204,19 @@ describe('Authorise', function() { it('should expose the user id when setting userId', function (done) { var app = bootstrap({ - model: { - getAccessToken: function (token, callback) { - var expires = new Date(); - expires.setSeconds(expires.getSeconds() + 20); - callback(false, { expires: expires , userId: 1 }); - } + getAccessToken: function (token, callback) { + var expires = new Date(Date.now() * 2); + + callback(false, { expires: expires , userId: 1 }); } - }, false); + }); - app.get('/', app.oauth.authorise(), function (req, res) { + app.get('/', function (req, res) { req.should.have.property('user'); req.user.should.have.property('id'); req.user.id.should.equal(1); - res.send('nightworld'); }); - app.use(app.oauth.errorHandler()); - request(app) .get('/?access_token=thom') .expect(200, /nightworld/, done); @@ -212,29 +224,24 @@ describe('Authorise', function() { it('should expose the user id when setting user object', function (done) { var app = bootstrap({ - model: { - getAccessToken: function (token, callback) { - var expires = new Date(); - expires.setSeconds(expires.getSeconds() + 20); - callback(false, { expires: expires , user: { id: 1, name: 'thom' }}); - } + getAccessToken: function (token, callback) { + var expires = new Date(Date.now() * 2); + + callback(false, { expires: expires, user: { id: 1, name: 'thom' }}); } - }, false); + }); - app.get('/', app.oauth.authorise(), function (req, res) { + app.get('/', function (req, res) { req.should.have.property('user'); req.user.should.have.property('id'); req.user.id.should.equal(1); req.user.should.have.property('name'); req.user.name.should.equal('thom'); - res.send('nightworld'); }); - app.use(app.oauth.errorHandler()); - request(app) .get('/?access_token=thom') .expect(200, /nightworld/, done); }); -}); \ No newline at end of file +}); diff --git a/test/grant.authorization_code.js b/test/grant.authorization_code.js index bfd6f853d..101c6bfd6 100644 --- a/test/grant.authorization_code.js +++ b/test/grant.authorization_code.js @@ -197,10 +197,11 @@ describe('Granting with authorization_code grant type', function () { callback(false, { clientId: 'thom', expires: new Date(), - userId: '123' + userId: '123', + scope: 'foobar' }); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { cb(); }, saveRefreshToken: function (data, cb) { @@ -208,6 +209,9 @@ describe('Granting with authorization_code grant type', function () { }, expireRefreshToken: function (refreshToken, callback) { callback(); + }, + validateScope: function (scope, client, user, cb) { + cb(null, 'foobar', false); } }, grants: ['authorization_code'] diff --git a/test/grant.client_credentials.js b/test/grant.client_credentials.js index 730946167..43994eed0 100644 --- a/test/grant.client_credentials.js +++ b/test/grant.client_credentials.js @@ -70,4 +70,4 @@ describe('Granting with client_credentials grant type', function () { .expect(400, /client credentials are invalid/i, done); }); -}); \ No newline at end of file +}); diff --git a/test/grant.extended.js b/test/grant.extended.js index 6c6191118..6c585beeb 100644 --- a/test/grant.extended.js +++ b/test/grant.extended.js @@ -131,6 +131,9 @@ describe('Granting with extended grant type', function () { }, saveAccessToken: function () { done(); // That's enough + }, + validateScope: function (scope, client, user, cb) { + cb(false, '', false); } }, grants: ['http://custom.com'] diff --git a/test/grant.js b/test/grant.js index 2d5820de0..ec5ca03ee 100644 --- a/test/grant.js +++ b/test/grant.js @@ -43,7 +43,8 @@ var validBody = { client_id: 'thom', client_secret: 'nightworld', username: 'thomseddon', - password: 'nightworld' + password: 'nightworld', + scope: 'foobar' }; describe('Grant', function() { @@ -236,9 +237,12 @@ describe('Grant', function() { generateToken: function (type, req, callback) { callback(false, 'thommy'); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { token.should.equal('thommy'); cb(); + }, + validateScope: function (scope, client, user, cb) { + cb(null, '', false); } }, grants: ['password'] @@ -270,9 +274,12 @@ describe('Grant', function() { req.user.id.should.equal(1); callback(false, 'thommy'); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { token.should.equal('thommy'); cb(); + }, + validateScope: function (scope, client, user, cb) { + cb(null, '', false); } }, grants: ['password'] @@ -301,8 +308,11 @@ describe('Grant', function() { generateToken: function (type, req, callback) { callback(false, { accessToken: 'thommy' }); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { cb(new Error('Should not be saving')); + }, + validateScope: function (scope, client, user, cb) { + cb(null, '', false); } }, grants: ['password'] @@ -330,13 +340,17 @@ describe('Grant', function() { getUser: function (uname, pword, callback) { callback(false, { id: 1 }); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { token.should.be.instanceOf(String); token.should.have.length(40); clientId.should.equal('thom'); user.id.should.equal(1); + scope.should.equal('foobar'); (+expires).should.be.within(10, (+new Date()) + 3600000); cb(); + }, + validateScope: function (scope, client, user, cb) { + cb(null, 'foobar', false); } }, grants: ['password'] @@ -362,16 +376,19 @@ describe('Grant', function() { getUser: function (uname, pword, callback) { callback(false, { id: 1 }); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { cb(); }, - saveRefreshToken: function (token, clientId, expires, user, cb) { + saveRefreshToken: function (token, clientId, expires, user, scope, cb) { token.should.be.instanceOf(String); token.should.have.length(40); clientId.should.equal('thom'); user.id.should.equal(1); (+expires).should.be.within(10, (+new Date()) + 1209600000); cb(); + }, + validateScope: function (scope, client, user, cb) { + cb(null, '', false); } }, grants: ['password', 'refresh_token'] @@ -399,8 +416,11 @@ describe('Grant', function() { getUser: function (uname, pword, callback) { callback(false, { id: 1 }); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { cb(); + }, + validateScope: function (scope, client, user, cb) { + cb(null, '', false); } }, grants: ['password'] @@ -437,11 +457,14 @@ describe('Grant', function() { getUser: function (uname, pword, callback) { callback(false, { id: 1 }); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { cb(); }, - saveRefreshToken: function (token, clientId, expires, user, cb) { + saveRefreshToken: function (token, clientId, expires, user, scope, cb) { cb(); + }, + validateScope: function (scope, client, user, cb) { + cb(null, '', false); } }, grants: ['password', 'refresh_token'] @@ -469,6 +492,53 @@ describe('Grant', function() { }); + it('should return an oauth compatible response with scope', function (done) { + var app = bootstrap({ + model: { + getClient: function (id, secret, callback) { + callback(false, { client_id: 'thom' }); + }, + grantTypeAllowed: function (clientId, grantType, callback) { + callback(false, true); + }, + getUser: function (uname, pword, callback) { + callback(false, { id: 1 }); + }, + saveAccessToken: function (token, clientId, expires, user, scope, cb) { + cb(); + }, + saveRefreshToken: function (token, clientId, expires, user, scope, cb) { + cb(); + }, + validateScope: function (scope, client, user, cb) { + cb(null, 'foobar', false); + } + }, + grants: ['password', 'refresh_token'] + }); + + request(app) + .post('/oauth/token') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ grant_type: 'password', client_id: 'thom', client_secret: 'nightworld', username: 'thomseddon', password: 'nightworld', scope: 'foobar' }) + .expect(200) + .end(function (err, res) { + if (err) return done(err); + + res.body.should.have.keys(['access_token', 'token_type', 'expires_in', + 'refresh_token', 'scope']); + res.body.access_token.should.be.instanceOf(String); + res.body.access_token.should.have.length(40); + res.body.expires_in.should.equal(3600); + res.body.refresh_token.should.be.instanceOf(String); + res.body.refresh_token.should.have.length(40); + res.body.scope.should.equal('foobar'); + res.body.token_type.should.equal('bearer'); + + done(); + }); + }); + it('should exclude expires_in if accessTokenLifetime = null', function (done) { var app = bootstrap({ model: { @@ -481,13 +551,16 @@ describe('Grant', function() { getUser: function (uname, pword, callback) { callback(false, { id: 1 }); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { should.strictEqual(null, expires); cb(); }, - saveRefreshToken: function (token, clientId, expires, user, cb) { + saveRefreshToken: function (token, clientId, expires, user, scope, cb) { should.strictEqual(null, expires); cb(); + }, + validateScope: function (scope, client, user, cb) { + cb(null, '', false); } }, grants: ['password', 'refresh_token'], @@ -515,7 +588,7 @@ describe('Grant', function() { }); - it('should continue after success response if continueAfterResponse1 = true', function (done) { + it('should continue after success response if continueAfterResponse = true', function (done) { var app = bootstrap({ model: { getClient: function (id, secret, callback) { @@ -527,8 +600,11 @@ describe('Grant', function() { getUser: function (uname, pword, callback) { callback(false, { id: 1 }); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { cb(); + }, + validateScope: function (scope, client, user, cb) { + cb(null, '', false); } }, grants: ['password'], @@ -554,4 +630,75 @@ describe('Grant', function() { }); + describe('saving scope', function () { + it('should not allow invalid scopes', function (done) { + var app = bootstrap({ + model: { + getClient: function (id, secret, callback) { + callback(false, { client_id: 'thom' }); + }, + grantTypeAllowed: function (clientId, grantType, callback) { + callback(false, true); + }, + getUser: function (uname, pword, callback) { + callback(false, { id: 1 }); + }, + saveAccessToken: function (token, clientId, expires, user, scope, cb) { + token.should.be.instanceOf(String); + token.should.have.length(40); + clientId.should.equal('thom'); + user.id.should.equal(1); + (+expires).should.be.within(10, (+new Date()) + 3600000); + cb(); + }, + validateScope: function(scope, client, user, cb) { + cb(false, false, true); + } + }, + grants: ['password'] + }); + + request(app) + .post('/oauth/token') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ grant_type: 'password', client_id: 'thom', client_secret: 'nightworld', username: 'thomseddon', password: 'nightworld', scope: 'foo bar' }) + .expect(400, /invalid_scope/, done); + + }); + + it('should allow valid scopes', function (done) { + var app = bootstrap({ + model: { + getClient: function (id, secret, callback) { + callback(false, { client_id: 'thom' }); + }, + grantTypeAllowed: function (clientId, grantType, callback) { + callback(false, true); + }, + getUser: function (uname, pword, callback) { + callback(false, { id: 1 }); + }, + saveAccessToken: function (token, clientId, expires, user, scope, cb) { + token.should.be.instanceOf(String); + token.should.have.length(40); + clientId.should.equal('thom'); + user.id.should.equal(1); + (+expires).should.be.within(10, (+new Date()) + 3600000); + cb(); + }, + validateScope: function(scope, client, user, cb) { + cb(false, 'foo bar', false); + } + }, + grants: ['password'] + }); + + request(app) + .post('/oauth/token') + .set('Content-Type', 'application/x-www-form-urlencoded') + .send({ grant_type: 'password', client_id: 'thom', client_secret: 'nightworld', username: 'thomseddon', password: 'nightworld', scope: 'foo bar' }) + .expect(200, /foo bar/, done); + }); + }); + }); diff --git a/test/grant.refresh_token.js b/test/grant.refresh_token.js index 804cff8ef..254e9977e 100644 --- a/test/grant.refresh_token.js +++ b/test/grant.refresh_token.js @@ -171,14 +171,17 @@ describe('Granting with refresh_token grant type', function () { userId: '123' }); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { cb(); }, - saveRefreshToken: function (token, clientId, expires, user, cb) { + saveRefreshToken: function (token, clientId, expires, user, scope, cb) { cb(); }, expireRefreshToken: function (refreshToken, callback) { callback(); + }, + validateScope: function (scope, client, user, cb) { + cb(false, '', false); } }, grants: ['password', 'refresh_token'] @@ -216,14 +219,17 @@ describe('Granting with refresh_token grant type', function () { } }); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { cb(); }, - saveRefreshToken: function (token, clientId, expires, user, cb) { + saveRefreshToken: function (token, clientId, expires, user, scope, cb) { cb(); }, expireRefreshToken: function (refreshToken, callback) { callback(); + }, + validateScope: function (scope, client, user, cb) { + cb(false, '', false); } }, grants: ['password', 'refresh_token'] @@ -258,14 +264,17 @@ describe('Granting with refresh_token grant type', function () { userId: '123' }); }, - saveAccessToken: function (token, clientId, expires, user, cb) { + saveAccessToken: function (token, clientId, expires, user, scope, cb) { cb(); }, - saveRefreshToken: function (token, clientId, expires, user, cb) { + saveRefreshToken: function (token, clientId, expires, scope, user, cb) { cb(); }, expireRefreshToken: function (refreshToken, callback) { callback(); + }, + validateScope: function (scope, client, user, cb) { + cb(false, '', false); } }, grants: ['password', 'refresh_token'] diff --git a/test/scope.js b/test/scope.js new file mode 100644 index 000000000..e21df5c18 --- /dev/null +++ b/test/scope.js @@ -0,0 +1,83 @@ +/** + * Copyright 2013-present NightWorld. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var express = require('express'), + bodyParser = require('body-parser'), + request = require('supertest'), + should = require('should'); + +var oauth2server = require('../'); + +var bootstrap = function (options) { + var app = express(); + + app.oauth = oauth2server({ + model: { + getAccessToken: function (token, callback) { + var expires = new Date(Date.now() * 2); + + callback(false, { expires: expires }); + }, + authoriseScope: function (accessToken, scope, cb) { + cb(false, 'my-scope' !== scope); + } + } + }); + + app.use(bodyParser()); + + app.get('/', app.oauth.authorise(), app.oauth.scope(options), function (req, res) { + res.send('nightworld'); + }); + + app.use(app.oauth.errorHandler()); + + return app; +}; + +describe('Scope', function () { + + it('should not allow if not authorized', function (done) { + var app = bootstrap('foobar'); + + app.get('/unauthorised', app.oauth.scope('foobar'), function (req, res) { + res.send('nightworld'); + }); + + app.use(app.oauth.errorHandler()); + + request(app) + .get('/unauthorised') + .expect(400, /invalid_request/, done); + }); + + it('should not allow if scope is invalid', function (done) { + var app = bootstrap('foobar'); + + request(app) + .get('/?access_token=thom') + .expect(400, /invalid_scope/, done); + }); + + it('should allow if scope is valid', function (done) { + var app = bootstrap('my-scope'); + + request(app) + .get('/?access_token=thom') + .expect(200, /nightworld/, done); + }); + +});