diff --git a/API.md b/API.md index a68fc54..161e4e6 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. 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 diff --git a/lib/plugin.js b/lib/plugin.js index 43ce624..8b745cb 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,23 @@ 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]); + + // 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]; + } + 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'); }); });