Last active
November 12, 2019 17:30
-
-
Save clshortfuse/e6137de04aa761cd9b874197c69afbc7 to your computer and use it in GitHub Desktop.
Express-Controlled JWT
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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