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');
+ });
+});