@lykmapipo/jwt-common
Version:
Helper utilities for day to day jwt usage.
479 lines (432 loc) • 13.5 kB
JavaScript
import { isFunction, isEmpty, get, flattenDeep } from 'lodash';
import { sign, verify, decode as decode$1 } from 'jsonwebtoken';
import { waterfall, parallel } from 'async';
import { compact, mergeObjects, uniq } from '@lykmapipo/common';
import { getString } from '@lykmapipo/env';
/**
* @function withDefaults
* @name withDefaults
* @description merge provided options with defaults
* @param {object} [optns] provided options
* @returns {object} merged options with environment variables
* @author lally elias <lallyelias87@mail.com>
* @license MIT
* @since 0.1.0
* @version 0.1.0
* @static
* @public
* @example
*
* const { withDefaults } = require('@lykmapipo/jwt-common');
* withDefaults({ secret: 'xo67Rw' }) // => { secret: 'xo67Rw', ...}
*/
const withDefaults = (optns) => {
// obtain defaults
const defaults = {
secret: getString('JWT_SECRET'),
algorithm: getString('JWT_ALGORITHM', 'HS256'),
audience: getString('JWT_AUDIENCE'),
issuer: getString('JWT_ISSUER'),
subject: getString('JWT_SUBJECT'),
expiresIn: getString('JWT_EXPIRES_IN'),
};
// merge provided with defaults
const options = compact(mergeObjects(defaults, optns));
// return merged options
return options;
};
/**
* @function encode
* @name encode
* @description encode given payload as jwt.
* @param {object} payload data to encode.
* @param {object} [optns] jwt sign or encoding options.
* @param {Function} cb callback to invoke on success or failure.
* @returns {string | Error} jwt token if success or error.
* @author lally elias <lallyelias87@mail.com>
* @license MIT
* @since 0.1.0
* @version 0.1.0
* @static
* @public
* @example
*
* const { encode } = require('@lykmapipo/jwt-common');
*
* const payload = { _id: 'xo5', permissions: ['user:read'] };
*
* // encode with default options
* encode(payload, (error, jwt) => { ... });
*
* // encode with merged options
* encode(payload, { secret: 'xo67Rw' }, (error, jwt) => { ... });
*/
const encode = (payload, optns, cb) => {
// normalize arguments
const options = withDefaults(isFunction(optns) ? {} : optns);
const done = isFunction(optns) ? optns : cb;
// throw if empty payload
if (isEmpty(payload)) {
const error = new Error('Payload Required');
error.status = 400;
return done(error);
}
// continue with encoding
// prepare jwt sign options
const { secret, ...jwtSignOptns } = options;
// generate jwt
return sign(payload, secret, jwtSignOptns, done);
};
/**
* @function decode
* @name decode
* @description decode and verify given jwt.
* @param {string} token jwt token to decode.
* @param {object} [optns] jwt verify or decoding options.
* @param {Function} cb callback to invoke on success or failure.
* @returns {object|Error} payload if success or error.
* @author lally elias <lallyelias87@mail.com>
* @license MIT
* @since 0.1.0
* @version 0.1.0
* @static
* @public
* @example
*
* const { decode } = require('@lykmapipo/jwt-common');
*
* const token = 'eyJhbGciOiJIUz...';
*
* // decode with default options
* decode(token, (error, payload) => { ... });
*
* // decode with provided options
* decode(token, { secret: 'xo67Rw' }, (error, payload) => { ... });
*/
const decode = (token, optns, cb) => {
// normalize arguments
const options = withDefaults(isFunction(optns) ? {} : optns);
const done = isFunction(optns) ? optns : cb;
// prepare jwt decoding options
const { secret, ...jwtVerifyOptns } = options;
// decode and verify
return verify(token, secret, jwtVerifyOptns, done);
};
/**
* @function refresh
* @name refresh
* @description decode a given jwt, if expired return new jwt.
* @param {string} token jwt token to refresh.
* @param {object} payload data to encode.
* @param {object} [optns] jwt verify or decoding options.
* @param {Function} cb callback to invoke on success or failure.
* @returns {string | Error} jwt token if success or error.
* @author lally elias <lallyelias87@mail.com>
* @license MIT
* @since 0.4.0
* @version 0.1.0
* @static
* @public
* @example
*
* const { refresh } = require('@lykmapipo/jwt-common');
*
* const token = 'eyJhbGciOiJIUz...';
* const payload = { _id: 'xo5', permissions: ['user:read'] };
*
* // refresh with default options
* refresh(token, payload, (error, jwt) => { ... });
*
* // refresh with provided options
* refresh(token, payload, { secret: 'xo67Rw' }, (error, jwt) => { ... });
*/
const refresh = (token, payload, optns, cb) => {
// normalize arguments
const options = withDefaults(isFunction(optns) ? {} : optns);
const done = isFunction(optns) ? optns : cb;
// try decode token
const doDecode = (next) => {
// decode jwt
return decode(token, options, (error, decoded) => {
// ignore if expired(or jwt errors)
return next(null, decoded || {});
});
};
// try return fresh token
const doEncode = (decoded, next) => {
// return token if still valid
if (!isEmpty(decoded)) {
return next(null, token);
}
// create fresh jwt
return encode(payload, options, next);
};
// prepare refresh tasks
const tasks = [doDecode, doEncode];
// do refresh
return waterfall(tasks, done);
};
/**
* @function isExpired
* @name isExpired
* @description check if jwt expired without verifying if
* the signature is valid.
* @param {string} token jwt token to check for expiry.
* @param {object} [optns] jwt verify or decoding options.
* @returns {boolean} whether jwt expired.
* @author lally elias <lallyelias87@mail.com>
* @license MIT
* @since 0.4.0
* @version 0.1.0
* @static
* @public
* @example
*
* const { isExpired } = require('@lykmapipo/jwt-common');
*
* const token = 'eyJhbGciOiJIUz...';
*
* // isExpired with default options
* isExpired(token); //=> false
*
* // isExpired with provided options
* const optns = { clockTimestamp : Math.floor(Date.now() / 1000) }
* isExpired(token, optns); //=> true
*/
const isExpired = (token, optns) => {
// normalize arguments
const options = withDefaults(optns);
// obtain clock timestamp
const clockTimestamp =
options.clockTimestamp || Math.floor(Date.now() / 1000);
// decode jwt
const { payload } = decode$1(token, { complete: true }) || {};
// check for expiry
if (payload && payload.exp) {
return clockTimestamp >= payload.exp;
}
// always true if error
return true;
};
/**
* @function decodeJwtToUser
* @name decodeJwtToUser
* @description return a function used to decode jwt to user.
* @param {object} [optns] decoding options.
* @param {Function} [optns.user] custom user fetch function
* @returns {Function} jwt to user decoder
* @author lally elias <lallyelias87@mail.com>
* @license MIT
* @since 0.2.0
* @version 0.1.0
* @private
*/
const decodeJwtToUser = (optns = {}) => {
// obtain jwt to user decoder
const { user = (token, next) => next(null, null) } = optns;
// wrap decoder
const decodeToUser = (token, next) => {
user(token, (error, foundUser) => next(error, token, foundUser));
};
// return jwt to user decoder
return decodeToUser;
};
/**
* @function parseJwtFromHttpHeaders
* @name parseJwtFromHttpHeaders
* @description parse request headers to get jwt.
* @param {object} request valid http request object.
* @param {Function} done callback to invoke on success or failure.
* @returns {object|Error} jwt if success or error.
* @author lally elias <lallyelias87@mail.com>
* @license MIT
* @since 0.1.0
* @version 0.1.0
* @static
* @public
* @example
*
* const { parseJwtFromHttpHeaders } = require('@lykmapipo/jwt-common');
* parseJwtFromHttpHeaders(request, (error, jwt) => { ... });
*/
const parseJwtFromHttpHeaders = (request, done) => {
let token;
// get authorization header
const authorization =
get(request, 'headers.authorization') ||
get(request, 'headers.Authorization');
// parse jwt from header
if (!isEmpty(authorization)) {
// split authorization headers
const parts = authorization.split(' ');
const [scheme, parsedToken] = parts;
// is token in the form of Bearer token
if (/^Bearer$/i.test(scheme)) {
token = parsedToken;
}
// no its just a token
else {
token = scheme;
}
}
// return found token
return done(null, token);
};
/**
* @function parseJwtFromHttpQueryParams
* @name parseJwtFromHttpQueryParams
* @description parse request headers to get jwt.
* @param {object} request valid http request object.
* @param {Function} done callback to invoke on success or failure.
* @returns {object|Error} jwt if success or error.
* @author lally elias <lallyelias87@mail.com>
* @license MIT
* @since 0.1.0
* @version 0.1.0
* @static
* @public
* @example
*
* const { parseJwtFromHttpQueryParams } = require('@lykmapipo/jwt-common');
* parseJwtFromHttpQueryParams(request, (error, jwt) => { ... });
*/
const parseJwtFromHttpQueryParams = (request, done) => {
// get jwt from request query params
const token = get(request, 'query.token');
if (!isEmpty(token)) {
// delete the token from query params
delete request.query.token;
}
// return found token
return done(null, token);
};
/**
* @function parseJwtFromHttpRequest
* @name parseJwtFromHttpRequest
* @description parse request headers to get jwt.
* @param {object} request valid http request object.
* @param {Function} done callback to invoke on success or failure.
* @returns {object|Error} jwt if success or error.
* @author lally elias <lallyelias87@mail.com>
* @license MIT
* @since 0.1.0
* @version 0.1.0
* @static
* @public
* @example
*
* const { parseJwtFromHttpRequest } = require('@lykmapipo/jwt-common');
* parseJwtFromHttpRequest(request, (error, jwt) => { ... });
*/
const parseJwtFromHttpRequest = (request, done) => {
// parse for jwt from request headers and query params
return parallel(
{
headerToken: (next) => parseJwtFromHttpHeaders(request, next),
urlToken: (next) => parseJwtFromHttpQueryParams(request, next),
},
(error, results = {}) => {
// collect parsed header
const { headerToken, urlToken } = results;
const token = headerToken || urlToken;
if (error || isEmpty(token)) {
error = error || new Error('Unauthorized'); //eslint-disable-line
error.status = error.status || 401; //eslint-disable-line
error.message = error.message || 'Unauthorized'; //eslint-disable-line
return done(error);
}
return done(null, token);
}
);
};
/**
* @function jwtAuth
* @name jwtAuth
* @description create middlware to authorize request using jwt
* @param {object} [optns] jwt verify or decoding options.
* @returns {Function} express compactoble middleware.
* @author lally elias <lallyelias87@mail.com>
* @license MIT
* @since 0.1.0
* @version 0.1.0
* @static
* @public
* @example
*
* const { jwtAuth } = require('@lykmapipo/jwt-common');
*
* app.get('/users', jwtAuth({ secret: 'xo67Rw' }), (req, res, next) => { ... });
*/
const jwtAuth = (optns) => {
// implement jwt authorize middleware
const jwtAuthorize = (request, response, next) => {
// parse jwt from request
const parseJwt = (cb) => parseJwtFromHttpRequest(request, cb);
// decode jwt from request
const decodeJwt = (token, cb) => decode(token, optns, cb);
// run
waterfall(
[parseJwt, decodeJwt, decodeJwtToUser(optns)],
(error, token, user) => {
// handle error
if (error) {
error.status = error.status || 401; //eslint-disable-line
error.message = error.message || 'Unauthorized'; //eslint-disable-line
return next(error);
}
// set jwt and continue
request.jwt = token;
request.user = user;
return next();
}
);
};
// return
return jwtAuthorize;
};
/**
* @function jwtPermit
* @name jwtPermit
* @description create middlware to check request for jwt permissions(or scopes).
* @param {string[] | ...string} requiredScopes required scopes or permissions.
* @returns {Function} express compactoble middleware.
* @author lally elias <lallyelias87@mail.com>
* @license MIT
* @since 0.1.0
* @version 0.1.0
* @static
* @public
* @example
*
* const { jwtPermit } = require('@lykmapipo/jwt-common');
*
* app.get('/users', jwtPermit('user:read'), (req, res, next) => { ... });
*/
const jwtPermit = (...requiredScopes) => {
// implement jwt permit
const checkJwtPermit = (request, response, next) => {
// obtain user and jwt from request
const { user = {}, jwt = {} } = request;
// obtain scopes
const jwtScopes = jwt.scope || jwt.scopes || jwt.permissions;
const userScopes = user.scope || user.scopes || user.permissions;
let givenScopes = [].concat(userScopes || jwtScopes);
givenScopes = uniq(
flattenDeep(givenScopes.map((scope) => scope.split(' ')))
);
// check for required scopes
const permits = uniq([].concat(...requiredScopes));
const allowed = permits.some((scope) => givenScopes.includes(scope));
// has scopes
if (allowed) {
return next();
}
// has no scopes
const error = new Error('Forbidden');
error.status = 403;
return next(error);
};
// return
return checkJwtPermit;
};
export { decode, decodeJwtToUser, encode, isExpired, jwtAuth, jwtPermit, parseJwtFromHttpHeaders, parseJwtFromHttpQueryParams, parseJwtFromHttpRequest, refresh, withDefaults };