Updated members-ssr to use token from query string

no-issue

This changes the exchangeTokenForSession method to read the token from a
`token` query string, rather than from the request body.

This also includes a refactor to change MembersSSR into a class, and
document all methods with JsDoc type annotations which can be
interpreted by the typescript compiler
This commit is contained in:
Fabien O'Carroll 2019-09-16 12:32:51 +08:00
parent e2d06307f2
commit 016422ce06

View File

@ -1,170 +1,326 @@
const concat = require('concat-stream');
const Cookies = require('cookies');
const {parse: parseUrl} = require('url');
const createCookies = require('cookies');
const ignition = require('ghost-ignition');
const {
BadRequestError
} = ignition.errors;
const EMPTY = {};
/**
* @typedef {import('http').IncomingMessage} Request
* @typedef {import('http').ServerResponse} Response
* @typedef {import('cookies').ICookies} Cookies
* @typedef {import('cookies').Option} CookiesOptions
* @typedef {import('cookies').SetOption} SetCookieOptions
* @typedef {string} JWT
*/
/**
* @typedef {object} Member
* @prop {string} email
*/
const SIX_MONTHS_MS = 1000 * 60 * 60 * 24 * 184;
const ONE_DAY_MS = 1000 * 60 * 60 * 24;
const withCookies = (fn, cookieConfig) => (req, res) => {
return new Promise((resolve) => {
const cookies = new Cookies(req, res, cookieConfig);
resolve(fn(req, res, {cookies}));
});
};
class MembersSSR {
/**
* @typedef {object} MembersSSROptions
*
* @prop {string|string[]} cookieKeys - A secret or array of secrets used to sign cookies
* @prop {() => object} getMembersApi - A function which returns an instance of members-api
* @prop {boolean} [cookieSecure = true] - Whether the cookie should have Secure flag
* @prop {string} [cookieName] - The name of the members-ssr cookie
* @prop {number} [cookieMaxAge] - The max age in ms of the members-ssr cookie
* @prop {string} [cookieCacheName] - The name of the members-ssr-cache cookie
* @prop {number} [cookieCacheMaxAge] - The max age in ms of the members-ssr-cache cookie
* @prop {string} [cookiePath] - The Path flag for the cookie
*/
const withBodyAndCookies = (fn, cookieConfig) => (req, res) => {
return new Promise((resolve, reject) => {
const cookies = new Cookies(req, res, cookieConfig);
req.on('error', reject);
req.pipe(concat(function (buff) {
const body = buff.toString();
resolve(fn(req, res, {body, cookies}));
}));
});
};
/**
* Create an instance of MembersSSR
*
* @param {MembersSSROptions} options - The options for the members ssr class
*/
constructor(options) {
const {
cookieSecure = true,
cookieName = 'members-ssr',
cookieMaxAge = SIX_MONTHS_MS,
cookieCacheName = 'members-ssr-cache',
cookieCacheMaxAge = ONE_DAY_MS,
cookiePath = '/',
cookieKeys,
getMembersApi
} = options;
const get = (value) => {
return typeof value === 'function' ? value() : value;
};
if (!getMembersApi) {
throw new Error('Missing option getMembersApi');
}
module.exports = function create(options = EMPTY) {
if (options === EMPTY) {
throw new Error('Must pass options');
this._getMembersApi = getMembersApi;
if (!cookieKeys) {
throw new Error('Missing option cookieKeys');
}
this.sessionCookieName = cookieName;
this.cacheCookieName = cookieCacheName;
/**
* @type SetCookieOptions
*/
this.sessionCookieOptions = {
signed: true,
httpOnly: true,
sameSite: 'lax',
maxAge: cookieMaxAge,
path: cookiePath
};
/**
* @type SetCookieOptions
*/
this.cacheCookieOptions = {
signed: true,
httpOnly: true,
sameSite: 'lax',
maxAge: cookieCacheMaxAge,
path: cookiePath
};
/**
* @type CookiesOptions
*/
this.cookiesOptions = {
keys: Array.isArray(cookieKeys) ? cookieKeys : [cookieKeys],
secure: cookieSecure
};
}
const {
cookieMaxAge = SIX_MONTHS_MS,
cookieSecure = true,
cookieName = 'members-ssr',
cookieCacheName = 'members-ssr-cache',
cookieCacheMaxAge = ONE_DAY_MS,
cookiePath = '/',
cookieKeys,
membersApi
} = options;
if (!membersApi) {
throw new Error('Missing option membersApi');
/**
* @method _getCookies
*
* @param {Request} req
* @param {Response} res
*
* @returns {Cookies} An instance of the cookies object for current request/response
*/
_getCookies(req, res) {
return createCookies(req, res, this.cookiesOptions);
}
if (!cookieKeys) {
throw new Error('Missing option cookieKeys');
/**
* @method _removeSessionCookie
*
* @param {Request} req
* @param {Response} res
*/
_removeSessionCookie(req, res) {
const cookies = this._getCookies(req, res);
cookies.set(this.sessionCookieName, this.sessionCookieOptions);
}
const cookieConfig = {
keys: [].concat(cookieKeys),
secure: cookieSecure
};
/**
* @method _setSessionCookie
*
* @param {Request} req
* @param {Response} res
* @param {string} value
*/
_setSessionCookie(req, res, value) {
const cookies = this._getCookies(req, res);
cookies.set(this.sessionCookieName, value, this.sessionCookieOptions);
}
const getMemberDataFromToken = token => get(membersApi).getMemberDataFromMagicLinkToken(token);
/**
* @method _getSessionCookies
*
* @param {Request} req
* @param {Response} res
*
* @returns {string} The cookie value
*/
_getSessionCookies(req, res) {
const cookies = this._getCookies(req, res);
const value = cookies.get(this.sessionCookieName, {signed: true});
if (!value) {
throw new BadRequestError({
message: `Cookie ${this.sessionCookieName} not found`
});
}
return value;
}
const exchangeTokenForSession = withBodyAndCookies(async (_req, _res, {body, cookies}) => {
const token = body;
if (!body || typeof body !== 'string') {
/**
* @method _removeCacheCookie
*
* @param {Request} req
* @param {Response} res
*/
_removeCacheCookie(req, res) {
const cookies = this._getCookies(req, res);
cookies.set(this.cacheCookieName, this.cacheCookieOptions);
}
/**
* @method _setCacheCookie
*
* @param {Request} req
* @param {Response} res
* @param {object} value
*/
_setCacheCookie(req, res, value) {
const cookies = this._getCookies(req, res);
cookies.set(this.cacheCookieName, JSON.stringify(value), this.cacheCookieOptions);
}
/**
* @method _getCacheCookie
*
* @param {Request} req
* @param {Response} res
*
* @returns {object|null} The cookie value
*/
_getCacheCookie(req, res) {
const cookies = this._getCookies(req, res);
const value = cookies.get(this.cacheCookieName, {signed: true});
if (!value) {
return null;
}
try {
return JSON.parse(value);
} catch (err) {
this._removeCacheCookie(req, res);
throw new BadRequestError({
message: `Invalid JSON found in cookie ${this.cacheCookieName}`
});
}
}
/**
* @method _getMemberDataFromToken
*
* @param {JWT} token
*
* @returns {Promise<Member>} member
*/
async _getMemberDataFromToken(token) {
const api = await this._getMembersApi();
return api.getMemberDataFromMagicLinkToken(token);
}
/**
* @method _getMemberIdentityData
*
* @param {string} email
*
* @returns {Promise<Member>} member
*/
async _getMemberIdentityData(email) {
const api = await this._getMembersApi();
return api.getMemberIdentityData(email);
}
/**
* @method _getMemberIdentityToken
*
* @param {string} email
*
* @returns {Promise<JWT>} member
*/
async _getMemberIdentityToken(email) {
const api = await this._getMembersApi();
return api.getMemberIdentityToken(email);
}
/**
* @method exchangeTokenForSession
* @param {Request} req
* @param {Response} res
*
* @returns {Promise<Member>} The member the session was created for
*/
async exchangeTokenForSession(req, res) {
if (!req.url) {
return Promise.reject(new BadRequestError({
message: 'Expected body containing JWT'
message: 'Expected token param containing JWT'
}));
}
const member = await getMemberDataFromToken(token);
cookies.set(cookieName, member.email, {
signed: true,
httpOnly: true,
sameSite: 'lax',
maxAge: cookieMaxAge,
path: cookiePath
});
cookies.set(cookieCacheName, JSON.stringify(member), {
signed: true,
httpOnly: true,
sameSite: 'lax',
maxAge: cookieCacheMaxAge,
path: cookiePath
});
}, cookieConfig);
const deleteSession = withCookies((_req, _res, {cookies}) => {
cookies.set(cookieName, {
signed: true,
httpOnly: true,
sameSite: 'lax',
maxAge: cookieMaxAge,
path: cookiePath
});
cookies.set(cookieCacheName, {
signed: true,
httpOnly: true,
sameSite: 'lax',
maxAge: cookieCacheMaxAge,
path: cookiePath
});
}, cookieConfig);
const getMemberDataFromSession = withCookies(async (_req, _res, {cookies}) => {
const email = cookies.get(cookieName, {
signed: true
});
if (!email) {
throw new BadRequestError({
message: `Cookie ${cookieName} not found`
});
const {query} = parseUrl(req.url, true);
if (!query || !query.token) {
return Promise.reject(new BadRequestError({
message: 'Expected token param containing JWT'
}));
}
const cachedMember = cookies.get(cookieCacheName, {
signed: true
});
const token = Array.isArray(query.token) ? query.token[0] : query.token;
const member = await this._getMemberDataFromToken(token);
if (cachedMember) {
try {
return JSON.parse(cachedMember);
} catch (e) {
cookies.set(cookieCacheName, {
signed: true,
httpOnly: true,
sameSite: 'lax',
maxAge: cookieCacheMaxAge,
path: cookiePath
});
throw new BadRequestError({
message: `Invalid JSON found in cookie ${cookieCacheName}`
});
}
}
const member = await get(membersApi).getMemberIdentityData(email);
cookies.set(cookieCacheName, JSON.stringify(member), {
signed: true,
httpOnly: true,
sameSite: 'lax',
maxAge: cookieCacheMaxAge,
path: cookiePath
});
this._setSessionCookie(req, res, member.email);
this._setCacheCookie(req, res, member);
return member;
}, cookieConfig);
}
const getIdentityTokenForMemberFromSession = withCookies(async (_req, _res, {cookies}) => {
try {
const email = cookies.get(cookieName, {
signed: true
});
return get(membersApi).getMemberIdentityToken(email);
} catch (e) {
throw new BadRequestError({
message: `Cookie ${cookieName} not found`
});
/**
* @method deleteSession
* @param {Request} req
* @param {Response} res
*
* @returns {Promise<void>}
*/
async deleteSession(req, res) {
this._removeSessionCookie(req, res);
this._removeCacheCookie(req, res);
}
/**
* @method getMemberDataFromSession
*
* @param {Request} req
* @param {Response} res
*
* @returns {Promise<Member>}
*/
async getMemberDataFromSession(req, res) {
const email = this._getSessionCookies(req, res);
const cachedMember = this._getCacheCookie(req, res);
if (cachedMember) {
return cachedMember;
}
}, cookieConfig);
return {
exchangeTokenForSession,
deleteSession,
getMemberDataFromSession,
getIdentityTokenForMemberFromSession
};
const member = await this._getMemberIdentityData(email);
this._setCacheCookie(req, res, member);
return member;
}
/**
* @method getIdentityTokenForMemberFromSession
*
* @param {Request} req
* @param {Response} res
*
* @returns {Promise<JWT>} identity token
*/
async getIdentityTokenForMemberFromSession(req, res) {
const email = this._getSessionCookies(req, res);
return this._getMemberIdentityToken(email);
}
}
/**
* Factory function for creating instance of MembersSSR
*
* @param {MembersSSROptions} options
* @returns {MembersSSR}
*/
module.exports = function create(options) {
if (!options) {
throw new Error('Must pass options');
}
return new MembersSSR(options);
};