Skip to content

Instantly share code, notes, and snippets.

@clshortfuse
Last active November 12, 2019 17:30
Show Gist options
  • Save clshortfuse/e6137de04aa761cd9b874197c69afbc7 to your computer and use it in GitHub Desktop.
Save clshortfuse/e6137de04aa761cd9b874197c69afbc7 to your computer and use it in GitHub Desktop.
Express-Controlled JWT
import express from 'express';
import cors from 'cors';
import { sign, verify } from 'jsonwebtoken';
/**
* @typedef AuthToken
* @prop {string=} sub UserID
* @prop {number} iat Issuance date in seconds
* @prop {number=} exp Expiration date in seconds
*/
/**
* @typedef AuthResponse
* @prop {AuthToken} payload
* @prop {string} signed
*/
const SESSION_COOKIE_NAME = 'connect.sid';
const JWT_COOKIE_NAME = 'token';
const MIN_REISSUANCE_TIME_MS = 15 * 60 * 1000;
const MAX_TOKEN_DURATION_SEC = 90 * 24 * 60 * 60;
const CORS_ALLOWLIST = [
'https://api.mydomain.com',
'https://client.mydomain.com',
'https://www.mydomain.com',
];
const corsOptions = {
origin(origin, callback) {
const originIsWhitelisted = CORS_ALLOWLIST.indexOf(origin) !== -1;
callback(null, originIsWhitelisted);
},
credentials: true,
};
const router = express.Router();
/** @return {string} */
function getSigningKey() {
return 'INSECURE_KEY';
}
/**
* @param {AuthToken} payload
* @return {Promise<AuthResponse>}
*/
function signToken(payload) {
const key = getSigningKey();
return new Promise((resolve, reject) => {
sign(payload, key, (tokenErr, signed) => {
if (tokenErr) {
reject(tokenErr);
return;
}
resolve({ payload, signed });
});
});
}
/**
* @param {string} token
* @return {Promise<Object>} decodedToken
*/
function verifyJWT(token) {
const key = getSigningKey();
return new Promise((resolve, reject) => {
verify(token, key, (err, decoded) => {
if (err || typeof decoded === 'string') {
reject(new Error('INVALID TOKEN'));
return;
}
resolve(decoded);
});
});
}
/**
* @param {string} UserId
* @return {Promise}
*/
function checkUserId(userId) {
// Check against server here
return Promise.reject(new Error("Not implemented!"));
}
/**
* @param {AuthToken} token
* @return {Promise<AuthResponse>}
*/
function renewToken(token) {
const newToken = Object.assign({}, token);
// Put token new data here
return checkUserId(newToken.sub).then(() => {
// Bump expiration date
const expDate = Math.floor((Date.now() / 1000) + (MAX_TOKEN_DURATION_SEC));
if (!newToken.exp || newToken.exp < expDate) {
newToken.exp = expDate;
}
return newToken;
}).then(signToken);
}
/**
* @param {string} token
* @return {Promise<AuthResponse>}
*/
function processJWT(token) {
return verifyJWT(token).then((decoded) => {
const issuedAt = (decoded.iat || 0) * 1000;
const timeSinceIssuance = Date.now() - issuedAt;
const hasInvalidIssuedAt = decoded.exp && decoded.iat && (decoded.iat > decoded.exp);
const needsDbCheck = timeSinceIssuance > MIN_REISSUANCE_TIME_MS;
if (hasInvalidIssuedAt || needsDbCheck) {
return renewToken(decoded);
}
// Return same token
return ({
payload: decoded,
signed: token,
});
});
}
/**
* @param {string} sessionId
* @return {Promise<AuthResponse>}
*/
function verifySession(sessionId) {
return runRawQuery(
'SELECT session, expires from dbo.sessions where sid = @sid',
{ sid: sessionId }
).then((recordset) => {
if (!recordset.length) {
throw new Error('INVALID_SESSION');
}
const record = recordset[0];
const expDate = new Date(record.expires);
if (!expDate || new Date() >= expDate) {
throw new Error('EXPIRED_SESSION');
}
const sessionDataString = record.session;
if (!sessionDataString) {
throw new Error('INVALID_SESSION');
}
const data = JSON.parse(sessionDataString);
if (!data) {
throw new Error('INVALID_SESSION');
}
const userId = data.passport.user;
if (!userId) {
throw new Error('INVALID_SESSION');
}
if (typeof userId === 'string') {
return parseInt(userId, 10);
}
return userId;
}).then((userId) => {
iat: Math.floor(Date.now() / 1000),
sub: userId,
exp: Math.floor((Date.now() / 1000) + MAX_TOKEN_DURATION_SEC),
}).then(signToken);
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
* @return {void}
*/
function antiCSRFMiddleware(req, res, next) {
switch (req.method) {
case 'GET':
case 'HEAD':
case 'OPTIONS':
break;
default:
// TODO: Implement Cookie-to-Header check instead
if (!req.headers['content-type'] || req.headers['content-type'].toLowerCase() !== 'application/json') {
res.status(400);
res.end();
return;
}
}
next();
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
* @return {void}
*/
function sessionAuthMiddleware(req, res, next) {
/** @type {string} */
const sessionCookie = req.cookies[SESSION_COOKIE_NAME];
if (!sessionCookie) {
next();
return;
}
let sessionId = sessionCookie;
if (sessionCookie.startsWith('s:')) {
sessionId = sessionCookie.slice(2).split('.')[0];
}
verifySession(sessionId)
.then((authResponse) => {
// Old session is valid
res.locals.auth = authResponse.payload;
res.locals.newToken = authResponse.signed;
}).catch((err) => {
// Old session is invalid
// console.error(err);
})
.then(() => {
res.clearCookie(SESSION_COOKIE_NAME);
next();
});
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
* @return {void}
*/
function tokenAuthMiddleware(req, res, next) {
if (res.locals.auth || !req.cookies[JWT_COOKIE_NAME]) {
next();
return;
}
processJWT(req.cookies[JWT_COOKIE_NAME])
.then((authResponse) => {
res.locals.auth = authResponse.payload;
if (authResponse.signed !== req.cookies[JWT_COOKIE_NAME]) {
res.locals.newToken = authResponse.signed;
}
}).catch((error) => {
log('err', 'api', 'Token is invalid!', error);
res.clearCookie(JWT_COOKIE_NAME);
}).then(() => {
next();
});
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
* @return {void}
*/
function renewTokenMiddleware(req, res, next) {
if (res.locals.renewToken && !res.locals.newToken) {
// Building new token
renewToken(res.locals.auth).then((authResponse) => {
res.locals.auth = authResponse.payload;
res.locals.newToken = authResponse.signed;
}).catch((error) => {
// Token is invalid
console.log('token is invalid!', error);
res.clearCookie(JWT_COOKIE_NAME);
}).then(() => {
next();
});
} else {
next();
}
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @param {express.NextFunction} next
* @return {void}
*/
function setTokenCookieMiddleware(req, res, next) {
if (res.locals.newToken) {
// console.log('setting new token cookie', res.locals.newToken);
res.cookie(JWT_COOKIE_NAME, res.locals.newToken, {
maxAge: res.locals.auth.exp * 1000,
httpOnly: true,
// secure: true,
});
}
next();
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @return {boolean}
*/
function isAuthenticated(req, res) {
if (res.locals.auth) {
return true;
}
return false;
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @return {void}
*/
function onGetUserId(req, res) {
if (!isAuthenticated(req, res)) {
res.status(401);
res.end();
return;
}
/** @type {AuthToken} */
const authToken = res.locals.auth;
res.status(200).json({userId: authToken.sub});
res.end();
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @return {void}
*/
function onPostLogout(req, res) {
res.clearCookie(JWT_COOKIE_NAME);
res.status(204);
res.end();
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @return {Promise<AuthToken>}
*/
function validateLogin(req, res) {
// Implement username/password check
// Return Promise with token payload
return Promise.reject(new Error('Not implemented'));
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @return {void}
*/
function onPostLogin(req, res) {
validateLogin(req, res)
.then(signToken)
.then((authResponse) => {
res.cookie(JWT_COOKIE_NAME, authResponse.signed, {
maxAge: authResponse.payload.exp * 1000,
httpOnly: true,
// secure: true,
});
res.status(204).end();
})
.catch((err) => {
log('err', 'api', err);
res.status(401).json(err.message);
res.end();
});
}
/**
* @param {express.Request} req
* @param {express.Response} res
* @return {void}
*/
function onPostLogin(req, res) {
if (!isAuthenticated(req, res)) {
res.status(401);
res.end();
return;
}
res.clearCookie(JWT_COOKIE_NAME);
res.status(204);
res.end();
}
/** @return {void} */
function setupRouter() {
router.use(cors(corsOptions));
// No auth needed requests
router.post('/login', onPostLogin);
router.use(antiCSRFMiddleware);
router.use(sessionAuthMiddleware);
router.use(tokenAuthMiddleware);
// Optional auto-reject with 401 here instead of per request
// router.use(sendUnauthorizedOnNoToken);
router.post('/logout', onPostLogout); // Log out doesn't renew token
router.use(renewTokenMiddleware);
router.use(setTokenCookieMiddleware);
// TODO:
// router.use(setAntiCSRFHeaderMiddleware);
// All other requests
router.post('/getUserId', onGetUserId);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment