hapi-auth-jwt2
Version:
Hapi.js Authentication Plugin/Scheme using JSON Web Tokens (JWT)
444 lines (418 loc) • 13.9 kB
JavaScript
const Boom = require('@hapi/boom'); // error handling https://github.com/hapijs/boom
const assert = require('assert'); // use assert to check if options are set
const JWT = require('jsonwebtoken'); // https://github.com/docdis/learn-json-web-tokens
const extract = require('./extract.cjs'); // extract token from Auth Header, URL or Cookie
const pkg = require('../package.json'); // use package name and version rom package.json
const internals = {}; // see: https://hapi.dev/policies/styleguide/#module-globals
/**
* register registers the name and exposes the implementation of the plugin
* see: https://hapi.dev/api/#-serveroptionsplugins for plugin format
* @param {Object} server - the hapi server to which we are attaching the plugin
* @param {Object} options - any options set during plugin registration
* in this case we are not using the options during register but we do later.
* @param {Function} next - the callback called once registration succeeds
* @returns {Function} next - returns (calls) the callback when complete.
*/
exports.plugin = {
register: (server, options) => {
server.auth.scheme('jwt', internals.implementation); // hapijs.com/api#serverauthapi
},
};
/**
* attributes merely aliases the package.json (re-uses package name & version)
* simple example: github.com/hapijs/hapi/blob/master/API.md#serverplugins
*/
exports.plugin.pkg = pkg; // hapi requires attributes for a plugin.
// also see: https://hapi.dev/tutorials/plugins/
/**
* specify peer dependency on hapi, enforced by hapi at runtime
*/
exports.plugin.requirements = {
hapi: '>=17',
};
internals.FIRST_PASS_AUTHENTICATION_FAILED = 'firstPassAuthenticationFailed';
/**
* checkObjectType returns the class of the object's prototype
* @param {Object} objectToCheck - the object for which we want to check the type
* @returns {String} - the string of the object class
*/
internals.checkObjectType = (objectToCheck) => {
const toString = Object.prototype.toString;
return toString.call(objectToCheck);
};
/**
* isFunction checks if a given value is a function.
* @param {Object} functionToCheck - the object we want to confirm is a function
* @returns {Boolean} - true if the functionToCheck is a function. :-)
*/
internals.isFunction = (functionToCheck) => {
return (
functionToCheck &&
(internals.checkObjectType(functionToCheck) === '[object Function]' ||
internals.checkObjectType(functionToCheck) === '[object AsyncFunction]')
);
};
internals.getKeys = async (decoded, options) => {
// if keyFunc is function allow dynamic key lookup: https://git.io/vXjvY
const { key, ...extraInfo } = internals.isFunction(options.key)
? await options.key(decoded)
: { key: options.key };
const keys = Array.isArray(key) ? key : [key];
return { keys, extraInfo };
};
internals.verifyJwt = (token, keys, options) => {
let error;
for (const k of keys) {
try {
return JWT.verify(token, k, options.verifyOptions);
} catch (verify_err) {
error = verify_err;
}
}
throw error;
};
internals.authenticate = async (token, options, request, h) => {
let tokenType = options.tokenType || 'Token'; // see: https://git.io/vXje9
let decoded;
if (!token) {
return {
error: internals.raiseError(
options,
request,
h,
'unauthorized',
'token is null',
{ scheme: tokenType },
true //flag missing token to HAPI auth framework to allow subsequent auth strategies
),
payload: {
credentials: tokenType,
},
};
}
// quick check for validity of token format
if (!extract.isValid(token)) {
return {
error: internals.raiseError(
options,
request,
h,
'unauthorized',
'Invalid token format',
{ scheme: tokenType },
true //flag missing token to HAPI auth framework to allow subsequent auth strategies
),
payload: {
credentials: token,
},
};
}
// verification is done later, but we want to avoid decoding if malformed
request.auth.token = token; // keep encoded JWT available in the request
// otherwise use the same key (String) to validate all JWTs
let decodeErr;
try {
decoded = JWT.decode(token, { complete: options.complete || false });
} catch (e) {
// fix for https://github.com/dwyl/hapi-auth-jwt2/issues/328 -
// JWT.decode() can fail either by throwing an exception or by
// returning null, so here we just fall through to the following
// block that tests if decoded is not set, so that we can handle
// both failure types at once
decodeErr = e;
}
if (!decoded) {
return {
error: internals.raiseError(
options,
request,
h,
'unauthorized',
'Invalid token format',
{ scheme: tokenType, error: decodeErr }
),
payload: {
credentials: token,
},
};
}
if (typeof options.validate === 'function') {
const { keys, extraInfo } = await internals.getKeys(decoded, options);
/* istanbul ignore else */
if (extraInfo) {
request.plugins[pkg.name] = { extraInfo };
}
let verify_decoded;
try {
verify_decoded = internals.verifyJwt(token, keys, options);
} catch (verify_err) {
let err_message =
verify_err.message === 'jwt expired'
? 'Expired token'
: 'Invalid token';
return {
error: internals.raiseError(
options,
request,
h,
'unauthorized',
err_message,
{ scheme: tokenType, decoded, error: verify_err }
),
payload: { credentials: token },
};
}
try {
let { isValid, credentials, response, errorMessage } =
await options.validate(verify_decoded, request, h);
if (response !== undefined) {
return { response };
}
if (!isValid) {
// invalid credentials
return {
error: internals.raiseError(
options,
request,
h,
'unauthorized',
errorMessage || 'Invalid credentials',
{ scheme: tokenType, decoded }
),
payload: { credentials: decoded },
};
}
// valid key and credentials
return {
payload: {
credentials:
credentials && typeof credentials === 'object'
? credentials
: decoded,
artifacts: {
token,
decoded,
},
},
};
} catch (validate_err) {
return {
error: internals.raiseError(
options,
request,
h,
'boomify',
validate_err,
{ decoded }
),
payload: {
credentials: decoded,
},
};
}
}
// see: https://github.com/dwyl/hapi-auth-jwt2/issues/130
try {
// note: at this point, we know options.verify must be non-null,
// because options.validate or options.verify are required to have
// been provided, and if options.validate were non-null, then we
// would have hit the above block and already returned out of this
// function
let { isValid, credentials } = await options.verify(decoded, request);
if (!isValid) {
return {
error: internals.raiseError(
options,
request,
h,
'unauthorized',
'Invalid credentials',
{ scheme: tokenType, decoded }
),
payload: { credentials: decoded },
};
}
return {
payload: {
credentials: credentials,
artifacts: {
token,
decoded,
},
},
};
} catch (verify_error) {
return {
error: internals.raiseError(
options,
request,
h,
'boomify',
verify_error,
{ decoded }
),
payload: {
credentials: decoded,
},
};
}
};
// allow custom error raising or default to Boom if no errorFunc is defined
internals.raiseError = function raiseError(
options,
request,
h,
errorType,
errorOrMessage,
extraContext,
isMissingToken
) {
let errorContext = {
errorType: errorType,
message: errorOrMessage.toString(),
error: typeof errorOrMessage === 'object' ? errorOrMessage : undefined,
};
Object.assign(errorContext, extraContext);
if (internals.isFunction(options.errorFunc)) {
errorContext = options.errorFunc(errorContext, request, h);
}
// Since it is clearly specified in the docs that
// the errorFunc must return an object with keys:
// errorType and message, we need not worry about
// errorContext being undefined
let error;
if (
errorContext.error instanceof Error &&
errorContext.errorType === 'boomify'
) {
error = Boom.boomify(errorContext.error);
} else {
error = Boom[errorContext.errorType](
errorContext.message,
errorContext.scheme,
errorContext.attributes
);
}
return isMissingToken
? Object.assign(error, {
isMissing: true,
})
: error;
};
/**
* implementation is the "main" interface to the plugin and contains all the
* "implementation details" (methods) such as authenticate, response & raiseError
* @param {Object} server - the Hapi.js server object we are attaching the
* the hapi-auth-jwt2 plugin to.
* @param {Object} options - any configuration options passed in.
* @returns {Function} authenticate - we return the authenticate method after
* registering the plugin as that's the method that gets called for each route.
*/
internals.implementation = (server, options) => {
assert(options, 'options are required for jwt auth scheme'); // pre-auth checks
assert(
options.validate || options.verify,
'validate OR verify function is required!'
);
return {
/**
* authenticate is the "work horse" of the plugin. it's the method that gets
* called every time a route is requested and needs to validate/verify a JWT
* @param {Object} request - the standard route handler request object
* @param {Object} h - the standard hapi reply interface
* @returns {Boolean} if the JWT is valid we return a credentials object
* otherwise throw an error to inform the app & client of unauthorized req.
*/
authenticate: async (request, h) => {
let token = extract(request, options); // extract token Header/Cookie/Query
if (
token == null &&
options.attemptToExtractTokenInPayload &&
request.method.toLowerCase() === 'post'
) {
return h.authenticated({
credentials: {
error: internals.FIRST_PASS_AUTHENTICATION_FAILED,
},
});
}
const result = await internals.authenticate(token, options, request, h);
if (result.error) {
return h.unauthenticated(result.error, result.payload);
} else if (result.response) {
return h.response(result.response).takeover();
} else {
return h.authenticated(result.payload);
}
},
/**
* payload is an Optional method called if an options.payload is set.
* cf. https://hapi.dev/tutorials/auth/
* @param {Object} request - the standard route handler request object
* @param {Object} h - the standard hapi reply interface ...
* after we run the custom options.payloadFunc we h.continue to execute
* the next plugin in the list.
* @returns {Boolean} true. always return true (unless there's an error...)
*/
payload: async (request, h) => {
if (
options.attemptToExtractTokenInPayload &&
request.auth.credentials.error ===
internals.FIRST_PASS_AUTHENTICATION_FAILED
) {
const token = extract(request, options);
const result = await internals.authenticate(token, options, request, h);
if (result && !result.error && result.payload) {
request.auth.credentials = result.payload.credentials;
request.auth.token = result.payload.token;
} else {
delete result.error.isMissing;
return result.error;
}
}
const payloadFunc = options.payloadFunc;
if (payloadFunc && typeof payloadFunc === 'function') {
return payloadFunc(request, h);
}
return h.continue;
},
/**
* response is an Optional method called if an options.responseFunc is set.
* @param {Object} request - the standard route handler request object
* @param {Object} h - the standard hapi reply interface ...
* after we run the custom options.responseFunc we h.continue to execute
* the next plugin in the list.
* @returns {Boolean} true. always return true (unless there's an error...)
*/
response: (request, h) => {
const responseFunc = options.responseFunc;
if (responseFunc && typeof responseFunc === 'function') {
if (
internals.checkObjectType(responseFunc) === '[object AsyncFunction]'
) {
return responseFunc(request, h)
.then(() => h.continue)
.catch((err) =>
internals.raiseError(options, request, h, 'boomify', err)
);
}
try {
// allow responseFunc to decorate or throw
responseFunc(request, h);
} catch (err) {
throw internals.raiseError(options, request, h, 'boomify', err);
}
}
return h.continue;
},
verify: async (auth) => {
const token = auth.artifacts.token;
const decoded = JWT.decode(token, {
complete: options.complete || false,
});
const { keys } = await internals.getKeys(decoded, options);
internals.verifyJwt(token, keys, options);
},
};
};
;