flowstate
Advanced tools
Comparing version 0.5.1 to 0.6.0
@@ -9,2 +9,24 @@ # Changelog | ||
## [0.6.0] - 2023-10-18 | ||
TODO: Review this for accuracy. | ||
### Added | ||
- Added third `preventReturnTo` argument to `res.redirect` (or second, when | ||
optional `status` is not supplied) that prevents adding the `return_to` query | ||
parameter to the URL. Useful when the URL includes that a different parameter | ||
set to that value. | ||
### Changed | ||
- Renamed `resumeState` property of state objects to `state`. | ||
- Initializing `returnTo` property of current state in all cases. This avoids | ||
needing to reference the `location` property of the state to resume, | ||
optimizing state store access and allowing state targetting different | ||
locations to be stored in different stores. | ||
- Both `returnTo` and `state` parameters are added as query parameters when | ||
redirecting. | ||
- Both `returnTo` and `state` variables are set as locals when rendering. | ||
- Both `returnTo` and `state` are set when pushing state. | ||
## [0.5.1] - 2021-12-13 | ||
@@ -15,3 +37,3 @@ ### Added | ||
[Unreleased]: https://github.com/jaredhanson/flowstate/compare/v0.5.0...HEAD | ||
[Unreleased]: https://github.com/jaredhanson/flowstate/compare/v0.5.1...HEAD | ||
[0.5.1]: https://github.com/jaredhanson/flowstate/compare/v0.5.0...v0.5.1 |
@@ -0,1 +1,12 @@ | ||
/** | ||
* flowstate is.... | ||
* | ||
* @module flowstate | ||
*/ | ||
/* | ||
* State middleware | ||
* | ||
* @type {function} | ||
*/ | ||
exports = module.exports = require('./middleware/state'); | ||
@@ -2,0 +13,0 @@ exports.clean = require('./middleware/clean'); |
var Meta = module.exports = function Meta() { | ||
this.originalHash = null; | ||
this.savedHash = null; | ||
this.complete = false; | ||
//this.complete = false; | ||
this.destroyed = false; | ||
this.returning = false; | ||
} | ||
@@ -7,0 +8,0 @@ |
@@ -15,2 +15,10 @@ var SessionStore = require('../store/session'); | ||
// TODO: clean this up | ||
states.forEach(function(state) { | ||
if (typeof state.expires === 'string') { | ||
// convert expires to a Date object | ||
state.expires = new Date(state.expires); | ||
} | ||
}) | ||
var evict = 0; | ||
@@ -17,0 +25,0 @@ if (limit) { evict = states.length - limit; } |
@@ -0,1 +1,2 @@ | ||
// Module dependencies. | ||
var uri = require('url') | ||
@@ -10,2 +11,3 @@ , State = require('../state') | ||
, debug = require('debug')('flowstate') | ||
, qs = require('querystring') | ||
, SessionStore = require('../store/session'); | ||
@@ -15,8 +17,4 @@ | ||
// TIPS: Generally, push a state before redirecting (unless wizard style | ||
// alternatively, handler can lazily push, for its own state, to preserve storage | ||
// which could be done based on the presence of a returnTo (which is not the handlers URL) | ||
/** | ||
* Load state. | ||
* Create state middleware with the given `options`. | ||
* | ||
@@ -33,24 +31,26 @@ * This middleware is used to load state associated with a request. The state | ||
* | ||
* Options: | ||
* @public | ||
* @name module:flowstate | ||
* @param {Object} [options] | ||
* @param {string[]} [options.mutationMethods=['POST', 'PUT', 'PATCH', 'DELETE']] - | ||
* An array of methods for which request handling is expected to | ||
* complete a state. | ||
* @param {function} [options.getHandle] - Function that the middleware will | ||
* invoke to read the state handle from the request. The function is | ||
* called as `getHandle(req)` and is expected to return the handle as a | ||
* string. | ||
* | ||
* The default value is a function that reads the handle from the following | ||
* locations, in order: | ||
* | ||
* name set req.state only if the state name is equal to the option value | ||
* required require state, failing otherwise (default: false) | ||
* | ||
* Examples: | ||
* | ||
* app.get('/login', | ||
* flowstate.loadState(), | ||
* function(req, res, next) { | ||
* res.locals.failureCount = req.state.failureCount; | ||
* res.render('login'); | ||
* }); | ||
* | ||
* app.get('/login/callback', | ||
* flowstate.loadState({ name: 'oauth2-redirect', required: true }), | ||
* function(req, res, next) { | ||
* // ... | ||
* }); | ||
* | ||
* @return {Function} | ||
* @api public | ||
* - `req.query.state` - a built-in from Express.js to read from the URL query | ||
* string. | ||
* - `req.body.state` - typically generated by the `body-parser` module. | ||
* @param {function} [options.genh] - Function to call to generate a new session | ||
* handle. Provide a function that returns a string that will be used | ||
* as a session handle. The default value is a function which uses the | ||
* `uid2` library to generate IDs. | ||
* @param {Store} [options.store] - The state store instance, defaults to a new | ||
* `{@link SessionStore}` instance. | ||
* @return {function} | ||
*/ | ||
@@ -61,2 +61,3 @@ module.exports = function(options) { | ||
var store = options.store || new SessionStore(options); | ||
// TODO: Rename this to `handle` to match csurf conventions | ||
var getHandle = options.getHandle || function(req) { | ||
@@ -71,2 +72,4 @@ return (req.query && req.query.state) || (req.body && req.body.state); | ||
: options.mutationMethods; | ||
// TODO: add back support for a `required` options, which forces a matching state targetting location | ||
@@ -80,2 +83,11 @@ return function state(req, res, next) { | ||
/** | ||
* Add state to be made available on a request for `url`. | ||
* | ||
* @public | ||
* @name ServerResponse#pushState | ||
* @function | ||
* @param {object} data | ||
* @param {string} url | ||
*/ | ||
req.pushState = function(data, url, options, cb) { | ||
@@ -88,8 +100,22 @@ if (typeof options == 'function') { | ||
/* | ||
var immediate = false; | ||
if (typeof options == 'boolean') { | ||
immediate = true; | ||
options = {}; | ||
} | ||
*/ | ||
// TODO: only support handle as option? | ||
data.location = uri.resolve(utils.originalURL(req), url); | ||
// TODO: Should this be taking the top of the state stack?? In case multiple pushes? | ||
if (req.state.returnTo && !req.state.external) { | ||
data.returnTo = req.state.returnTo; | ||
} else if (req.state.resumeState) { | ||
data.resumeState = req.state.resumeState; | ||
} | ||
if (req.state.state) { | ||
data.state = req.state.state; | ||
} | ||
@@ -100,2 +126,3 @@ var state = new State(req, data); | ||
if (cb) { | ||
// TODO: Why this typeof check? | ||
if (typeof store.set == 'function') { state.handle = options.handle || generateHandle(); } | ||
@@ -107,4 +134,13 @@ state.save(options, function(err) { | ||
} | ||
// TODO: maybe return the new state here, add methods for controlling params | ||
} | ||
/** | ||
* Remove a previously added, but uncommited, state. | ||
* | ||
* @public | ||
* @name ServerResponse#popState | ||
* @function | ||
*/ | ||
req.popState = function() { | ||
@@ -115,2 +151,10 @@ if (req._stateStack.length == 1) { return; } // don't pop current state | ||
/** | ||
* Resume prior state, if any, by redirecting its URL. | ||
* | ||
* @public | ||
* @name ServerResponse#resumeState | ||
* @function | ||
* @param {object} [yields] | ||
*/ | ||
res.resumeState = function(yields, cb) { | ||
@@ -124,26 +168,84 @@ if (typeof yields == 'function') { | ||
if (req.state.returnTo) { | ||
// TODO: underscore the parameters, adn redirect with query | ||
this.redirect(req.state.returnTo); | ||
} else if (req.state.resumeState) { | ||
var self = this; | ||
store.get(req, req.state.resumeState, function(err, data) { | ||
if (err) { return cb(err); } | ||
var state = new State(req, data, req.state.resumeState); | ||
merge(state, yields); | ||
req._stateStack.push(state); | ||
self.redirect(state.location); | ||
}); | ||
} else { | ||
cb(); | ||
if (!req.state.returnTo) { return cb(); } | ||
var loc = uri.parse(req.state.returnTo, true); | ||
delete loc.search; | ||
// TODO: test case for preserving query params | ||
if (yields) { | ||
merge(loc.query, yields); | ||
} | ||
req.state.returning(); | ||
this.redirect(uri.format(loc)); | ||
} | ||
/** | ||
* Redirect to the given `url` with optional response `status` defaulting to | ||
* 302. | ||
* | ||
* This function calls {@link https://expressjs.com/en/4x/api.html#res.redirect `redirect`}, | ||
* which is enhanced by {@link https://expressjs.com/ Express} on {@link https://nodejs.org/ Node.js}'s | ||
* {@link https://nodejs.org/api/http.html#http_class_http_serverresponse `http.ServerResponse`}. | ||
* | ||
* Prior to redirecting, any uncommited state is commited. Either a | ||
* `return_to` or `state` query parameter will be added to the given `url` | ||
* to represent the state. The state is then available to the ensuing | ||
* request to `url`. | ||
* | ||
* @public | ||
* @name ServerResponse#redirect | ||
* @function | ||
* @param {number} [status=302] | ||
* @param {string} url | ||
*/ | ||
// swizzle redirect to commit the state | ||
swizzleRedirect(res, function(url, cb) { | ||
if ((mutationMethods.indexOf(req.method) != -1) && !req.state.isModified()) { | ||
if ((mutationMethods.indexOf(req.method) != -1) && req.state.isComplete() === undefined) { | ||
req.state.complete(); | ||
} | ||
commit2(function(err, nstate) { | ||
if (err) { return next(err); } | ||
var l = uri.parse(url, true); | ||
delete l.search; | ||
if (nstate) { | ||
var returnTo = nstate.location; | ||
var state = nstate.handle; | ||
if (!returnTo && !state) { return cb(); } | ||
// Don't put a return_to on a external link | ||
// TODO: rename so to validOrigin | ||
var so = returnTo && validate.origin(uri.resolve(returnTo, url), req); | ||
if (returnTo && so) { | ||
l.query.return_to = returnTo; | ||
} | ||
if (state) { l.query.state = state; } | ||
return cb(null, uri.format(l)); | ||
} | ||
if (!req.state.handle) { | ||
if (req.state.external && req.state.isComplete()) { | ||
// TODO: clean this up to remove this empty branch | ||
} else if (req.state.returnTo) { | ||
if (uri.resolve(req.state.returnTo, url) !== req.state.returnTo && !req.state.isReturning()) { | ||
l.query.return_to = req.state.returnTo; | ||
} | ||
if (req.state.state) { l.query.state = req.state.state; } | ||
} | ||
} else { | ||
// TODO: what is correct here? | ||
var currentlyAt = utils.originalURLWithoutState(req); | ||
//var currentlyAt = utils.originalURLWithoutQuery(req); | ||
if (uri.resolve(currentlyAt, url) !== currentlyAt) { | ||
l.query.return_to = currentlyAt; | ||
} | ||
l.query.state = req.state.handle; | ||
} | ||
return cb(null, uri.format(l)); | ||
}); | ||
/* | ||
commit(function(err, returnTo, state) { | ||
@@ -155,11 +257,35 @@ if (err) { return next(err); } | ||
delete l.search; | ||
if (returnTo) { l.query.return_to = returnTo; } | ||
else if (state) { l.query.state = state; } | ||
if (returnTo && !preventReturnTo) { l.query.return_to = returnTo; } | ||
if (state) { l.query.state = state; } | ||
return cb(null, uri.format(l)); | ||
}); | ||
*/ | ||
}); | ||
/** | ||
* Render `view` with the given `options` and optional `callback`. When a | ||
* callback function is given a response will _not_ be made automatically, | ||
* otherwise a response of _200_ and _text/html_ is given. | ||
* | ||
* This function calls {@link https://expressjs.com/en/4x/api.html#res.render `render`}, | ||
* which is enhanced by {@link https://expressjs.com/ Express} on {@link https://nodejs.org/ Node.js}'s | ||
* {@link https://nodejs.org/api/http.html#http_class_http_serverresponse `http.ServerResponse`}. | ||
* | ||
* Prior to redirecting, any uncommited state is commited. Either a | ||
* `return_to` or `state` variable will be set on {@link https://expressjs.com/en/4x/api.html#res.locals locals} | ||
* to represent the state. The view can then make the state available to | ||
* subsequent requests initiated via links, forms, or other methods. | ||
* | ||
* @public | ||
* @name ServerResponse#render | ||
* @function | ||
* @param {string} view | ||
* @param {object} [options] | ||
* @param {function} [callback] | ||
*/ | ||
// swizzle render to commit the state | ||
swizzleRender(res, function(cb) { | ||
// TODO: dont' complete the state if it is flagged to continue | ||
// TODO: Check if the handler explicilty completed before doing this, add test case | ||
if ((mutationMethods.indexOf(req.method) != -1) && (Math.floor(res.statusCode / 100) == 2)) { | ||
@@ -169,2 +295,47 @@ req.state.complete(); | ||
commit2(function(err, nstate) { | ||
if (err) { return next(err); } // FIXME: This should call cb? | ||
if (nstate) { | ||
console.log('TODO: handle push state on render'); | ||
return; | ||
} | ||
// TODO: handle case where there is an nstate (via pushState) | ||
if (!req.state.handle) { | ||
// The request handler is rendering without setting any state that | ||
// was persisted for subsequent requests. This typically occurs under | ||
// two situations: | ||
// | ||
// 1. State was initialized to eventually redirect to a URL with | ||
// (optional) state using `return_to` and (optional) `state` | ||
// parameters. No modifications were made to this initial state. | ||
// 2. Previously persisted state intended for this endpoint was | ||
// loaded using `state` parameter. Processing of this this state | ||
// was completed. | ||
// | ||
// In either case, the current state set at `req.state` is complete, | ||
// and the location (and state) to eventually redirect to needs to be | ||
// propagated to the next request. Propagation is done by setting | ||
// `returnTo` and `state` properties on `res.locals`. It is expected | ||
// that the view being rendered will add these values to any necessary | ||
// links and/or forms to ensure the state flows through subsequent | ||
// user interactions. | ||
if (req.state.returnTo) { | ||
res.locals.returnTo = req.state.returnTo; | ||
// TODO: test case for not populating initial state with an invalid state | ||
// when return_to is not present. | ||
if (req.state.state) { res.locals.state = req.state.state; } | ||
} | ||
} else { | ||
// Processing of this request created state | ||
// TODO: document this more | ||
res.locals.state = req.state.handle; | ||
} | ||
return cb(); | ||
}); | ||
/* | ||
commit(function(err, returnTo, state) { | ||
@@ -174,9 +345,10 @@ if (err) { return next(err); } // FIXME: This should call cb? | ||
if (returnTo) { res.locals.returnTo = returnTo; } | ||
else if (state) { res.locals.state = state; } | ||
if (state) { res.locals.state = state; } | ||
cb(); | ||
}); | ||
*/ | ||
}); | ||
swizzleEnd(res, function(cb) { | ||
commit(function() { | ||
commit2(function() { | ||
cb(); | ||
@@ -187,16 +359,21 @@ }); | ||
function generate() { | ||
// To doc: a new state will have "returnTo" and "state", Such a state doesn't | ||
// need to be serialized, since it can be passed as URL parameters. | ||
function generate(url, state) { | ||
var data = { | ||
location: utils.originalURLWithoutQuery(req) | ||
location: url | ||
}; | ||
var returnTo = (options.external ? utils.originalURL(req) : undefined) | ||
|| (req.query && req.query.return_to) | ||
|| (req.body && req.body.return_to) | ||
|| (req.header ? req.header('referer') : undefined); | ||
if (returnTo) { | ||
var valid = validateRedirect(returnTo, req); | ||
if (valid) { | ||
data.returnTo = returnTo; | ||
} | ||
// TODO: clean this up and handle external states in a clear condition | ||
// TODO: add tests for preserving query params | ||
// TODO: test case for not adding state or parsing params when external | ||
var returnTo = options.external ? utils.originalURL(req) | ||
: ((req.query && req.query.return_to) || | ||
(req.body && req.body.return_to) || | ||
(req.header ? req.header('referer') : undefined)); | ||
if (returnTo && validateRedirect(returnTo, req)) { | ||
data.returnTo = returnTo; | ||
if (state && !options.external) { data.state = state; } | ||
} | ||
@@ -208,7 +385,47 @@ | ||
function inflate(state, h) { | ||
req.state = new State(req, state, h); | ||
function inflate(data, h) { | ||
req.state = new State(req, data, h); | ||
req._stateStack = [ req.state ]; | ||
} | ||
// TODO: dedupe this with commit below, if the same function works in all places | ||
function commit2(cb) { | ||
var stack = req._stateStack | ||
, i = 0; | ||
function iter(err, nstate) { | ||
if (err) { return cb(err); } // TODO: Test case for this | ||
var state = stack[i++]; | ||
if (!state) { return cb(null, i == 2 ? undefined : nstate); } | ||
if (state.isComplete()) { | ||
debug('destroying %O (%s)', state, state.handle); | ||
// TODO: optimization: don't call destroy if its not actually persisted (doesn't have. ahandle) | ||
state.destroy(function(err) { | ||
if (err) { return iter(err); } | ||
debug('destroyed'); | ||
iter(null, state); | ||
}); | ||
} else if (state.isModified() || (i > 1 && !state.isSaved())) { | ||
// FIXME: the i>1 condition gets pushed states (whcih are not modified but need saving) | ||
//. make this more obvious by adding a method to check | ||
if (!state.handle && (typeof store.set == 'function')) { | ||
state.handle = generateHandle(); | ||
} | ||
state.save(function(err) { | ||
if (err) { return iter(err); } | ||
debug('saved (%s)', state.handle); | ||
iter(null, state); | ||
}); | ||
} else { | ||
iter(null, state); | ||
} | ||
} | ||
iter(); | ||
} | ||
// TODO: dead code, can be removed | ||
/* | ||
function commit(cb) { | ||
@@ -229,3 +446,3 @@ var stack = req._stateStack | ||
debug('destroyed'); | ||
iter(null, undefined, state.resumeState); | ||
iter(null, state.returnTo, state.state); | ||
}); | ||
@@ -240,7 +457,10 @@ } else if (state.isModified() || (i > 1 && !state.isSaved())) { | ||
debug('saved (%s)', state.handle); | ||
iter(null, undefined, state.handle); | ||
iter(null, state.location, state.handle); | ||
}); | ||
} else if (state.isNew()) { | ||
iter(null, state.returnTo, state.resumeState); | ||
iter(null, state.returnTo, state.state); | ||
} else { // current | ||
if (state.isContinue()) { | ||
return iter(null, state.location, state.handle); | ||
} | ||
iter(null, undefined, state.handle); | ||
@@ -251,16 +471,20 @@ } | ||
} | ||
*/ | ||
// Initialize a state if the browser didn't send a state handle, or the | ||
// endpoint indicated that state, if any, is external to this app. | ||
var url = utils.originalURLWithoutQuery(req); | ||
var h = getHandle(req); | ||
// Create a new "uninitialized" state if the request didn't send a state | ||
// handle, or the endpoint is configured to consider any state as external | ||
// to this application. | ||
// | ||
// The latter case is common with federated protocols, such as OpenID | ||
// Connect and OAuth 2.0, in which clients make requests that initiate | ||
// stateful transactions within the app making use of this middleware. | ||
// These requests may have a `state` parameter, but its value is meant to be | ||
// included when the browser is redirected back to the client so the client | ||
// can maintain its state. | ||
var h = getHandle(req); | ||
// External state is typically relayed by third-party applications when | ||
// making a request as part of a federated protocol, such as OAuth 2.0 or | ||
// OpenID Connect. In such cases, the `state` parameter does not indentify | ||
// state associated with this application, but rather a parameter that is | ||
// expected to be echo'd back to the calling application so that it can | ||
// subsequently load state when the user is redirected back. | ||
if (!h || options.external) { | ||
generate(); | ||
generate(url); | ||
next(); | ||
@@ -270,7 +494,16 @@ return; | ||
// TODO: can optimize this by not getting state when return_to parameter is present | ||
//. YES, likely a good idea | ||
// Attempt to load any previously serialized state identified by the state | ||
// handle. | ||
store.get(req, h, function(err, state) { | ||
if (err) { return next(err); } | ||
// No previously serialized state was found for the given state handle. | ||
// Ignore the parameter and create a new "uninitialized" state. The | ||
// uninitialized state will preserve the state handle if it is intended | ||
// to be passed as a parameter to the redirect URL, if any. | ||
if (!state) { | ||
generate(); | ||
generate(url, h); | ||
next(); | ||
@@ -280,19 +513,13 @@ return; | ||
var url = utils.originalURLWithoutQuery(req); | ||
if (state.location !== url) { | ||
// The referened state is not intended for the endpoint that was | ||
// requested. | ||
// | ||
// A new state will be initialized which, when complete, will trigger | ||
// the referenced state to resume. This process is managed by building | ||
// a logical stack of states as the browser is redirected or navigated | ||
// to a series of pages, where a reference to the current state is | ||
// relayed with each request. Once a request is processed which does | ||
// not result in a further redirect, the stack unwinds by popping the | ||
// state to be resumed. | ||
inflate({ location: url, resumeState: h }); | ||
} else { | ||
inflate(state, h); | ||
// The loaded state is not intended for this endpoint. Ignore the state | ||
// and create a new "uninitialized" state. The uninitialized state will | ||
// preserve the state handle if it is intended to be passed as a | ||
// parameter to the redirect URL, if any. | ||
generate(url, h); | ||
next(); | ||
return; | ||
} | ||
inflate(state, h); | ||
next(); | ||
@@ -299,0 +526,0 @@ }); |
@@ -0,1 +1,2 @@ | ||
// Module dependencies. | ||
var Meta = require('./meta') | ||
@@ -5,2 +6,8 @@ , crc = require('crc').crc32; | ||
/** | ||
* Create a new `State` with the given request and `data`. | ||
* | ||
* @protected | ||
* @class | ||
*/ | ||
function State(req, data, handle, external) { | ||
@@ -12,2 +19,6 @@ Object.defineProperty(this, '_req', { value: req }); | ||
if (data.handle) { | ||
this.handle = data.handle; | ||
} | ||
if (typeof data === 'object' && data !== null) { | ||
@@ -22,2 +33,7 @@ // merge data into this, ignoring prototype properties | ||
if (typeof this.expires === 'string') { | ||
// convert expires to a Date object | ||
this.expires = new Date(this.expires); | ||
} | ||
this._meta.originalHash = hash(this); | ||
@@ -29,7 +45,12 @@ if (this.handle) { | ||
defineMethod(State.prototype, 'complete', function isDirty() { | ||
this._meta.complete = true; | ||
defineMethod(State.prototype, 'complete', function complete(v) { | ||
this._meta.complete = v !== undefined ? v : true; | ||
return this; | ||
}); | ||
defineMethod(State.prototype, 'returning', function returning(v) { | ||
this._meta.returning = v !== undefined ? v : true; | ||
return this; | ||
}); | ||
defineMethod(State.prototype, 'save', function save(options, cb) { | ||
@@ -86,3 +107,3 @@ if (typeof options == 'function') { | ||
defineMethod(State.prototype, 'isNew', function isDirty() { | ||
defineMethod(State.prototype, 'isNew', function isNew() { | ||
// TODO: test case for the !destroyed case | ||
@@ -97,3 +118,7 @@ // TODO: destroyed can be removed, with a strict check for handle === undefined | ||
defineMethod(State.prototype, 'isReturning', function isReturning() { | ||
return this._meta.returning; | ||
}); | ||
module.exports = State; | ||
@@ -100,0 +125,0 @@ |
@@ -0,10 +1,38 @@ | ||
// Module dependencies. | ||
var Store = require('../store'); | ||
var clone = require('clone'); | ||
var util = require('util'); | ||
/** | ||
* Create a new session-based state store. | ||
* | ||
* @public | ||
* @class | ||
* @param {Object} [options] | ||
* @param {string} [options.key='state'] - Determines what property ("key") on | ||
* the session object where state is located. The state data is stored | ||
* and read from `req.session[key]`. | ||
*/ | ||
function SessionStore(options) { | ||
options = options || {}; | ||
Store.call(this); | ||
this._key = options.key || 'state'; | ||
} | ||
// Inherit from `Store`. | ||
util.inherits(SessionStore, Store) | ||
/** | ||
* Get all states. | ||
* | ||
* @param {IncomingRequest} req | ||
* @param {function} callback | ||
* @param {Error} callback.err | ||
* @param {object[]} callback.states | ||
* @public | ||
*/ | ||
SessionStore.prototype.all = function(req, cb) { | ||
if (!req.session) { return cb(new Error('State requires session support. Did you forget to use `express-session` middleware?')); } | ||
var key = this._key; | ||
@@ -21,16 +49,23 @@ if (!req.session || !req.session[key]) { | ||
state = clone(req.session[key][h]); | ||
if (typeof state.expires === 'string') { | ||
// convert expires to a Date object | ||
state.expires = new Date(state.expires); | ||
} | ||
state.handle = h; | ||
arr.push(state); | ||
} | ||
return cb(null, arr); | ||
} | ||
/** | ||
* Fetch state by the given state handle. | ||
* | ||
* @public | ||
* @param {IncomingRequest} req | ||
* @param {string} handle | ||
* @param {function} callback | ||
* @param {Error} callback.err | ||
* @param {object} callback.state | ||
*/ | ||
SessionStore.prototype.get = function(req, h, cb) { | ||
if (!req.session) { return cb(new Error('State requires session support. Did you forget to use `express-session` middleware?')); } | ||
var key = this._key; | ||
if (!req.session || !req.session[key] || !req.session[key][h]) { | ||
if (!req.session[key] || !req.session[key][h]) { | ||
return cb(); | ||
@@ -40,19 +75,38 @@ } | ||
var state = clone(req.session[key][h]); | ||
if (typeof state.expires === 'string') { | ||
// convert expires to a Date object | ||
state.expires = new Date(state.expires); | ||
} | ||
return cb(null, state); | ||
} | ||
/** | ||
* Commit the given state associated with the given state handle to the store. | ||
* | ||
* @param {IncomingRequest} req | ||
* @param {string} handle | ||
* @param {object} state | ||
* @param {function} callback | ||
* @param {Error} callback.err | ||
* @public | ||
*/ | ||
SessionStore.prototype.set = function(req, h, state, cb) { | ||
if (!req.session) { return cb(new Error('State requires session support. Did you forget to use `express-session` middleware?')); } | ||
var key = this._key; | ||
req.session[key] = req.session[key] || {}; | ||
req.session[key][h] = clone(state); | ||
req.session[key][h] = JSON.parse(JSON.stringify(state)); | ||
return cb(null); | ||
} | ||
/** | ||
* Destroy the state associated with the given state handle. | ||
* | ||
* @param {IncomingRequest} req | ||
* @param {string} handle | ||
* @param {function} callback | ||
* @param {Error} callback.err | ||
* @public | ||
*/ | ||
SessionStore.prototype.destroy = function(req, h, cb) { | ||
if (!req.session) { return cb(new Error('State requires session support. Did you forget to use `express-session` middleware?')); } | ||
var key = this._key; | ||
if (!req.session || !req.session[key] || !req.session[key][h]) { | ||
if (!req.session[key] || !req.session[key][h]) { | ||
return cb(); | ||
@@ -68,3 +122,3 @@ } | ||
// Export `SessionStore`. | ||
module.exports = SessionStore; |
@@ -41,1 +41,11 @@ var uri = require('url'); | ||
}; | ||
exports.originalURLWithoutState = function(req, options) { | ||
var loc = uri.parse(exports.originalURL(req, options), false); | ||
delete loc.search; | ||
delete loc.query; | ||
// TODO: test case for preserving query params, but not state | ||
return uri.format(loc); | ||
}; |
@@ -5,2 +5,4 @@ var uri = require('url'); | ||
var u = uri.parse(url); | ||
if (!u.host) { return true; } | ||
// TODO: Need to respect proxy settings here | ||
@@ -7,0 +9,0 @@ var host = req.headers['host']; |
{ | ||
"name": "flowstate", | ||
"version": "0.5.1", | ||
"description": "Stateful, transactional flows using page-based navigation.", | ||
"version": "0.6.0", | ||
"description": "Per-request state management middleware.", | ||
"keywords": [ | ||
@@ -6,0 +6,0 @@ "express", |
270
README.md
# flowstate | ||
This middleware manages and propagates per-request state across HTTP requests to | ||
a web application. This allows for implementing flows which are sequences of | ||
requests and responses that, taken together, culminate in a desired outcome. | ||
By default, this state is kept in the session. The session itself stores state | ||
by setting a cookie which applies to _all_ requests to an application. This | ||
middleware isolates that state so it can be applied to an individual sequence of | ||
requests. To do this, state is propagated in `return_to` and `state` parameters | ||
across requests. This middleware does this automatically whenever possible, | ||
such as when redirecting. When not possible, such as when rendering a view, | ||
locals and helpers are made available to the view so that `return_to` and | ||
`state` parameters can be added to links and forms. | ||
This middleware emerged from the state management functionality implemented by | ||
authentication-related packages, in particular [passport-oauth2](https://www.passportjs.org/packages/passport-oauth2/) and | ||
[oauth2orize](https://www.oauth2orize.org/) which implement OAuth 2.0. With | ||
this package, the functionality is made generic so that it can be applied to | ||
any HTTP endpoint. | ||
<div align="right"> | ||
<sup>Developed by <a href="#authors">Jared Hanson</a>.</sub> | ||
</div> | ||
## Install | ||
```bash | ||
$ npm install flowstate | ||
``` | ||
## Usage | ||
#### Add Middleware | ||
Add state middleware to your application or route: | ||
```js | ||
var flowstate = require('flowstate'); | ||
app.get('/login', flowstate(), function(req, res, next) { | ||
// ... | ||
}); | ||
``` | ||
The middleware will attempt to load any state intended for the endpoint, based | ||
the `state` parameter in the query or body of the request. If state is loaded, | ||
it will be set at `req.state` so that the handler can process it. The value set | ||
at `req.state` is referred to as the "current state". | ||
If state is not loaded, an "uninitialized" state will be set at `req.state`. A | ||
state is uninitialized when it is new but not modified. If the request contains | ||
a `return_to` and optional `state` parameter, those will be captured by the | ||
uninitialized state as the location to return the user to when the current state | ||
has been completely processed. | ||
When a response is sent, any modifications to the current state will be saved | ||
if the state is not complete. If the state is complete, any persisted state | ||
will be removed. Note that an uninitialized state will never be saved since it | ||
is not modified. However, the location to return the user to will be preserved | ||
by propagating the `return_to` and optional `state` parameters on subsequent | ||
requests. | ||
#### Render a View | ||
```js | ||
app.get('/login', flowstate(), function(req, res, next) { | ||
var msgs = req.state.messages || []; | ||
res.locals.messages = msgs; | ||
res.locals.hasMessages = !! msgs.length; | ||
res.render('login'); | ||
}); | ||
``` | ||
When a response is sent by rendering a view, if there is state associated with | ||
the request, `res.locals.state` will be set to the current state's handle. | ||
Otherwise the `return_to` and `state` parameters, if any, will be propagated by | ||
setting `res.locals.returnTo` and `res.locals.state`. The view is expected to | ||
decorate links with these properties and add them as hidden input to forms, in | ||
order to propagate state to subsequent requests. | ||
For example, if the above `/login` endpoint is requested with a `return_to` | ||
parameter: | ||
```http | ||
GET /login?return_to=%2Fdashboard HTTP/1.1 | ||
``` | ||
Then `res.locals.returnTo` will be set to `/dashboard`, making it available to | ||
the view. | ||
If the `/login` endpoint is requested with both a `return_to` and `state` | ||
parameter: | ||
```http | ||
GET /login?return_to=%2Fauthorize%2Fcontinue&state=xyz HTTP/1.1 | ||
``` | ||
Then `res.locals.returnTo` will be set to `/authorize/continue` and `res.locals.state` | ||
will be set to `xyz`, making them available to the view. | ||
If the `/login` endpoint is requested with: | ||
```http | ||
GET /login?state=Zwu8y84x HTTP/1.1 | ||
``` | ||
Assuming the state was valid and intended for `/login`, `res.locals.state` will | ||
be set to `Zwu8y84x` and made available to the view. `res.locals.returnTo` will | ||
_not_ be set. | ||
#### Redirect to a Location | ||
```js | ||
app.post('/login', flowstate(), authenticate(), function(req, res, next) { | ||
if (mfaRequired(req.user)) { | ||
return res.redirect('/stepup'); | ||
} | ||
// ... | ||
}, function(err, req, res, next) { | ||
if (err.status !== 401) { return next(err); } | ||
req.state.messages = req.state.messages || []; | ||
req.state.messages.push('Invalid username or password.'); | ||
req.state.failureCount = req.state.failureCount ? req.state.failureCount + 1 : 1; | ||
req.state.complete(false); | ||
res.redirect('/login'); | ||
}); | ||
``` | ||
When a response redirects the browser, if the current state is complete, any | ||
`return_to` and `state` parameters will be propagated by decorating the target | ||
URL. If the current state is not complete, modifications will be saved and the | ||
redirect will be decorated with the current state's handle. | ||
For example, if the above `/login` endpoint is requested with a `return_to` and | ||
`state` parameter: | ||
```http | ||
POST /login HTTP/1.1 | ||
Host: www.example.com | ||
Content-Type: application/x-www-form-urlencoded | ||
username=alice&password=letmein&return_to=%2Fauthorize%2Fcontinue&state=xyz | ||
``` | ||
Then the user will be redirected to `/stepup?return_to=%2Fauthorize%2Fcontinue&state=xyz`, | ||
assuming the password is valid and MFA is required. | ||
If the password is not valid, an uninitialized state is set at `req.state` that | ||
captures the `return_to` and `state` parameters. It is then saved and the user | ||
is redirected to `/login?state=Zwu8y84x` (where `'Zwu8y84x'` is the handle of | ||
the newly saved state). The state data stored in the session is as follows: | ||
```json | ||
{ | ||
"state": { | ||
"Zwu8y84x": { | ||
"location": "https://www.example.com/login", | ||
"messages": [ "Invalid username or password." ], | ||
"failureCount": 1, | ||
"returnTo": "/authorize/continue", | ||
"state": "xyz" | ||
} | ||
} | ||
} | ||
``` | ||
This redirect will cause the browser to request the `GET /login` route above. | ||
Since the request is made with a `state=Zwu8y84x` query parameter, the route will | ||
load the state and make the handle (as well as messages) available to the view. | ||
The view must add the handle to the login form as a hidden input field named | ||
`state`. When submitted, the browser will then make a request with that `state` | ||
parameter: | ||
```http | ||
POST /login HTTP/1.1 | ||
Host: www.example.com | ||
Content-Type: application/x-www-form-urlencoded | ||
username=alice&password=letmeinnow&state=Zwu8y84x | ||
``` | ||
This time, the `POST /login` route will load the state. If the password is | ||
valid and MFA is required, the user will be will be redirected to | ||
`/stepup?return_to=%2Fauthorize%2Fcontinue&state=xyz`, as before. This is | ||
because the original `return_to` and `state` parameters were captured by the | ||
loaded state object, and are propagated by decorating the redirect location. | ||
If another invalid password is submitted, the cycle of redirecting, rendering | ||
the login view, and prompting the user for a password will repeat, with the | ||
`failureCount` incremented and saved each time. | ||
#### Resume State | ||
```js | ||
app.post('/login', flowstate(), authenticate(), function(req, res, next) { | ||
if (mfaRequired(req.user)) { | ||
return res.redirect('/stepup'); | ||
} | ||
res.resumeState(next); | ||
}, function(req, res, next) { | ||
res.redirect('/'); | ||
}, function(err, req, res, next) { | ||
// ... | ||
}); | ||
``` | ||
When a user has completed a given flow, they should be returned to the location | ||
they were navigating prior to entering the flow. This is accomplished by | ||
calling `resumeState()`, a function added to the response by this middleware. | ||
If a current state was loaded, `resumeState()` will return the user to the | ||
captured `return_to` and `state` parameters, if any. Otherwise, it will return | ||
the user to the `return_to` and `state` parameters carried by the request. If | ||
neither of these exist, `resumeState()` will call a callback, which will | ||
typically be `next` to invoke the next middleware. This middleware can then | ||
redirect the user to a default location. | ||
For example, when `POST /login` is requested with a `state` parameter: | ||
```http | ||
POST /login HTTP/1.1 | ||
Host: www.example.com | ||
Content-Type: application/x-www-form-urlencoded | ||
username=alice&password=letmeinnow&state=Zwu8y84x | ||
``` | ||
Then the user will be redirected to `/authorize/continue&state=xyz`, | ||
assuming the password is valid and MFA is not required. | ||
If the `/login` endpoint is requested with a `return_to` parameter: | ||
```http | ||
POST /login HTTP/1.1 | ||
Host: www.example.com | ||
Content-Type: application/x-www-form-urlencoded | ||
username=alice&password=letmein&return_to=%2Fdashboard | ||
``` | ||
Then the user will be redirected to `/dashboard`, after logging in. | ||
If the `/login` endpoint is requested without any state-related parameters: | ||
```http | ||
POST /login HTTP/1.1 | ||
Host: www.example.com | ||
Content-Type: application/x-www-form-urlencoded | ||
username=alice&password=letmein | ||
``` | ||
Then the user will be redirected to `/` by the next middleware in the stack. | ||
#### Push State | ||
## Authors | ||
- [Jared Hanson](https://www.jaredhanson.me/) { [![WWW](https://raw.githubusercontent.com/jaredhanson/jaredhanson/master/images/globe-12x12.svg)](https://www.jaredhanson.me/) [![Facebook](https://raw.githubusercontent.com/jaredhanson/jaredhanson/master/images/facebook-12x12.svg)](https://www.facebook.com/jaredhanson) [![LinkedIn](https://raw.githubusercontent.com/jaredhanson/jaredhanson/master/images/linkedin-12x12.svg)](https://www.linkedin.com/in/jaredhanson) [![Twitter](https://raw.githubusercontent.com/jaredhanson/jaredhanson/master/images/twitter-12x12.svg)](https://twitter.com/jaredhanson) [![GitHub](https://raw.githubusercontent.com/jaredhanson/jaredhanson/master/images/github-12x12.svg)](https://github.com/jaredhanson) } | ||
## License | ||
[The MIT License](http://opensource.org/licenses/MIT) | ||
Copyright (c) 2016-2023 Jared Hanson |
46129
19
934
272