diff --git a/lib/handlers/revoke-handler.js b/lib/handlers/revoke-handler.js new file mode 100644 index 000000000..db02ababa --- /dev/null +++ b/lib/handlers/revoke-handler.js @@ -0,0 +1,326 @@ +'use strict'; + +/** + * Module dependencies. + */ + +var InvalidArgumentError = require('../errors/invalid-argument-error'); +var InvalidClientError = require('../errors/invalid-client-error'); +var InvalidTokenError = require('../errors/invalid-token-error'); +var InvalidRequestError = require('../errors/invalid-request-error'); +var OAuthError = require('../errors/oauth-error'); +var Promise = require('bluebird'); +var promisify = require('promisify-any'); +var Request = require('../request'); +var Response = require('../response'); +var ServerError = require('../errors/server-error'); +var auth = require('basic-auth'); +var is = require('../validator/is'); + +/** + * Constructor. + */ + +function RevokeHandler(options) { + options = options || {}; + + if (!options.model) { + throw new InvalidArgumentError('Missing parameter: `model`'); + } + + if (!options.model.getClient) { + throw new InvalidArgumentError('Invalid argument: model does not implement `getClient()`'); + } + + if (!options.model.getRefreshToken) { + throw new InvalidArgumentError('Invalid argument: model does not implement `getRefreshToken()`'); + } + + if (!options.model.getAccessToken) { + throw new InvalidArgumentError('Invalid argument: model does not implement `getAccessToken()`'); + } + + if (!options.model.revokeToken) { + throw new InvalidArgumentError('Invalid argument: model does not implement `revokeToken()`'); + } + + this.model = options.model; +} + +/** + * Revoke Handler. + */ + +RevokeHandler.prototype.handle = function(request, response) { + if (!(request instanceof Request)) { + throw new InvalidArgumentError('Invalid argument: `request` must be an instance of Request'); + } + + if (!(response instanceof Response)) { + throw new InvalidArgumentError('Invalid argument: `response` must be an instance of Response'); + } + + if (request.method !== 'POST') { + return Promise.reject(new InvalidRequestError('Invalid request: method must be POST')); + } + + if (!request.is('application/x-www-form-urlencoded')) { + return Promise.reject(new InvalidRequestError('Invalid request: content must be application/x-www-form-urlencoded')); + } + + return Promise.bind(this) + .then(function() { + return this.getClient(request, response); + }) + .then(function(client) { + return this.handleRevokeToken(request, client); + }) + .catch(function(e) { + if (!(e instanceof OAuthError)) { + e = new ServerError(e); + } + /** + * All necessary information is conveyed in the response code. + * + * Note: invalid tokens do not cause an error response since the client + * cannot handle such an error in a reasonable way. Moreover, the + * purpose of the revocation request, invalidating the particular token, + * is already achieved. + * @see https://tools.ietf.org/html/rfc7009#section-2.2 + */ + if (!(e instanceof InvalidTokenError)) { + this.updateErrorResponse(response, e); + } + + throw e; + }); +}; + +/** + * Revoke a refresh or access token. + * + * Handle the revoking of refresh tokens, and access tokens if supported / desirable + * RFC7009 specifies that "If the server is unable to locate the token using + * the given hint, it MUST extend its search across all of its supported token types" + */ + +RevokeHandler.prototype.handleRevokeToken = function(request, client) { + return Promise.bind(this) + .then(function() { + return this.getTokenFromRequest(request); + }) + .then(function(token) { + return Promise.any([ + this.getAccessToken(token, client), + this.getRefreshToken(token, client) + ]) + .catch(Promise.AggregateError, function(err) { + err.forEach(function(e) { + throw e; + }); + }) + .bind(this) + .tap(function(token) { + return this.revokeToken(token); + }); + }); +}; + +/** + * Get the client from the model. + */ + +RevokeHandler.prototype.getClient = function(request, response) { + var credentials = this.getClientCredentials(request); + + if (!credentials.clientId) { + throw new InvalidRequestError('Missing parameter: `client_id`'); + } + + if (!credentials.clientSecret) { + throw new InvalidRequestError('Missing parameter: `client_secret`'); + } + + if (!is.vschar(credentials.clientId)) { + throw new InvalidRequestError('Invalid parameter: `client_id`'); + } + + if (!is.vschar(credentials.clientSecret)) { + throw new InvalidRequestError('Invalid parameter: `client_secret`'); + } + + return Promise.try(promisify(this.model.getClient, 2), [credentials.clientId, credentials.clientSecret]) + .then(function(client) { + if (!client) { + throw new InvalidClientError('Invalid client: client is invalid'); + } + + if (!client.grants) { + throw new ServerError('Server error: missing client `grants`'); + } + + if (!(client.grants instanceof Array)) { + throw new ServerError('Server error: `grants` must be an array'); + } + + return client; + }) + .catch(function(e) { + // Include the "WWW-Authenticate" response header field if the client + // attempted to authenticate via the "Authorization" request header. + // + // @see https://tools.ietf.org/html/rfc6749#section-5.2. + if ((e instanceof InvalidClientError) && request.get('authorization')) { + response.set('WWW-Authenticate', 'Basic realm="Service"'); + + throw new InvalidClientError(e, { code: 401 }); + } + + throw e; + }); +}; + +/** + * Get client credentials. + * + * The client credentials may be sent using the HTTP Basic authentication scheme or, alternatively, + * the `client_id` and `client_secret` can be embedded in the body. + * + * @see https://tools.ietf.org/html/rfc6749#section-2.3.1 + */ + +RevokeHandler.prototype.getClientCredentials = function(request) { + var credentials = auth(request); + + if (credentials) { + return { clientId: credentials.name, clientSecret: credentials.pass }; + } + + if (request.body.client_id && request.body.client_secret) { + return { clientId: request.body.client_id, clientSecret: request.body.client_secret }; + } + + throw new InvalidClientError('Invalid client: cannot retrieve client credentials'); +}; + +/** + * Get the token from the body. + * + * @see https://tools.ietf.org/html/rfc7009#section-2.1 + */ + +RevokeHandler.prototype.getTokenFromRequest = function(request) { + var bodyToken = request.body.token; + + if (!bodyToken) { + throw new InvalidRequestError('Missing parameter: `token`'); + } + + return bodyToken; +}; + +/** + * Get refresh token. + */ + +RevokeHandler.prototype.getRefreshToken = function(token, client) { + return Promise.try(promisify(this.model.getRefreshToken, 1), token) + .then(function(token) { + if (!token) { + throw new InvalidTokenError('Invalid token: refresh token is invalid'); + } + + if (!token.client) { + throw new ServerError('Server error: `getRefreshToken()` did not return a `client` object'); + } + + if (!token.user) { + throw new ServerError('Server error: `getRefreshToken()` did not return a `user` object'); + } + + if (token.client.id !== client.id) { + throw new InvalidClientError('Invalid client: client is invalid'); + } + + if (token.refreshTokenExpiresAt && !(token.refreshTokenExpiresAt instanceof Date)) { + throw new ServerError('Server error: `refreshTokenExpiresAt` must be a Date instance'); + } + + if (token.refreshTokenExpiresAt && token.refreshTokenExpiresAt < new Date()) { + throw new InvalidTokenError('Invalid token: refresh token has expired'); + } + + return token; + }); +}; + +/** + * Get the access token from the model. + */ + +RevokeHandler.prototype.getAccessToken = function(token, client) { + return Promise.try(promisify(this.model.getAccessToken, 1), token) + .then(function(accessToken) { + if (!accessToken) { + throw new InvalidTokenError('Invalid token: access token is invalid'); + } + + if (!accessToken.client) { + throw new ServerError('Server error: `getAccessToken()` did not return a `client` object'); + } + + if (!accessToken.user) { + throw new ServerError('Server error: `getAccessToken()` did not return a `user` object'); + } + + if (accessToken.client.id !== client.id) { + throw new InvalidClientError('Invalid client: client is invalid'); + } + + if (accessToken.accessTokenExpiresAt && !(accessToken.accessTokenExpiresAt instanceof Date)) { + throw new ServerError('Server error: `expires` must be a Date instance'); + } + + if (accessToken.accessTokenExpiresAt && accessToken.accessTokenExpiresAt < new Date()) { + throw new InvalidTokenError('Invalid token: access token has expired.'); + } + + return accessToken; + }); +}; + +/** + * Revoke the token. + * + * @see https://tools.ietf.org/html/rfc6749#section-6 + */ + +RevokeHandler.prototype.revokeToken = function(token) { + return Promise.try(promisify(this.model.revokeToken, 1), token) + .then(function(token) { + if (!token) { + throw new InvalidTokenError('Invalid token: token is invalid'); + } + + return token; + }); +}; + +/** + * Update response when an error is thrown. + */ + +RevokeHandler.prototype.updateErrorResponse = function(response, error) { + response.body = { + error: error.name, + error_description: error.message + }; + + response.status = error.code; +}; + +/** + * Export constructor. + */ + +module.exports = RevokeHandler; diff --git a/lib/server.js b/lib/server.js index 570bd2b62..3a4901c70 100644 --- a/lib/server.js +++ b/lib/server.js @@ -9,6 +9,7 @@ var AuthenticateHandler = require('./handlers/authenticate-handler'); var AuthorizeHandler = require('./handlers/authorize-handler'); var InvalidArgumentError = require('./errors/invalid-argument-error'); var TokenHandler = require('./handlers/token-handler'); +var RevokeHandler = require('./handlers/revoke-handler'); /** * Constructor. @@ -77,6 +78,18 @@ OAuth2Server.prototype.token = function(request, response, options, callback) { .nodeify(callback); }; +/** + * Revoke a token. + */ + +OAuth2Server.prototype.revoke = function(request, response, options, callback) { + options = _.assign(this.options, options); + + return new RevokeHandler(options) + .handle(request, response) + .nodeify(callback); +}; + /** * Export constructor. */ diff --git a/test/integration/handlers/revoke-handler_test.js b/test/integration/handlers/revoke-handler_test.js new file mode 100644 index 000000000..8327f72ff --- /dev/null +++ b/test/integration/handlers/revoke-handler_test.js @@ -0,0 +1,860 @@ +'use strict'; + +/** + * Module dependencies. + */ + +var AccessDeniedError = require('../../../lib/errors/access-denied-error'); +var InvalidArgumentError = require('../../../lib/errors/invalid-argument-error'); +var InvalidClientError = require('../../../lib/errors/invalid-client-error'); +var InvalidRequestError = require('../../../lib/errors/invalid-request-error'); +var InvalidTokenError = require('../../../lib/errors/invalid-token-error'); +var Promise = require('bluebird'); +var Request = require('../../../lib/request'); +var Response = require('../../../lib/response'); +var ServerError = require('../../../lib/errors/server-error'); +var RevokeHandler = require('../../../lib/handlers/revoke-handler'); +var should = require('should'); +var util = require('util'); + +/** + * Test `RevokeHandler` integration. + */ + +describe('RevokeHandler integration', function() { + describe('constructor()', function() { + + it('should throw an error if `options.model` is missing', function() { + try { + new RevokeHandler({}); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Missing parameter: `model`'); + } + }); + + it('should throw an error if the model does not implement `getClient()`', function() { + try { + new RevokeHandler({ model: {} }); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Invalid argument: model does not implement `getClient()`'); + } + }); + + it('should set the `model`', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + handler.model.should.equal(model); + }); + }); + + describe('handle()', function() { + it('should throw an error if `request` is missing', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + try { + handler.handle(); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Invalid argument: `request` must be an instance of Request'); + } + }); + + it('should throw an error if `response` is missing', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: {}, headers: {}, method: {}, query: {} }); + + try { + handler.handle(request); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Invalid argument: `response` must be an instance of Response'); + } + }); + + it('should throw an error if the method is not `POST`', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: {}, headers: {}, method: 'GET', query: {} }); + var response = new Response({ body: {}, headers: {} }); + + return handler.handle(request, response) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Invalid request: method must be POST'); + }); + }); + + it('should throw an error if the media type is not `application/x-www-form-urlencoded`', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: {}, headers: {}, method: 'POST', query: {} }); + var response = new Response({ body: {}, headers: {} }); + + return handler.handle(request, response) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Invalid request: content must be application/x-www-form-urlencoded'); + }); + }); + + it('should throw the error if an oauth error is thrown', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { token: 'hash' }, headers: { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' }, method: 'POST', query: {} }); + var response = new Response({ body: {}, headers: {} }); + + return handler.handle(request, response) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidClientError); + e.message.should.equal('Invalid client: cannot retrieve client credentials'); + }); + }); + + it('should throw the error if an oauth error is thrown', function() { + var model = { + getClient: function() { return { grants: ['password'] }; }, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 12345, client_secret: 'secret' }, headers: { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' }, method: 'POST', query: {} }); + var response = new Response({ body: {}, headers: {} }); + + return handler.handle(request, response) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Missing parameter: `token`'); + }); + }); + + it('should throw a server error if a non-oauth error is thrown', function() { + var model = { + getClient: function() { + throw new Error('Unhandled exception'); + }, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ + body: { + client_id: 12345, + client_secret: 'secret', + token: 'hash' + }, + headers: { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' }, + method: 'POST', + query: {} + }); + var response = new Response({ body: {}, headers: {} }); + + return handler.handle(request, response) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(ServerError); + e.message.should.equal('Unhandled exception'); + e.inner.should.be.an.instanceOf(Error); + }); + }); + + it('should update the response if an error is thrown', function() { + var model = { + getClient: function() { + throw new Error('Unhandled exception'); + }, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ + body: { + client_id: 12345, + client_secret: 'secret', + grant_type: 'password', + password: 'bar', + username: 'foo' + }, + headers: { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' }, + method: 'POST', + query: {} + }); + var response = new Response({ body: {}, headers: {} }); + + return handler.handle(request, response) + .then(should.fail) + .catch(function() { + response.body.should.eql({ error: 'server_error', error_description: 'Unhandled exception' }); + response.status.should.equal(500); + }); + }); + + it('should not update the response if an invalid token error is thrown', function() { + var token = { refreshToken: 'hash', client: {}, user: {}, refreshTokenExpiresAt: new Date('2015-01-01') }; + var client = { grants: ['password'] }; + var model = { + getClient: function() { return client; }, + revokeToken: function() { return token; }, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ + body: { + client_id: 12345, + client_secret: 'secret', + token: 'hash' + }, + headers: { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' }, + method: 'POST', + query: {} + }); + var response = new Response({ body: {}, headers: {} }); + + return handler.handle(request, response) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidTokenError); + response.body.should.eql({}); + response.status.should.equal(200); + }); + }); + + it('should return an empty object if successful', function() { + var token = { refreshToken: 'hash', client: {}, user: {}, refreshTokenExpiresAt: new Date(new Date() * 2) }; + var client = { grants: ['password'] }; + var model = { + getClient: function() { return client; }, + revokeToken: function() { return token; }, + getRefreshToken: function() { return token; }, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ + body: { + client_id: 12345, + client_secret: 'secret', + token: 'hash' + }, + headers: { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' }, + method: 'POST', + query: {} + }); + var response = new Response({ body: {}, headers: {} }); + + return handler.handle(request, response) + .then(function(data) { + should.exist(data); + }) + .catch(should.fail); + }); + }); + + describe('getClient()', function() { + it('should throw an error if `clientId` is invalid', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 'øå€£‰', client_secret: 'foo' }, headers: {}, method: {}, query: {} }); + + try { + handler.getClient(request); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Invalid parameter: `client_id`'); + } + }); + + it('should throw an error if `clientId` is invalid', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 'foo', client_secret: 'øå€£‰' }, headers: {}, method: {}, query: {} }); + + try { + handler.getClient(request); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Invalid parameter: `client_secret`'); + } + }); + + it('should throw an error if `client` is missing', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 12345, client_secret: 'secret' }, headers: {}, method: {}, query: {} }); + + return handler.getClient(request) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidClientError); + e.message.should.equal('Invalid client: client is invalid'); + }); + }); + + it('should throw an error if `client.grants` is missing', function() { + var model = { + getClient: function() { return {}; }, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 12345, client_secret: 'secret' }, headers: {}, method: {}, query: {} }); + + return handler.getClient(request) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(ServerError); + e.message.should.equal('Server error: missing client `grants`'); + }); + }); + + it('should throw a 401 error if the client is invalid and the request contains an authorization header', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ + body: {}, + headers: { 'authorization': util.format('Basic %s', new Buffer('foo:bar').toString('base64')) }, + method: {}, + query: {} + }); + var response = new Response({ body: {}, headers: {} }); + + return handler.getClient(request, response) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidClientError); + e.code.should.equal(401); + e.message.should.equal('Invalid client: client is invalid'); + + response.get('WWW-Authenticate').should.equal('Basic realm="Service"'); + }); + }); + + it('should return a client', function() { + var client = { id: 12345, grants: [] }; + var model = { + getClient: function() { return client; }, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 12345, client_secret: 'secret' }, headers: {}, method: {}, query: {} }); + + return handler.getClient(request) + .then(function(data) { + data.should.equal(client); + }) + .catch(should.fail); + }); + + it('should support promises', function() { + var model = { + getClient: function() { return Promise.resolve({ grants: [] }); }, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 12345, client_secret: 'secret' }, headers: {}, method: {}, query: {} }); + + handler.getClient(request).should.be.an.instanceOf(Promise); + }); + + it('should support callbacks', function() { + var model = { + getClient: function(clientId, clientSecret, callback) { + callback(null, { grants: [] }); + }, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 12345, client_secret: 'secret' }, headers: {}, method: {}, query: {} }); + + handler.getClient(request).should.be.an.instanceOf(Promise); + }); + + it('should support non-promises', function() { + var model = { + getClient: function() { return { grants: [] }; }, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 12345, client_secret: 'secret' }, headers: {}, method: {}, query: {} }); + + handler.getClient(request).should.be.an.instanceOf(Promise); + }); + }); + + describe('getClientCredentials()', function() { + it('should throw an error if `client_id` is missing', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_secret: 'foo' }, headers: {}, method: {}, query: {} }); + + try { + handler.getClientCredentials(request); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidClientError); + e.message.should.equal('Invalid client: cannot retrieve client credentials'); + } + }); + + it('should throw an error if `client_secret` is missing', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 'foo' }, headers: {}, method: {}, query: {} }); + + try { + handler.getClientCredentials(request); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidClientError); + e.message.should.equal('Invalid client: cannot retrieve client credentials'); + } + }); + + describe('with `client_id` and `client_secret` in the request header as basic auth', function() { + it('should return a client', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ + body: {}, + headers: { + 'authorization': util.format('Basic %s', new Buffer('foo:bar').toString('base64')) + }, + method: {}, + query: {} + }); + var credentials = handler.getClientCredentials(request); + + credentials.should.eql({ clientId: 'foo', clientSecret: 'bar' }); + }); + }); + + describe('with `client_id` and `client_secret` in the request body', function() { + it('should return a client', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 'foo', client_secret: 'bar' }, headers: {}, method: {}, query: {} }); + var credentials = handler.getClientCredentials(request); + + credentials.should.eql({ clientId: 'foo', clientSecret: 'bar' }); + }); + }); + }); + + describe('handleRevokeToken()', function() { + it('should throw an error if `token` is missing', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: {}, headers: {}, method: {}, query: {} }); + + return handler.handleRevokeToken(request) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Missing parameter: `token`'); + }); + }); + + it('should return a token', function() { + var client = { id: 12345, grants: ['password'] }; + var token = { accessToken: 'hash', client: { id: 12345 }, accessTokenExpiresAt: new Date(new Date() * 2), user: {} }; + var model = { + getClient: function() {}, + revokeToken: function() { return token; }, + getRefreshToken: function() {}, + getAccessToken: function() { return token; } + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { token: 'hash' }, headers: {}, method: {}, query: {} }); + + return handler.handleRevokeToken(request, client) + .then(function(data) { + should.exist(data); + }) + .catch(should.fail); + }); + + it('should return a token', function() { + var client = { id: 12345, grants: ['password'] }; + var token = { refreshToken: 'hash', client: { id: 12345 }, refreshTokenExpiresAt: new Date(new Date() * 2), user: {} }; + var model = { + getClient: function() {}, + revokeToken: function() { return token; }, + getRefreshToken: function() { return token; }, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { token: 'hash' }, headers: {}, method: {}, query: {} }); + + return handler.handleRevokeToken(request, client) + .then(function(data) { + should.exist(data); + }) + .catch(should.fail); + }); + }); + + describe('getRefreshToken()', function() { + it('should throw an error if the `refreshToken` is invalid', function() { + var client = {}; + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + return handler.getRefreshToken('hash', client) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidTokenError); + e.message.should.equal('Invalid token: refresh token is invalid'); + }); + }); + + it('should throw an error if the `client_id` does not match', function() { + var client = { id: 'foo' }; + var token = { refreshToken: 'hash', client: { id: 'baz'}, user: {}, refreshTokenExpiresAt: new Date(new Date() * 2) }; + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() { return token; }, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + return handler.getRefreshToken('hash', client) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidClientError); + e.message.should.equal('Invalid client: client is invalid'); + }); + }); + + it('should return a token', function() { + var client = { id: 'foo' }; + var token = { refreshToken: 'hash', client: { id: 'foo'}, user: {}, refreshTokenExpiresAt: new Date(new Date() * 2) }; + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() { return token; }, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + return handler.getRefreshToken('hash', client) + .then(function(token) { + should.exist(token); + }) + .catch(should.fail); + }); + + it('should support callbacks', function() { + var client = { id: 'foo' }; + var token = { refreshToken: 'hash', client: { id: 'foo'}, user: {}, refreshTokenExpiresAt: new Date(new Date() * 2) }; + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function(refreshToken, callback) { + callback(null, token); + }, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + return handler.getRefreshToken('hash', client) + .then(function(token) { + should.exist(token); + }) + .catch(should.fail); + }); + }); + + describe('getAccessToken()', function() { + it('should throw an error if the `accessToken` is invalid', function() { + var client = {}; + var model = { + getClient: function() {}, + revokeToken: function() {}, + getAccessToken: function() {}, + getRefreshToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + return handler.getAccessToken('hash', client) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidTokenError); + e.message.should.equal('Invalid token: access token is invalid'); + }); + }); + + it('should throw an error if the `client_id` does not match', function() { + var client = { id: 'foo' }; + var token = { accessToken: 'hash', client: { id: 'baz'}, user: {}, accessTokenExpiresAt: new Date(new Date() * 2) }; + var model = { + getClient: function() {}, + revokeToken: function() {}, + getAccessToken: function() { return token; }, + getRefreshToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + return handler.getAccessToken('hash', client) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidClientError); + e.message.should.equal('Invalid client: client is invalid'); + }); + }); + + it('should return a token', function() { + var client = { id: 'foo' }; + var token = { accessToken: 'hash', client: { id: 'foo'}, user: {}, accessTokenExpiresAt: new Date(new Date() * 2) }; + var model = { + getClient: function() {}, + revokeToken: function() {}, + getAccessToken: function() { return token; }, + getRefreshToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + return handler.getAccessToken('hash', client) + .then(function(token) { + should.exist(token); + }) + .catch(should.fail); + }); + + it('should support callbacks', function() { + var client = { id: 'foo' }; + var token = { accessToken: 'hash', client: { id: 'foo'}, user: {}, accessTokenExpiresAt: new Date(new Date() * 2) }; + var model = { + getClient: function() {}, + revokeToken: function() {}, + getAccessToken: function(accessToken, callback) { + callback(null, token); + }, + getRefreshToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + return handler.getAccessToken('hash', client) + .then(function(token) { + should.exist(token); + }) + .catch(should.fail); + }); + }); + + describe('revokeToken()', function() { + it('should throw an error if the `refreshToken` is invalid', function() { + var token = 'hash'; + var client = {}; + var model = { + getClient: function() {}, + revokeToken: function() { return false; }, + getRefreshToken: function() { return { client: {}, user: {}};}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + return handler.revokeToken(token, client) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidTokenError); + e.message.should.equal('Invalid token: token is invalid'); + }); + }); + + it('should support callbacks', function() { + var token = {}; + var client = {}; + var model = { + getClient: function() {}, + revokeToken: function(tokenObject, callback) { + callback(null, null); + }, + getRefreshToken: function(refreshToken, callback) { + callback(null, { client: {}, user: {}}); + }, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + + return handler.revokeToken(token, client) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidTokenError); + e.message.should.equal('Invalid token: token is invalid'); + }); + }); + }); + + describe('getTokenFromRequest()', function() { + it('should throw an error if `accessToken` is missing', function() { + + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: {}, headers: {}, method: {}, query: {} }); + + try { + handler.getTokenFromRequest(request); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Missing parameter: `token`'); + } + }); + }); + + describe('updateErrorResponse()', function() { + it('should set the `body`', function() { + var error = new AccessDeniedError('Cannot request a revoke'); + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var response = new Response({ body: {}, headers: {} }); + + handler.updateErrorResponse(response, error); + + response.body.error.should.equal('access_denied'); + response.body.error_description.should.equal('Cannot request a revoke'); + }); + + it('should set the `status`', function() { + var error = new AccessDeniedError('Cannot request a revoke'); + var model = { + getClient: function() {}, + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var response = new Response({ body: {}, headers: {} }); + + handler.updateErrorResponse(response, error); + + response.status.should.equal(400); + }); + }); +}); diff --git a/test/integration/server_test.js b/test/integration/server_test.js index 84761fb21..4db76d2ba 100644 --- a/test/integration/server_test.js +++ b/test/integration/server_test.js @@ -234,4 +234,62 @@ describe('Server integration', function() { server.token(request, response, null, next); }); }); + + describe('revoke()', function() { + + it('should return a promise', function() { + var model = { + getClient: function() { + return { id: 1234, grants: ['password'] }; + }, + getRefreshToken: function() { + return { + client: { + id: 1234 + }, + user: {} + }; + }, + getAccessToken: function() { + return null; + }, + revokeToken: function() { + return true; + } + }; + var server = new Server({ model: model }); + var request = new Request({ body: { client_id: 1234, client_secret: 'secret', token: 'hash', token_type_hint: 'refresh_token' }, headers: { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' }, method: 'POST', query: {} }); + var response = new Response({ body: {}, headers: {} }); + var handler = server.revoke(request, response); + + handler.should.be.an.instanceOf(Promise); + }); + + it('should support callbacks', function(next) { + var model = { + getClient: function() { + return { id: 1234, grants: ['password'] }; + }, + getRefreshToken: function() { + return { + client: { + id: 1234 + }, + user: {} + }; + }, + getAccessToken: function() { + return null; + }, + revokeToken: function() { + return true; + } + }; + var server = new Server({ model: model }); + var request = new Request({ body: { client_id: 1234, client_secret: 'secret', token: 'hash', token_type_hint: 'refresh_token' }, headers: { 'content-type': 'application/x-www-form-urlencoded', 'transfer-encoding': 'chunked' }, method: 'POST', query: {} }); + var response = new Response({ body: {}, headers: {} }); + + server.revoke(request, response, null, next); + }); + }); }); diff --git a/test/unit/handlers/revoke-handler_test.js b/test/unit/handlers/revoke-handler_test.js new file mode 100644 index 000000000..9cc56015d --- /dev/null +++ b/test/unit/handlers/revoke-handler_test.js @@ -0,0 +1,103 @@ +'use strict'; + +/** + * Module dependencies. + */ + +var Request = require('../../../lib/request'); +var RevokeHandler = require('../../../lib/handlers/revoke-handler'); +var sinon = require('sinon'); +var should = require('should'); + +/** + * Test `RevokeHandler`. + */ + +describe('RevokeHandler', function() { + describe('handleRevokeToken()', function() { + it('should call `model.getAccessToken()` and `model.getRefreshToken()`', function() { + var model = { + getClient: function() {}, + revokeToken: sinon.stub().returns( true), + getRefreshToken: sinon.stub().returns({ refreshToken: 'hash', client: {}, refreshTokenExpiresAt: new Date(new Date() * 2), user: {} }), + getAccessToken: sinon.stub().returns( false) + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { token: 'foo' }, headers: {}, method: {}, query: {} }); + var client = {}; + + return handler.handleRevokeToken(request, client) + .then(function() { + model.getAccessToken.callCount.should.equal(1); + model.getAccessToken.firstCall.args[0].should.equal('foo'); + model.getRefreshToken.callCount.should.equal(1); + model.getRefreshToken.firstCall.args[0].should.equal('foo'); + }) + .catch(should.fail); + }); + }); + + describe('getClient()', function() { + it('should call `model.getClient()`', function() { + var model = { + getClient: sinon.stub().returns({ grants: ['password'] }), + revokeToken: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var request = new Request({ body: { client_id: 12345, client_secret: 'secret' }, headers: {}, method: {}, query: {} }); + + return handler.getClient(request) + .then(function() { + model.getClient.callCount.should.equal(1); + model.getClient.firstCall.args.should.have.length(2); + model.getClient.firstCall.args[0].should.equal(12345); + model.getClient.firstCall.args[1].should.equal('secret'); + }) + .catch(should.fail); + }); + }); + + describe('getRefreshToken()', function() { + it('should call `model.getRefreshToken()`', function() { + var model = { + getClient: function() {}, + revokeToken: function() {}, + getAccessToken: function() {}, + getRefreshToken: sinon.stub().returns({ refreshToken: 'hash', client: {}, refreshTokenExpiresAt: new Date(new Date() * 2), user: {} }) + }; + var handler = new RevokeHandler({ model: model }); + var token = 'hash'; + var client = {}; + + return handler.getRefreshToken(token, client) + .then(function() { + model.getRefreshToken.callCount.should.equal(1); + model.getRefreshToken.firstCall.args.should.have.length(1); + model.getRefreshToken.firstCall.args[0].should.equal(token); + }) + .catch(should.fail); + }); + }); + + describe('revokeToken()', function() { + it('should call `model.revokeToken()`', function() { + var model = { + getClient: function() {}, + revokeToken: sinon.stub().returns( true), + getRefreshToken: sinon.stub().returns({ refreshToken: 'hash', client: {}, refreshTokenExpiresAt: new Date(new Date() * 2), user: {} }), + getAccessToken: function() {} + }; + var handler = new RevokeHandler({ model: model }); + var token = 'hash'; + + return handler.revokeToken(token) + .then(function() { + model.revokeToken.callCount.should.equal(1); + model.revokeToken.firstCall.args.should.have.length(1); + }) + .catch(should.fail); + }); + }); +}); diff --git a/test/unit/server_test.js b/test/unit/server_test.js index e7c343f0c..21083bef6 100644 --- a/test/unit/server_test.js +++ b/test/unit/server_test.js @@ -9,6 +9,7 @@ var AuthorizeHandler = require('../../lib/handlers/authorize-handler'); var Promise = require('bluebird'); var Server = require('../../lib/server'); var TokenHandler = require('../../lib/handlers/token-handler'); +var RevokeHandler = require('../../lib/handlers/revoke-handler'); var sinon = require('sinon'); /** @@ -87,4 +88,24 @@ describe('Server', function() { TokenHandler.prototype.handle.restore(); }); }); + + describe('revoke()', function() { + it('should call `handle`', function() { + var model = { + getClient: function() {}, + getRefreshToken: function() {}, + getAccessToken: function() {}, + revokeToken: function() {} + }; + var server = new Server({ model: model }); + + sinon.stub(RevokeHandler.prototype, 'handle').returns(Promise.resolve()); + + server.revoke('foo', 'bar'); + + RevokeHandler.prototype.handle.callCount.should.equal(1); + RevokeHandler.prototype.handle.firstCall.args[0].should.equal('foo'); + RevokeHandler.prototype.handle.restore(); + }); + }); });