diff --git a/docs/model/overview.rst b/docs/model/overview.rst index 5e345ab..62c9ffa 100644 --- a/docs/model/overview.rst +++ b/docs/model/overview.rst @@ -37,6 +37,7 @@ Model functions used by the authorization code grant: - :ref:`Model#saveAuthorizationCode` - :ref:`Model#revokeAuthorizationCode` - :ref:`Model#validateScope` +- :ref:`Model#validateRedirectUri` -------- diff --git a/docs/model/spec.rst b/docs/model/spec.rst index 33d8b89..953c281 100644 --- a/docs/model/spec.rst +++ b/docs/model/spec.rst @@ -984,3 +984,45 @@ Returns ``true`` if the access token passes, ``false`` otherwise. let authorizedScopes = token.scope.split(' '); return requestedScopes.every(s => authorizedScopes.indexOf(s) >= 0); } + +-------- + +.. _Model#validateRedirectUri: + +``validateRedirectUri(redirectUri, client, [callback])`` +================================================================ + +Invoked to check if the provided ``redirectUri`` is valid for a particular ``client``. + +This model function is **optional**. If not implemented, the ``redirectUri`` should be included in the provided ``redirectUris`` of the client. + +**Invoked during:** + +- ``authorization_code`` grant + +**Arguments:** + ++-----------------+----------+---------------------------------------------------------------------+ +| Name | Type | Description | ++=================+==========+=====================================================================+ +| redirect_uri | String | The redirect URI to validate. | ++-----------------+----------+---------------------------------------------------------------------+ +| client | Object | The associated client. | ++-----------------+----------+---------------------------------------------------------------------+ + +**Return value:** + +Returns ``true`` if the ``redirectUri`` is valid, ``false`` otherwise. + +**Remarks:** +When implementing this method you should take care of possible security risks related to ``redirectUri``. +.. _rfc6819: https://datatracker.ietf.org/doc/html/rfc6819 + +Section-5.2.3.5 is implemented by default. +.. _Section-5.2.3.5: https://datatracker.ietf.org/doc/html/rfc6819#section-5.2.3.5 + +:: + + function validateRedirectUri(redirectUri, client) { + return client.redirectUris.includes(redirectUri); + } diff --git a/lib/handlers/authorize-handler.js b/lib/handlers/authorize-handler.js index 6b42fa0..b3f1d5e 100644 --- a/lib/handlers/authorize-handler.js +++ b/lib/handlers/authorize-handler.js @@ -165,6 +165,7 @@ AuthorizeHandler.prototype.getAuthorizationCodeLifetime = function() { */ AuthorizeHandler.prototype.getClient = function(request) { + const self = this; const clientId = request.body.client_id || request.query.client_id; if (!clientId) { @@ -198,10 +199,17 @@ AuthorizeHandler.prototype.getClient = function(request) { throw new InvalidClientError('Invalid client: missing client `redirectUri`'); } - if (redirectUri && !client.redirectUris.includes(redirectUri)) { - throw new InvalidClientError('Invalid client: `redirect_uri` does not match client value'); + if (redirectUri) { + return self.validateRedirectUri(redirectUri, client) + .then(function(valid) { + if (!valid) { + throw new InvalidClientError('Invalid client: `redirect_uri` does not match client value'); + } + return client; + }); + } else { + return client; } - return client; }); }; @@ -295,6 +303,14 @@ AuthorizeHandler.prototype.saveAuthorizationCode = function(authorizationCode, e return promisify(this.model.saveAuthorizationCode, 3).call(this.model, code, client, user); }; + +AuthorizeHandler.prototype.validateRedirectUri = function(redirectUri, client) { + if (this.model.validateRedirectUri) { + return promisify(this.model.validateRedirectUri, 2).call(this.model, redirectUri, client); + } + + return Promise.resolve(client.redirectUris.includes(redirectUri)); +}; /** * Get response type. */ diff --git a/test/integration/handlers/authorize-handler_test.js b/test/integration/handlers/authorize-handler_test.js index 3e597ad..efcdf76 100644 --- a/test/integration/handlers/authorize-handler_test.js +++ b/test/integration/handlers/authorize-handler_test.js @@ -655,6 +655,65 @@ describe('AuthorizeHandler integration', function() { }); }); + describe('validateRedirectUri()', function() { + it('should support empty method', function() { + const model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {} + }; + + const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model }); + + handler.validateRedirectUri('http://example.com/a', { redirectUris: ['http://example.com/a'] }).should.be.an.instanceOf(Promise); + }); + + it('should support promises', function() { + const model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {}, + validateRedirectUri: function() { + return Promise.resolve(true); + } + }; + + const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model }); + + handler.validateRedirectUri('http://example.com/a', { }).should.be.an.instanceOf(Promise); + }); + + it('should support non-promises', function() { + const model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {}, + validateRedirectUri: function() { + return true; + } + }; + + const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model }); + + handler.validateRedirectUri('http://example.com/a', { }).should.be.an.instanceOf(Promise); + }); + + it('should support callbacks', function() { + const model = { + getAccessToken: function() {}, + getClient: function() {}, + saveAuthorizationCode: function() {}, + validateRedirectUri: function(redirectUri, client, callback) { + callback(null, false); + } + }; + + const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model }); + + handler.validateRedirectUri('http://example.com/a', { }).should.be.an.instanceOf(Promise); + }); + }); + describe('getClient()', function() { it('should throw an error if `client_id` is missing', function() { const model = { diff --git a/test/unit/handlers/authorize-handler_test.js b/test/unit/handlers/authorize-handler_test.js index 86ce336..376bc1e 100644 --- a/test/unit/handlers/authorize-handler_test.js +++ b/test/unit/handlers/authorize-handler_test.js @@ -99,4 +99,80 @@ describe('AuthorizeHandler', function() { .catch(should.fail); }); }); + + describe('validateRedirectUri()', function() { + it('should call `model.validateRedirectUri()`', function() { + const client = { grants: ['authorization_code'], redirectUris: ['http://example.com/cb'] }; + const redirect_uri = 'http://example.com/cb/2'; + const model = { + getAccessToken: function() {}, + getClient: sinon.stub().returns(client), + saveAuthorizationCode: function() {}, + validateRedirectUri: sinon.stub().returns(true) + }; + const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model }); + const request = new Request({ body: { client_id: 12345, client_secret: 'secret', redirect_uri }, 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.thisValue.should.equal(model); + + model.validateRedirectUri.callCount.should.equal(1); + model.validateRedirectUri.firstCall.args.should.have.length(2); + model.validateRedirectUri.firstCall.args[0].should.equal(redirect_uri); + model.validateRedirectUri.firstCall.args[1].should.equal(client); + model.validateRedirectUri.firstCall.thisValue.should.equal(model); + }) + .catch(should.fail); + }); + + it('should be successful validation', function () { + const client = { grants: ['authorization_code'], redirectUris: ['http://example.com/cb'] }; + const redirect_uri = 'http://example.com/cb'; + const model = { + getAccessToken: function() {}, + getClient: sinon.stub().returns(client), + saveAuthorizationCode: function() {}, + validateRedirectUri: function (redirectUri, client) { + return client.redirectUris.includes(redirectUri); + } + }; + + const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model }); + const request = new Request({ body: { client_id: 12345, client_secret: 'secret', redirect_uri }, headers: {}, method: {}, query: {} }); + + return handler.getClient(request) + .then((client) => { + client.should.equal(client); + }); + }); + + it('should be unsuccessful validation', function () { + const client = { grants: ['authorization_code'], redirectUris: ['http://example.com/cb'] }; + const redirect_uri = 'http://example.com/callback'; + const model = { + getAccessToken: function() {}, + getClient: sinon.stub().returns(client), + saveAuthorizationCode: function() {}, + validateRedirectUri: function (redirectUri, client) { + return client.redirectUris.includes(redirectUri); + } + }; + + const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model: model }); + const request = new Request({ body: { client_id: 12345, client_secret: 'secret', redirect_uri }, headers: {}, method: {}, query: {} }); + + return handler.getClient(request) + .then(() => { + throw Error('should not resolve'); + }) + .catch((err) => { + err.name.should.equal('invalid_client'); + err.message.should.equal('Invalid client: `redirect_uri` does not match client value'); + }); + }); + }); });