Skip to content

Commit

Permalink
Custom Session Stores
Browse files Browse the repository at this point in the history
  • Loading branch information
davidpatrick committed Feb 9, 2021
1 parent a25f393 commit 2b64657
Show file tree
Hide file tree
Showing 7 changed files with 356 additions and 24 deletions.
30 changes: 30 additions & 0 deletions examples/custom-session-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
const express = require('express');
const { auth } = require('../');

const app = express();
const IN_MEMORY_SESSION = {};

app.use(
auth({
idpLogout: true,
sessionStore: function Store(config) {
return {
get: function get (id, cb) {
console.log('sessionStoreget', {id});
cb(null, IN_MEMORY_SESSION[id]);
},
set: function set (id, data, cb) {
console.log('sessionstoreSave', {id, data});
IN_MEMORY_SESSION[id] = data;
cb();
}
}
}
})
);

app.get('/', (req, res) => {
res.send(`hello ${req.oidc.user.sub}`);
});

module.exports = app;
4 changes: 4 additions & 0 deletions lib/appSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ module.exports = (config) => {
res,
{ uat = epoch(), iat = uat, exp = calculateExp(iat, uat) }
) {
console.log('setcookie...')
const cookieOptions = {
...cookieConfig,
expires: cookieConfig.transient ? 0 : new Date(exp * 1000),
Expand All @@ -102,6 +103,7 @@ module.exports = (config) => {
);
for (const cookieName of Object.keys(req[COOKIES])) {
if (cookieName.match(`^${sessionName}(?:\\.\\d)?$`)) {
debugger;
res.clearCookie(cookieName, {
domain: cookieOptions.domain,
path: cookieOptions.path,
Expand All @@ -128,9 +130,11 @@ module.exports = (config) => {
(i + 1) * CHUNK_BYTE_SIZE
);
const chunkCookieName = `${sessionName}.${i}`;
debugger;
res.cookie(chunkCookieName, chunkValue, cookieOptions);
}
} else {
debugger;
res.cookie(sessionName, value, cookieOptions);
}
}
Expand Down
1 change: 1 addition & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const { defaultState: getLoginState } = require('./hooks/getLoginState');
const isHttps = /^https:/i;

const paramsSchema = Joi.object({
sessionStore: Joi.function().optional(),
secret: Joi.alternatives([
Joi.string().min(8),
Joi.binary().min(8),
Expand Down
179 changes: 179 additions & 0 deletions lib/session/cookie.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
const crypto = require('crypto');
const cookie = require('cookie');
const { strict: assert, AssertionError } = require('assert');
const {
JWK,
JWKS,
JWE,
errors: { JOSEError },
} = require('jose');
const { encryption: deriveKey } = require('../hkdf');
const COOKIES = require('../cookies');
const debug = require('../debug')('sessionCookie');

const epoch = () => (Date.now() / 1000) | 0;
const CHUNK_BYTE_SIZE = 4000;
const ALG = 'dir';
const ENC = 'A256GCM';

class Cookie {
constructor(config, secret) {
this.sessionName = config.name;
this.cookieConfig = config.cookie;
this.absoluteDuration = config.absoluteDuration
this.rolling = config.rollingEnabled
this.rollingDuration = config.rollingDuration

this.keys = this.generateKeyStore(secret);
}

get(req) {
req[COOKIES] = cookie.parse(req.get('cookie') || '');

let existingSessionValue;
if (req[COOKIES].hasOwnProperty(this.sessionName)) {
debug('reading session from %s cookie', this.sessionName);
existingSessionValue = req[COOKIES][this.sessionName];
} else if (req[COOKIES].hasOwnProperty(`${this.sessionName}.0`)) {
debug('reading session from chunked cookie');
existingSessionValue = this.readChunkedCookie(req[COOKIES], this.sessionName);
}

if (existingSessionValue) {
const { protected: header, cleartext } = this.decrypt(existingSessionValue);
const { iat, uat, exp } = header;
// check that the existing session isn't expired based on options when it was established
assert(
exp > epoch(),
'it is expired based on options when it was established'
);

// check that the existing session isn't expired based on current rollingDuration rules
if (this.rollingDuration) {
assert(
uat + this.rollingDuration > epoch(),
'it is expired based on current rollingDuration rules'
);
}

// check that the existing session isn't expired based on current absoluteDuration rules
if (this.absoluteDuration) {
assert(
iat + this.absoluteDuration > epoch(),
'it is expired based on current absoluteDuration rules'
);
}

return { data: JSON.parse(cleartext), iat }
}
}

save(req, res, { data, uat = epoch(), iat = uat, exp = this.calculateExp(iat, uat) }) {
console.log('Saving Cookie...');
const header = { iat, uat, exp };
const value = this.encrypt(JSON.stringify(data), header);

const cookieOptions = {
...this.cookieConfig,
expires: this.cookieConfig.transient ? 0 : new Date(exp * 1000),
};
delete cookieOptions.transient;

const chunkCount = Math.ceil(value.length / CHUNK_BYTE_SIZE);
if (chunkCount > 1) {
debug('cookie size greater than %d, chunking', CHUNK_BYTE_SIZE);
for (let i = 0; i < chunkCount; i++) {
const chunkValue = value.slice(
i * CHUNK_BYTE_SIZE,
(i + 1) * CHUNK_BYTE_SIZE
);
const chunkCookieName = `${this.sessionName}.${i}`;
res.cookie(chunkCookieName, chunkValue, cookieOptions);
}
} else {
res.cookie(this.sessionName, value, cookieOptions);
}
}

clearSession(req, res) {
for (const cookieName of Object.keys(req[COOKIES] || '')) {
if (cookieName.match(`^${this.sessionName}(?:\\.\\d)?$`)) {
res.clearCookie(cookieName, {
domain: this.cookieConfig.domain,
path: this.cookieConfig.path,
});
}
}
}

generateKeyStore(secret) {
const secrets = Array.isArray(secret) ? secret : [secret];
let keyStore = new JWKS.KeyStore();
let currentKey

secrets.forEach((secretString, i) => {
const key = JWK.asKey(deriveKey(secretString));
if (i === 0) {
currentKey = key;
}
keyStore.add(key);
});

if (keyStore.size === 1) {
keyStore = currentKey;
}

return { keyStore, currentKey };
}

decrypt(jwe) {
return JWE.decrypt(jwe, this.keys.keyStore, {
complete: true,
contentEncryptionAlgorithms: [ENC],
keyManagementAlgorithms: [ALG],
});
}

encrypt(payload, headers) {
return JWE.encrypt(payload, this.keys.currentKey, {
alg: ALG,
enc: ENC,
...headers
});
}

calculateExp(iat, uat) {
if (!this.rollingEnabled) {
return iat + this.absoluteDuration;
}

return Math.min(
...[uat + this.rollingDuration, iat + this.absoluteDuration].filter(Boolean)
);
}

generateUid() {
return crypto.randomBytes(16).toString("hex")
}

readChunkedCookie(cookies, sessionName) {
return Object.entries(cookies)
.map(([cookie, value]) => {
const match = cookie.match(`^${sessionName}\\.(\\d+)$`);
if (match) {
return [match[1], value];
}
})
.filter(Boolean)
.sort(([a], [b]) => {
return parseInt(a, 10) - parseInt(b, 10);
})
.map(([i, chunk]) => {
debug('reading session chunk from %s.%d cookie', sessionName, i);
return chunk;
})
.join('');
}
}

module.exports = Cookie;
118 changes: 118 additions & 0 deletions lib/session/middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
const promisify = require('util').promisify;
const onHeaders = require('on-headers');
const { strict: AssertionError } = require('assert');
const { errors: { JOSEError } } = require('jose');
const debug = require('../debug')('sessionMiddleware');
const Cookie = require('./cookie');

module.exports = (config) => {
const cookie = new Cookie(config.session, config.secret)

const isAsync = !!config.sessionStore;
let sessionStore;
let asyncStoreFetch;

if (isAsync) {
sessionStore = new config.sessionStore(config.sessionStoreConfig)
asyncStoreFetch = promisify(sessionStore.get);
}

return async (req, res, next) => {
let sessionCookieData;
let iat;

try {
const cookieData = cookie.get(req);
if (cookieData) {
({ data: sessionCookieData, iat } = cookieData);
}
} catch (err) {
if (err instanceof AssertionError) {
debug('existing session was rejected because', err.message);
} else if (err instanceof JOSEError) {
debug('existing session was rejected because it could not be decrypted', err);
} else {
debug('unexpected error handling session', err);
}
}

let session;
if (isAsync) {
// if async fetch, session should have id
if (sessionCookieData && sessionCookieData.id) {
try {
session = await asyncStoreFetch(sessionCookieData.id);
} catch (error) {
debug('error fetching session', error);
}
}
} else if (sessionCookieData) {
// if not async, session cookie data is the complete session
session = sessionCookieData;
}

// attach the session to the request
const sessionName = config.session.name;
attachSessionObject(req, sessionName, session || {});

// when the request ends, save the data to the session store
if (isAsync) {
// to handle async saving we have to patch express res.end
const { end: _end } = res
res.end = function resEnd (...args) {
if (!req[sessionName] || !Object.keys(req[sessionName]).length) {
debug('session was deleted or is empty, clearing all matching session cookies');
cookie.clearSession(req, res);
_end.call(res, ...args)
} else {
debug('found session, creating signed session cookie(s) with name %o(.i)', sessionName);
const sessionData = req[sessionName];
const sessionId = sessionData.id || (sessionData.id = cookie.generateUid());

sessionStore.set(sessionId, sessionData, (error) => {
if (error) {
debug('error saving session', error);
} else {
cookie.save(req, res, { data: { id: sessionId }, iat });
}
_end.call(res, ...args)
});
}
}
} else {
onHeaders(res, () => {
if (!req[sessionName] || !Object.keys(req[sessionName]).length) {
debug(
'session was deleted or is empty, clearing all matching session cookies'
);
cookie.clearSession(req, res);
} else {
debug(
'found session, creating signed session cookie(s) with name %o(.i)',
sessionName
);
cookie.save(req, res, { data: req[sessionName], iat });
}
});
}

return next()
};
}

function attachSessionObject(req, sessionName, value) {
Object.defineProperty(req, sessionName, {
enumerable: true,
get() {
return value;
},
set(arg) {
if (arg === null || arg === undefined) {
value = arg;
} else {
throw new TypeError('session object cannot be reassigned');
}
return undefined;
},
});
}
4 changes: 2 additions & 2 deletions middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const { requiresAuth } = require('./requiresAuth');
const attemptSilentLogin = require('./attemptSilentLogin');
const TransientCookieHandler = require('../lib/transientHandler');
const { RequestContext, ResponseContext } = require('../lib/context');
const appSession = require('../lib/appSession');
const sessionMiddleware = require('../lib/session/middleware');
const { decodeState } = require('../lib/hooks/getLoginState');

const enforceLeadingSlash = (path) => {
Expand All @@ -29,7 +29,7 @@ module.exports = function (params) {
const router = new express.Router();
const transient = new TransientCookieHandler(config);

router.use(appSession(config));
router.use(sessionMiddleware(config));

// Express context and OpenID Issuer discovery.
router.use(async (req, res, next) => {
Expand Down
Loading

0 comments on commit 2b64657

Please sign in to comment.