diff --git a/CHANGELOG.md b/CHANGELOG.md index c616fd136..a9e7df8ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,8 @@ * BREAKING: Remove support for node v4 * new: Added revoke-handler to revoke access token * new: Added implicit grant flow -* new: Switch from jshint to eslint +* new: Switch from jshint to eslin +* fix: authorization_code grant should not be required in implicit flowt ### 3.1.0 * new: Added package-lock.json diff --git a/docs/model/spec.rst b/docs/model/spec.rst index 674f389a5..bc81cef47 100644 --- a/docs/model/spec.rst +++ b/docs/model/spec.rst @@ -336,25 +336,29 @@ This model function is **required** if the ``authorization_code`` grant is used. An ``Object`` representing the authorization code and associated data. -+--------------------+--------+--------------------------------------------------------------+ -| Name | Type | Description | -+====================+========+==============================================================+ -| code | Object | The return value. | -+--------------------+--------+--------------------------------------------------------------+ -| code.code | String | The authorization code passed to ``getAuthorizationCode()``. | -+--------------------+--------+--------------------------------------------------------------+ -| code.expiresAt | Date | The expiry time of the authorization code. | -+--------------------+--------+--------------------------------------------------------------+ -| [code.redirectUri] | String | The redirect URI of the authorization code. | -+--------------------+--------+--------------------------------------------------------------+ -| [code.scope] | String | The authorized scope of the authorization code. | -+--------------------+--------+--------------------------------------------------------------+ -| code.client | Object | The client associated with the authorization code. | -+--------------------+--------+--------------------------------------------------------------+ -| code.client.id | String | A unique string identifying the client. | -+--------------------+--------+--------------------------------------------------------------+ -| code.user | Object | The user associated with the authorization code. | -+--------------------+--------+--------------------------------------------------------------+ ++----------------------------+--------+---------------------------------------------------------------+ +| Name | Type | Description | ++============================+========+===============================================================+ +| code | Object | The return value. | ++----------------------------+--------+---------------------------------------------------------------+ +| code.code | String | The authorization code passed to ``getAuthorizationCode()``. | ++----------------------------+--------+---------------------------------------------------------------+ +| code.expiresAt | Date | The expiry time of the authorization code. | ++----------------------------+--------+---------------------------------------------------------------+ +| [code.redirectUri] | String | The redirect URI of the authorization code. | ++----------------------------+--------+---------------------------------------------------------------+ +| [code.scope] | String | The authorized scope of the authorization code. | ++----------------------------+--------+---------------------------------------------------------------+ +| code.client | Object | The client associated with the authorization code. | ++----------------------------+--------+---------------------------------------------------------------+ +| code.client.id | String | A unique string identifying the client. | ++----------------------------+--------+---------------------------------------------------------------+ +| code.user | Object | The user associated with the authorization code. | ++----------------------------+--------+---------------------------------------------------------------+ +| [code.codeChallenge] | String | The code challenge string used with PKCE (RFC7636). | ++----------------------------+--------+---------------------------------------------------------------+ +| [code.codeChallengeMethod] | String | The string for the code challenge method (`S256` or `plain`). | ++----------------------------+--------+---------------------------------------------------------------+ ``code.client`` and ``code.user`` can carry additional properties that will be ignored by *oauth2-server*. @@ -379,7 +383,9 @@ An ``Object`` representing the authorization code and associated data. redirectUri: code.redirect_uri, scope: code.scope, client: client, // with 'id' property - user: user + user: user, + codeChallenge: code.code_challenge, + codeChallengeMethod: code.code_challenge_method }; }); } @@ -665,25 +671,29 @@ This model function is **required** if the ``authorization_code`` grant is used. **Arguments:** -+------------------------+----------+---------------------------------------------------------------------+ -| Name | Type | Description | -+========================+==========+=====================================================================+ -| code | Object | The code to be saved. | -+------------------------+----------+---------------------------------------------------------------------+ -| code.authorizationCode | String | The authorization code to be saved. | -+------------------------+----------+---------------------------------------------------------------------+ -| code.expiresAt | Date | The expiry time of the authorization code. | -+------------------------+----------+---------------------------------------------------------------------+ -| code.redirectUri | String | The redirect URI associated with the authorization code. | -+------------------------+----------+---------------------------------------------------------------------+ -| [code.scope] | String | The authorized scope of the authorization code. | -+------------------------+----------+---------------------------------------------------------------------+ -| client | Object | The client associated with the authorization code. | -+------------------------+----------+---------------------------------------------------------------------+ -| user | Object | The user associated with the authorization code. | -+------------------------+----------+---------------------------------------------------------------------+ -| [callback] | Function | Node-style callback to be used instead of the returned ``Promise``. | -+------------------------+----------+---------------------------------------------------------------------+ ++----------------------------+----------+---------------------------------------------------------------------+ +| Name | Type | Description | ++============================+==========+=====================================================================+ +| code | Object | The code to be saved. | ++----------------------------+----------+---------------------------------------------------------------------+ +| code.authorizationCode | String | The authorization code to be saved. | ++----------------------------+----------+---------------------------------------------------------------------+ +| code.expiresAt | Date | The expiry time of the authorization code. | ++----------------------------+----------+---------------------------------------------------------------------+ +| code.redirectUri | String | The redirect URI associated with the authorization code. | ++----------------------------+----------+---------------------------------------------------------------------+ +| [code.scope] | String | The authorized scope of the authorization code. | ++----------------------------+----------+---------------------------------------------------------------------+ +| [code.codeChallenge] | String | The code challenge string used with PKCE (RFC7636). | ++----------------------------+----------+---------------------------------------------------------------------+ +| [code.codeChallengeMethod] | String | The string for the code challenge method (`S256` or `plain`). | ++----------------------------+----------+---------------------------------------------------------------------+ +| client | Object | The client associated with the authorization code. | ++----------------------------+----------+---------------------------------------------------------------------+ +| user | Object | The user associated with the authorization code. | ++----------------------------+----------+---------------------------------------------------------------------+ +| [callback] | Function | Node-style callback to be used instead of the returned ``Promise``. | ++----------------------------+----------+---------------------------------------------------------------------+ .. todo:: Is ``code.scope`` really optional? @@ -691,25 +701,29 @@ This model function is **required** if the ``authorization_code`` grant is used. An ``Object`` representing the authorization code and associated data. -+------------------------+--------+---------------------------------------------------------------+ -| Name | Type | Description | -+========================+========+===============================================================+ -| code | Object | The return value. | -+------------------------+--------+---------------------------------------------------------------+ -| code.authorizationCode | String | The authorization code passed to ``saveAuthorizationCode()``. | -+------------------------+--------+---------------------------------------------------------------+ -| code.expiresAt | Date | The expiry time of the authorization code. | -+------------------------+--------+---------------------------------------------------------------+ -| code.redirectUri | String | The redirect URI associated with the authorization code. | -+------------------------+--------+---------------------------------------------------------------+ -| [code.scope] | String | The authorized scope of the authorization code. | -+------------------------+--------+---------------------------------------------------------------+ -| code.client | Object | The client associated with the authorization code. | -+------------------------+--------+---------------------------------------------------------------+ -| code.client.id | String | A unique string identifying the client. | -+------------------------+--------+---------------------------------------------------------------+ -| code.user | Object | The user associated with the authorization code. | -+------------------------+--------+---------------------------------------------------------------+ ++----------------------------+--------+----------------------------------------------------------------+ +| Name | Type | Description | ++============================+========+================================================================+ +| code | Object | The return value. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.authorizationCode | String | The authorization code passed to ``saveAuthorizationCode()``. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.expiresAt | Date | The expiry time of the authorization code. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.redirectUri | String | The redirect URI associated with the authorization code. | ++----------------------------+--------+----------------------------------------------------------------+ +| [code.scope] | String | The authorized scope of the authorization code. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.client | Object | The client associated with the authorization code. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.client.id | String | A unique string identifying the client. | ++----------------------------+--------+----------------------------------------------------------------+ +| code.user | Object | The user associated with the authorization code. | ++----------------------------+--------+----------------------------------------------------------------+ +| [code.codeChallenge] | String | The code challenge string used with PKCE (RFC7636). | ++----------------------------+--------+----------------------------------------------------------------+ +| [code.codeChallengeMethod] | String | The string for the code challenge method (`S256` or `plain` | ++----------------------------+--------+----------------------------------------------------------------+ ``code.client`` and ``code.user`` can carry additional properties that will be ignored by *oauth2-server*. @@ -725,7 +739,9 @@ An ``Object`` representing the authorization code and associated data. redirect_uri: code.redirectUri, scope: code.scope, client_id: client.id, - user_id: user.id + user_id: user.id, + code_challenge: code.codeChallenge, + code_challenge_method: code.codeChallengeMethod }; return db.saveAuthorizationCode(authCode) .then(function(authorizationCode) { @@ -735,7 +751,9 @@ An ``Object`` representing the authorization code and associated data. redirectUri: authorizationCode.redirect_uri, scope: authorizationCode.scope, client: {id: authorizationCode.client_id}, - user: {id: authorizationCode.user_id} + user: {id: authorizationCode.user_id}, + codeChallenge: authorizationCode.code_challenge, + codeChallengeMethod: authorizationCode.code_challenge_method }; }); } diff --git a/lib/grant-types/authorization-code-grant-type.js b/lib/grant-types/authorization-code-grant-type.js index 97c126793..e34c46a51 100644 --- a/lib/grant-types/authorization-code-grant-type.js +++ b/lib/grant-types/authorization-code-grant-type.js @@ -13,6 +13,8 @@ var promisify = require('promisify-any').use(Promise); var ServerError = require('../errors/server-error'); var is = require('../validator/is'); var util = require('util'); +var crypto = require('crypto'); +var stringUtil = require('../utils/string-util'); /** * Constructor. @@ -118,6 +120,34 @@ AuthorizationCodeGrantType.prototype.getAuthorizationCode = function(request, cl throw new InvalidGrantError('Invalid grant: `redirect_uri` is not a valid URI'); } + if (code.codeChallenge) { + if (!request.body.code_verifier) { + throw new InvalidGrantError('Missing parameter: `code_verifier`'); + } + + var hash; + switch(code.codeChallengeMethod) { + case 'plain': + hash = request.body.code_verifier; + break; + case 'S256': + hash = stringUtil.base64URLEncode(crypto.createHash('sha256').update(request.body.code_verifier).digest()); + break; + default: + throw new ServerError('Server error: `getAuthorizationCode()` did not return a valid `codeChallengeMethod` property'); + } + + if (code.codeChallenge !== hash) { + throw new InvalidGrantError('Invalid grant: code verifier is invalid'); + } + } + else { + if (request.body.code_verifier) { + // No code challenge but code_verifier was passed in. + throw new InvalidGrantError('Invalid grant: code verifier is invalid'); + } + } + return code; }); }; diff --git a/lib/handlers/token-handler.js b/lib/handlers/token-handler.js index af162ca38..087bf0324 100644 --- a/lib/handlers/token-handler.js +++ b/lib/handlers/token-handler.js @@ -118,12 +118,13 @@ TokenHandler.prototype.handle = function(request, response) { TokenHandler.prototype.getClient = function(request, response) { var credentials = this.getClientCredentials(request); var grantType = request.body.grant_type; + var isPkce = this.isPKCERequest(request, grantType); if (!credentials.clientId) { throw new InvalidRequestError('Missing parameter: `client_id`'); } - if (this.isClientAuthenticationRequired(grantType) && !credentials.clientSecret) { + if (this.isClientAuthenticationRequired(grantType) && !credentials.clientSecret && !isPkce) { throw new InvalidRequestError('Missing parameter: `client_secret`'); } @@ -187,6 +188,12 @@ TokenHandler.prototype.getClientCredentials = function(request) { return { clientId: request.body.client_id, clientSecret: request.body.client_secret }; } + if (this.isPKCERequest(request, grantType)) { + if(request.body.client_id) { + return { clientId: request.body.client_id }; + } + } + if (!this.isClientAuthenticationRequired(grantType)) { if(request.body.client_id) { return { clientId: request.body.client_id }; @@ -293,6 +300,17 @@ TokenHandler.prototype.isClientAuthenticationRequired = function(grantType) { } }; +/** + * Check if the request is a PCKE request. We assume PKCE if grant type is 'authorization_code' + * and code verifier is present. + */ +TokenHandler.prototype.isPKCERequest = function(request, grantType) { + if (grantType === 'authorization_code' && request.body.code_verifier) { + return true; + } + return false; +}; + /** * Export constructor. */ diff --git a/lib/response-types/code-response-type.js b/lib/response-types/code-response-type.js index f0dd8a141..3fb459748 100644 --- a/lib/response-types/code-response-type.js +++ b/lib/response-types/code-response-type.js @@ -5,6 +5,7 @@ */ var InvalidArgumentError = require('../errors/invalid-argument-error'); +var InvalidRequestError = require('../errors/invalid-request-error'); var tokenUtil = require('../utils/token-util'); var Promise = require('bluebird'); @@ -53,6 +54,13 @@ CodeResponseType.prototype.handle = function(request, client, user, uri, scope) throw new InvalidArgumentError('Missing parameter: `uri`'); } + var codeChallenge = this.getCodeChallenge(request); + var codeChallengeMethod = this.getCodeChallengeMethod(request); + + if (!codeChallenge && codeChallengeMethod) { + throw new InvalidArgumentError('Missing parameter: `code_challenge`'); + } + var fns = [ this.generateAuthorizationCode(), this.getAuthorizationCodeExpiresAt(client) @@ -61,7 +69,7 @@ CodeResponseType.prototype.handle = function(request, client, user, uri, scope) return Promise.all(fns) .bind(this) .spread(function(authorizationCode, expiresAt) { - return this.saveAuthorizationCode(authorizationCode, expiresAt, scope, client, uri, user); + return this.saveAuthorizationCode(authorizationCode, expiresAt, scope, client, uri, user, codeChallenge, codeChallengeMethod); }) .then(function(code) { this.code = code.authorizationCode; @@ -94,7 +102,7 @@ CodeResponseType.prototype.getAuthorizationCodeLifetime = function(client) { * Save authorization code. */ -CodeResponseType.prototype.saveAuthorizationCode = function(authorizationCode, expiresAt, scope, client, redirectUri, user) { +CodeResponseType.prototype.saveAuthorizationCode = function(authorizationCode, expiresAt, scope, client, redirectUri, user, codeChallenge, codeChallengeMethod) { var code = { authorizationCode: authorizationCode, expiresAt: expiresAt, @@ -102,6 +110,14 @@ CodeResponseType.prototype.saveAuthorizationCode = function(authorizationCode, e scope: scope }; + if (codeChallenge) { + code.codeChallenge = codeChallenge; + + // Section 4.3 - https://tools.ietf.org/html/rfc7636#section-4 + // Defaults to "plain" if not present in the request. + code.codeChallengeMethod = codeChallengeMethod || 'plain'; + } + return Promise.try(this.model.saveAuthorizationCode, [code, client, user]); }; @@ -117,6 +133,43 @@ CodeResponseType.prototype.generateAuthorizationCode = function() { return tokenUtil.generateRandomToken(); }; +/** + * Get Code challenge + */ +CodeResponseType.prototype.getCodeChallenge = function(request) { + var codeChallenge = request.body.code_challenge || request.query.code_challenge; + + if (!codeChallenge) { + return null; + } + + // https://tools.ietf.org/html/rfc7636#section-4 + if (!codeChallenge.match(/^([A-Za-z0-9\.\-\_\~]){43,128}$/)) { + throw new InvalidRequestError('Invalid parameter: `code_challenge`'); + } + + return codeChallenge; +}; + +/** + * Get Code challenge method + */ +CodeResponseType.prototype.getCodeChallengeMethod = function(request) { + var codeChallengeMethod = request.body.code_challenge_method || request.query.code_challenge_method; + + // https://tools.ietf.org/html/rfc7636#section-4 + // Section 4.3 - codeChallengeMethod is optional. + if (!codeChallengeMethod) { + return null; + } + + if (codeChallengeMethod !== 'S256' && codeChallengeMethod !== 'plain') { + throw new InvalidRequestError('Invalid parameter: `code_challenge_method`'); + } + + return codeChallengeMethod; +}; + /** * Build redirect uri. */ diff --git a/lib/utils/string-util.js b/lib/utils/string-util.js new file mode 100644 index 000000000..fd5ac72a8 --- /dev/null +++ b/lib/utils/string-util.js @@ -0,0 +1,14 @@ +'use strict'; + +/** + * Export `StringUtil`. + */ + +module.exports = { + base64URLEncode: function(str) { + return str.toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, ''); + } +}; \ No newline at end of file diff --git a/test/integration/grant-types/authorization-code-grant-type_test.js b/test/integration/grant-types/authorization-code-grant-type_test.js index 7f84e3443..81e927281 100644 --- a/test/integration/grant-types/authorization-code-grant-type_test.js +++ b/test/integration/grant-types/authorization-code-grant-type_test.js @@ -12,6 +12,8 @@ var Promise = require('bluebird'); var Request = require('../../../lib/request'); var ServerError = require('../../../lib/errors/server-error'); var should = require('should'); +var stringUtil = require('../../../lib/utils/string-util'); +var crypto = require('crypto'); /** * Test `AuthorizationCodeGrantType` integration. @@ -361,6 +363,114 @@ describe('AuthorizationCodeGrantType integration', function() { }); }); + describe('with PKCE', function() { + it('should throw an error if the `code_verifier` is invalid with S256 code challenge method', function() { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'S256', + codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest()) + }; + var client = { id: 'foobar', isPublic: true }; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + revokeAuthorizationCode: function() {}, + saveToken: function() {} + }; + var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model }); + var request = new Request({ body: { code: 12345, code_verifier: 'foo' }, headers: {}, method: {}, query: {} }); + + return grantType.getAuthorizationCode(request, client) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidGrantError); + e.message.should.equal('Invalid grant: code verifier is invalid'); + }); + }); + + it('should throw an error if the `code_verifier` is invalid with plain code challenge method', function() { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'plain', + codeChallenge: codeVerifier + }; + var client = { id: 'foobar', isPublic: true }; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + revokeAuthorizationCode: function() {}, + saveToken: function() {} + }; + var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model }); + var request = new Request({ body: { code: 12345, code_verifier: 'foo' }, headers: {}, method: {}, query: {} }); + + return grantType.getAuthorizationCode(request, client) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidGrantError); + e.message.should.equal('Invalid grant: code verifier is invalid'); + }); + }); + + it('should return an auth code when `code_verifier` is valid with S256 code challenge method', function() { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar', isPublic: true }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'S256', + codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest()) + }; + var client = { id: 'foobar', isPublic: true }; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + revokeAuthorizationCode: function() {}, + saveToken: function() {} + }; + var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model }); + var request = new Request({ body: { code: 12345, code_verifier: codeVerifier }, headers: {}, method: {}, query: {} }); + + return grantType.getAuthorizationCode(request, client) + .then(function(data) { + data.should.equal(authorizationCode); + }) + .catch(should.fail); + }); + + it('should return an auth code when `code_verifier` is valid with plain code challenge method', function() { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'plain', + codeChallenge: codeVerifier + }; + var client = { id: 'foobar', isPublic: true }; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + revokeAuthorizationCode: function() {}, + saveToken: function() {} + }; + var grantType = new AuthorizationCodeGrantType({ accessTokenLifetime: 123, model: model }); + var request = new Request({ body: { code: 12345, code_verifier: codeVerifier }, headers: {}, method: {}, query: {} }); + + return grantType.getAuthorizationCode(request, client) + .then(function(data) { + data.should.equal(authorizationCode); + }) + .catch(should.fail); + }); + }); + it('should return an auth code', function() { var authorizationCode = { authorizationCode: 12345, client: { id: 'foobar' }, expiresAt: new Date(new Date() * 2), user: {} }; var client = { id: 'foobar' }; diff --git a/test/integration/handlers/token-handler_test.js b/test/integration/handlers/token-handler_test.js index f3fd00ab5..da3f5b51b 100644 --- a/test/integration/handlers/token-handler_test.js +++ b/test/integration/handlers/token-handler_test.js @@ -20,6 +20,8 @@ var UnauthorizedClientError = require('../../../lib/errors/unauthorized-client-e var UnsupportedGrantTypeError = require('../../../lib/errors/unsupported-grant-type-error'); var should = require('should'); var util = require('util'); +var crypto = require('crypto'); +var stringUtil = require('../../../lib/utils/string-util'); /** * Test `TokenHandler` integration. @@ -827,6 +829,197 @@ describe('TokenHandler integration', function() { }) .catch(should.fail); }); + + describe('with PKCE', function() { + it('should return a token when code verifier is valid using S256 code challenge method', function() { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'S256', + codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest()) + }; + var client = { id: 'foobar', grants: ['authorization_code'] }; + var token = {}; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + getClient: function() {}, + saveToken: function() { return token; }, + validateScope: function() { return 'foo'; }, + revokeAuthorizationCode: function() { return authorizationCode; } + }; + var handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + var request = new Request({ + body: { + code: 12345, + grant_type: 'authorization_code', + code_verifier: codeVerifier + }, + headers: {}, + method: {}, + query: {} + }); + + return handler.handleGrantType(request, client) + .then(function(data) { + data.should.equal(token); + }) + .catch(should.fail); + }); + + it('should return a token when code verifier is valid using plain code challenge method', function() { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'plain', + codeChallenge: codeVerifier + }; + var client = { id: 'foobar', grants: ['authorization_code'] }; + var token = {}; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + getClient: function() {}, + saveToken: function() { return token; }, + validateScope: function() { return 'foo'; }, + revokeAuthorizationCode: function() { return authorizationCode; } + }; + var handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + var request = new Request({ + body: { + code: 12345, + grant_type: 'authorization_code', + code_verifier: codeVerifier + }, + headers: {}, + method: {}, + query: {} + }); + + return handler.handleGrantType(request, client) + .then(function(data) { + data.should.equal(token); + }) + .catch(should.fail); + }); + + it('should throw an invalid grant error when code verifier is invalid', function() { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'S256', + codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest()) + }; + var client = { id: 'foobar', grants: ['authorization_code'] }; + var token = {}; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + getClient: function() {}, + saveToken: function() { return token; }, + validateScope: function() { return 'foo'; }, + revokeAuthorizationCode: function() { return authorizationCode; } + }; + var handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + var request = new Request({ + body: { + code: 12345, + grant_type: 'authorization_code', + code_verifier: '123123123123123123123123123123123123123123123' + }, + headers: {}, + method: {}, + query: {} + }); + + return handler.handleGrantType(request, client) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidGrantError); + e.message.should.equal('Invalid grant: code verifier is invalid'); + }); + }); + + it('should throw an invalid grant error when code verifier is missing', function() { + var codeVerifier = stringUtil.base64URLEncode(crypto.randomBytes(32)); + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() * 2), + user: {}, + codeChallengeMethod: 'S256', + codeChallenge: stringUtil.base64URLEncode(crypto.createHash('sha256').update(codeVerifier).digest()) + }; + var client = { id: 'foobar', grants: ['authorization_code'] }; + var token = {}; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + getClient: function() {}, + saveToken: function() { return token; }, + validateScope: function() { return 'foo'; }, + revokeAuthorizationCode: function() { return authorizationCode; } + }; + var handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + var request = new Request({ + body: { + code: 12345, + grant_type: 'authorization_code' + }, + headers: {}, + method: {}, + query: {} + }); + + return handler.handleGrantType(request, client) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidGrantError); + e.message.should.equal('Missing parameter: `code_verifier`'); + }); + }); + + it('should throw an invalid grant error when code verifier is present but code challenge is missing', function() { + var authorizationCode = { + authorizationCode: 12345, + client: { id: 'foobar' }, + expiresAt: new Date(new Date().getTime() * 2), + user: {} + }; + var client = { id: 'foobar', grants: ['authorization_code'] }; + var token = {}; + var model = { + getAuthorizationCode: function() { return authorizationCode; }, + getClient: function() {}, + saveToken: function() { return token; }, + validateScope: function() { return 'foo'; }, + revokeAuthorizationCode: function() { return authorizationCode; } + }; + var handler = new TokenHandler({ accessTokenLifetime: 120, model: model, refreshTokenLifetime: 120 }); + var request = new Request({ + body: { + code: 12345, + grant_type: 'authorization_code', + code_verifier: '123123123123123123123123123123123123123123123' + }, + headers: {}, + method: {}, + query: {} + }); + + return handler.handleGrantType(request, client) + .then(should.fail) + .catch(function(e) { + e.should.be.an.instanceOf(InvalidGrantError); + e.message.should.equal('Invalid grant: code verifier is invalid'); + }); + }); + }); }); describe('with grant_type `client_credentials`', function() { diff --git a/test/integration/response-types/code-response-type_test.js b/test/integration/response-types/code-response-type_test.js index 19f1d26ae..98bee6d69 100644 --- a/test/integration/response-types/code-response-type_test.js +++ b/test/integration/response-types/code-response-type_test.js @@ -6,6 +6,8 @@ var CodeResponseType = require('../../../lib/response-types/code-response-type'); var InvalidArgumentError = require('../../../lib/errors/invalid-argument-error'); +var InvalidRequestError = require('../../../lib/errors/invalid-request-error'); +var Request = require('../../../lib/request'); var Promise = require('bluebird'); var should = require('should'); var sinon = require('sinon'); @@ -230,6 +232,65 @@ describe('CodeResponseType integration', function() { }) .catch(should.fail); }); + + describe('with PKCE', function() { + it('should save codeChallenge and codeChallengeMethod', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: sinon.stub().returns({}) + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + + return handler.saveAuthorizationCode('foo', new Date(12345), 'qux', { id: 'biz' }, 'baz', { id: 'boz' }, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', 'S256') + .then(function() { + model.saveAuthorizationCode.callCount.should.equal(1); + model.saveAuthorizationCode.firstCall.args.should.have.length(3); + model.saveAuthorizationCode.firstCall.args[0].should.eql({ authorizationCode: 'foo', expiresAt: new Date(12345), redirectUri: 'baz', scope: 'qux', codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', codeChallengeMethod: 'S256' }); + model.saveAuthorizationCode.firstCall.args[1].id.should.equal('biz'); + model.saveAuthorizationCode.firstCall.args[2].id.should.equal('boz'); + }) + .catch(should.fail); + }); + + it('should save codeChallenge and set codeChallengeMethod to `plain` when codeChallengeMethod is not present', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: sinon.stub().returns({}) + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + + return handler.saveAuthorizationCode('foo', new Date(12345), 'qux', { id: 'biz' }, 'baz', { id: 'boz' }, 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', null) + .then(function() { + model.saveAuthorizationCode.callCount.should.equal(1); + model.saveAuthorizationCode.firstCall.args.should.have.length(3); + model.saveAuthorizationCode.firstCall.args[0].should.eql({ authorizationCode: 'foo', expiresAt: new Date(12345), redirectUri: 'baz', scope: 'qux', codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', codeChallengeMethod: 'plain' }); + model.saveAuthorizationCode.firstCall.args[1].id.should.equal('biz'); + model.saveAuthorizationCode.firstCall.args[2].id.should.equal('boz'); + }) + .catch(should.fail); + }); + + it('should save code but not save codeChallenge or codeChallengeMethod when codeChallenge is not present and codeChallengeMethod is present', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: sinon.stub().returns({}) + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + + return handler.saveAuthorizationCode('foo', new Date(12345), 'qux', { id: 'biz' }, 'baz', { id: 'boz' }, '', 'S256') + .then(function() { + model.saveAuthorizationCode.callCount.should.equal(1); + model.saveAuthorizationCode.firstCall.args.should.have.length(3); + model.saveAuthorizationCode.firstCall.args[0].should.eql({ authorizationCode: 'foo', expiresAt: new Date(12345), redirectUri: 'baz', scope: 'qux' }); + model.saveAuthorizationCode.firstCall.args[1].id.should.equal('biz'); + model.saveAuthorizationCode.firstCall.args[2].id.should.equal('boz'); + }) + .catch(should.fail); + }); + }); }); describe('generateAuthorizationCode()', function() { @@ -249,4 +310,152 @@ describe('CodeResponseType integration', function() { .catch(should.fail); }); }); + + describe('with PKCE', function() { + describe('getCodeChallenge()', function() { + describe('with invalid `code_challenge`', function() { + it('should throw an error if code_challenge is too short', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {} + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + var request = new Request({ body: { code_challenge: 'foo' }, headers: {}, method: {}, query: {} }); + + try { + handler.getCodeChallenge(request); + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Invalid parameter: `code_challenge`'); + } + }); + + it('should throw an error if code_challenge is too long', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {} + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + var request = new Request({ body: { code_challenge: '1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890' }, headers: {}, method: {}, query: {} }); + + try { + handler.getCodeChallenge(request); + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Invalid parameter: `code_challenge`'); + } + }); + + it('should throw an error if code_challenge has invalid characters', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {} + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + var request = new Request({ body: { code_challenge: 'E9M!!!!!oa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' }, headers: {}, method: {}, query: {} }); + + try { + handler.getCodeChallenge(request); + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Invalid parameter: `code_challenge`'); + } + }); + }); + + describe('with `code_challenge` in the request body', function() { + it('should return the code_challenge', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {} + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + var request = new Request({ body: { code_challenge: '_-~.E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' }, headers: {}, method: {}, query: {} }); + + handler.getCodeChallenge(request).should.equal('_-~.E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'); + }); + }); + + describe('with `code_challenge` in the request query', function() { + it('should return the code_challenge', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {} + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + var request = new Request({ body: {}, headers: {}, method: {}, query: { code_challenge: '_-~.E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' } }); + + handler.getCodeChallenge(request).should.equal('_-~.E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'); + }); + }); + }); + + describe('getCodeChallengeMethod()', function() { + it('should throw an error if `code_challenge_method` is invalid', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {} + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + var request = new Request({ body: { code_challenge_method: 'foo' }, headers: {}, method: {}, query: {} }); + + try { + handler.getCodeChallengeMethod(request); + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidRequestError); + e.message.should.equal('Invalid parameter: `code_challenge_method`'); + } + }); + + it('should return null if `code_challenge_method` is not provided', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {} + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + var request = new Request({ body: { }, headers: {}, method: {}, query: {} }); + + (handler.getCodeChallengeMethod(request) === null).should.equal(true); + }); + + describe('with `code_challenge_method` in the request body', function() { + it('should return the code_challenge_method', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {} + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + var request = new Request({ body: { code_challenge_method: 'plain' }, headers: {}, method: {}, query: {} }); + + handler.getCodeChallengeMethod(request).should.equal('plain'); + }); + }); + + describe('with `code_challenge_method` in the request query', function() { + it('should return the code_challenge_method', function() { + var model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {} + }; + var handler = new CodeResponseType({ authorizationCodeLifetime: 120, model: model }); + var request = new Request({ body: {}, headers: {}, method: {}, query: { code_challenge_method: 'S256' } }); + + handler.getCodeChallengeMethod(request).should.equal('S256'); + }); + }); + }); + }); });