From 69df11bb8e72a33321bacb60db2be7b68c68c9a7 Mon Sep 17 00:00:00 2001 From: Raymond Feng Date: Tue, 2 May 2017 10:55:51 -0700 Subject: [PATCH] Refactor access token to make it extensible 1. Make it possible to reuse getIdForRequest() 2. Introduce a flag to control if oAuth2 bearer token should be base64 encoded 3. Promote resolve() to locate/validate access tokens by id --- common/models/access-token.js | 213 +++++++++++++++++++--------------- server/middleware/token.js | 6 + test/access-token.test.js | 107 +++++++++++++---- 3 files changed, 211 insertions(+), 115 deletions(-) diff --git a/common/models/access-token.js b/common/models/access-token.js index 76d37081..209057b1 100644 --- a/common/models/access-token.js +++ b/common/models/access-token.js @@ -78,6 +78,121 @@ module.exports = function(AccessToken) { }); }); + /** + * Extract the access token id from the HTTP request + * @param {Request} req HTTP request object + * @options {Object} [options] Each option array is used to add additional keys to find an `accessToken` for a `request`. + * @property {Array} [cookies] Array of cookie names. + * @property {Array} [headers] Array of header names. + * @property {Array} [params] Array of param names. + * @property {Boolean} [searchDefaultTokenKeys] Use the default search locations for Token in request + * @property {Boolean} [bearerTokenBase64Encoded] Defaults to `true`. For `Bearer` token based `Authorization` headers, + * decode the value from `Base64`. If set to `false`, the decoding will be skipped and the token id will be the raw value + * parsed from the header. + * @return {String} The access token + */ + AccessToken.getIdForRequest = function(req, options) { + options = options || {}; + var params = options.params || []; + var headers = options.headers || []; + var cookies = options.cookies || []; + var i = 0; + var length, id; + + // https://github.com/strongloop/loopback/issues/1326 + if (options.searchDefaultTokenKeys !== false) { + params = params.concat(['access_token']); + headers = headers.concat(['X-Access-Token', 'authorization']); + cookies = cookies.concat(['access_token', 'authorization']); + } + + for (length = params.length; i < length; i++) { + var param = params[i]; + // replacement for deprecated req.param() + id = req.params && req.params[param] !== undefined ? req.params[param] : + req.body && req.body[param] !== undefined ? req.body[param] : + req.query && req.query[param] !== undefined ? req.query[param] : + undefined; + + if (typeof id === 'string') { + return id; + } + } + + for (i = 0, length = headers.length; i < length; i++) { + id = req.header(headers[i]); + + if (typeof id === 'string') { + // Add support for oAuth 2.0 bearer token + // http://tools.ietf.org/html/rfc6750 + if (id.indexOf('Bearer ') === 0) { + id = id.substring(7); + if (options.bearerTokenBase64Encoded) { + // Decode from base64 + var buf = new Buffer(id, 'base64'); + id = buf.toString('utf8'); + } + } else if (/^Basic /i.test(id)) { + id = id.substring(6); + id = (new Buffer(id, 'base64')).toString('utf8'); + // The spec says the string is user:pass, so if we see both parts + // we will assume the longer of the two is the token, so we will + // extract "a2b2c3" from: + // "a2b2c3" + // "a2b2c3:" (curl http://a2b2c3@localhost:3000/) + // "token:a2b2c3" (curl http://token:a2b2c3@localhost:3000/) + // ":a2b2c3" + var parts = /^([^:]*):(.*)$/.exec(id); + if (parts) { + id = parts[2].length > parts[1].length ? parts[2] : parts[1]; + } + } + return id; + } + } + + if (req.signedCookies) { + for (i = 0, length = cookies.length; i < length; i++) { + id = req.signedCookies[cookies[i]]; + + if (typeof id === 'string') { + return id; + } + } + } + return null; + }; + + /** + * Resolve and validate the access token by id + * @param {String} id Access token + * @callback {Function} cb Callback function + * @param {Error} err Error information + * @param {Object} Resolved access token object + */ + AccessToken.resolve = function(id, cb) { + this.findById(id, function(err, token) { + if (err) { + cb(err); + } else if (token) { + token.validate(function(err, isValid) { + if (err) { + cb(err); + } else if (isValid) { + cb(null, token); + } else { + var e = new Error(g.f('Invalid Access Token')); + e.status = e.statusCode = 401; + e.code = 'INVALID_TOKEN'; + cb(e); + } + }); + } else { + cb(); + } + }); + }; + /** * Find a token for the given `ServerRequest`. * @@ -87,40 +202,18 @@ module.exports = function(AccessToken) { * @param {Error} err * @param {AccessToken} token */ - AccessToken.findForRequest = function(req, options, cb) { if (cb === undefined && typeof options === 'function') { cb = options; options = {}; } - var id = tokenIdForRequest(req, options); + var id = this.getIdForRequest(req, options); if (id) { - this.findById(id, function(err, token) { - if (err) { - cb(err); - } else if (token) { - token.validate(function(err, isValid) { - if (err) { - cb(err); - } else if (isValid) { - cb(null, token); - } else { - var e = new Error(g.f('Invalid Access Token')); - e.status = e.statusCode = 401; - e.code = 'INVALID_TOKEN'; - cb(e); - } - }); - } else { - cb(); - } - }); + this.resolve(id, cb); } else { - process.nextTick(function() { - cb(); - }); + process.nextTick(cb); } }; @@ -131,7 +224,6 @@ module.exports = function(AccessToken) { * @param {Error} err * @param {Boolean} isValid */ - AccessToken.prototype.validate = function(cb) { try { assert( @@ -181,73 +273,4 @@ module.exports = function(AccessToken) { }); } }; - - function tokenIdForRequest(req, options) { - var params = options.params || []; - var headers = options.headers || []; - var cookies = options.cookies || []; - var i = 0; - var length, id; - - // https://github.com/strongloop/loopback/issues/1326 - if (options.searchDefaultTokenKeys !== false) { - params = params.concat(['access_token']); - headers = headers.concat(['X-Access-Token', 'authorization']); - cookies = cookies.concat(['access_token', 'authorization']); - } - - for (length = params.length; i < length; i++) { - var param = params[i]; - // replacement for deprecated req.param() - id = req.params && req.params[param] !== undefined ? req.params[param] : - req.body && req.body[param] !== undefined ? req.body[param] : - req.query && req.query[param] !== undefined ? req.query[param] : - undefined; - - if (typeof id === 'string') { - return id; - } - } - - for (i = 0, length = headers.length; i < length; i++) { - id = req.header(headers[i]); - - if (typeof id === 'string') { - // Add support for oAuth 2.0 bearer token - // http://tools.ietf.org/html/rfc6750 - if (id.indexOf('Bearer ') === 0) { - id = id.substring(7); - // Decode from base64 - var buf = new Buffer(id, 'base64'); - id = buf.toString('utf8'); - } else if (/^Basic /i.test(id)) { - id = id.substring(6); - id = (new Buffer(id, 'base64')).toString('utf8'); - // The spec says the string is user:pass, so if we see both parts - // we will assume the longer of the two is the token, so we will - // extract "a2b2c3" from: - // "a2b2c3" - // "a2b2c3:" (curl http://a2b2c3@localhost:3000/) - // "token:a2b2c3" (curl http://token:a2b2c3@localhost:3000/) - // ":a2b2c3" - var parts = /^([^:]*):(.*)$/.exec(id); - if (parts) { - id = parts[2].length > parts[1].length ? parts[2] : parts[1]; - } - } - return id; - } - } - - if (req.signedCookies) { - for (i = 0, length = cookies.length; i < length; i++) { - id = req.signedCookies[cookies[i]]; - - if (typeof id === 'string') { - return id; - } - } - } - return null; - } }; diff --git a/server/middleware/token.js b/server/middleware/token.js index 2268f91e..0af1e24a 100644 --- a/server/middleware/token.js +++ b/server/middleware/token.js @@ -88,6 +88,9 @@ function escapeRegExp(str) { * @property {Boolean} [overwriteExistingToken] only has effect in combination with `enableDoublecheck`. If truthy, will allow to overwrite an existing accessToken. * @property {Function|String} [model] AccessToken model name or class to use. * @property {String} [currentUserLiteral] String literal for the current user. + * @property {Boolean} [bearerTokenBase64Encoded] Defaults to `true`. For `Bearer` token based `Authorization` headers, + * decode the value from `Base64`. If set to `false`, the decoding will be skipped and the token id will be the raw value + * parsed from the header. * @header loopback.token([options]) */ @@ -104,6 +107,9 @@ function token(options) { currentUserLiteral = escapeRegExp(currentUserLiteral); } + if (options.bearerTokenBase64Encoded === undefined) { + options.bearerTokenBase64Encoded = true; + } var enableDoublecheck = !!options.enableDoublecheck; var overwriteExistingToken = !!options.overwriteExistingToken; diff --git a/test/access-token.test.js b/test/access-token.test.js index 6e74ce69..718f130f 100644 --- a/test/access-token.test.js +++ b/test/access-token.test.js @@ -115,14 +115,14 @@ describe('loopback.token(options)', function() { }); }); - it('should populate req.token from the query string', function(done) { + it('populates req.token from the query string', function(done) { createTestAppAndRequest(this.token, done) .get('/?access_token=' + this.token.id) .expect(200) .end(done); }); - it('should populate req.token from an authorization header', function(done) { + it('populates req.token from an authorization header', function(done) { createTestAppAndRequest(this.token, done) .get('/') .set('authorization', this.token.id) @@ -130,7 +130,7 @@ describe('loopback.token(options)', function() { .end(done); }); - it('should populate req.token from an X-Access-Token header', function(done) { + it('populates req.token from an X-Access-Token header', function(done) { createTestAppAndRequest(this.token, done) .get('/') .set('X-Access-Token', this.token.id) @@ -138,7 +138,7 @@ describe('loopback.token(options)', function() { .end(done); }); - it('should not search default keys when searchDefaultTokenKeys is false', + it('does not search default keys when searchDefaultTokenKeys is false', function(done) { var tokenId = this.token.id; var app = createTestApp( @@ -162,7 +162,8 @@ describe('loopback.token(options)', function() { }); }); - it('should populate req.token from an authorization header with bearer token', function(done) { + it('populates req.token from an authorization header with bearer token with base64', + function(done) { var token = this.token.id; token = 'Bearer ' + new Buffer(token).toString('base64'); createTestAppAndRequest(this.token, done) @@ -172,6 +173,16 @@ describe('loopback.token(options)', function() { .end(done); }); + it('populates req.token from an authorization header with bearer token', function(done) { + var token = this.token.id; + token = 'Bearer ' + token; + createTestAppAndRequest(this.token, {token: {bearerTokenBase64Encoded: false}}, done) + .get('/') + .set('authorization', token) + .expect(200) + .end(done); + }); + describe('populating req.token from HTTP Basic Auth formatted authorization header', function() { it('parses "standalone-token"', function(done) { var token = this.token.id; @@ -214,7 +225,7 @@ describe('loopback.token(options)', function() { }); }); - it('should populate req.token from a secure cookie', function(done) { + it('populates req.token from a secure cookie', function(done) { var app = createTestApp(this.token, done); request(app) @@ -227,7 +238,7 @@ describe('loopback.token(options)', function() { }); }); - it('should populate req.token from a header or a secure cookie', function(done) { + it('populates req.token from a header or a secure cookie', function(done) { var app = createTestApp(this.token, done); var id = this.token.id; request(app) @@ -241,7 +252,7 @@ describe('loopback.token(options)', function() { }); }); - it('should rewrite url for the current user literal at the end without query', + it('rewrites url for the current user literal at the end without query', function(done) { var app = createTestApp(this.token, done); var id = this.token.id; @@ -257,7 +268,7 @@ describe('loopback.token(options)', function() { }); }); - it('should rewrite url for the current user literal at the end with query', + it('rewrites url for the current user literal at the end with query', function(done) { var app = createTestApp(this.token, done); var id = this.token.id; @@ -273,7 +284,7 @@ describe('loopback.token(options)', function() { }); }); - it('should rewrite url for the current user literal in the middle', + it('rewrites url for the current user literal in the middle', function(done) { var app = createTestApp(this.token, done); var id = this.token.id; @@ -289,7 +300,7 @@ describe('loopback.token(options)', function() { }); }); - it('should generate a 401 on a current user literal route without an authToken', + it('generates a 401 on a current user literal route without an authToken', function(done) { var app = createTestApp(null, done); request(app) @@ -299,7 +310,7 @@ describe('loopback.token(options)', function() { .end(done); }); - it('should generate a 401 on a current user literal route with invalid authToken', + it('generates a 401 on a current user literal route with invalid authToken', function(done) { var app = createTestApp(this.token, done); request(app) @@ -309,7 +320,7 @@ describe('loopback.token(options)', function() { .end(done); }); - it('should skip when req.token is already present', function(done) { + it('skips when req.token is already present', function(done) { var tokenStub = {id: 'stub id'}; app.use(function(req, res, next) { req.accessToken = tokenStub; @@ -334,7 +345,7 @@ describe('loopback.token(options)', function() { }); describe('loading multiple instances of token middleware', function() { - it('should skip when req.token is already present and no further options are set', + it('skips when req.token is already present and no further options are set', function(done) { var tokenStub = {id: 'stub id'}; app.use(function(req, res, next) { @@ -359,7 +370,7 @@ describe('loopback.token(options)', function() { }); }); - it('should not overwrite valid existing token (has "id" property) ' + + it('does not overwrite valid existing token (has "id" property) ' + ' when overwriteExistingToken is falsy', function(done) { var tokenStub = {id: 'stub id'}; @@ -388,7 +399,7 @@ describe('loopback.token(options)', function() { }); }); - it('should overwrite invalid existing token (is !== undefined and has no "id" property) ' + + it('overwrites invalid existing token (is !== undefined and has no "id" property) ' + ' when enableDoublecheck is true', function(done) { var token = this.token; @@ -421,7 +432,7 @@ describe('loopback.token(options)', function() { }); }); - it('should overwrite existing token when enableDoublecheck ' + + it('overwrites existing token when enableDoublecheck ' + 'and overwriteExistingToken options are truthy', function(done) { var token = this.token; @@ -462,12 +473,20 @@ describe('loopback.token(options)', function() { describe('AccessToken', function() { beforeEach(createTestingToken); - it('should auto-generate id', function() { + it('has getIdForRequest method', function() { + expect(typeof Token.getIdForRequest).to.eql('function'); + }); + + it('has resolve method', function() { + expect(typeof Token.resolve).to.eql('function'); + }); + + it('generates id automatically', function() { assert(this.token.id); assert.equal(this.token.id.length, 64); }); - it('should auto-generate created date', function() { + it('generates created date automatically', function() { assert(this.token.created); assert(Object.prototype.toString.call(this.token.created), '[object Date]'); }); @@ -525,6 +544,54 @@ describe('AccessToken', function() { }); }); + it('allows getIdForRequest() to be overridden', function(done) { + var expectedTokenId = this.token.id; + var current = Token.getIdForRequest; + var called = false; + Token.getIdForRequest = function(req, options) { + called = true; + return expectedTokenId; + }; + var req = mockRequest({ + headers: {'authorization': 'dummy'}, + }); + + Token.findForRequest(req, function(err, token) { + Token.getIdForRequest = current; + if (err) return done(err); + + expect(token.id).to.eql(expectedTokenId); + expect(called).to.be.true(); + + done(); + }); + }); + + it('allows resolve() to be overridden', function(done) { + var expectedTokenId = this.token.id; + var current = Token.resolve; + var called = false; + Token.resolve = function(id, cb) { + called = true; + process.nextTick(function() { + cb(null, {id: expectedTokenId}); + }); + }; + var req = mockRequest({ + headers: {'authorization': expectedTokenId}, + }); + + Token.findForRequest(req, function(err, token) { + Token.validate = current; + if (err) return done(err); + + expect(token.id).to.eql(expectedTokenId); + expect(called).to.be.true(); + + done(); + }); + }); + function mockRequest(opts) { return extend( { @@ -618,7 +685,7 @@ describe('app.enableAuth()', function() { }); }); - it('prevent remote call if the accessToken is missing and required', function(done) { + it('prevents remote call if the accessToken is missing and required', function(done) { createTestAppAndRequest(null, done) .del('/tests/123') .expect(401)