-
Notifications
You must be signed in to change notification settings - Fork 141
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
a25f393
commit 2b64657
Showing
7 changed files
with
356 additions
and
24 deletions.
There are no files selected for viewing
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
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; |
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
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
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
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; |
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
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; | ||
}, | ||
}); | ||
} |
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
Oops, something went wrong.