diff --git a/docs/api/response-types/code-response-type.md b/docs/api/response-types/code-response-type.md index 1142f18b..000c4c56 100644 --- a/docs/api/response-types/code-response-type.md +++ b/docs/api/response-types/code-response-type.md @@ -25,7 +25,7 @@ **Kind**: instance method of [CodeResponseType](#CodeResponseType) **Throws**: -- InvalidArgumentError if redirectUri is missing +- InvalidArgumentError if redirectUri is missing or not an absolute URI | Param | diff --git a/docs/api/utils/url-util.md b/docs/api/utils/url-util.md new file mode 100644 index 00000000..1af7f87a --- /dev/null +++ b/docs/api/utils/url-util.md @@ -0,0 +1,41 @@ + + +## UrlUtil + +* [UrlUtil](#module_UrlUtil) + * [~parseRedirectUri(redirectUri, [parseQueryString])](#module_UrlUtil..parseRedirectUri) ⇒ object + * [~parseAuth(parsed)](#module_UrlUtil..parseAuth) ⇒ string \| null + + + +### UrlUtil~parseRedirectUri(redirectUri, [parseQueryString]) ⇒ object +Parses a redirect URI with the WHATWG `URL` API and returns it in the shape +of Node's legacy `url.parse()` result, which `url.format()` serializes. +The redirect builders keep their return type without calling `url.parse()`, +which Node deprecates at runtime (DEP0169). + +**Kind**: inner method of [UrlUtil](#module_UrlUtil) +**Returns**: object - the parsed URI: `protocol`, `slashes`, `auth`, `host`, `port`, `hostname`, `hash`, `search`, + `query`, `pathname`, `path` and `href` +**Throws**: + +- InvalidArgumentError if `redirectUri` is not an absolute URI + + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| redirectUri | string | | an absolute URI | +| [parseQueryString] | boolean | false | return `query` as an object, as `url.parse(redirectUri, true)` does | + + + +### UrlUtil~parseAuth(parsed) ⇒ string \| null +The userinfo as `url.parse()` returns it: decoded, `user` or `user:password`, +and `null` when there is none. `url.format()` encodes it again. + +**Kind**: inner method of [UrlUtil](#module_UrlUtil) + +| Param | Type | +| --- | --- | +| parsed | URL | + diff --git a/lib/handlers/authorize-handler.js b/lib/handlers/authorize-handler.js index 4aef3492..49428d7e 100644 --- a/lib/handlers/authorize-handler.js +++ b/lib/handlers/authorize-handler.js @@ -21,6 +21,7 @@ const tokenUtil = require('../utils/token-util'); const url = require('url'); const pkce = require('../pkce/pkce'); const { parseScope } = require('../utils/scope-util'); +const { parseRedirectUri } = require('../utils/url-util'); /** * Response types. @@ -358,7 +359,7 @@ class AuthorizeHandler { */ buildErrorRedirectUri(redirectUri, error) { - const uri = url.parse(redirectUri); + const uri = parseRedirectUri(redirectUri); uri.query = { error: error.name, diff --git a/lib/response-types/code-response-type.js b/lib/response-types/code-response-type.js index d53770b7..4d35d0db 100644 --- a/lib/response-types/code-response-type.js +++ b/lib/response-types/code-response-type.js @@ -5,7 +5,7 @@ */ const InvalidArgumentError = require('../errors/invalid-argument-error'); -const url = require('url'); +const { parseRedirectUri } = require('../utils/url-util'); /** * @class @@ -28,14 +28,14 @@ class CodeResponseType { /** * @param redirectUri * @return {UrlWithParsedQuery} - * @throws {InvalidArgumentError} if redirectUri is missing + * @throws {InvalidArgumentError} if redirectUri is missing or not an absolute URI */ buildRedirectUri(redirectUri) { if (!redirectUri) { throw new InvalidArgumentError('Missing parameter: `redirectUri`'); } - const uri = url.parse(redirectUri, true); + const uri = parseRedirectUri(redirectUri, true); uri.query.code = this.code; uri.search = null; diff --git a/lib/utils/url-util.js b/lib/utils/url-util.js new file mode 100644 index 00000000..ce826461 --- /dev/null +++ b/lib/utils/url-util.js @@ -0,0 +1,84 @@ +'use strict'; + +/* + * Module dependencies. + */ + +const InvalidArgumentError = require('../errors/invalid-argument-error'); +const querystring = require('querystring'); + +/** + * @module UrlUtil + */ + +/** + * Parses a redirect URI with the WHATWG `URL` API and returns it in the shape + * of Node's legacy `url.parse()` result, which `url.format()` serializes. + * The redirect builders keep their return type without calling `url.parse()`, + * which Node deprecates at runtime (DEP0169). + * + * @param {string} redirectUri an absolute URI + * @param {boolean} [parseQueryString=false] return `query` as an object, as `url.parse(redirectUri, true)` does + * @return {object} the parsed URI: `protocol`, `slashes`, `auth`, `host`, `port`, `hostname`, `hash`, `search`, + * `query`, `pathname`, `path` and `href` + * @throws {InvalidArgumentError} if `redirectUri` is not an absolute URI + */ +function parseRedirectUri(redirectUri, parseQueryString = false) { + let parsed; + + try { + parsed = new URL(redirectUri); + } catch { + throw new InvalidArgumentError('Invalid parameter: `redirectUri`'); + } + + // `URL` reports an empty query (`…/cb?`) as `''`, as if there were none; + // `url.parse()` keeps it as `'?'`. + const search = parsed.search || (parsed.href.split('#')[0].endsWith('?') ? '?' : null); + const pathname = parsed.pathname || null; + // `url.parse()` gives an IPv6 hostname without its brackets; `host` keeps them. + const hostname = parsed.hostname.startsWith('[') ? parsed.hostname.slice(1, -1) : parsed.hostname; + let query = search ? search.slice(1) : null; + + if (parseQueryString) { + query = querystring.parse(query || ''); + } + + return { + protocol: parsed.protocol, + slashes: parsed.href.startsWith(`${parsed.protocol}//`) ? true : null, + auth: parseAuth(parsed), + host: parsed.host || null, + port: parsed.port || null, + hostname: hostname || null, + hash: parsed.hash || null, + search, + query, + pathname, + path: pathname || search ? `${pathname || ''}${search || ''}` : null, + href: parsed.href, + }; +} + +/** + * The userinfo as `url.parse()` returns it: decoded, `user` or `user:password`, + * and `null` when there is none. `url.format()` encodes it again. + * + * @param {URL} parsed + * @return {string|null} + */ +function parseAuth(parsed) { + if (!parsed.username && !parsed.password) { + return null; + } + + const auth = parsed.password ? `${parsed.username}:${parsed.password}` : parsed.username; + + try { + return decodeURIComponent(auth); + } catch { + return auth; + } +} + +module.exports = { parseRedirectUri }; diff --git a/test/integration/handlers/authorize-handler_test.js b/test/integration/handlers/authorize-handler_test.js index aa81ab6a..268771c3 100644 --- a/test/integration/handlers/authorize-handler_test.js +++ b/test/integration/handlers/authorize-handler_test.js @@ -19,7 +19,9 @@ const Response = require('../../../lib/response'); const ServerError = require('../../../lib/errors/server-error'); const UnauthorizedClientError = require('../../../lib/errors/unauthorized-client-error'); const should = require('chai').should(); +const sinon = require('sinon'); const url = require('url'); +const { parseRedirectUri } = require('../../../lib/utils/url-util'); const createModel = (model = {}) => { return Model.from({ @@ -457,6 +459,91 @@ describe('AuthorizeHandler integration', function () { .should.equal('http://example.com/cb?code=fooobar-long-authzcode-%3F&state=foobarbazstatemoo'); }); + it('should build the redirect without the deprecated url.parse()', async function () { + const client = { + id: 'client-12343434', + grants: ['authorization_code'], + redirectUris: ['http://example.com/cb'], + }; + const model = createModel({ + getAccessToken: async function () { + return { + client, + user: {}, + accessTokenExpiresAt: new Date(new Date().getTime() + 10000), + }; + }, + getClient: async function () { + return { ...client }; + }, + saveAuthorizationCode: async function () { + return { authorizationCode: 'foo', client }; + }, + }); + const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model }); + const request = new Request({ + body: { client_id: client.id, response_type: 'code' }, + headers: { Authorization: 'Bearer foobarbaztokenmoo' }, + method: {}, + query: { state: 'foobarbazstatemoo' }, + }); + const response = new Response({ body: {}, headers: {} }); + const parse = sinon.spy(url, 'parse'); + + try { + await handler.handle(request, response); + parse.called.should.equal(false); + response.get('location').should.equal('http://example.com/cb?code=foo&state=foobarbazstatemoo'); + } finally { + parse.restore(); + } + }); + + it('should build the error redirect without the deprecated url.parse()', async function () { + const client = { + id: 'client-12343434', + grants: ['authorization_code'], + redirectUris: ['http://example.com/cb'], + }; + const model = createModel({ + getAccessToken: async function () { + return { + client, + user: {}, + accessTokenExpiresAt: new Date(new Date().getTime() + 10000), + }; + }, + getClient: async function () { + return { ...client }; + }, + }); + const handler = new AuthorizeHandler({ authorizationCodeLifetime: 120, model }); + const request = new Request({ + body: { client_id: client.id, response_type: 'code', allowed: 'false' }, + headers: { Authorization: 'Bearer foobarbaztokenmoo' }, + method: {}, + query: { state: 'foobarbazstatemoo' }, + }); + const response = new Response({ body: {}, headers: {} }); + const parse = sinon.spy(url, 'parse'); + + try { + await handler.handle(request, response); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(AccessDeniedError); + parse.called.should.equal(false); + response + .get('location') + .should.equal( + 'http://example.com/cb?error=access_denied&error_description=Access%20denied%3A%20user%20denied%20access%20to%20application&state=foobarbazstatemoo', + ); + } finally { + parse.restore(); + } + }); + it('should redirect to an error response if `scope` is invalid', async function () { const model = createModel({ getAccessToken: async function () { @@ -1813,6 +1900,22 @@ describe('AuthorizeHandler integration', function () { .format(redirectUri) .should.equal('http://example.com/cb?error=invalid_client&error_description=Bad%20Request'); }); + + it('should throw an error if the `redirectUri` is not an absolute URI', function () { + const handler = new AuthorizeHandler({ + authorizationCodeLifetime: 120, + model: createModel(), + }); + + try { + handler.buildErrorRedirectUri('/cb', new InvalidClientError()); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Invalid parameter: `redirectUri`'); + } + }); }); describe('updateResponse()', function () { @@ -1827,7 +1930,7 @@ describe('AuthorizeHandler integration', function () { model, }); const response = new Response({ body: {}, headers: {} }); - const uri = url.parse('http://example.com/cb'); + const uri = parseRedirectUri('http://example.com/cb'); handler.updateResponse(response, uri, 'foobar'); diff --git a/test/integration/response-types/code-response-type_test.js b/test/integration/response-types/code-response-type_test.js index d97c48e3..44a47141 100644 --- a/test/integration/response-types/code-response-type_test.js +++ b/test/integration/response-types/code-response-type_test.js @@ -7,6 +7,7 @@ const CodeResponseType = require('../../../lib/response-types/code-response-type'); const InvalidArgumentError = require('../../../lib/errors/invalid-argument-error'); const should = require('chai').should(); +const sinon = require('sinon'); const url = require('url'); /** @@ -60,5 +61,29 @@ describe('CodeResponseType integration', function () { url.format(redirectUri).should.equal('http://example.com/cb?foo=bar&code=foo'); }); + + it('should throw an error if the `redirectUri` is not an absolute URI', function () { + const responseType = new CodeResponseType('foo'); + + try { + responseType.buildRedirectUri('/cb'); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Invalid parameter: `redirectUri`'); + } + }); + + it('should not call the deprecated url.parse()', function () { + const parse = sinon.spy(url, 'parse'); + + try { + new CodeResponseType('foo').buildRedirectUri('http://example.com/cb?foo=bar'); + parse.called.should.equal(false); + } finally { + parse.restore(); + } + }); }); }); diff --git a/test/unit/utils/url-util_test.js b/test/unit/utils/url-util_test.js new file mode 100644 index 00000000..b2bec19e --- /dev/null +++ b/test/unit/utils/url-util_test.js @@ -0,0 +1,136 @@ +const { parseRedirectUri } = require('../../../lib/utils/url-util'); +const InvalidArgumentError = require('../../../lib/errors/invalid-argument-error'); +const should = require('chai').should(); +const url = require('url'); + +describe(parseRedirectUri.name, () => { + it('should return the fields of a legacy url.parse() result', () => { + const uri = parseRedirectUri('https://user:pa%20ss@example.com:8443/a/b?x=1&x=2&y=#frag'); + + uri.should.deep.equal({ + protocol: 'https:', + slashes: true, + auth: 'user:pa ss', + host: 'example.com:8443', + port: '8443', + hostname: 'example.com', + hash: '#frag', + search: '?x=1&x=2&y=', + query: 'x=1&x=2&y=', + pathname: '/a/b', + path: '/a/b?x=1&x=2&y=', + href: 'https://user:pa%20ss@example.com:8443/a/b?x=1&x=2&y=#frag', + }); + }); + + it('should parse the query into an object when asked to', () => { + const uri = parseRedirectUri('https://example.com/cb?x=1&x=2&y=', true); + + // A null-prototype object, as url.parse(value, true) returns. + should.equal(Object.getPrototypeOf(uri.query), null); + ({ ...uri.query }).should.deep.equal({ x: ['1', '2'], y: '' }); + }); + + it('should return an empty query object for a URI without a query when asked to', () => { + const uri = parseRedirectUri('https://example.com/cb', true); + + ({ ...uri.query }).should.deep.equal({}); + (uri.search === null).should.equal(true); + }); + + it('should keep an empty query as `?`, as url.parse() does', () => { + const uri = parseRedirectUri('https://example.com/cb?'); + + uri.search.should.equal('?'); + uri.query.should.equal(''); + uri.path.should.equal('/cb?'); + }); + + it('should return null for the parts a URI does not have', () => { + const uri = parseRedirectUri('http://localhost:3333/callback'); + + (uri.auth === null).should.equal(true); + (uri.hash === null).should.equal(true); + (uri.search === null).should.equal(true); + (uri.query === null).should.equal(true); + uri.path.should.equal('/callback'); + }); + + it('should give an IPv6 hostname without brackets, as url.parse() does', () => { + const uri = parseRedirectUri('http://[::1]:5000/cb'); + + uri.host.should.equal('[::1]:5000'); + uri.hostname.should.equal('::1'); + }); + + it('should support private-use URI schemes for native apps (RFC 8252)', () => { + const slashless = parseRedirectUri('com.example.app:/oauth2redirect'); + const slashed = parseRedirectUri('com.example.app://callback'); + + slashless.protocol.should.equal('com.example.app:'); + (slashless.slashes === null).should.equal(true); + (slashless.host === null).should.equal(true); + slashless.pathname.should.equal('/oauth2redirect'); + slashed.slashes.should.equal(true); + slashed.host.should.equal('callback'); + (slashed.pathname === null).should.equal(true); + (slashed.path === null).should.equal(true); + parseRedirectUri('com.example.app://callback?x=1').path.should.equal('?x=1'); + }); + + it('should serialize with url.format() as a url.parse() result does', () => { + const uris = [ + 'http://example.com/cb', + 'http://example.com/cb?foo=bar', + 'https://example.com', + 'https://example.com:8443/a/b?x=1&x=2&y=', + 'http://[::1]:5000/cb?a=b', + 'https://user:pa%20ss@example.com/cb', + 'https://example.com/cb?q=a%20b&r=%E2%9C%93', + 'com.example.app:/oauth2redirect', + 'myapp://auth/callback?x=1', + ]; + const expected = [ + 'http://example.com/cb?code=c&state=s', + 'http://example.com/cb?foo=bar&code=c&state=s', + 'https://example.com/?code=c&state=s', + 'https://example.com:8443/a/b?x=1&x=2&y=&code=c&state=s', + 'http://[::1]:5000/cb?a=b&code=c&state=s', + 'https://user:pa%20ss@example.com/cb?code=c&state=s', + 'https://example.com/cb?q=a%20b&r=%E2%9C%93&code=c&state=s', + 'com.example.app:/oauth2redirect?code=c&state=s', + 'myapp://auth/callback?x=1&code=c&state=s', + ]; + + uris.forEach((value, index) => { + // As CodeResponseType#buildRedirectUri and AuthorizeHandler#updateResponse change it. + const uri = parseRedirectUri(value, true); + uri.query.code = 'c'; + uri.search = null; + uri.query.state = 's'; + + url.format(uri).should.equal(expected[index]); + }); + }); + + it('should throw on a value that is not an absolute URI', () => { + const invalid = ['/cb', 'cb', '', 'http://', '//example.com/cb', 'https://exa mple.com/cb']; + + invalid.forEach((value) => { + try { + parseRedirectUri(value); + + should.fail(); + } catch (e) { + e.should.be.an.instanceOf(InvalidArgumentError); + e.message.should.equal('Invalid parameter: `redirectUri`'); + } + }); + }); + + it('should return the userinfo as it is when it is not valid percent-encoding', () => { + const uri = parseRedirectUri('https://us%zzer@example.com/cb'); + + uri.auth.should.equal('us%zzer'); + }); +});