Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom Session Stores #190

Merged
merged 12 commits into from
Feb 23, 2021
38 changes: 35 additions & 3 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Examples

1. [Basic setup](#1-basic-setup)
2. [Require authentication for specific routes](#2-require-authentication-for-specific-routes)
3. [Route customization](#3-route-customization)
4. [Obtaining access tokens to call external APIs](#4-obtaining-access-tokens-to-call-external-apis)
5. [Obtaining and using refresh tokens](#5-obtaining-and-using-refresh-tokens)
6. [Calling userinfo](#6-calling-userinfo)
7. [Protect a route based on specific claims](#6-protect-a-route-based-on-specific-claims)
8. [Logout from Identity Provider](#7-logout-from-identity-provider)
9. [Validate Claims from an ID token before logging a user in](#8-validate-claims-from-an-id-token-before-logging-a-user-in)
10. [Use a custom session store](#9-use-a-custom-session-store)

## 1. Basic setup

The simplest use case for this middleware. By default all routes are protected. The middleware uses the [Implicit Flow with Form Post](https://auth0.com/docs/flows/concepts/implicit) to acquire an ID Token from the authorization server and an encrypted cookie session to persist it.
Expand Down Expand Up @@ -204,7 +215,7 @@ app.get(

## 7. Logout from Identity Provider

When using an IDP, such as Auth0, the default configuration will only log the user out of your application session. When the user logs in again, they will be automatically logged back in to the IDP session. To have the user additionally logged out of the IDP session you will need to add `idpLogout: true` to the middleware configuration.
When using an IDP, such as Auth0, the default configuration will only log the user out of your application session. When the user logs in again, they will be automatically logged back in to the IDP session. To have the user additionally logged out of the IDP session you will need to add `idpLogout: true` to the middleware configuration.

```js
const { auth } = require('express-openid-connect');
Expand All @@ -230,8 +241,29 @@ app.use(
throw new Error('User is not a part of the Required Organization');
}
return session;
}
},
})
);
```

## 9. Use a custom session store

By default the session is stored in an encrypted cookie. But when the session gets too large it can bump up against the limits of cookie storage. In these instances you can use a custom session store. The store should have `get`, `set` and `destroy` methods, making it compatible with [express-session stores](https://github.com/expressjs/session#session-store-implementation).

```js
const { auth } = require('express-openid-connect');
const redis = require('redis');
const RedisStore = require('connect-redis')(auth);

const redisClient = redis.createClient();

app.use(
auth({
session: {
store: new RedisStore({ client: redisClient }),
},
})
);
```

```
Full example at [custom-session-store.js](./examples/custom-session-store.js), to run it: `npm run start:example -- custom-session-store`
22 changes: 22 additions & 0 deletions examples/custom-session-store.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const express = require('express');
const { auth } = require('../');
const MemoryStore = require('memorystore')(auth);

const app = express();

app.use(
auth({
idpLogout: true,
session: {
store: new MemoryStore({
checkPeriod: 24 * 60 * 1000,
}),
},
})
);

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

module.exports = app;
66 changes: 64 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ interface ConfigParams {
/**
* Object defining application session cookie attributes.
*/
session?: boolean | SessionConfigParams;
session?: SessionConfigParams;

/**
* Boolean value to enable idpLogout with an Auth0 custom domain
Expand Down Expand Up @@ -355,7 +355,12 @@ interface ConfigParams {
* }))
* ``
*/
afterCallback?: (req: OpenidRequest, res: OpenidResponse, session: Session, decodedState: {[key: string]: any}) => Promise<Session> | Session;
afterCallback?: (
req: OpenidRequest,
res: OpenidResponse,
session: Session,
decodedState: { [key: string]: any }
) => Promise<Session> | Session;

/**
* Array value of claims to remove from the ID token before storing the cookie session.
Expand Down Expand Up @@ -423,6 +428,54 @@ interface ConfigParams {
clientAuthMethod?: string;
}

interface SessionStorePayload {
header: {
/**
* timestamp (in secs) when the session was created.
*/
iat: number;
/**
* timestamp (in secs) when the session was last touched.
*/
uat: number;
/**
* timestamp (in secs) when the session expires.
*/
exp: number;
};

/**
* The session data.
*/
data: Session;
}

interface SessionStore {
/**
* Gets the session from the store given a session ID and passes it to `callback`.
*/
get(
sid: string,
callback: (err: any, session?: SessionStorePayload | null) => void
): void;

/**
* Upsert a session in the store given a session ID and `SessionData`
*/
set(
sid: string,
session: SessionStorePayload,
callback?: (err?: any) => void
): void;

/**
* Destroys the session with the given session ID.
*/
destroy(sid: string, callback?: (err?: any) => void): void;

[key: string]: any;
}

/**
* Configuration parameters used for the application session.
*/
Expand All @@ -434,6 +487,15 @@ interface SessionConfigParams {
*/
name?: string;

/**
* By default the session is stored in an encrypted cookie. But when the session
* gets too large it can bump up against the limits of cookie storage.
* In these instances you can use a custom session store. The store should
* have `get`, `set` and `destroy` methods, making it compatible
* with [express-session stores](https://github.com/expressjs/session#session-store-implementation).
*/
store?: SessionStore;

/**
* If you want your session duration to be rolling, eg reset everytime the
* user is active on your site, set this to a `true`. If you want the session
Expand Down
83 changes: 78 additions & 5 deletions lib/appSession.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ const {
JWE,
errors: { JOSEError },
} = require('jose');
const onHeaders = require('on-headers');
const crypto = require('crypto');
const { promisify } = require('util');
const cookie = require('cookie');
const COOKIES = require('./cookies');
const { encryption: deriveKey } = require('./hkdf');
Expand Down Expand Up @@ -136,7 +137,66 @@ module.exports = (config) => {
}
}

return (req, res, next) => {
class CookieStore {
async get(idOrVal) {
const { protected: header, cleartext } = decrypt(idOrVal);
return {
header,
data: JSON.parse(cleartext),
};
}

async set(id, req, res, iat) {
setCookie(req, res, iat);
}
}

class CustomStore {
constructor(store) {
this._get = promisify(store.get).bind(store);
this._set = promisify(store.set).bind(store);
this._destroy = promisify(store.destroy).bind(store);
}

async get(id) {
return this._get(id);
}

async set(
id,
req,
res,
{ uat = epoch(), iat = uat, exp = calculateExp(iat, uat) }
) {
if (!req[sessionName] || !Object.keys(req[sessionName]).length) {
if (id) {
res.clearCookie(sessionName, {
domain: cookieConfig.domain,
path: cookieConfig.path,
});
await this._destroy(id);
}
} else {
id = id || crypto.randomBytes(16).toString('hex');
await this._set(id, {
header: { iat, uat, exp },
data: req[sessionName],
});
const cookieOptions = {
...cookieConfig,
expires: cookieConfig.transient ? 0 : new Date(exp * 1000),
};
delete cookieOptions.transient;
res.cookie(sessionName, id, cookieOptions);
}
}
}

const store = config.session.store
? new CustomStore(config.session.store)
: new CookieStore();

return async (req, res, next) => {
if (req.hasOwnProperty(sessionName)) {
debug(
'request object (req) already has %o property, this is indicative of a middleware setup problem',
Expand Down Expand Up @@ -185,7 +245,7 @@ module.exports = (config) => {
.join('');
}
if (existingSessionValue) {
const { protected: header, cleartext } = decrypt(existingSessionValue);
const { header, data } = await store.get(existingSessionValue);
({ iat, uat, exp } = header);

// check that the existing session isn't expired based on options when it was established
Expand All @@ -210,7 +270,7 @@ module.exports = (config) => {
);
}

attachSessionObject(req, sessionName, JSON.parse(cleartext));
attachSessionObject(req, sessionName, data);
}
} catch (err) {
if (err instanceof AssertionError) {
Expand All @@ -229,7 +289,20 @@ module.exports = (config) => {
attachSessionObject(req, sessionName, {});
}

onHeaders(res, setCookie.bind(undefined, req, res, { iat }));
const { end: origEnd } = res;
res.end = async function resEnd(...args) {
try {
await store.set(existingSessionValue, req, res, {
iat,
});
origEnd.call(res, ...args);
} catch (e) {
// need to restore the original `end` so that it gets
// called after `next(e)` calls the express error handling mw
res.end = origEnd;
process.nextTick(() => next(e));
}
};

return next();
};
Expand Down
9 changes: 3 additions & 6 deletions lib/config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
const Joi = require('@hapi/joi');
const clone = require('clone');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@adamjmcgrath what is the reason for this clone in the first place? I can't remember.

@davidpatrick what is the reason for this deletion?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I deleted it because I didn't want to clone the store instance (in case user's added event listeners to it etc.)

I believe it was there to prevent unwanted mutations to the user's config, but objects passed into joi are effectively immutable and mutating the store instance is desirable (adding event listeners etc.) - so I don't think it's necessary (I removed it for the Next SDK as well)

const { defaultState: getLoginState } = require('./hooks/getLoginState');
const isHttps = /^https:/i;

Expand Down Expand Up @@ -38,6 +37,7 @@ const paramsSchema = Joi.object({
.optional()
.default(7 * 24 * 60 * 60), // 7 days,
name: Joi.string().token().optional().default('appSession'),
store: Joi.object().optional(),
cookie: Joi.object({
domain: Joi.string().optional(),
transient: Joi.boolean().optional().default(false),
Expand Down Expand Up @@ -134,8 +134,7 @@ const paramsSchema = Joi.object({
getLoginState: Joi.function()
.optional()
.default(() => getLoginState),
afterCallback: Joi.function()
.optional(),
afterCallback: Joi.function().optional(),
identityClaimFilter: Joi.array()
.optional()
.default([
Expand Down Expand Up @@ -186,8 +185,7 @@ const paramsSchema = Joi.object({
}),
});

module.exports.get = function (params) {
let config = typeof params === 'object' ? clone(params) : {};
module.exports.get = function (config = {}) {
config = {
secret: process.env.SECRET,
issuerBaseURL: process.env.ISSUER_BASE_URL,
Expand All @@ -204,6 +202,5 @@ module.exports.get = function (params) {
if (warning) {
console.warn(warning.message);
}

return value;
};
16 changes: 15 additions & 1 deletion middleware/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ const enforceLeadingSlash = (path) => {
*
* @returns {express.Router} the router
*/
module.exports = function (params) {
const auth = function (params) {
const config = getConfig(params);
debug('configuration object processed, resulting configuration: %O', config);
const router = new express.Router();
Expand Down Expand Up @@ -157,3 +157,17 @@ module.exports = function (params) {

return router;
};

/**
* Used for instantiating a custom session store. eg
*
* ```js
* const { auth } = require('express-openid-connect');
* const MemoryStore = require('memorystore')(auth);
* ```
*
* @constructor
*/
auth.Store = function () {};

module.exports = auth;
Loading