diff --git a/lib/config.yml b/lib/config.yml index 4e8e77a2..012cd571 100644 --- a/lib/config.yml +++ b/lib/config.yml @@ -30,6 +30,9 @@ web: password: enabled: true validationStrategy: "local" + revoke: + enabled: true + uri: '/oauth/revoke' accessTokenCookie: diff --git a/lib/controllers/get-token.js b/lib/controllers/get-token.js index 59567e59..83d5e5bf 100644 --- a/lib/controllers/get-token.js +++ b/lib/controllers/get-token.js @@ -36,6 +36,25 @@ module.exports = function (req, res) { res.json(authResult.accessTokenResponse); } + function resolveClientCredentialsAuthFields(req) { + var authHeader = req && req.headers && req.headers.authorization; + + if (authHeader && authHeader.match(/Basic/i)) { + var authorization = authHeader.split(' ').pop(); + var parts = new Buffer(authorization, 'base64').toString('utf8').split(':'); + + req.body.apiKey = { + id: parts[0], + secret: parts[1] + }; + } else if (req.body && req.body.client_id && req.body.client_secret) { + req.body.apiKey = { + id: req.body.client_id, + secret: req.body.client_secret + }; + } + } + function continueWithHandlers(authResult, preHandler, postHandler, onCompleted) { var options = req.body || {}; @@ -88,8 +107,18 @@ module.exports = function (req, res) { break; case 'password': case 'refresh_token': + case 'client_credentials': var authenticator = new stormpath.OAuthAuthenticator(application); + if (config.web.scopeFactory) { + authenticator.setScopeFactory(config.web.scopeFactory); + authenticator.setScopeFactorySigningKey(config.client.apiKey.secret); + } + + if (grantType === 'client_credentials') { + resolveClientCredentialsAuthFields(req); + } + authenticator.authenticate(req, function (err, authResult) { if (err) { return writeErrorResponse(err); @@ -108,22 +137,6 @@ module.exports = function (req, res) { }); break; - case 'client_credentials': - application.authenticateApiRequest({ - request: req, - ttl: config.web.oauth2.client_credentials.accessToken.ttl, - scopeFactory: function (account, requestedScopes) { - return requestedScopes; - } - }, function (err, authResult) { - if (err) { - return writeErrorResponse(err); - } - - res.json(authResult.tokenResponse); - }); - break; - default: writeErrorResponse({ error: 'unsupported_grant_type' diff --git a/lib/controllers/index.js b/lib/controllers/index.js index a8235a59..db19d840 100644 --- a/lib/controllers/index.js +++ b/lib/controllers/index.js @@ -14,5 +14,6 @@ module.exports = { login: require('./login'), logout: require('./logout'), register: require('./register'), - verifyEmail: require('./verify-email') + verifyEmail: require('./verify-email'), + revokeToken: require('./revoke-token') }; diff --git a/lib/controllers/revoke-token.js b/lib/controllers/revoke-token.js new file mode 100644 index 00000000..7bb43ed4 --- /dev/null +++ b/lib/controllers/revoke-token.js @@ -0,0 +1,134 @@ +'use strict'; + +var middleware = require('../middleware'); +var nJwt = require('njwt'); + +/** + * Revokes an OAuth token (an access token or a refresh token). When an access + * token is revoked, the associated access token is revoked as well. + * + * The URL this controller is bound to can be controlled via express-stormpath + * settings. + * + * @method + * + * @param {Object} req - The http request. + * @param {Object} res - The http response. + */ +module.exports = function (req, res) { + var config = req.app.get('stormpathConfig'); + var logger = req.app.get('stormpathLogger'); + + var token = req.body.token; + var jwtSigningKey = config.client.apiKey.secret; + + function writeErrorResponse(err) { + var error = { + error: err.error, + message: err.userMessage || err.message + }; + + logger.info('An OAuth token revoke failed due to an improperly formed request.'); + + return res.status(err.status || err.statusCode || 400).json(error); + } + + /** + * @private + * + * Given a token's resource ID and its type (access or refresh), + * retrieves a token with that ID if and only if it is one of the tokens + * belonging to this user. If there is no such token, the callback is called + * with no data nonetheless, due to how RFC 7009 defines this case. + * + * @param {String} tokenId Token resource identifier + * @param {String} tokenType The type of the token, `access` or `refresh` + * @param {Function} callback Function to be called after completion + */ + function loadTokenForUser(tokenId, tokenType, callback) { + var getTokens; + switch (tokenType) { + case 'access': + getTokens = req.user.getAccessTokens.bind(req.user); + break; + case 'refresh': + getTokens = req.user.getRefreshTokens.bind(req.user); + break; + default: + return writeErrorResponse({ + error: 'unsupported_token_type' + }); + } + + getTokens(function (err, collection) { + if (err) { + return callback(err); + } + + var validTokens = collection.items.filter(function (token) { + return token.href.indexOf(tokenId) !== -1; + }); + + if (validTokens.length) { + return callback(null, validTokens[0]); + } + + // RFC 7009 states that if there is no token, it counts as already invalidated, + // and the process should proceed as it were found and invalidated. + callback(); + }); + + } + + /** + * @private + * + * Unpacks a token from its compact form and retrieves the correct token resource + * belonging to the current user from that data, if there is one such token. + * + * @param {String} compactToken Token in compact string form + * @param {Function} callback Function to be called after completion + */ + function getTokenResource(compactToken, callback) { + nJwt.verify(compactToken, jwtSigningKey, function (err, parsedToken) { + if (err) { + return callback(); // Ignore failure, means token is already invalid + } + + var tokenType = parsedToken.header.stt; + var tokenId = parsedToken.body.jti; + + loadTokenForUser(tokenId, tokenType, callback); + }); + } + + middleware.apiAuthenticationRequired(req, res, function (err) { + if (err) { + return writeErrorResponse(err); + } + + if (!token) { + return writeErrorResponse({ + error: 'invalid_request' + }); + } + + getTokenResource(token, function (err, resource) { + if (err) { + return writeErrorResponse(err); + } + + if (!resource) { + return res.status(200).end(); + } + + resource.delete(function (err) { + if (err) { + return writeErrorResponse(err); + } + + res.status(200).end(); + }); + }); + }); +}; \ No newline at end of file diff --git a/lib/middleware/api-authentication-required.js b/lib/middleware/api-authentication-required.js index 1e8dc091..8ebd1bd6 100644 --- a/lib/middleware/api-authentication-required.js +++ b/lib/middleware/api-authentication-required.js @@ -16,6 +16,7 @@ var stormpath = require('stormpath'); */ module.exports = function (req, res, next) { var application = req.app.get('stormpathApplication'); + var client = req.app.get('stormpathClient'); var config = req.app.get('stormpathConfig'); var logger = req.app.get('stormpathLogger'); @@ -64,7 +65,7 @@ module.exports = function (req, res, next) { var isBasic = req.headers.authorization && req.headers.authorization.match(/Basic .+/); if (token) { - var authenticator = new stormpath.JwtAuthenticator(application); + var authenticator = new stormpath.StormpathAccessTokenAuthenticator(client); if (config.web.oauth2.password.validationStrategy === 'local') { authenticator.withLocalValidation(); } diff --git a/lib/stormpath.js b/lib/stormpath.js index 4b6f50d7..907167ef 100644 --- a/lib/stormpath.js +++ b/lib/stormpath.js @@ -220,6 +220,10 @@ module.exports.init = function (app, opts) { if (web.oauth2.enabled) { router.all(web.oauth2.uri, bodyParser.form(), stormpathMiddleware, controllers.getToken); + + if (web.oauth2.revoke.enabled) { + addPostRoute(web.oauth2.revoke.uri, controllers.revokeToken); + } } client.getApplication(config.application.href, function (err, application) { diff --git a/test/controllers/test-get-token.js b/test/controllers/test-get-token.js index a717cbb8..aedc7bdf 100644 --- a/test/controllers/test-get-token.js +++ b/test/controllers/test-get-token.js @@ -3,10 +3,12 @@ var assert = require('assert'); var request = require('supertest'); var uuid = require('uuid'); +var nJwt = require('njwt'); var DefaultExpressApplicationFixture = require('../fixtures/default-express-application'); var helpers = require('../helpers'); var Oauth2DisabledFixture = require('../fixtures/oauth2-disabled'); +var ScopeFactoryFixture = require('../fixtures/scope-factory'); describe('getToken (OAuth2 token exchange endpoint)', function () { var username = uuid.v4() + '@stormpath.com'; @@ -22,21 +24,36 @@ describe('getToken (OAuth2 token exchange endpoint)', function () { var stormpathApplication; var enabledFixture; var disabledFixture; + var scopeFactoryFixture; var refreshToken; + var scopeFactory; + var requestScope; + var createScope; before(function (done) { /** - * Epic hack to observe two ready events and know when they are both done + * Epic hack to observe all ready events and know when they are both done */ var readyCount = 0; function ready() { readyCount++; - if (readyCount === 2) { - done(); + if (readyCount === 3) { + setTimeout(done, 1500); // HACK see what's up with this! } } + requestScope = 'admin'; + + createScope = function (scope) { + return scope + '-' + username; + }; + + scopeFactory = function (authenticationResult, requestedScope, callback) { + assert.equal(requestScope, requestedScope); + callback(null, createScope(requestedScope)); + }; + helpers.createApplication(helpers.createClient(), function (err, app) { if (err) { return done(err); @@ -46,6 +63,7 @@ describe('getToken (OAuth2 token exchange endpoint)', function () { enabledFixture = new DefaultExpressApplicationFixture(stormpathApplication); disabledFixture = new Oauth2DisabledFixture(stormpathApplication); + scopeFactoryFixture = new ScopeFactoryFixture(stormpathApplication, scopeFactory); app.createAccount(accountData, function (err, account) { if (err) { @@ -62,7 +80,7 @@ describe('getToken (OAuth2 token exchange endpoint)', function () { enabledFixture.expressApp.on('stormpath.ready', ready); disabledFixture.expressApp.on('stormpath.ready', ready); - + scopeFactoryFixture.expressApp.on('stormpath.ready', ready); }); }); }); @@ -103,11 +121,13 @@ describe('getToken (OAuth2 token exchange endpoint)', function () { request(enabledFixture.expressApp) .post('/oauth/token') - .auth('woot', 'woot') + .send('client_id=woot') + .send('client_secret=woot') .send('grant_type=client_credentials') + .auth('woot', 'woot') .expect(401) .end(function (err, res) { - assert.equal(res.body && res.body.message, 'Invalid Client Credentials'); + assert.equal(res.body && res.body.message, 'API Key Authentication failed.'); assert.equal(res.body && res.body.error, 'invalid_client'); done(); }); @@ -150,19 +170,35 @@ describe('getToken (OAuth2 token exchange endpoint)', function () { }); - it('should return an access token if grant_type=client_credentials and the credentials are valid', function (done) { - - request(enabledFixture.expressApp) - .post('/oauth/token') - .auth(stormpathAccountApiKey.id, stormpathAccountApiKey.secret) - .send('grant_type=client_credentials') - .expect(200) - .end(function (err, res) { - assert(res.body && res.body.access_token); - assert.equal(res.body && res.body.expires_in && res.body.expires_in, 3600); - done(); - }); + describe('with Auth header', function () { + it('should return an access token if grant_type=client_credentials and the credentials are valid', function (done) { + request(enabledFixture.expressApp) + .post('/oauth/token') + .auth(stormpathAccountApiKey.id, stormpathAccountApiKey.secret) + .send('grant_type=client_credentials') + .expect(200) + .end(function (err, res) { + assert(res.body && res.body.access_token); + assert.equal(res.body && res.body.expires_in && res.body.expires_in, 3600); + done(); + }); + }); + }); + describe('with data fields', function () { + it('should return an access token if grant_type=client_credentials and the credentials are valid', function (done) { + request(enabledFixture.expressApp) + .post('/oauth/token') + .send('client_id=' + stormpathAccountApiKey.id) + .send('client_secret=' + stormpathAccountApiKey.secret) + .send('grant_type=client_credentials') + .expect(200) + .end(function (err, res) { + assert(res.body && res.body.access_token); + assert.equal(res.body && res.body.expires_in && res.body.expires_in, 3600); + done(); + }); + }); }); it('should return an access token & refresh token if grant_type=password and the username & password are valid', function (done) { @@ -211,4 +247,55 @@ describe('getToken (OAuth2 token exchange endpoint)', function () { }); }); + + describe('scope factories', function () { + var secret; + + before(function () { + var config = scopeFactoryFixture.expressApp.get('stormpathConfig'); + secret = config.client.apiKey.secret; + }); + + it('should utilize the scope factory if defined for password grant type', function (done) { + request(scopeFactoryFixture.expressApp) + .post('/oauth/token') + .send('grant_type=password') + .send('username=' + accountData.email) + .send('password=' + accountData.password) + .send('scope=' + requestScope) + .expect(200) + .end(function (err, res) { + assert(res.body && res.body.access_token); + nJwt.verify(res.body.access_token, secret, function (err, token) { + if (err) { + return done(err); + } + + assert.equal(token.body.scope, createScope(requestScope)); + done(); + }); + }); + }); + + it('should utilize the scope factory if defined for client_credentials grant type', function (done) { + request(scopeFactoryFixture.expressApp) + .post('/oauth/token') + .send('client_id=' + stormpathAccountApiKey.id) + .send('client_secret=' + stormpathAccountApiKey.secret) + .send('grant_type=client_credentials') + .send('scope=' + requestScope) + .expect(200) + .end(function (err, res) { + assert(res.body && res.body.access_token); + nJwt.verify(res.body.access_token, secret, function (err, token) { + if (err) { + return done(err); + } + + assert.equal(token.body.scope, createScope(requestScope)); + done(); + }); + }); + }); + }); }); diff --git a/test/controllers/test-revoke-token.js b/test/controllers/test-revoke-token.js new file mode 100644 index 00000000..9aaa29ab --- /dev/null +++ b/test/controllers/test-revoke-token.js @@ -0,0 +1,178 @@ +'use strict'; + +var assert = require('assert'); +var request = require('supertest'); +var uuid = require('uuid'); +var nJwt = require('njwt'); + +var DefaultExpressApplicationFixture = require('../fixtures/default-express-application'); +var helpers = require('../helpers'); + +describe('revokeToken (OAuth2 token invalidation endpoint)', function () { + var application; + var userAccount; + var appFixture; + var accessToken; + var accessTokenJwt; + var refreshToken; + var refreshTokenJwt; + var username; + var password; + var accountData; + + before(function (done) { + username = uuid.v4() + '@stormpath.com'; + password = uuid.v4() + uuid.v4().toUpperCase(); + accountData = { + email: username, + password: password, + givenName: uuid.v4(), + surname: uuid.v4() + }; + + helpers.createApplication(helpers.createClient(), function (err, app) { + if (err) { + return done(err); + } + + application = app; + appFixture = new DefaultExpressApplicationFixture(application); + + app.createAccount(accountData, function (err, acc) { + userAccount = acc; + + appFixture.expressApp.on('stormpath.ready', done); + }); + }); + }); + + after(function (done) { + helpers.destroyApplication(application, done); + }); + + beforeEach(function (done) { + request(appFixture.expressApp) + .post('/oauth/token') + .send('grant_type=password') + .send('username=' + accountData.email) + .send('password=' + accountData.password) + .expect(200) + .end(function (err, res) { + if (err) { + return done(err); + } + + var secret = appFixture.expressApp.get('stormpathConfig').client.apiKey.secret; + + + accessToken = res.body.access_token; + refreshToken = res.body.refresh_token; + accessTokenJwt = nJwt.verify(accessToken, secret); + refreshTokenJwt = nJwt.verify(refreshToken, secret); + done(); + }); + }); + + it('should require authorization', function (done) { + request(appFixture.expressApp) + .post('/oauth/revoke') + .send('token=sometoken') + .expect(401) + .end(done); + }); + + it('should require the token parameter to be sent', function (done) { + request(appFixture.expressApp) + .post('/oauth/revoke') + .set('Authorization', 'Bearer ' + accessToken) + .expect(400, {error: 'invalid_request'}) + .end(done); + }); + + it('should return 200 even if there is no such token or the token is invalid', function (done) { + request(appFixture.expressApp) + .post('/oauth/revoke') + .send('token=nonexistingtoken') + .set('Authorization', 'Bearer ' + accessToken) + .expect(200) + .end(done); + }); + + it('should invalidate access tokens', function (done) { + request(appFixture.expressApp) + .post('/oauth/revoke') + .send('token=' + accessToken) + .set('Authorization', 'Bearer ' + accessToken) + .expect(200) + .end(function (err) { + if (err) { + return done(err); + } + + userAccount.getAccessTokens(function (err, collection) { + if (err) { + return done(err); + } + + var matchingTokens = collection.items.filter(function (item) { + return item.href === accessTokenJwt.body.jti; + }); + + assert.equal(matchingTokens.length, 0); + done(); + }); + }); + }); + + it('should invalidate refresh tokens', function (done) { + request(appFixture.expressApp) + .post('/oauth/revoke') + .send('token=' + refreshToken) + .set('Authorization', 'Bearer ' + accessToken) + .expect(200) + .end(function (err) { + if (err) { + return done(err); + } + + userAccount.getRefreshTokens(function (err, collection) { + if (err) { + return done(err); + } + + var matchingTokens = collection.items.filter(function (item) { + return item.href === refreshTokenJwt.body.jti; + }); + + assert.equal(matchingTokens.length, 0); + done(); + }); + }); + }); + + it('should invalidate the access token when the matching refresh token is invalidated', function (done) { + request(appFixture.expressApp) + .post('/oauth/revoke') + .send('token=' + refreshToken) + .set('Authorization', 'Bearer ' + accessToken) + .expect(200) + .end(function (err) { + if (err) { + return done(err); + } + + userAccount.getAccessTokens(function (err, collection) { + if (err) { + return done(err); + } + + var matchingTokens = collection.items.filter(function (item) { + return item.href === accessTokenJwt.body.jti; + }); + + assert.equal(matchingTokens.length, 0); + done(); + }); + }); + }); +}); \ No newline at end of file diff --git a/test/fixtures/scope-factory.js b/test/fixtures/scope-factory.js new file mode 100644 index 00000000..055e26df --- /dev/null +++ b/test/fixtures/scope-factory.js @@ -0,0 +1,24 @@ +'use strict'; + +var helpers = require('../helpers'); + +/** + * This fixture creates an Express application which has express-stormpath + * integrated and uses a scope factory. + * + * It takes the Stormpath application reference and the requisite scope factory + * as its fixture constructor arguments. It is assumed that API Keys for + * Stormpath are already in the environment. + * + * @param {object} stormpathApplication + */ +function DefaultExpressApplicationFixtureFixture(stormpathApplication, scopeFactory) { + this.expressApp = helpers.createStormpathExpressApp({ + application: stormpathApplication, + web: { + scopeFactory: scopeFactory + } + }); +} + +module.exports = DefaultExpressApplicationFixtureFixture; diff --git a/test/middlewares/test-api-authentication-required.js b/test/middlewares/test-api-authentication-required.js index 87b07b50..b2731b89 100644 --- a/test/middlewares/test-api-authentication-required.js +++ b/test/middlewares/test-api-authentication-required.js @@ -175,4 +175,4 @@ describe('apiAuthenticationRequired', function () { }); -}); \ No newline at end of file +});