From 35f1aab19bf0eec06f9e054bfc88eb7f4d1e55f8 Mon Sep 17 00:00:00 2001 From: Allen Francis Date: Thu, 1 Sep 2022 19:43:28 +0530 Subject: [PATCH 1/4] feat: add support for reading token from url query parameters --- lib/plugin.js | 52 ++++++++++---- test/plugin.js | 191 ++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 222 insertions(+), 21 deletions(-) diff --git a/lib/plugin.js b/lib/plugin.js index 43ce624..77db1dd 100755 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -73,19 +73,16 @@ internals.schema.strategy = Joi.object({ cookieName: Utils.validHttpTokenSchema .optional() .messages({ - 'string.pattern.base': - 'Cookie name cannot start or end with special characters. Valid characters in cookie name are _, -, numbers and alphabets' + 'string.pattern.base': 'cookieName must be a valid http header name following https://tools.ietf.org/html/rfc7230#section-3.2.6' }), - headerName: Joi.any().when('cookieName', { - is: Joi.exist(), - then: Joi.string().forbidden().messages({ 'any.unknown': 'headerName not allowed when cookieName is specified' }), - otherwise: Utils.validHttpTokenSchema.optional() - .default('authorization') - .messages({ - 'string.pattern.base': 'Header name must be a valid header name following https://tools.ietf.org/html/rfc7230#section-3.2.6' - }) - }), + headerName: Utils.validHttpTokenSchema + .optional() + .messages({ + 'string.pattern.base': 'headerName must be a valid http header name following https://tools.ietf.org/html/rfc7230#section-3.2.6' + }), + + urlQueryParamName: Joi.string().optional(), headless: [Joi.string(), Joi.object({ alg: Joi.string().valid(...Keys.supportedAlgorithms).required(), typ: Joi.valid('JWT') }).unknown()], @@ -133,7 +130,25 @@ internals.schema.strategy = Joi.object({ }) .when('.validate', { is: Joi.not(false), then: Joi.allow(false) }) .required() -}); +}).when( + Joi.object({ + headerName: Joi.any(), + cookieName: Joi.any(), + urlQueryParamName: Joi.any() + }) + .unknown() + .or('cookieName', 'headerName', 'urlQueryParamName'), + { + then: Joi.object().xor('cookieName', 'headerName', 'urlQueryParamName') + .messages({ + 'object.xor': 'cookieName, headerName and urlQueryParam cannot be specified at the same time' + }), + otherwise: Joi.object({ + headerName: Utils.validHttpTokenSchema.default('authorization') + }) + } +); + internals.implementation = function (server, options) { @@ -252,9 +267,20 @@ internals.token = function (request, settings, missing, unauthorized) { if (settings.headerName) { authorization = request.headers[settings.headerName]; } - else { + else if (settings.cookieName) { authorization = request.state[settings.cookieName]; } + else { + const isQueryParamArray = Array.isArray(request.query[settings.urlQueryParamName]); + + if (isQueryParamArray) { + authorization = + request.query[settings.urlQueryParamName][request.query[settings.urlQueryParamName].length - 1]; + } + else { + authorization = request.query[settings.urlQueryParamName]; + } + } if (!authorization) { throw missing; diff --git a/test/plugin.js b/test/plugin.js index c6746d8..a63357a 100755 --- a/test/plugin.js +++ b/test/plugin.js @@ -528,13 +528,42 @@ describe('Plugin', () => { await jwks.server.stop(); }); - it('uses authorization as headerName when cookieName or headerName is not specified in config', async () => { + it('does not allow to specify multiple token sources at the same time', async () => { const secret = 'some_shared_secret'; const server = Hapi.server(); - server.register(Jwt); + await server.register(Jwt); + + expect( + () => { + + server.auth.strategy('jwt', 'jwt', { + keys: secret, + verify: { + aud: 'urn:audience:test', + iss: 'urn:issuer:test', + sub: false + }, + validate: (artifacts, request, h) => { + + return { isValid: true, credentials: { user: artifacts.decoded.payload.user } }; + }, + headerName: 'test', + urlQueryParamName: 'test' + }); + } + ).to.throw('cookieName, headerName and urlQueryParam cannot be specified at the same time'); + }); + + it('uses authorization as headerName when cookieName or headerName or urlQueryParamName is not specified in config', async () => { + + const secret = 'some_shared_secret'; + + const server = Hapi.server(); + + await server.register(Jwt); server.auth.strategy('jwt', 'jwt', { keys: secret, @@ -626,7 +655,7 @@ describe('Plugin', () => { expect(res.result).to.equal('steve'); }); - it('errors when token is not present at headerName or cookieName', async () => { + it('errors when token is not present at headerName or cookieName or urlQueryParamName specified', async () => { const secret = 'some_shared_secret'; @@ -636,6 +665,7 @@ describe('Plugin', () => { const headerName = 'random-header'; const cookieName = 'random-cookie'; + const urlQueryParamName = 'random-url-param'; server.auth.strategy('jwt-header', 'jwt', { keys: secret, @@ -665,23 +695,168 @@ describe('Plugin', () => { cookieName }); + server.auth.strategy('jwt-url-query-param', 'jwt', { + keys: secret, + verify: { + aud: 'urn:audience:test', + iss: 'urn:issuer:test', + sub: false + }, + validate: (artifacts, request, h) => { + + return { isValid: true, credentials: { user: artifacts.decoded.payload.user } }; + }, + urlQueryParamName + }); + + const handler = (request) => request.auth.credentials.user; + + server.route({ + path: '/header-strategy', + method: 'GET', + options: { + auth: { + strategy: 'jwt-header' + } + }, + handler + }); + + server.route({ + path: '/cookie-strategy', + method: 'GET', + options: { + auth: { + strategy: 'jwt-cookie' + } + }, + handler + }); + server.route({ - path: '/', + path: '/url-strategy', method: 'GET', options: { auth: { - strategies: ['jwt-header', 'jwt-cookie'] + strategy: 'jwt-url-query-param' } }, - handler: (request) => request.auth.credentials.user + handler }); const token = Jwt.token.generate({ user: 'steve', aud: 'urn:audience:test', iss: 'urn:issuer:test' }, secret, { header: { kid: 'some' } }); - const resWithHeader = await server.inject({ url: '/', headers: { 'another-header-name': `Bearer ${token}` } }); + const resWithHeader = await server.inject({ url: '/header-strategy', headers: { 'another-header-name': `Bearer ${token}` } }); expect(resWithHeader.statusCode).to.equal(401); - const resWithCookie = await server.inject({ url: '/', headers: { 'cookie': `another-cookie=${token}` } }); + const resWithCookie = await server.inject({ url: '/cookie-strategy', headers: { 'cookie': `another-cookie=${token}` } }); expect(resWithCookie.statusCode).to.equal(401); + + const resWithUrl = await server.inject({ url: '/url-strategy?another-query-param=test-token' }); + expect(resWithUrl.statusCode).to.equal(401); + }); + + it('reads token from url search param specified in urlQueryParamName and authenticates', async () => { + + const secret = 'some_shared_secret'; + + const server = Hapi.server(); + + server.register(Jwt); + + const urlQueryParamName = 'random-url-search-param'; + + server.auth.strategy('jwt', 'jwt', { + keys: secret, + verify: { + aud: 'urn:audience:test', + iss: 'urn:issuer:test', + sub: false + }, + validate: (artifacts, request, h) => { + + return { isValid: true, credentials: { user: artifacts.decoded.payload.user } }; + }, + urlQueryParamName + }); + + server.auth.default('jwt'); + + server.route({ path: '/', method: 'GET', handler: (request) => request.auth.credentials.user }); + + const token = Jwt.token.generate({ user: 'steve', aud: 'urn:audience:test', iss: 'urn:issuer:test' }, secret, { header: { kid: 'some' } }); + const res = await server.inject({ url: `/?${urlQueryParamName}=${token}` }); + + expect(res.result).to.equal('steve'); + }); + + it('reads token from url query param specified in urlQueryParamName and authenticates', async () => { + + const secret = 'some_shared_secret'; + + const server = Hapi.server(); + + server.register(Jwt); + + const urlQueryParamName = 'random-url-search-param'; + + server.auth.strategy('jwt', 'jwt', { + keys: secret, + verify: { + aud: 'urn:audience:test', + iss: 'urn:issuer:test', + sub: false + }, + validate: (artifacts, request, h) => { + + return { isValid: true, credentials: { user: artifacts.decoded.payload.user } }; + }, + urlQueryParamName + }); + + server.auth.default('jwt'); + + server.route({ path: '/', method: 'GET', handler: (request) => request.auth.credentials.user }); + + const token = Jwt.token.generate({ user: 'steve', aud: 'urn:audience:test', iss: 'urn:issuer:test' }, secret, { header: { kid: 'some' } }); + const res = await server.inject({ url: `/?${urlQueryParamName}=${token}` }); + + expect(res.result).to.equal('steve'); + }); + + it('reads token from last url query param value is an array', async () => { + + const secret = 'some_shared_secret'; + + const server = Hapi.server(); + + server.register(Jwt); + + const urlQueryParamName = 'random-url-search-param'; + + server.auth.strategy('jwt', 'jwt', { + keys: secret, + verify: { + aud: 'urn:audience:test', + iss: 'urn:issuer:test', + sub: false + }, + validate: (artifacts, request, h) => { + + return { isValid: true, credentials: { user: artifacts.decoded.payload.user } }; + }, + urlQueryParamName + }); + + server.auth.default('jwt'); + + server.route({ path: '/', method: 'GET', handler: (request) => request.auth.credentials.user }); + + const token1 = Jwt.token.generate({ user: 'steve1', aud: 'urn:audience:test', iss: 'urn:issuer:test' }, secret, { header: { kid: 'some' } }); + const token2 = Jwt.token.generate({ user: 'steve2', aud: 'urn:audience:test', iss: 'urn:issuer:test' }, secret, { header: { kid: 'some' } }); + + const res = await server.inject({ url: `/?${urlQueryParamName}=${token1}&${urlQueryParamName}=${token2}` }); + + expect(res.result).to.equal('steve2'); }); }); From 21dc34925994e1030857bce4f40d1977f71e78a8 Mon Sep 17 00:00:00 2001 From: Allen Francis Date: Thu, 1 Sep 2022 19:47:57 +0530 Subject: [PATCH 2/4] docs: update docs with the usage of reading from url query parameter --- API.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/API.md b/API.md index a68fc54..81bfc8a 100644 --- a/API.md +++ b/API.md @@ -256,7 +256,11 @@ validate: (artifacts, request, h) => { ##### cookieName - - `cookieName` - Tells the jwt plugin to read the token from the cookie specified. Note that the plugin does not allow you to read from cookie and header at the same time, either read from a header or from a cookie. If you want to read from cookie and header you must use multiple strategies with in which one will have `headerName` config and other will have `cookieName` config. Defaults to `undefined`. + - `cookieName` - Tells the jwt plugin to read the token from the cookie specified. Note that the plugin does not allow you to read from cookie, header and url query parameter at the same time, either read from a header or from a cookie or from a query parameter. If you want to read from multiple sources you must use multiple strategies in which one will have `headerName` config and other will have `cookieName` config and so on. Defaults to `undefined`. + + ##### urlQueryParamName + + - `urlQueryParamName` - Tells the jwt plugin to read the token from the url query parameter specified. Note that the plugin does not allow you to read from cookie, header and url query parameter at the same time, either read from a header or from a cookie or from a query parameter. Defaults to `undefined`. ## token From 0890fc7e473624e0ba44280cc3403083279ef62e Mon Sep 17 00:00:00 2001 From: Allen Francis Date: Thu, 1 Sep 2022 19:57:53 +0530 Subject: [PATCH 3/4] docs: add comments to show the rationale for checking if its an array --- lib/plugin.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/plugin.js b/lib/plugin.js index 77db1dd..8b745cb 100755 --- a/lib/plugin.js +++ b/lib/plugin.js @@ -273,6 +273,9 @@ internals.token = function (request, settings, missing, unauthorized) { else { const isQueryParamArray = Array.isArray(request.query[settings.urlQueryParamName]); + // We have to check this because there can be multiple query parameters passed which are same + // which will be kept as an array + if (isQueryParamArray) { authorization = request.query[settings.urlQueryParamName][request.query[settings.urlQueryParamName].length - 1]; From 2d1009166584ecb7179aa46899f83b2e2e8cdc02 Mon Sep 17 00:00:00 2001 From: Allen Francis Date: Thu, 8 Sep 2022 17:20:33 +0530 Subject: [PATCH 4/4] docs: update doc on query parameter specified multiple times --- API.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/API.md b/API.md index 81bfc8a..161e4e6 100644 --- a/API.md +++ b/API.md @@ -260,7 +260,7 @@ validate: (artifacts, request, h) => { ##### urlQueryParamName - - `urlQueryParamName` - Tells the jwt plugin to read the token from the url query parameter specified. Note that the plugin does not allow you to read from cookie, header and url query parameter at the same time, either read from a header or from a cookie or from a query parameter. Defaults to `undefined`. + - `urlQueryParamName` - Tells the jwt plugin to read the token from the url query parameter specified. When the url query parameter is specified multiple times (`https://example.com?token=token1&token=token2`) the value of the last one is used. Note that the plugin does not allow you to read from cookie, header and url query parameter at the same time, either read from a header or from a cookie or from a query parameter. Defaults to `undefined`. ## token