@cyclic.sh/session-store
Version:
Express middleware that stores sessions in DynamoDB tables for Cyclic apps.
263 lines (233 loc) • 7.15 kB
JavaScript
// @flow
const { Store } = require('express-session')
const {docClient} = require('./ddb_client')
const { PutCommand, GetCommand, DeleteCommand, UpdateCommand } = require("@aws-sdk/lib-dynamodb")
// const AWS = require('aws-sdk'); // eslint-disable-line
// @flow
const {
DEFAULT_HASH_KEY,
DEFAULT_HASH_PREFIX,
DEFAULT_SORT_KEY,
DEFAULT_TTL,
DEFAULT_TOUCH_INTERVAL,
DEFAULT_KEEP_EXPIRED_POLICY,
} = require('./constants')
const { toSecondsEpoch, debug, isExpired } = require('./util')
/**
* Express.js session store for DynamoDB.
*/
class CyclicSessionStore extends Store {
/**
* Constructor.
* @param {Object} options Store
*/
constructor (options = {}) {
super()
debug('Initializing store', options)
// debug('AWS sdk: ', AWS);
this.setOptionsAsInstanceAttributes(options)
const dynamoConfig = options.dynamoConfig || {}
// dynamodb client configuration
this.documentClient = docClient
}
/**
* Saves the informed store options as instance attributes.
* @param {Object} options Store options.
*/
setOptionsAsInstanceAttributes (options) {
const {
table = {},
touchInterval = DEFAULT_TOUCH_INTERVAL,
ttl,
keepExpired = DEFAULT_KEEP_EXPIRED_POLICY
} = options
const {
name = DEFAULT_TABLE_NAME,
hashPrefix = DEFAULT_HASH_PREFIX,
hashKey = DEFAULT_HASH_KEY,
sortKey = DEFAULT_SORT_KEY,
} = table
this.tableName = name
this.hashPrefix = hashPrefix
this.hashKey = hashKey
this.sortKey = sortKey
this.touchInterval = touchInterval
this.ttl = ttl
this.keepExpired = keepExpired
this.keySchema = [{ AttributeName: this.hashKey, KeyType: 'HASH' }]
this.attributeDefinitions = [{ AttributeName: this.hashKey, AttributeType: 'S' }]
if (this.sortKey) {
this.keySchema.push({ AttributeName: this.sortKey, KeyType: 'RANGE' })
this.attributeDefinitions.push({ AttributeName: this.sortKey, AttributeType: 'S' })
}
debug('optionsToInstance dump', this);
}
/**
* Stores a session.
* @param {String} sid Session ID.
* @param {Object} sess The session object.
* @param {Function} callback Callback to be invoked at the end of the execution.
*/
set (sid, sess, callback) {
try {
const sessionId = this.getSessionId(sid)
const expires = this.getExpirationDate(sess)
const params = {
TableName: this.tableName,
Item: {
[this.hashKey]: sessionId,
[this.sortKey]: sessionId,
expires: toSecondsEpoch(expires),
sess: {
...sess,
updated: Date.now()
}
}
}
debug(`Saving session '${sid}'`, sess)
this.documentClient.send(new PutCommand(params)).then(callback)
} catch (err) {
debug('Error saving session', {
sid,
sess,
err
})
callback(err)
}
}
/**
* Retrieves a session from dynamo.
* @param {String} sid Session ID.
* @param {Function} callback Callback to be invoked at the end of the execution.
*/
async get (sid, callback) {
try {
const sessionId = this.getSessionId(sid)
const params = {
TableName: this.tableName,
Key: {
[this.hashKey]: sessionId,
[this.sortKey]: sessionId
},
ConsistentRead: true
}
debug('dynamodb session params', params);
const { Item: record } = await this.documentClient.send(new GetCommand(params))
if (!record) {
debug(`Session '${sid}' not found`)
callback(null, null)
} else if (isExpired(record.expires)) {
this.handleExpiredSession(sid, callback)
} else {
debug(`Session '${sid}' found`, record.sess)
callback(null, record.sess)
}
} catch (err) {
debug(`Error getting session '${sid}'`, err)
callback(err)
}
}
/**
* Deletes a session from dynamo.
* @param {String} sid Session ID.
* @param {Function} callback Callback to be invoked at the end of the execution.
*/
async destroy (sid, callback = DEFAULT_CALLBACK) {
try {
const sessionId = this.getSessionId(sid)
const params = {
TableName: this.tableName,
Key: {
[this.hashKey]: sessionId,
[this.sortKey]: sessionId
}
}
await this.documentClient.send(new DeleteCommand(
params
))
debug(`Destroyed session '${sid}'`)
callback(null, null)
} catch (err) {
debug(`Error destroying session '${sid}'`, err)
callback(err)
}
}
/**
* Updates the expiration time of an existing session.
* @param {String} sid Session ID.
* @param {Object} sess The session object.
* @param {Function} callback Callback to be invoked at the end of the execution.
*/
touch (sid, sess, callback) {
try {
if (!sess.updated || Number(sess.updated) + this.touchInterval <= Date.now()) {
const sessionId = this.getSessionId(sid)
const expires = this.getExpirationDate(sess)
const params = {
TableName: this.tableName,
Key: {
[this.hashKey]: sessionId,
[this.sortKey]: sessionId
},
UpdateExpression: 'set expires = :e, sess.#up = :n',
ExpressionAttributeNames: {
'#up': 'updated'
},
ExpressionAttributeValues: {
':e': toSecondsEpoch(expires),
':n': Date.now()
},
ReturnValues: 'UPDATED_NEW'
}
debug(`Touching session '${sid}'`)
this.documentClient.send(new UpdateCommand(params)).then(callback)
} else {
debug(`Skipping touch of session '${sid}'`)
callback()
}
} catch (err) {
debug(`Error touching session '${sid}'`, err)
callback(err)
}
}
/**
* Handles get requests that found expired sessions.
* @param {String} sid Original session id.
* @param {Function} callback Callback to be invoked at the end of the execution.
*/
async handleExpiredSession (sid, callback) {
debug(`Found session '${sid}' but it is expired`)
if (this.keepExpired) {
callback(null, null)
} else {
this.destroy(sid, callback)
}
}
/**
* Builds the session ID foe storage.
* @param {String} sid Original session id.
* @return {String} Prefix + original session id.
*/
getSessionId (sid) {
return `${this.hashPrefix}${sid}`
}
/**
* Calculates the session expiration date.
* @param {Object} sess The session object.
* @return {Date} the session expiration date.
*/
getExpirationDate (sess) {
let expirationDate = Date.now()
if (this.ttl !== undefined) {
expirationDate += this.ttl
} else if (sess.cookie && Number.isInteger(sess.cookie.maxAge)) {
expirationDate += sess.cookie.maxAge
} else {
expirationDate += DEFAULT_TTL
}
return new Date(expirationDate)
}
}
module.exports = {
CyclicSessionStore
}