From 8e5b70877940ce90083fc223cd53414c3c089815 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Tue, 10 Dec 2019 15:12:02 -0800 Subject: [PATCH 01/19] Remove session use, switch to cookie session for identity --- EXAMPLES.md | 5 +- lib/config.js | 9 ++ lib/context.js | 27 +--- lib/hooks/getUser.js | 16 ++- lib/loadEnvs.js | 1 + middleware/auth.js | 23 ++-- package-lock.json | 179 +++++++++++++++---------- package.json | 12 +- test/callback_route_form_post.tests.js | 2 +- test/fixture/server.js | 2 +- 10 files changed, 158 insertions(+), 118 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 7f5c9a5d..7bb8e046 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -160,15 +160,12 @@ app.use(auth({ handleCallback: async function (req, res, next) { const client = req.openid.client; try { - req.session.userinfo = await client.userinfo(req.session.openidTokens); + req.identity.claims = await client.userinfo(req.openidTokens); next(); } catch(e) { next(e); } }, - getUser: function(tokenSet) { - return tokenSet && ( tokenSet.userinfo || tokenSet.claims() ); - }, authorizationParams: { response_type: 'code', scope: 'openid profile email' diff --git a/lib/config.js b/lib/config.js index 687e0ff2..54f0dc00 100644 --- a/lib/config.js +++ b/lib/config.js @@ -3,6 +3,7 @@ const clone = require('clone'); const loadEnvs = require('./loadEnvs'); const getUser = require('./hooks/getUser'); const handleCallback = require('./hooks/handleCallback'); +const crypto = require('crypto'); const defaultAuthorizeParams = { response_type: 'id_token', @@ -36,6 +37,14 @@ const paramsSchema = Joi.object().keys({ loginPath: Joi.string().optional().default('/login'), logoutPath: Joi.string().optional().default('/logout'), legacySameSiteCookie: Joi.boolean().optional().default(true), + + // TODO: validate for cookie name + sessionName: Joi.string().optional().default('identity'), + // TODO: default here will kill all sessions on server restart ... better to fail? + sessionSecret: Joi.string().optional().default(crypto.randomBytes(20).toString('hex')), + // TODO: think about default session length + sessionLength: Joi.number().optional().default(7 * 24 * 60 * 60 * 1000), + idpLogout: Joi.boolean().optional().default(false) .when('auth0Logout', { is: true, then: Joi.boolean().optional().default(true) }) }); diff --git a/lib/context.js b/lib/context.js index 58453a07..50b7af69 100644 --- a/lib/context.js +++ b/lib/context.js @@ -2,7 +2,6 @@ const cb = require('cb'); const urlJoin = require('url-join'); const transient = require('./transientHandler'); const { get: getClient } = require('./client'); -const { TokenSet } = require('openid-client'); class RequestContext { constructor(config, req, res, next) { @@ -12,17 +11,6 @@ class RequestContext { this._next = next; } - get tokens() { - if (!this._req.session || !this._req.session.openidTokens) { - return undefined; - } - return new TokenSet(this._req.session.openidTokens); - } - - set tokens(value) { - this._req.session.openidTokens = value; - } - get isAuthenticated() { return !!this.user; } @@ -31,8 +19,9 @@ class RequestContext { if (!this.client) { this.client = await getClient(this._config); } - if (this.tokens) { - this.user = await this._config.getUser(this.tokens); + + if (this._req[this._config.sessionName].claims) { + this.user = await this._config.getUser(this._req[this._config.sessionName].claims); } } } @@ -98,15 +87,7 @@ class ResponseContext { const res = this._res; const returnURL = params.returnTo || this._config.baseURL; - if (!req.session || !req.openid) { - return res.redirect(returnURL); - } - - if (typeof req.session.destroy === 'function') { - req.session.destroy(); - } else { - req.session = null; - } + req[this._config.sessionName].destroy(); if (!this._config.idpLogout) { return res.redirect(returnURL); diff --git a/lib/hooks/getUser.js b/lib/hooks/getUser.js index b942b0bf..ccbc84af 100644 --- a/lib/hooks/getUser.js +++ b/lib/hooks/getUser.js @@ -2,6 +2,18 @@ * Default function for mapping a tokenSet to a user. * This can be used for adjusting or augmenting profile data. */ -module.exports = function(tokenSet) { - return tokenSet && tokenSet.claims(); +module.exports = function(idClaims) { + if (!idClaims || typeof idClaims !== 'object') { + return null; + } + + delete idClaims.iat; + delete idClaims.exp; + delete idClaims.aud; + delete idClaims.nonce; + delete idClaims.iss; + delete idClaims.azp; + delete idClaims.auth_time; + + return idClaims; }; diff --git a/lib/loadEnvs.js b/lib/loadEnvs.js index d187952a..55afb96c 100644 --- a/lib/loadEnvs.js +++ b/lib/loadEnvs.js @@ -3,6 +3,7 @@ const fieldsEnvMap = { 'baseURL': 'BASE_URL', 'clientID': 'CLIENT_ID', 'clientSecret': 'CLIENT_SECRET', + 'sessionSecret': 'SESSION_SECRET', }; module.exports = function(params) { diff --git a/middleware/auth.js b/middleware/auth.js index 20f36725..d45f6df0 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -2,6 +2,8 @@ const express = require('express'); const cb = require('cb'); const createError = require('http-errors'); const cookieParser = require('cookie-parser'); +const idSession = require('client-sessions'); + const { get: getConfig } = require('../lib/config'); const { get: getClient } = require('../lib/client'); const requiresAuth = require('./requiresAuth'); @@ -45,20 +47,22 @@ module.exports = function (params) { const router = express.Router(); - router.use(async (req, res, next) => { + router.use(idSession({ + cookieName: config.sessionName, + secret: config.sessionSecret, + duration: config.sessionLength + })); + router.use(async (req, res, next) => { + req.openid = new RequestContext(config, req, res, next); try { - req.openid = new RequestContext(config, req, res, next); await req.openid.load(); - - res.openid = new ResponseContext(config, req, res, next); - - req.isAuthenticated = () => req.openid.isAuthenticated; - - next(); } catch(err) { next(err); } + res.openid = new ResponseContext(config, req, res, next); + req.isAuthenticated = () => req.openid.isAuthenticated; + next(); }); if (config.routes) { @@ -101,7 +105,8 @@ module.exports = function (params) { throw createError.BadRequest(err.message); } - req.session.openidTokens = tokenSet; + req.openidTokens = tokenSet; + req[config.sessionName].claims = tokenSet.claims(); next(); } catch (err) { next(err); diff --git a/package-lock.json b/package-lock.json index 85279872..62bf2940 100644 --- a/package-lock.json +++ b/package-lock.json @@ -129,9 +129,9 @@ } }, "@types/express": { - "version": "4.17.1", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.1.tgz", - "integrity": "sha512-VfH/XCP0QbQk5B5puLqTLEeFgR8lfCJHZJKkInZ9mkYd+u8byX0kztXEQxEk4wZXJs8HI+7km2ALXjn4YKcX9w==", + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.2.tgz", + "integrity": "sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==", "dev": true, "requires": { "@types/body-parser": "*", @@ -140,15 +140,37 @@ } }, "@types/express-serve-static-core": { - "version": "4.16.9", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.9.tgz", - "integrity": "sha512-GqpaVWR0DM8FnRUJYKlWgyARoBUAVfRIeVDZQKOttLFp5SmhhF9YFIYeTPwMd/AXfxlP7xVO2dj1fGu0Q+krKQ==", + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.0.tgz", + "integrity": "sha512-Xnub7w57uvcBqFdIGoRg1KhNOeEj0vB6ykUM7uFWyxvbdE89GFyqgmUcanAriMr4YOxNFZBAWkfcWIb4WBPt3g==", "dev": true, "requires": { "@types/node": "*", "@types/range-parser": "*" } }, + "@types/got": { + "version": "9.6.9", + "resolved": "https://registry.npmjs.org/@types/got/-/got-9.6.9.tgz", + "integrity": "sha512-w+ZE+Ovp6fM+1sHwJB7RN3f3pTJHZkyABuULqbtknqezQyWadFEp5BzOXaZzRqAw2md6/d3ybxQJt+BNgpvzOg==", + "requires": { + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + }, + "dependencies": { + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + } + } + }, "@types/mime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", @@ -156,10 +178,9 @@ "dev": true }, "@types/node": { - "version": "12.7.11", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.11.tgz", - "integrity": "sha512-Otxmr2rrZLKRYIybtdG/sgeO+tHY20GxeDjcGmUnmmlCWyEnv2a2x1ZXBo3BTec4OiTXMQCiazB8NMBf0iRlFw==", - "dev": true + "version": "12.12.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.17.tgz", + "integrity": "sha512-Is+l3mcHvs47sKy+afn2O1rV4ldZFU7W8101cNlOd+MRbjM4Onida8jSZnJdTe/0Pcf25g9BNIUsuugmE6puHA==" }, "@types/range-parser": { "version": "1.2.3", @@ -177,6 +198,11 @@ "@types/mime": "*" } }, + "@types/tough-cookie": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.6.tgz", + "integrity": "sha512-wHNBMnkoEBiRAd3s8KTKwIuO9biFtTf0LehITzBhSco+HQI0xkXZbLOD55SW3Aqw3oUkHstkm5SPv58yaAdFPQ==" + }, "abab": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.2.tgz", @@ -342,8 +368,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "aws-sign2": { "version": "0.7.0", @@ -564,6 +589,14 @@ "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", "dev": true }, + "client-sessions": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/client-sessions/-/client-sessions-0.8.0.tgz", + "integrity": "sha1-p9jFVYrV1W8qGZ81M+tlS134k/0=", + "requires": { + "cookies": "^0.7.0" + } + }, "cliui": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", @@ -635,7 +668,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -723,7 +755,6 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.7.1.tgz", "integrity": "sha1-fIphX1SBxhq58WyDNzG8uPZjuZs=", - "dev": true, "requires": { "depd": "~1.1.1", "keygrip": "~1.0.2" @@ -748,6 +779,11 @@ "which": "^1.2.9" } }, + "crypto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", + "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==" + }, "cssom": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", @@ -822,9 +858,9 @@ "dev": true }, "defer-to-connect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.0.2.tgz", - "integrity": "sha512-k09hcQcTDY+cwgiwa6PYKLm3jlagNzQ+RSvhjzESOGOx+MNOuXkxTfEvPrO1IOQ81tArCFYQgi631clB70RpQw==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.1.tgz", + "integrity": "sha512-J7thop4u3mRTkYRQ+Vpfwy2G5Ehoy82I14+14W4YMDLKdWloI9gSzRbV30s/NckQGVJtPkWNcW4oMAUigTdqiQ==" }, "define-properties": { "version": "1.1.3", @@ -838,8 +874,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "depd": { "version": "1.1.2", @@ -927,27 +962,27 @@ } }, "es-abstract": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.15.0.tgz", - "integrity": "sha512-bhkEqWJ2t2lMeaJDuk7okMkJWI/yqgH/EoGwpcvv0XW9RWQsRspI4wt6xuyuvMvvQE3gg/D9HXppgk21w78GyQ==", + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.16.3.tgz", + "integrity": "sha512-WtY7Fx5LiOnSYgF5eg/1T+GONaGmpvpPdCpSnYij+U2gDTL0UPfWrhDw7b2IYb+9NQJsYpCA0wOQvZfsd6YwRw==", "dev": true, "requires": { - "es-to-primitive": "^1.2.0", + "es-to-primitive": "^1.2.1", "function-bind": "^1.1.1", "has": "^1.0.3", - "has-symbols": "^1.0.0", + "has-symbols": "^1.0.1", "is-callable": "^1.1.4", "is-regex": "^1.0.4", - "object-inspect": "^1.6.0", + "object-inspect": "^1.7.0", "object-keys": "^1.1.1", "string.prototype.trimleft": "^2.1.0", "string.prototype.trimright": "^2.1.0" } }, "es-to-primitive": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", - "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", + "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", "dev": true, "requires": { "is-callable": "^1.1.4", @@ -1444,9 +1479,9 @@ "dev": true }, "has-symbols": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", - "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", + "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", "dev": true }, "he": { @@ -1633,12 +1668,12 @@ } }, "is-symbol": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz", - "integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", + "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", "dev": true, "requires": { - "has-symbols": "^1.0.0" + "has-symbols": "^1.0.1" } }, "is-typedarray": { @@ -1674,9 +1709,9 @@ "dev": true }, "jose": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-1.10.1.tgz", - "integrity": "sha512-E6ev76KFgNT/+qN/owc7EYG7M3nJGSLguF7Jr2bpImUTpIwZS5ALsZA7l5/MOlVm52Kc+lcM/FeOfg3i/VG7qA==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-1.17.1.tgz", + "integrity": "sha512-HsCRnV2ZPaLbGLIKIVcRqepx0E/b/acIAF7cgnr/nJb7jRwk13fIViErVJ2a7r6Pgz7OX+oZieOOaPi57pbjgg==", "requires": { "asn1.js": "^5.2.0" } @@ -1834,8 +1869,7 @@ "keygrip": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.0.3.tgz", - "integrity": "sha512-/PpesirAIfaklxUzp4Yb7xBper9MwP6hNRA6BGGUFCgbJ+BM5CKBtsoxinNXkLHAr+GXS1/lSlF2rP7cv5Fl+g==", - "dev": true + "integrity": "sha512-/PpesirAIfaklxUzp4Yb7xBper9MwP6hNRA6BGGUFCgbJ+BM5CKBtsoxinNXkLHAr+GXS1/lSlF2rP7cv5Fl+g==" }, "keyv": { "version": "3.1.0", @@ -1996,14 +2030,12 @@ "mime-db": { "version": "1.40.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", - "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", - "dev": true + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==" }, "mime-types": { "version": "2.1.24", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", - "dev": true, "requires": { "mime-db": "1.40.0" } @@ -2048,9 +2080,9 @@ } }, "mocha": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-6.2.1.tgz", - "integrity": "sha512-VCcWkLHwk79NYQc8cxhkmI8IigTIhsCwZ6RTxQsqK6go4UvEhzJkYuHm8B2YtlSxcYq2fY+ucr4JBwoD6ci80A==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-6.2.2.tgz", + "integrity": "sha512-FgDS9Re79yU1xz5d+C4rv1G7QagNGHZ+iXF81hO8zY35YZZcLEsJVfFolfsqKFWunATEvNzMK0r/CwWd/szO9A==", "dev": true, "requires": { "ansi-colors": "3.2.3", @@ -2179,9 +2211,9 @@ } }, "nock": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/nock/-/nock-11.4.0.tgz", - "integrity": "sha512-UrVEbEAvhyDoUttrS0fv3znhZ5nEJvlxqgmrC6Gb2Mf9cFci65RMK17e6EjDDQB57g5iwZw1TFnVvyeL0eUlhQ==", + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/nock/-/nock-11.7.0.tgz", + "integrity": "sha512-7c1jhHew74C33OBeRYyQENT+YXQiejpwIrEjinh6dRurBae+Ei4QjeUaPlkptIF0ZacEiVCnw8dWaxqepkiihg==", "dev": true, "requires": { "chai": "^4.1.2", @@ -2243,14 +2275,14 @@ "dev": true }, "object-hash": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.1.tgz", - "integrity": "sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA==" + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.0.1.tgz", + "integrity": "sha512-HgcGMooY4JC2PBt9sdUdJ6PMzpin+YtY3r/7wg0uTifP+HJWW8rammseSEHuyt0UeShI183UGssCJqm1bJR7QA==" }, "object-inspect": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz", - "integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", + "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", "dev": true }, "object-keys": { @@ -2282,9 +2314,9 @@ } }, "oidc-token-hash": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-3.0.2.tgz", - "integrity": "sha512-dTzp80/y/da+um+i+sOucNqiPpwRL7M/xPwj7pH1TFA2/bqQ+OK2sJahSXbemEoLtPkHcFLyhLhLWZa9yW5+RA==" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.0.tgz", + "integrity": "sha512-8Yr4CZSv+Tn8ZkN3iN2i2w2G92mUKClp4z7EGUfdsERiYSbj7P4i/NHm72ft+aUdsiFx9UdIPSTwbyzQ6C4URg==" }, "on-finished": { "version": "2.3.0", @@ -2327,18 +2359,19 @@ } }, "openid-client": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-3.7.3.tgz", - "integrity": "sha512-t7GL9Yl9tL6ybY8040pzIVw8+akF5z3GgMEG8iZ2UbFfuMEXSKmHiCU00LvWF9dL4UNMlIjTSBpskQIIeJBwpw==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-3.9.0.tgz", + "integrity": "sha512-MBvXMrZ6U8XGncTW37il4UaBUq5fkOF3ySnJPb0wZw7fZHk0gywWvlp3Tzd9t3TKeejh+80QWeLl9FDhIJSk9Q==", "requires": { + "@types/got": "^9.6.9", "base64url": "^3.0.1", "got": "^9.6.0", - "jose": "^1.10.0", - "lodash": "^4.17.13", + "jose": "^1.16.2", + "lodash": "^4.17.15", "lru-cache": "^5.1.1", "make-error": "^1.3.5", - "object-hash": "^1.3.1", - "oidc-token-hash": "^3.0.2", + "object-hash": "^2.0.1", + "oidc-token-hash": "^5.0.0", "p-any": "^2.1.0" } }, @@ -2701,21 +2734,21 @@ } }, "request-promise-core": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz", - "integrity": "sha512-UHYyq1MO8GsefGEt7EprS8UrXsm1TxEvFUX1IMTuSLU2Rh7fTIdFtl8xD7JiEYiWU2dl+NYAjCTksTehQUxPag==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz", + "integrity": "sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==", "dev": true, "requires": { - "lodash": "^4.17.11" + "lodash": "^4.17.15" } }, "request-promise-native": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.7.tgz", - "integrity": "sha512-rIMnbBdgNViL37nZ1b3L/VfPOpSi0TqVDQPAvO6U14lMzOLrt5nilxCQqtDKhZeDiW0/hkCXGoQjhgJd/tCh6w==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.8.tgz", + "integrity": "sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ==", "dev": true, "requires": { - "request-promise-core": "1.1.2", + "request-promise-core": "1.1.3", "stealthy-require": "^1.1.1", "tough-cookie": "^2.3.3" } diff --git a/package.json b/package.json index b04de575..8242c4e3 100644 --- a/package.json +++ b/package.json @@ -13,26 +13,28 @@ "dependencies": { "@hapi/joi": "^14.5.0", "cb": "^0.1.0", + "client-sessions": "^0.8.0", "clone": "^2.1.2", "cookie-parser": "^1.4.4", + "crypto": "^1.0.1", "http-errors": "^1.7.3", - "openid-client": "^3.7.3", + "openid-client": "^3.9.0", "p-memoize": "^3.1.0", "url-join": "^4.0.1" }, "devDependencies": { - "@types/express": "^4.17.1", + "@types/express": "^4.17.2", "chai": "^4.2.0", "cookie-session": "^2.0.0-beta.3", "eslint": "^5.16.0", "express": "^4.17.1", "jsdom": "^13.2.0", "jsonwebtoken": "^8.5.1", - "mocha": "^6.2.1", - "nock": "^11.4.0", + "mocha": "^6.2.2", + "nock": "^11.7.0", "pem-jwk": "^2.0.0", "proxyquire": "^2.1.3", - "request-promise-native": "^1.0.7", + "request-promise-native": "^1.0.8", "selfsigned": "^1.10.7", "sinon": "^7.5.0" }, diff --git a/test/callback_route_form_post.tests.js b/test/callback_route_form_post.tests.js index 108d9550..e5802ca2 100644 --- a/test/callback_route_form_post.tests.js +++ b/test/callback_route_form_post.tests.js @@ -227,7 +227,7 @@ describe('callback routes response_type: id_token, response_mode: form_post', fu }); it('should contain the claims in the current session', function() { - assert.ok(this.currentSession.openidTokens); + assert.ok(this.currentSession.claims); }); it('should expose the user in the request', async function() { diff --git a/test/fixture/server.js b/test/fixture/server.js index b192a32c..65c6e393 100644 --- a/test/fixture/server.js +++ b/test/fixture/server.js @@ -17,7 +17,7 @@ module.exports.create = function(router, protect) { app.use(router); app.get('/session', (req, res) => { - res.json(req.session); + res.json(req.identity); }); app.get('/user', (req, res) => { From dc78ac4ad8faf103586118cd1c5256cd720f2afb Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 16 Dec 2019 21:18:24 -0800 Subject: [PATCH 02/19] Documentation changes and add req.makeTokenSet() --- API.md | 6 ++-- EXAMPLES.md | 73 ++++++++++++++++++++++++++++------------------ README.md | 7 +++-- lib/config.js | 2 ++ lib/context.js | 5 ++++ middleware/auth.js | 2 +- 6 files changed, 60 insertions(+), 35 deletions(-) diff --git a/API.md b/API.md index a9f3aef6..e835a85f 100644 --- a/API.md +++ b/API.md @@ -100,12 +100,12 @@ This library adds properties and methods to the request and response objects use ### Request -Every request object (typically named `req` in your route handler) is augmented with the following when the request is authenticated. If the request is not authenticated, `req.openid` is `undefined`. +Every request object (typically named `req` in your route handler) is augmented with the following when the request is authenticated. If the request is not authenticated, `req.openid` is `undefined` and `req.isAuthenticated()` returns `false`. -- **`req.openid.user`** - Contains the user information returned from the authorization server. You can change what is provided here by using the `getUser` configuration key. -- **`req.openid.tokens`** - Is the [TokenSet](https://github.com/panva/node-openid-client/blob/master/docs/README.md#tokenset) instance obtained during login. +- **`req.openid.user`** - Contains the user information returned from the authorization server. You can change what is provided here by passing a function to the `getUser` configuration key. - **`req.openid.client`** - Is the [OpenID Client](https://github.com/panva/node-openid-client/blob/master/docs/README.md#client) instance that can be used for additional OAuth2 and OpenID calls. See [the examples](EXAMPLES.md) for more information on how this is used. - **`req.isAuthenticated()`** - Returns true if the request is authenticated. +- **`req.makeTokenSet()`** - Make a TokenSet object from a JSON representation of one. ### Response diff --git a/EXAMPLES.md b/EXAMPLES.md index 7bb8e046..43457280 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -10,21 +10,12 @@ The simplest use case for this middleware: ISSUER_BASE_URL=https://YOUR_DOMAIN CLIENT_ID=YOUR_CLIENT_ID BASE_URL=https://YOUR_APPLICATION_ROOT_URL -SESSION_NAME=YOUR_SESSION_NAME -COOKIE_SECRET=LONG_RANDOM_VALUE +SESSION_SECRET=LONG_RANDOM_STRING ``` ```javascript // app.js const { auth } = require('express-openid-connect'); -const session = require('cookie-session'); - -app.use(express.urlencoded({ extended: false })); - -app.use(session({ - name: process.env.SESSION_NAME, - secret: process.env.COOKIE_SECRET -})); app.use(auth({ required: true @@ -65,7 +56,6 @@ Another way to configure this scenario: ```js const { auth } = require('express-openid-connect'); -//initialization app.use(auth({ required: req => req.originalUrl.startsWith('/admin/') })); @@ -98,7 +88,47 @@ app.use(auth({ Please note that both of these routes are completely optional and not required. Trying to access any protected resource triggers a redirect directly to Auth0 to login. -## 4. Using refresh tokens +## 4. Using access tokens + +If your application needs to request and store access and/or refresh tokens, you must provide a method to store the incoming tokens during callback. We recommend to use a persistant store, like a database or Redis, to store these tokens directly associated with the user for which they were requested. + +If the tokens only need to be used during the user's session, they can be stored using a session middleware like `express-session`. We recommend persisting the session in a session store other than in-memory (default). The basics of handling the tokens is below: + +```js +const { auth } = require('./express-openid-connect'); +const session = require('express-session'); + +app.use(session({ + secret: process.env.SESSION_SECRET +})); + +app.use(auth({ + authorizationParams: { + response_type: 'code id_token', + response_mode: 'form_post', + audience: process.env.API_URL, + scope: 'openid profile email read:reports' + }, + handleCallback: async function (req, res, next) { + req.session.openIdTokens = req.openIdTokens; + next(); + } +})); +``` + +On a route that needs to use the access token, pull the token data from the storage and initialize a new `TokenSet` using `makeTokenSet()` method exposed by this library: + +```js +app.get('/route-that-calls-an-api', async (req, res, next) => { + + const tokenSet = req.openid.makeTokenSet(req.session.openIdTokens); + let apiData = {}; + + // Use tokenSet.access_token for the API call. +}); +``` + +## 5. Using refresh tokens Refresh tokens can be requested along with access tokens using the `offline_access` scope during login: @@ -132,26 +162,11 @@ app.get('/route-that-calls-an-api', async (req, res, next) => { req.openid.tokens = tokenSet; } - try { - apiData = await request( - process.env.API_URL, - { - headers: { authorization: `Bearer ${tokenSet.access_token}` }, - json: true - } - ); - } catch(err) { - next(err); - } - - res.render('api-data-template', { - user: req.openid && req.openid.user, - apiData - }); + // Use tokenSet.access_token for the API call. }); ``` -## 5. Calling userinfo +## 6. Calling userinfo If your application needs to call the userinfo endpoint for the user's identity, add a `handleCallback` function during initialization that will make this call. To map the incoming claims to the user identity, also add a `getUser` function. diff --git a/README.md b/README.md index 4d0e518b..d7f837c3 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ These can be configured in a `.env` file in the root of your application: ISSUER_BASE_URL=https://YOUR_DOMAIN CLIENT_ID=YOUR_CLIENT_ID BASE_URL=https://YOUR_APPLICATION_ROOT_URL +SESSION_SECRET=LONG_RANDOM_VALUE ``` ... or in the library initialization: @@ -62,15 +63,17 @@ BASE_URL=https://YOUR_APPLICATION_ROOT_URL ```js // index.js +const { auth } = require('express-openid-connect'); app.use(auth({ required: true, issuerBaseURL: 'https://YOUR_DOMAIN', baseURL: 'https://YOUR_APPLICATION_ROOT_URL', - clientID: 'YOUR_CLIENT_ID' + clientID: 'YOUR_CLIENT_ID', + sessionName: 'LONG_RANDOM_STRING' })); ``` -See [Examples](EXAMPLES.md) for how to get started authenticating users. +See the [Examples](EXAMPLES.md) for how to get started authenticating users. ## Contributing diff --git a/lib/config.js b/lib/config.js index 54f0dc00..f58b24fa 100644 --- a/lib/config.js +++ b/lib/config.js @@ -38,6 +38,8 @@ const paramsSchema = Joi.object().keys({ logoutPath: Joi.string().optional().default('/logout'), legacySameSiteCookie: Joi.boolean().optional().default(true), + // TODO: API.md update for all of the below and re-think names + // TODO: validate for cookie name sessionName: Joi.string().optional().default('identity'), // TODO: default here will kill all sessions on server restart ... better to fail? diff --git a/lib/context.js b/lib/context.js index 50b7af69..4caffc5a 100644 --- a/lib/context.js +++ b/lib/context.js @@ -2,6 +2,7 @@ const cb = require('cb'); const urlJoin = require('url-join'); const transient = require('./transientHandler'); const { get: getClient } = require('./client'); +const { TokenSet } = require('openid-client'); class RequestContext { constructor(config, req, res, next) { @@ -15,6 +16,10 @@ class RequestContext { return !!this.user; } + makeTokenSet(tokenSet) { + return new TokenSet(tokenSet); + } + async load() { if (!this.client) { this.client = await getClient(this._config); diff --git a/middleware/auth.js b/middleware/auth.js index d45f6df0..479464d1 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -105,7 +105,7 @@ module.exports = function (params) { throw createError.BadRequest(err.message); } - req.openidTokens = tokenSet; + req.openIdTokens = tokenSet; req[config.sessionName].claims = tokenSet.claims(); next(); } catch (err) { From 4e283427c0447176d9bb85f8d5e91ee857f6d599 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Tue, 17 Dec 2019 09:27:27 -0800 Subject: [PATCH 03/19] Adjust internal session defaults; add docs --- API.md | 3 + lib/config.js | 13 +--- package-lock.json | 167 +++++++++++++++++++--------------------------- package.json | 11 ++- 4 files changed, 81 insertions(+), 113 deletions(-) diff --git a/API.md b/API.md index e835a85f..50e56561 100644 --- a/API.md +++ b/API.md @@ -11,6 +11,7 @@ The `auth()` middleware has a few configuration keys that are required for initi - **`baseURL`** - The root URL for the application router. This can be set automatically with a `BASE_URL` variable in your environment. - **`clientID`** - The Client ID for your application. This can be set automatically with a `CLIENT_ID` variable in your environment. - **`issuerBaseURL`** - The root URL for the token issuer with no trailing slash. In Auth0, this is your Application's **Domain** prepended with `https://`. This can be set automatically with an `ISSUER_BASE_URL` variable in your environment. +- **`sessionSecret`** - The private key used to encrypt the user identity in a cookie session. This can be set automatically with an `SESSION_SECRET` variable in your environment. If you are using a response type that includes `code` (typically combined with an `audience` parameter), you will need an additional key: @@ -33,6 +34,8 @@ Additional configuration keys that can be passed to `auth()` on initialization: - **`redirectUriPath`** - Relative path to the application callback to process the response from the authorization server. This value is combined with the `baseUrl` and sent to the authorize endpoint as the `redirectUri` parameter. Default is `/callback`. - **`required`** - Use a boolean value to require authentication for all routes. Pass a function instead to base this value on the request. Default is `true`. - **`routes`** - Boolean value to automatically install the login and logout routes. See [the examples](EXAMPLES.md) for more information on how this key is used. Default is `true`. +- **`sessionLength`** - Integer value, in microseconds, indicating application session length. Default is 7 days. +- **`sessionName`** - String value for the cookie name used for the internal session. This value must only include letters, numbers, and underscores. Default is `identity`. ### Authorization Params Key diff --git a/lib/config.js b/lib/config.js index f58b24fa..8206845e 100644 --- a/lib/config.js +++ b/lib/config.js @@ -3,7 +3,6 @@ const clone = require('clone'); const loadEnvs = require('./loadEnvs'); const getUser = require('./hooks/getUser'); const handleCallback = require('./hooks/handleCallback'); -const crypto = require('crypto'); const defaultAuthorizeParams = { response_type: 'id_token', @@ -37,15 +36,9 @@ const paramsSchema = Joi.object().keys({ loginPath: Joi.string().optional().default('/login'), logoutPath: Joi.string().optional().default('/logout'), legacySameSiteCookie: Joi.boolean().optional().default(true), - - // TODO: API.md update for all of the below and re-think names - - // TODO: validate for cookie name - sessionName: Joi.string().optional().default('identity'), - // TODO: default here will kill all sessions on server restart ... better to fail? - sessionSecret: Joi.string().optional().default(crypto.randomBytes(20).toString('hex')), - // TODO: think about default session length - sessionLength: Joi.number().optional().default(7 * 24 * 60 * 60 * 1000), + sessionName: Joi.string().token().optional().default('identity'), + sessionSecret: Joi.string().min(16).required().default(), + sessionLength: Joi.number().integer().optional().default(7 * 24 * 60 * 60 * 1000), idpLogout: Joi.boolean().optional().default(false) .when('auth0Logout', { is: true, then: Joi.boolean().optional().default(true) }) diff --git a/package-lock.json b/package-lock.json index 62bf2940..a192520e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -129,9 +129,9 @@ } }, "@types/express": { - "version": "4.17.2", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.2.tgz", - "integrity": "sha512-5mHFNyavtLoJmnusB8OKJ5bshSzw+qkMIBAobLrIM48HJvunFva9mOa6aBwh64lBFyNwBbs0xiEFuj4eU/NjCA==", + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.1.tgz", + "integrity": "sha512-VfH/XCP0QbQk5B5puLqTLEeFgR8lfCJHZJKkInZ9mkYd+u8byX0kztXEQxEk4wZXJs8HI+7km2ALXjn4YKcX9w==", "dev": true, "requires": { "@types/body-parser": "*", @@ -140,37 +140,15 @@ } }, "@types/express-serve-static-core": { - "version": "4.17.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.0.tgz", - "integrity": "sha512-Xnub7w57uvcBqFdIGoRg1KhNOeEj0vB6ykUM7uFWyxvbdE89GFyqgmUcanAriMr4YOxNFZBAWkfcWIb4WBPt3g==", + "version": "4.16.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.16.9.tgz", + "integrity": "sha512-GqpaVWR0DM8FnRUJYKlWgyARoBUAVfRIeVDZQKOttLFp5SmhhF9YFIYeTPwMd/AXfxlP7xVO2dj1fGu0Q+krKQ==", "dev": true, "requires": { "@types/node": "*", "@types/range-parser": "*" } }, - "@types/got": { - "version": "9.6.9", - "resolved": "https://registry.npmjs.org/@types/got/-/got-9.6.9.tgz", - "integrity": "sha512-w+ZE+Ovp6fM+1sHwJB7RN3f3pTJHZkyABuULqbtknqezQyWadFEp5BzOXaZzRqAw2md6/d3ybxQJt+BNgpvzOg==", - "requires": { - "@types/node": "*", - "@types/tough-cookie": "*", - "form-data": "^2.5.0" - }, - "dependencies": { - "form-data": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", - "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", - "requires": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - } - } - } - }, "@types/mime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", @@ -178,9 +156,10 @@ "dev": true }, "@types/node": { - "version": "12.12.17", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.17.tgz", - "integrity": "sha512-Is+l3mcHvs47sKy+afn2O1rV4ldZFU7W8101cNlOd+MRbjM4Onida8jSZnJdTe/0Pcf25g9BNIUsuugmE6puHA==" + "version": "12.7.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.11.tgz", + "integrity": "sha512-Otxmr2rrZLKRYIybtdG/sgeO+tHY20GxeDjcGmUnmmlCWyEnv2a2x1ZXBo3BTec4OiTXMQCiazB8NMBf0iRlFw==", + "dev": true }, "@types/range-parser": { "version": "1.2.3", @@ -198,11 +177,6 @@ "@types/mime": "*" } }, - "@types/tough-cookie": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.6.tgz", - "integrity": "sha512-wHNBMnkoEBiRAd3s8KTKwIuO9biFtTf0LehITzBhSco+HQI0xkXZbLOD55SW3Aqw3oUkHstkm5SPv58yaAdFPQ==" - }, "abab": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.2.tgz", @@ -368,7 +342,8 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true }, "aws-sign2": { "version": "0.7.0", @@ -668,6 +643,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -779,11 +755,6 @@ "which": "^1.2.9" } }, - "crypto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz", - "integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==" - }, "cssom": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", @@ -858,9 +829,9 @@ "dev": true }, "defer-to-connect": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.1.tgz", - "integrity": "sha512-J7thop4u3mRTkYRQ+Vpfwy2G5Ehoy82I14+14W4YMDLKdWloI9gSzRbV30s/NckQGVJtPkWNcW4oMAUigTdqiQ==" + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.0.2.tgz", + "integrity": "sha512-k09hcQcTDY+cwgiwa6PYKLm3jlagNzQ+RSvhjzESOGOx+MNOuXkxTfEvPrO1IOQ81tArCFYQgi631clB70RpQw==" }, "define-properties": { "version": "1.1.3", @@ -874,7 +845,8 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true }, "depd": { "version": "1.1.2", @@ -962,27 +934,27 @@ } }, "es-abstract": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.16.3.tgz", - "integrity": "sha512-WtY7Fx5LiOnSYgF5eg/1T+GONaGmpvpPdCpSnYij+U2gDTL0UPfWrhDw7b2IYb+9NQJsYpCA0wOQvZfsd6YwRw==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.15.0.tgz", + "integrity": "sha512-bhkEqWJ2t2lMeaJDuk7okMkJWI/yqgH/EoGwpcvv0XW9RWQsRspI4wt6xuyuvMvvQE3gg/D9HXppgk21w78GyQ==", "dev": true, "requires": { - "es-to-primitive": "^1.2.1", + "es-to-primitive": "^1.2.0", "function-bind": "^1.1.1", "has": "^1.0.3", - "has-symbols": "^1.0.1", + "has-symbols": "^1.0.0", "is-callable": "^1.1.4", "is-regex": "^1.0.4", - "object-inspect": "^1.7.0", + "object-inspect": "^1.6.0", "object-keys": "^1.1.1", "string.prototype.trimleft": "^2.1.0", "string.prototype.trimright": "^2.1.0" } }, "es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz", + "integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==", "dev": true, "requires": { "is-callable": "^1.1.4", @@ -1479,9 +1451,9 @@ "dev": true }, "has-symbols": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", - "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz", + "integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q=", "dev": true }, "he": { @@ -1668,12 +1640,12 @@ } }, "is-symbol": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", - "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz", + "integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==", "dev": true, "requires": { - "has-symbols": "^1.0.1" + "has-symbols": "^1.0.0" } }, "is-typedarray": { @@ -1709,9 +1681,9 @@ "dev": true }, "jose": { - "version": "1.17.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-1.17.1.tgz", - "integrity": "sha512-HsCRnV2ZPaLbGLIKIVcRqepx0E/b/acIAF7cgnr/nJb7jRwk13fIViErVJ2a7r6Pgz7OX+oZieOOaPi57pbjgg==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-1.10.1.tgz", + "integrity": "sha512-E6ev76KFgNT/+qN/owc7EYG7M3nJGSLguF7Jr2bpImUTpIwZS5ALsZA7l5/MOlVm52Kc+lcM/FeOfg3i/VG7qA==", "requires": { "asn1.js": "^5.2.0" } @@ -2030,12 +2002,14 @@ "mime-db": { "version": "1.40.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", - "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==" + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", + "dev": true }, "mime-types": { "version": "2.1.24", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", + "dev": true, "requires": { "mime-db": "1.40.0" } @@ -2080,9 +2054,9 @@ } }, "mocha": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/mocha/-/mocha-6.2.2.tgz", - "integrity": "sha512-FgDS9Re79yU1xz5d+C4rv1G7QagNGHZ+iXF81hO8zY35YZZcLEsJVfFolfsqKFWunATEvNzMK0r/CwWd/szO9A==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/mocha/-/mocha-6.2.1.tgz", + "integrity": "sha512-VCcWkLHwk79NYQc8cxhkmI8IigTIhsCwZ6RTxQsqK6go4UvEhzJkYuHm8B2YtlSxcYq2fY+ucr4JBwoD6ci80A==", "dev": true, "requires": { "ansi-colors": "3.2.3", @@ -2211,9 +2185,9 @@ } }, "nock": { - "version": "11.7.0", - "resolved": "https://registry.npmjs.org/nock/-/nock-11.7.0.tgz", - "integrity": "sha512-7c1jhHew74C33OBeRYyQENT+YXQiejpwIrEjinh6dRurBae+Ei4QjeUaPlkptIF0ZacEiVCnw8dWaxqepkiihg==", + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/nock/-/nock-11.4.0.tgz", + "integrity": "sha512-UrVEbEAvhyDoUttrS0fv3znhZ5nEJvlxqgmrC6Gb2Mf9cFci65RMK17e6EjDDQB57g5iwZw1TFnVvyeL0eUlhQ==", "dev": true, "requires": { "chai": "^4.1.2", @@ -2275,14 +2249,14 @@ "dev": true }, "object-hash": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.0.1.tgz", - "integrity": "sha512-HgcGMooY4JC2PBt9sdUdJ6PMzpin+YtY3r/7wg0uTifP+HJWW8rammseSEHuyt0UeShI183UGssCJqm1bJR7QA==" + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.1.tgz", + "integrity": "sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA==" }, "object-inspect": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", - "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz", + "integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ==", "dev": true }, "object-keys": { @@ -2314,9 +2288,9 @@ } }, "oidc-token-hash": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.0.tgz", - "integrity": "sha512-8Yr4CZSv+Tn8ZkN3iN2i2w2G92mUKClp4z7EGUfdsERiYSbj7P4i/NHm72ft+aUdsiFx9UdIPSTwbyzQ6C4URg==" + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-3.0.2.tgz", + "integrity": "sha512-dTzp80/y/da+um+i+sOucNqiPpwRL7M/xPwj7pH1TFA2/bqQ+OK2sJahSXbemEoLtPkHcFLyhLhLWZa9yW5+RA==" }, "on-finished": { "version": "2.3.0", @@ -2359,19 +2333,18 @@ } }, "openid-client": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-3.9.0.tgz", - "integrity": "sha512-MBvXMrZ6U8XGncTW37il4UaBUq5fkOF3ySnJPb0wZw7fZHk0gywWvlp3Tzd9t3TKeejh+80QWeLl9FDhIJSk9Q==", + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-3.7.3.tgz", + "integrity": "sha512-t7GL9Yl9tL6ybY8040pzIVw8+akF5z3GgMEG8iZ2UbFfuMEXSKmHiCU00LvWF9dL4UNMlIjTSBpskQIIeJBwpw==", "requires": { - "@types/got": "^9.6.9", "base64url": "^3.0.1", "got": "^9.6.0", - "jose": "^1.16.2", - "lodash": "^4.17.15", + "jose": "^1.10.0", + "lodash": "^4.17.13", "lru-cache": "^5.1.1", "make-error": "^1.3.5", - "object-hash": "^2.0.1", - "oidc-token-hash": "^5.0.0", + "object-hash": "^1.3.1", + "oidc-token-hash": "^3.0.2", "p-any": "^2.1.0" } }, @@ -2734,21 +2707,21 @@ } }, "request-promise-core": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.3.tgz", - "integrity": "sha512-QIs2+ArIGQVp5ZYbWD5ZLCY29D5CfWizP8eWnm8FoGD1TX61veauETVQbrV60662V0oFBkrDOuaBI8XgtuyYAQ==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.2.tgz", + "integrity": "sha512-UHYyq1MO8GsefGEt7EprS8UrXsm1TxEvFUX1IMTuSLU2Rh7fTIdFtl8xD7JiEYiWU2dl+NYAjCTksTehQUxPag==", "dev": true, "requires": { - "lodash": "^4.17.15" + "lodash": "^4.17.11" } }, "request-promise-native": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.8.tgz", - "integrity": "sha512-dapwLGqkHtwL5AEbfenuzjTYg35Jd6KPytsC2/TLkVMz8rm+tNt72MGUWT1RP/aYawMpN6HqbNGBQaRcBtjQMQ==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.7.tgz", + "integrity": "sha512-rIMnbBdgNViL37nZ1b3L/VfPOpSi0TqVDQPAvO6U14lMzOLrt5nilxCQqtDKhZeDiW0/hkCXGoQjhgJd/tCh6w==", "dev": true, "requires": { - "request-promise-core": "1.1.3", + "request-promise-core": "1.1.2", "stealthy-require": "^1.1.1", "tough-cookie": "^2.3.3" } diff --git a/package.json b/package.json index 8242c4e3..f29881dd 100644 --- a/package.json +++ b/package.json @@ -16,25 +16,24 @@ "client-sessions": "^0.8.0", "clone": "^2.1.2", "cookie-parser": "^1.4.4", - "crypto": "^1.0.1", "http-errors": "^1.7.3", - "openid-client": "^3.9.0", + "openid-client": "^3.7.3", "p-memoize": "^3.1.0", "url-join": "^4.0.1" }, "devDependencies": { - "@types/express": "^4.17.2", + "@types/express": "^4.17.1", "chai": "^4.2.0", "cookie-session": "^2.0.0-beta.3", "eslint": "^5.16.0", "express": "^4.17.1", "jsdom": "^13.2.0", "jsonwebtoken": "^8.5.1", - "mocha": "^6.2.2", - "nock": "^11.7.0", + "mocha": "^6.2.1", + "nock": "^11.4.0", "pem-jwk": "^2.0.0", "proxyquire": "^2.1.3", - "request-promise-native": "^1.0.8", + "request-promise-native": "^1.0.7", "selfsigned": "^1.10.7", "sinon": "^7.5.0" }, From d952cff4c39dd2f0c875b39b70878beee7b43c36 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Tue, 17 Dec 2019 10:51:21 -0800 Subject: [PATCH 04/19] Allow internal session handling to be turned off --- API.md | 2 +- lib/config.js | 2 +- lib/context.js | 4 +--- lib/hooks/getUser.js | 25 +++++++++++++++---------- lib/loadEnvs.js | 12 ++---------- middleware/auth.js | 19 +++++++++++++------ 6 files changed, 33 insertions(+), 31 deletions(-) diff --git a/API.md b/API.md index 50e56561..ed61c4ea 100644 --- a/API.md +++ b/API.md @@ -11,7 +11,7 @@ The `auth()` middleware has a few configuration keys that are required for initi - **`baseURL`** - The root URL for the application router. This can be set automatically with a `BASE_URL` variable in your environment. - **`clientID`** - The Client ID for your application. This can be set automatically with a `CLIENT_ID` variable in your environment. - **`issuerBaseURL`** - The root URL for the token issuer with no trailing slash. In Auth0, this is your Application's **Domain** prepended with `https://`. This can be set automatically with an `ISSUER_BASE_URL` variable in your environment. -- **`sessionSecret`** - The private key used to encrypt the user identity in a cookie session. This can be set automatically with an `SESSION_SECRET` variable in your environment. +- **`sessionSecret`** - The private key used to encrypt the user identity in a cookie session. Set this to `false` to skip this internal storage and provide your own session mechanism in `getUser`. This can be set automatically with an `SESSION_SECRET` variable in your environment. If you are using a response type that includes `code` (typically combined with an `audience` parameter), you will need an additional key: diff --git a/lib/config.js b/lib/config.js index 8206845e..8fc3ba52 100644 --- a/lib/config.js +++ b/lib/config.js @@ -37,7 +37,7 @@ const paramsSchema = Joi.object().keys({ logoutPath: Joi.string().optional().default('/logout'), legacySameSiteCookie: Joi.boolean().optional().default(true), sessionName: Joi.string().token().optional().default('identity'), - sessionSecret: Joi.string().min(16).required().default(), + sessionSecret: Joi.alternatives([ Joi.string().min(16), Joi.boolean().valid([false]) ]).required().default(), sessionLength: Joi.number().integer().optional().default(7 * 24 * 60 * 60 * 1000), idpLogout: Joi.boolean().optional().default(false) diff --git a/lib/context.js b/lib/context.js index 4caffc5a..e8fbfbe9 100644 --- a/lib/context.js +++ b/lib/context.js @@ -25,9 +25,7 @@ class RequestContext { this.client = await getClient(this._config); } - if (this._req[this._config.sessionName].claims) { - this.user = await this._config.getUser(this._req[this._config.sessionName].claims); - } + this.user = await this._config.getUser(this._req, this._config); } } diff --git a/lib/hooks/getUser.js b/lib/hooks/getUser.js index ccbc84af..39c55f20 100644 --- a/lib/hooks/getUser.js +++ b/lib/hooks/getUser.js @@ -2,18 +2,23 @@ * Default function for mapping a tokenSet to a user. * This can be used for adjusting or augmenting profile data. */ -module.exports = function(idClaims) { - if (!idClaims || typeof idClaims !== 'object') { +module.exports = function(req, config) { + + // If there is no sessionSecret, session handing is custom. + if (!config.sessionSecret || !req[config.sessionName] || !req[config.sessionName].claims) { return null; } - delete idClaims.iat; - delete idClaims.exp; - delete idClaims.aud; - delete idClaims.nonce; - delete idClaims.iss; - delete idClaims.azp; - delete idClaims.auth_time; + let identity = req[config.sessionName].claims; + + // Delete ID token validation claims to lower stored size. + delete identity.iat; + delete identity.exp; + delete identity.aud; + delete identity.nonce; + delete identity.iss; + delete identity.azp; + delete identity.auth_time; - return idClaims; + return identity; }; diff --git a/lib/loadEnvs.js b/lib/loadEnvs.js index 55afb96c..018bc23d 100644 --- a/lib/loadEnvs.js +++ b/lib/loadEnvs.js @@ -8,17 +8,9 @@ const fieldsEnvMap = { module.exports = function(params) { Object.keys(fieldsEnvMap).forEach(k => { - if (params[k]) { - return; + if (typeof params[k] === 'undefined') { + params[k] = process.env[fieldsEnvMap[k]]; } - params[k] = process.env[fieldsEnvMap[k]]; }); - - if (!params.baseURL && - !process.env.BASE_URL && - process.env.PORT && - process.env.NODE_ENV !== 'production') { - params.baseURL = `http://localhost:${process.env.PORT}`; - } }; diff --git a/middleware/auth.js b/middleware/auth.js index 479464d1..e4714ffd 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -47,11 +47,14 @@ module.exports = function (params) { const router = express.Router(); - router.use(idSession({ - cookieName: config.sessionName, - secret: config.sessionSecret, - duration: config.sessionLength - })); + // Only use the internal cookie-based session if sessionSecret is provided. + if (config.sessionSecret) { + router.use(idSession({ + cookieName: config.sessionName, + secret: config.sessionSecret, + duration: config.sessionLength + })); + } router.use(async (req, res, next) => { req.openid = new RequestContext(config, req, res, next); @@ -106,7 +109,11 @@ module.exports = function (params) { } req.openIdTokens = tokenSet; - req[config.sessionName].claims = tokenSet.claims(); + + if (config.sessionSecret) { + req[config.sessionName].claims = tokenSet.claims(); + } + next(); } catch (err) { next(err); From 348c0a7b73bfbb50faf8ccce20b71bb434b4e0bd Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Tue, 17 Dec 2019 11:58:13 -0800 Subject: [PATCH 05/19] Move identity claim filtering to callback; examples --- EXAMPLES.md | 73 +++++++++++++++++++++++++++++++++++--------- lib/hooks/getUser.js | 13 +------- middleware/auth.js | 3 ++ 3 files changed, 62 insertions(+), 27 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 43457280..93ab7dc0 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -90,22 +90,23 @@ Please note that both of these routes are completely optional and not required. ## 4. Using access tokens -If your application needs to request and store access and/or refresh tokens, you must provide a method to store the incoming tokens during callback. We recommend to use a persistant store, like a database or Redis, to store these tokens directly associated with the user for which they were requested. +If your application needs to request and store access tokens, you must provide a method to store the incoming tokens during callback. We recommend to use a persistant store, like a database or Redis, to store these tokens directly associated with the user for which they were requested. -If the tokens only need to be used during the user's session, they can be stored using a session middleware like `express-session`. We recommend persisting the session in a session store other than in-memory (default). The basics of handling the tokens is below: +If the tokens only need to be used during the user's session, they can be stored using a session middleware like `express-session`. We recommend persisting the data in a session store other than in-memory (which is the default), otherwise all tokens will be lost when the server restarts. The basics of handling the tokens is below: ```js -const { auth } = require('./express-openid-connect'); const session = require('express-session'); - app.use(session({ - secret: process.env.SESSION_SECRET + secret: 'replace this with a long, random, static string', + cookie: { + // Sets the session cookie to expire after 7 days. + maxAge: 7 * 24 * 60 * 60 * 1000 + } })); app.use(auth({ authorizationParams: { - response_type: 'code id_token', - response_mode: 'form_post', + response_type: 'code', audience: process.env.API_URL, scope: 'openid profile email read:reports' }, @@ -124,11 +125,41 @@ app.get('/route-that-calls-an-api', async (req, res, next) => { const tokenSet = req.openid.makeTokenSet(req.session.openIdTokens); let apiData = {}; - // Use tokenSet.access_token for the API call. + // Check for and use tokenSet.access_token for the API call ... }); ``` -## 5. Using refresh tokens +## 5. Custom user session handling + +By default, this library uses an encrypted cookie to store the user identity claims used as a session. If the size of the user identity is too large or you're concerned about sensitive data being stored, you can provide your own session handling as part of the `getUser` function. + +If, for example, you want the user session to be stored on the server, you can use a session middleware like `express-session`. We recommend persisting the data in a session store other than in-memory (which is the default), otherwise all sessions will be lost when the server restarts. The basics of handling the user identity server-side is below: + +```js +const session = require('express-session'); +app.use(session({ + secret: 'replace this with a long, random, static string', + cookie: { + // Sets the session cookie to expire after 7 days. + maxAge: 7 * 24 * 60 * 60 * 1000 + } +})); + +app.use(auth({ + // Setting this configuration key to false will turn off internal session handling. + sessionSecret: false, + handleCallback: async function (req, res, next) { + // This will store the user identity claims in the session + req.session.userIdentity = req.openIdTokens.claims(); + next(); + }, + getUser: async function (req) { + return req.session.userIdentity; + } +})); +``` + +## 6. Using refresh tokens Refresh tokens can be requested along with access tokens using the `offline_access` scope during login: @@ -137,8 +168,14 @@ app.use(auth({ authorizationParams: { response_type: 'code id_token', response_mode: 'form_post', + // API identifier to indicate which API this application will be calling. audience: process.env.API_URL, + // Include the required scopes as well as offline_access to generate a refresh token. scope: 'openid profile email read:reports offline_access' + }, + handleCallback: async function (req, res, next) { + // See the "Using access tokens" section above for token handling. + next(); } })); ``` @@ -149,24 +186,30 @@ On a route that calls an API, check for an expired token and attempt a refresh: app.get('/route-that-calls-an-api', async (req, res, next) => { let apiData = {}; - let tokenSet = req.openid.tokens; - if (tokenSet && tokenSet.expired() && tokenSet.refresh_token) { + // How the tokenSet is created will depend on how the tokens are stored. + let tokenSet = req.openid.makeTokenSet(req.session.openIdTokens); + let refreshToken = tokenSet.refresh_token; + + if (tokenSet && tokenSet.expired() && refreshToken) { try { tokenSet = await req.openid.client.refresh(tokenSet); } catch(err) { next(err); } - tokenSet.refresh_token = req.openid.tokens.refresh_token; - req.openid.tokens = tokenSet; + // New tokenSet may not include a new refresh token. + tokenSet.refresh_token = tokenSet.refresh_token ?? refreshToken; + + // Where you store the refreshed tokenSet will depend on how the tokens are stored. + req.session.openIdTokens = tokenSet; } - // Use tokenSet.access_token for the API call. + // Check for and use tokenSet.access_token for the API call ... }); ``` -## 6. Calling userinfo +## 7. Calling userinfo If your application needs to call the userinfo endpoint for the user's identity, add a `handleCallback` function during initialization that will make this call. To map the incoming claims to the user identity, also add a `getUser` function. diff --git a/lib/hooks/getUser.js b/lib/hooks/getUser.js index 39c55f20..78fed196 100644 --- a/lib/hooks/getUser.js +++ b/lib/hooks/getUser.js @@ -9,16 +9,5 @@ module.exports = function(req, config) { return null; } - let identity = req[config.sessionName].claims; - - // Delete ID token validation claims to lower stored size. - delete identity.iat; - delete identity.exp; - delete identity.aud; - delete identity.nonce; - delete identity.iss; - delete identity.azp; - delete identity.auth_time; - - return identity; + return req[config.sessionName].claims; }; diff --git a/middleware/auth.js b/middleware/auth.js index e4714ffd..95c767c2 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -111,6 +111,9 @@ module.exports = function (params) { req.openIdTokens = tokenSet; if (config.sessionSecret) { + let identityClaims = tokenSet.claims(); + // Remove validation claims to reduce stored size. + ['aud', 'iss', 'exp', 'nonce', 'azp', 'auth_time'].forEach(claim => delete identityClaims[claim]); req[config.sessionName].claims = tokenSet.claims(); } From 0978b1f8e1f865c6472e78878c4255b007559698 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 19 Dec 2019 14:53:32 +0100 Subject: [PATCH 06/19] JWE based session cookie mechanism --- API.md | 5 +- lib/config.js | 5 +- lib/context.js | 2 +- lib/session.js | 105 ++++++++++++++++++++++++++++++++++++++++ lib/transientHandler.js | 4 +- middleware/auth.js | 7 ++- package-lock.json | 98 +++++++++++++++++++++---------------- package.json | 6 ++- 8 files changed, 179 insertions(+), 53 deletions(-) create mode 100644 lib/session.js diff --git a/API.md b/API.md index ed61c4ea..26d1462b 100644 --- a/API.md +++ b/API.md @@ -11,7 +11,7 @@ The `auth()` middleware has a few configuration keys that are required for initi - **`baseURL`** - The root URL for the application router. This can be set automatically with a `BASE_URL` variable in your environment. - **`clientID`** - The Client ID for your application. This can be set automatically with a `CLIENT_ID` variable in your environment. - **`issuerBaseURL`** - The root URL for the token issuer with no trailing slash. In Auth0, this is your Application's **Domain** prepended with `https://`. This can be set automatically with an `ISSUER_BASE_URL` variable in your environment. -- **`sessionSecret`** - The private key used to encrypt the user identity in a cookie session. Set this to `false` to skip this internal storage and provide your own session mechanism in `getUser`. This can be set automatically with an `SESSION_SECRET` variable in your environment. +- **`sessionSecret`** - The secret used to derive an encryption key for the user identity in a session cookie. Set this to `false` to skip this internal storage and provide your own session mechanism in `getUser`. This can be set automatically with an `SESSION_SECRET` variable in your environment. It must be a string or an array of strings. When array is provided the first member is used for signing and other members can be used for decrypting old cookies, this is to enable sessionSecret rotation. If you are using a response type that includes `code` (typically combined with an `audience` parameter), you will need an additional key: @@ -34,8 +34,9 @@ Additional configuration keys that can be passed to `auth()` on initialization: - **`redirectUriPath`** - Relative path to the application callback to process the response from the authorization server. This value is combined with the `baseUrl` and sent to the authorize endpoint as the `redirectUri` parameter. Default is `/callback`. - **`required`** - Use a boolean value to require authentication for all routes. Pass a function instead to base this value on the request. Default is `true`. - **`routes`** - Boolean value to automatically install the login and logout routes. See [the examples](EXAMPLES.md) for more information on how this key is used. Default is `true`. -- **`sessionLength`** - Integer value, in microseconds, indicating application session length. Default is 7 days. +- **`sessionLength`** - Integer value, in seconds, indicating application session length. Default is 7 days. - **`sessionName`** - String value for the cookie name used for the internal session. This value must only include letters, numbers, and underscores. Default is `identity`. +- **`sessionEphemeral`** - Use a boolean to indicate the cookie should be ephemeral (no expiration on the cookie). Default is `false`. ### Authorization Params Key diff --git a/lib/config.js b/lib/config.js index 8fc3ba52..27c2b958 100644 --- a/lib/config.js +++ b/lib/config.js @@ -37,8 +37,9 @@ const paramsSchema = Joi.object().keys({ logoutPath: Joi.string().optional().default('/logout'), legacySameSiteCookie: Joi.boolean().optional().default(true), sessionName: Joi.string().token().optional().default('identity'), - sessionSecret: Joi.alternatives([ Joi.string().min(16), Joi.boolean().valid([false]) ]).required().default(), - sessionLength: Joi.number().integer().optional().default(7 * 24 * 60 * 60 * 1000), + sessionSecret: Joi.alternatives([ Joi.array().items(Joi.string()), Joi.string(), Joi.boolean().valid([false]) ]).required().default(), + sessionLength: Joi.number().integer().optional().default(7 * 24 * 60 * 60), + sessionEphemeral: Joi.boolean().optional().default(false), idpLogout: Joi.boolean().optional().default(false) .when('auth0Logout', { is: true, then: Joi.boolean().optional().default(true) }) diff --git a/lib/context.js b/lib/context.js index e8fbfbe9..8f6b5075 100644 --- a/lib/context.js +++ b/lib/context.js @@ -90,7 +90,7 @@ class ResponseContext { const res = this._res; const returnURL = params.returnTo || this._config.baseURL; - req[this._config.sessionName].destroy(); + req[this._config.sessionName] = undefined; if (!this._config.idpLogout) { return res.redirect(returnURL); diff --git a/lib/session.js b/lib/session.js new file mode 100644 index 00000000..0791adf5 --- /dev/null +++ b/lib/session.js @@ -0,0 +1,105 @@ +const { createHmac } = require('crypto'); +const { strict: assert } = require('assert'); + +const { JWK, JWKS, JWE } = require('jose'); +const onHeaders = require('on-headers'); +const cookie = require('cookie'); + +const deriveKey = (secret) => createHmac('sha256', secret).update('encryption').digest(); +const epoch = () => Date.now() / 1000 | 0; + +module.exports = ({ cookieName, propertyName, secret, duration, ephemeral, cookieOptions = {} }) => { + let current; + + const { domain, httpOnly, path, secure, sameSite } = cookieOptions; + + const COOKIES = Symbol(); + const alg = 'dir'; + const enc = 'A256GCM'; + + let keystore = new JWKS.KeyStore(); + + if (!Array.isArray(secret)) { + secret = [secret]; + } + + secret.forEach((secretString, i) => { + const key = JWK.asKey(deriveKey(secretString)); + if (i === 0) { + current = key; + } + keystore.add(key); + }); + + if (keystore.size === 1) { + keystore = current; + } + + function encrypt (payload, headers) { + return JWE.encrypt(payload, current, { alg, enc, zip: 'DEF', ...headers }); + } + + function decrypt (jwe) { + return JWE.decrypt(jwe, keystore, { complete: true, algorithms: ['A256GCM'] }); + } + + function setCookie (req, res, { uat = epoch(), iat = uat, exp = uat + duration }) { + if ((!req[propertyName] || !Object.keys(req[propertyName]).length) && cookieName in req[COOKIES]) { + res.clearCookie(cookieName); + return; + } + + if (req[propertyName] && Object.keys(req[propertyName]).length > 0) { + const value = encrypt(JSON.stringify(req[propertyName]), { iat, uat, exp }); + + // TODO: chunk + // if (Buffer.byteLength(value) >= 4050) { + // + // } + + res.cookie( + cookieName, + value, + { + domain, + httpOnly, + path, + secure, + sameSite, + expires: ephemeral ? 0 : new Date(exp * 1000) + } + ); + } + } + + return (req, res, next) => { + if (!(COOKIES in req)) { + req[COOKIES] = cookie.parse(req.get('cookie') || ''); + } + + if (propertyName in req) { + return next(); + } + + let iat; + let exp; + + try { + // TODO: detect and join chunks + if (cookieName in req[COOKIES]) { + const { protected: header, cleartext } = decrypt(req[COOKIES][cookieName]) + ;({ iat, exp } = header); + assert(exp > epoch()); + req[propertyName] = JSON.parse(cleartext); + } + } finally { + if (!(propertyName in req)) { + req[propertyName] = {}; + } + } + + onHeaders(res, setCookie.bind(undefined, req, res, { iat })); + + return next(); + }; +}; diff --git a/lib/transientHandler.js b/lib/transientHandler.js index 7eabcbd6..ee27b5ac 100644 --- a/lib/transientHandler.js +++ b/lib/transientHandler.js @@ -9,7 +9,7 @@ const crypto = require('crypto'); * @param {String} opts.sameSite SameSite attribute of "None," "Lax," or "Strict". Default is "None." * @param {String} opts.value Cookie value. Omit this key to store a generated value. * @param {Boolean} opts.legacySameSiteCookie Should a fallback cookie be set? Default is true. - * @param {Boolean} opts.maxAge Cookie MaxAge value, in microseconds. Default is 600000 (10 minutes). + * @param {Boolean} opts.maxAge Cookie MaxAge value, in milliseconds. Default is 600000 (10 minutes). * * @return {String} Cookie value that was set. */ @@ -87,4 +87,4 @@ function deleteCookie(name, res) { exports.store = store; exports.getOnce = getOnce; -exports.createNonce = createNonce; \ No newline at end of file +exports.createNonce = createNonce; diff --git a/middleware/auth.js b/middleware/auth.js index 95c767c2..61593054 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -2,13 +2,13 @@ const express = require('express'); const cb = require('cb'); const createError = require('http-errors'); const cookieParser = require('cookie-parser'); -const idSession = require('client-sessions'); const { get: getConfig } = require('../lib/config'); const { get: getClient } = require('../lib/client'); const requiresAuth = require('./requiresAuth'); const transient = require('../lib/transientHandler'); const { RequestContext, ResponseContext } = require('../lib/context'); +const idSession = require('../lib/session'); /** * Returns a router with two routes /login and /callback @@ -51,8 +51,11 @@ module.exports = function (params) { if (config.sessionSecret) { router.use(idSession({ cookieName: config.sessionName, + propertyName: config.sessionName, secret: config.sessionSecret, - duration: config.sessionLength + duration: config.sessionLength, + ephemeral: config.sessionEphemeral, + // TODO: cookieOptions: { domain, httpOnly, path, secure, sameSite } })); } diff --git a/package-lock.json b/package-lock.json index a192520e..94dce739 100644 --- a/package-lock.json +++ b/package-lock.json @@ -149,6 +149,28 @@ "@types/range-parser": "*" } }, + "@types/got": { + "version": "9.6.9", + "resolved": "https://registry.npmjs.org/@types/got/-/got-9.6.9.tgz", + "integrity": "sha512-w+ZE+Ovp6fM+1sHwJB7RN3f3pTJHZkyABuULqbtknqezQyWadFEp5BzOXaZzRqAw2md6/d3ybxQJt+BNgpvzOg==", + "requires": { + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + }, + "dependencies": { + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + } + } + }, "@types/mime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-2.0.1.tgz", @@ -158,8 +180,7 @@ "@types/node": { "version": "12.7.11", "resolved": "https://registry.npmjs.org/@types/node/-/node-12.7.11.tgz", - "integrity": "sha512-Otxmr2rrZLKRYIybtdG/sgeO+tHY20GxeDjcGmUnmmlCWyEnv2a2x1ZXBo3BTec4OiTXMQCiazB8NMBf0iRlFw==", - "dev": true + "integrity": "sha512-Otxmr2rrZLKRYIybtdG/sgeO+tHY20GxeDjcGmUnmmlCWyEnv2a2x1ZXBo3BTec4OiTXMQCiazB8NMBf0iRlFw==" }, "@types/range-parser": { "version": "1.2.3", @@ -177,6 +198,11 @@ "@types/mime": "*" } }, + "@types/tough-cookie": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-2.3.6.tgz", + "integrity": "sha512-wHNBMnkoEBiRAd3s8KTKwIuO9biFtTf0LehITzBhSco+HQI0xkXZbLOD55SW3Aqw3oUkHstkm5SPv58yaAdFPQ==" + }, "abab": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.2.tgz", @@ -342,8 +368,7 @@ "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", - "dev": true + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=" }, "aws-sign2": { "version": "0.7.0", @@ -564,14 +589,6 @@ "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", "dev": true }, - "client-sessions": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/client-sessions/-/client-sessions-0.8.0.tgz", - "integrity": "sha1-p9jFVYrV1W8qGZ81M+tlS134k/0=", - "requires": { - "cookies": "^0.7.0" - } - }, "cliui": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", @@ -643,7 +660,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "requires": { "delayed-stream": "~1.0.0" } @@ -680,8 +696,7 @@ "cookie": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz", - "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==", - "dev": true + "integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg==" }, "cookie-parser": { "version": "1.4.4", @@ -731,6 +746,7 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.7.1.tgz", "integrity": "sha1-fIphX1SBxhq58WyDNzG8uPZjuZs=", + "dev": true, "requires": { "depd": "~1.1.1", "keygrip": "~1.0.2" @@ -829,9 +845,9 @@ "dev": true }, "defer-to-connect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.0.2.tgz", - "integrity": "sha512-k09hcQcTDY+cwgiwa6PYKLm3jlagNzQ+RSvhjzESOGOx+MNOuXkxTfEvPrO1IOQ81tArCFYQgi631clB70RpQw==" + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-1.1.1.tgz", + "integrity": "sha512-J7thop4u3mRTkYRQ+Vpfwy2G5Ehoy82I14+14W4YMDLKdWloI9gSzRbV30s/NckQGVJtPkWNcW4oMAUigTdqiQ==" }, "define-properties": { "version": "1.1.3", @@ -845,8 +861,7 @@ "delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", - "dev": true + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=" }, "depd": { "version": "1.1.2", @@ -1681,9 +1696,9 @@ "dev": true }, "jose": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/jose/-/jose-1.10.1.tgz", - "integrity": "sha512-E6ev76KFgNT/+qN/owc7EYG7M3nJGSLguF7Jr2bpImUTpIwZS5ALsZA7l5/MOlVm52Kc+lcM/FeOfg3i/VG7qA==", + "version": "1.17.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-1.17.2.tgz", + "integrity": "sha512-HYbLNZACDgH2duIHY21ZYXWCX50V0sHdcuvPMPCiDRbL9ukVSkOTFyJjPGsdieUIooEOHCjCM9wnT60ZrTYcoQ==", "requires": { "asn1.js": "^5.2.0" } @@ -1841,7 +1856,8 @@ "keygrip": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.0.3.tgz", - "integrity": "sha512-/PpesirAIfaklxUzp4Yb7xBper9MwP6hNRA6BGGUFCgbJ+BM5CKBtsoxinNXkLHAr+GXS1/lSlF2rP7cv5Fl+g==" + "integrity": "sha512-/PpesirAIfaklxUzp4Yb7xBper9MwP6hNRA6BGGUFCgbJ+BM5CKBtsoxinNXkLHAr+GXS1/lSlF2rP7cv5Fl+g==", + "dev": true }, "keyv": { "version": "3.1.0", @@ -2002,14 +2018,12 @@ "mime-db": { "version": "1.40.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.40.0.tgz", - "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==", - "dev": true + "integrity": "sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==" }, "mime-types": { "version": "2.1.24", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.24.tgz", "integrity": "sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==", - "dev": true, "requires": { "mime-db": "1.40.0" } @@ -2249,9 +2263,9 @@ "dev": true }, "object-hash": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-1.3.1.tgz", - "integrity": "sha512-OSuu/pU4ENM9kmREg0BdNrUDIl1heYa4mBZacJc+vVWz4GtAwu7jO8s4AIt2aGRUTqxykpWzI3Oqnsm13tTMDA==" + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.0.1.tgz", + "integrity": "sha512-HgcGMooY4JC2PBt9sdUdJ6PMzpin+YtY3r/7wg0uTifP+HJWW8rammseSEHuyt0UeShI183UGssCJqm1bJR7QA==" }, "object-inspect": { "version": "1.6.0", @@ -2288,9 +2302,9 @@ } }, "oidc-token-hash": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-3.0.2.tgz", - "integrity": "sha512-dTzp80/y/da+um+i+sOucNqiPpwRL7M/xPwj7pH1TFA2/bqQ+OK2sJahSXbemEoLtPkHcFLyhLhLWZa9yW5+RA==" + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.0.tgz", + "integrity": "sha512-8Yr4CZSv+Tn8ZkN3iN2i2w2G92mUKClp4z7EGUfdsERiYSbj7P4i/NHm72ft+aUdsiFx9UdIPSTwbyzQ6C4URg==" }, "on-finished": { "version": "2.3.0", @@ -2304,8 +2318,7 @@ "on-headers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", - "dev": true + "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==" }, "once": { "version": "1.4.0", @@ -2333,18 +2346,19 @@ } }, "openid-client": { - "version": "3.7.3", - "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-3.7.3.tgz", - "integrity": "sha512-t7GL9Yl9tL6ybY8040pzIVw8+akF5z3GgMEG8iZ2UbFfuMEXSKmHiCU00LvWF9dL4UNMlIjTSBpskQIIeJBwpw==", + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-3.9.2.tgz", + "integrity": "sha512-EFysZbCyZeDIHtkx5wjFMxdFwhDtVQ0E5UbFIjOgGKoLHznUgN5XIl0EjwO5qmyrrZonDcwl4ohQtjmuA7Jvwg==", "requires": { + "@types/got": "^9.6.9", "base64url": "^3.0.1", "got": "^9.6.0", - "jose": "^1.10.0", - "lodash": "^4.17.13", + "jose": "^1.17.2", + "lodash": "^4.17.15", "lru-cache": "^5.1.1", "make-error": "^1.3.5", - "object-hash": "^1.3.1", - "oidc-token-hash": "^3.0.2", + "object-hash": "^2.0.1", + "oidc-token-hash": "^5.0.0", "p-any": "^2.1.0" } }, diff --git a/package.json b/package.json index f29881dd..de791ffb 100644 --- a/package.json +++ b/package.json @@ -13,11 +13,13 @@ "dependencies": { "@hapi/joi": "^14.5.0", "cb": "^0.1.0", - "client-sessions": "^0.8.0", "clone": "^2.1.2", + "cookie": "^0.4.0", "cookie-parser": "^1.4.4", "http-errors": "^1.7.3", - "openid-client": "^3.7.3", + "jose": "^1.17.2", + "on-headers": "^1.0.2", + "openid-client": "^3.9.2", "p-memoize": "^3.1.0", "url-join": "^4.0.1" }, From fa628bf02ddce13dad3b55a87085964b7cc7b62b Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 24 Dec 2019 09:30:57 +0100 Subject: [PATCH 07/19] use conform hkdf --- lib/session.js | 4 ++-- package-lock.json | 5 +++++ package.json | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/session.js b/lib/session.js index 0791adf5..f531bf73 100644 --- a/lib/session.js +++ b/lib/session.js @@ -1,11 +1,11 @@ -const { createHmac } = require('crypto'); const { strict: assert } = require('assert'); const { JWK, JWKS, JWE } = require('jose'); const onHeaders = require('on-headers'); const cookie = require('cookie'); +const hkdf = require('futoin-hkdf'); -const deriveKey = (secret) => createHmac('sha256', secret).update('encryption').digest(); +const deriveKey = (secret) => hkdf(secret, 32, { info: 'JWE CEK', hash: 'SHA-256' }); const epoch = () => Date.now() / 1000 | 0; module.exports = ({ cookieName, propertyName, secret, duration, ephemeral, cookieOptions = {} }) => { diff --git a/package-lock.json b/package-lock.json index 94dce739..3bb5d259 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1361,6 +1361,11 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "futoin-hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/futoin-hkdf/-/futoin-hkdf-1.2.1.tgz", + "integrity": "sha512-3VpqjhVhR/UCEs87YsUAvY59oimbAehXzJPBmYEMJZfOnPYoV8B92f1spBcuNlWsGIX+MWavCZI/Kw/w4b79IA==" + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", diff --git a/package.json b/package.json index de791ffb..c19542f4 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "clone": "^2.1.2", "cookie": "^0.4.0", "cookie-parser": "^1.4.4", + "futoin-hkdf": "^1.2.1", "http-errors": "^1.7.3", "jose": "^1.17.2", "on-headers": "^1.0.2", From c67777d6c61db1d4c1b7daff760ab0ad6bde569e Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 30 Dec 2019 12:40:26 -0800 Subject: [PATCH 08/19] Remove sessionEphemeral Switch to use sessionLength instead. Setting the length to 0 will indicate an ephemeral session, reducing the need for an additional key. --- API.md | 3 +-- lib/config.js | 10 ++++++++-- lib/session.js | 9 ++------- middleware/auth.js | 1 - 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/API.md b/API.md index 26d1462b..19a78b6f 100644 --- a/API.md +++ b/API.md @@ -34,9 +34,8 @@ Additional configuration keys that can be passed to `auth()` on initialization: - **`redirectUriPath`** - Relative path to the application callback to process the response from the authorization server. This value is combined with the `baseUrl` and sent to the authorize endpoint as the `redirectUri` parameter. Default is `/callback`. - **`required`** - Use a boolean value to require authentication for all routes. Pass a function instead to base this value on the request. Default is `true`. - **`routes`** - Boolean value to automatically install the login and logout routes. See [the examples](EXAMPLES.md) for more information on how this key is used. Default is `true`. -- **`sessionLength`** - Integer value, in seconds, indicating application session length. Default is 7 days. +- **`sessionLength`** - Integer value, in seconds, indicating application session length. Set to `0` to indicate the cookie should be ephemeral (no expiration). Default is 7 days. - **`sessionName`** - String value for the cookie name used for the internal session. This value must only include letters, numbers, and underscores. Default is `identity`. -- **`sessionEphemeral`** - Use a boolean to indicate the cookie should be ephemeral (no expiration on the cookie). Default is `false`. ### Authorization Params Key diff --git a/lib/config.js b/lib/config.js index 27c2b958..692ac2d9 100644 --- a/lib/config.js +++ b/lib/config.js @@ -37,9 +37,15 @@ const paramsSchema = Joi.object().keys({ logoutPath: Joi.string().optional().default('/logout'), legacySameSiteCookie: Joi.boolean().optional().default(true), sessionName: Joi.string().token().optional().default('identity'), - sessionSecret: Joi.alternatives([ Joi.array().items(Joi.string()), Joi.string(), Joi.boolean().valid([false]) ]).required().default(), + sessionSecret: Joi.alternatives([ + // Array of keys to allow for rotation. + Joi.array().items(Joi.string()), + // Single string key. + Joi.string(), + // False to stop client session from being created. + Joi.boolean().valid([false]) + ]).required(), sessionLength: Joi.number().integer().optional().default(7 * 24 * 60 * 60), - sessionEphemeral: Joi.boolean().optional().default(false), idpLogout: Joi.boolean().optional().default(false) .when('auth0Logout', { is: true, then: Joi.boolean().optional().default(true) }) diff --git a/lib/session.js b/lib/session.js index f531bf73..be25538d 100644 --- a/lib/session.js +++ b/lib/session.js @@ -8,7 +8,7 @@ const hkdf = require('futoin-hkdf'); const deriveKey = (secret) => hkdf(secret, 32, { info: 'JWE CEK', hash: 'SHA-256' }); const epoch = () => Date.now() / 1000 | 0; -module.exports = ({ cookieName, propertyName, secret, duration, ephemeral, cookieOptions = {} }) => { +module.exports = ({ cookieName, propertyName, secret, duration, cookieOptions = {} }) => { let current; const { domain, httpOnly, path, secure, sameSite } = cookieOptions; @@ -52,11 +52,6 @@ module.exports = ({ cookieName, propertyName, secret, duration, ephemeral, cooki if (req[propertyName] && Object.keys(req[propertyName]).length > 0) { const value = encrypt(JSON.stringify(req[propertyName]), { iat, uat, exp }); - // TODO: chunk - // if (Buffer.byteLength(value) >= 4050) { - // - // } - res.cookie( cookieName, value, @@ -66,7 +61,7 @@ module.exports = ({ cookieName, propertyName, secret, duration, ephemeral, cooki path, secure, sameSite, - expires: ephemeral ? 0 : new Date(exp * 1000) + expires: !duration ? 0 : new Date(exp * 1000) } ); } diff --git a/middleware/auth.js b/middleware/auth.js index 61593054..360d5988 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -54,7 +54,6 @@ module.exports = function (params) { propertyName: config.sessionName, secret: config.sessionSecret, duration: config.sessionLength, - ephemeral: config.sessionEphemeral, // TODO: cookieOptions: { domain, httpOnly, path, secure, sameSite } })); } From d0b43993adaf5d0cccb0d06d4b9dfcff66a437cb Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 30 Dec 2019 13:29:04 -0800 Subject: [PATCH 09/19] Rename session config keys Appending `app` to the session-realted config keys to make it more clear what they are used for. --- API.md | 6 +++--- EXAMPLES.md | 4 ++-- README.md | 4 ++-- lib/config.js | 6 +++--- lib/context.js | 2 +- lib/hooks/getUser.js | 6 +++--- lib/loadEnvs.js | 2 +- middleware/auth.js | 16 ++++++++-------- 8 files changed, 23 insertions(+), 23 deletions(-) diff --git a/API.md b/API.md index 19a78b6f..a76fedcc 100644 --- a/API.md +++ b/API.md @@ -11,7 +11,7 @@ The `auth()` middleware has a few configuration keys that are required for initi - **`baseURL`** - The root URL for the application router. This can be set automatically with a `BASE_URL` variable in your environment. - **`clientID`** - The Client ID for your application. This can be set automatically with a `CLIENT_ID` variable in your environment. - **`issuerBaseURL`** - The root URL for the token issuer with no trailing slash. In Auth0, this is your Application's **Domain** prepended with `https://`. This can be set automatically with an `ISSUER_BASE_URL` variable in your environment. -- **`sessionSecret`** - The secret used to derive an encryption key for the user identity in a session cookie. Set this to `false` to skip this internal storage and provide your own session mechanism in `getUser`. This can be set automatically with an `SESSION_SECRET` variable in your environment. It must be a string or an array of strings. When array is provided the first member is used for signing and other members can be used for decrypting old cookies, this is to enable sessionSecret rotation. +- **`appSessionSecret`** - The secret used to derive an encryption key for the user identity in a session cookie. Set this to `false` to skip this internal storage and provide your own session mechanism in `getUser`. This can be set automatically with an `APP_SESSION_SECRET` variable in your environment. It must be a string or an array of strings. When array is provided the first member is used for signing and other members can be used for decrypting old cookies, this is to enable appSessionSecret rotation. If you are using a response type that includes `code` (typically combined with an `audience` parameter), you will need an additional key: @@ -34,8 +34,8 @@ Additional configuration keys that can be passed to `auth()` on initialization: - **`redirectUriPath`** - Relative path to the application callback to process the response from the authorization server. This value is combined with the `baseUrl` and sent to the authorize endpoint as the `redirectUri` parameter. Default is `/callback`. - **`required`** - Use a boolean value to require authentication for all routes. Pass a function instead to base this value on the request. Default is `true`. - **`routes`** - Boolean value to automatically install the login and logout routes. See [the examples](EXAMPLES.md) for more information on how this key is used. Default is `true`. -- **`sessionLength`** - Integer value, in seconds, indicating application session length. Set to `0` to indicate the cookie should be ephemeral (no expiration). Default is 7 days. -- **`sessionName`** - String value for the cookie name used for the internal session. This value must only include letters, numbers, and underscores. Default is `identity`. +- **`appSessionLength`** - Integer value, in seconds, indicating application session length. Set to `0` to indicate the cookie should be ephemeral (no expiration). Default is 7 days. +- **`appSessionName`** - String value for the cookie name used for the internal session. This value must only include letters, numbers, and underscores. Default is `identity`. ### Authorization Params Key diff --git a/EXAMPLES.md b/EXAMPLES.md index 93ab7dc0..c169c334 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -10,7 +10,7 @@ The simplest use case for this middleware: ISSUER_BASE_URL=https://YOUR_DOMAIN CLIENT_ID=YOUR_CLIENT_ID BASE_URL=https://YOUR_APPLICATION_ROOT_URL -SESSION_SECRET=LONG_RANDOM_STRING +APP_SESSION_SECRET=LONG_RANDOM_STRING ``` ```javascript @@ -147,7 +147,7 @@ app.use(session({ app.use(auth({ // Setting this configuration key to false will turn off internal session handling. - sessionSecret: false, + appSessionSecret: false, handleCallback: async function (req, res, next) { // This will store the user identity claims in the session req.session.userIdentity = req.openIdTokens.claims(); diff --git a/README.md b/README.md index d7f837c3..eaeaf3da 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ These can be configured in a `.env` file in the root of your application: ISSUER_BASE_URL=https://YOUR_DOMAIN CLIENT_ID=YOUR_CLIENT_ID BASE_URL=https://YOUR_APPLICATION_ROOT_URL -SESSION_SECRET=LONG_RANDOM_VALUE +APP_SESSION_SECRET=LONG_RANDOM_VALUE ``` ... or in the library initialization: @@ -69,7 +69,7 @@ app.use(auth({ issuerBaseURL: 'https://YOUR_DOMAIN', baseURL: 'https://YOUR_APPLICATION_ROOT_URL', clientID: 'YOUR_CLIENT_ID', - sessionName: 'LONG_RANDOM_STRING' + appSessionKey: 'LONG_RANDOM_STRING' })); ``` diff --git a/lib/config.js b/lib/config.js index 692ac2d9..f8fac667 100644 --- a/lib/config.js +++ b/lib/config.js @@ -36,8 +36,8 @@ const paramsSchema = Joi.object().keys({ loginPath: Joi.string().optional().default('/login'), logoutPath: Joi.string().optional().default('/logout'), legacySameSiteCookie: Joi.boolean().optional().default(true), - sessionName: Joi.string().token().optional().default('identity'), - sessionSecret: Joi.alternatives([ + appSessionName: Joi.string().token().optional().default('identity'), + appSessionSecret: Joi.alternatives([ // Array of keys to allow for rotation. Joi.array().items(Joi.string()), // Single string key. @@ -45,7 +45,7 @@ const paramsSchema = Joi.object().keys({ // False to stop client session from being created. Joi.boolean().valid([false]) ]).required(), - sessionLength: Joi.number().integer().optional().default(7 * 24 * 60 * 60), + appSessionLength: Joi.number().integer().optional().default(7 * 24 * 60 * 60), idpLogout: Joi.boolean().optional().default(false) .when('auth0Logout', { is: true, then: Joi.boolean().optional().default(true) }) diff --git a/lib/context.js b/lib/context.js index 8f6b5075..b196c763 100644 --- a/lib/context.js +++ b/lib/context.js @@ -90,7 +90,7 @@ class ResponseContext { const res = this._res; const returnURL = params.returnTo || this._config.baseURL; - req[this._config.sessionName] = undefined; + req[this._config.appSessionName] = undefined; if (!this._config.idpLogout) { return res.redirect(returnURL); diff --git a/lib/hooks/getUser.js b/lib/hooks/getUser.js index 78fed196..26beb6d3 100644 --- a/lib/hooks/getUser.js +++ b/lib/hooks/getUser.js @@ -4,10 +4,10 @@ */ module.exports = function(req, config) { - // If there is no sessionSecret, session handing is custom. - if (!config.sessionSecret || !req[config.sessionName] || !req[config.sessionName].claims) { + // If there is no appSessionSecret, session handing is custom. + if (!config.appSessionSecret || !req[config.appSessionName] || !req[config.appSessionName].claims) { return null; } - return req[config.sessionName].claims; + return req[config.appSessionName].claims; }; diff --git a/lib/loadEnvs.js b/lib/loadEnvs.js index 018bc23d..9cbb7e94 100644 --- a/lib/loadEnvs.js +++ b/lib/loadEnvs.js @@ -3,7 +3,7 @@ const fieldsEnvMap = { 'baseURL': 'BASE_URL', 'clientID': 'CLIENT_ID', 'clientSecret': 'CLIENT_SECRET', - 'sessionSecret': 'SESSION_SECRET', + 'appSessionSecret': 'APP_SESSION_SECRET', }; module.exports = function(params) { diff --git a/middleware/auth.js b/middleware/auth.js index 360d5988..6114d8f6 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -47,13 +47,13 @@ module.exports = function (params) { const router = express.Router(); - // Only use the internal cookie-based session if sessionSecret is provided. - if (config.sessionSecret) { + // Only use the internal cookie-based session if appSessionSecret is provided. + if (config.appSessionSecret) { router.use(idSession({ - cookieName: config.sessionName, - propertyName: config.sessionName, - secret: config.sessionSecret, - duration: config.sessionLength, + cookieName: config.appSessionName, + propertyName: config.appSessionName, + secret: config.appSessionSecret, + duration: config.appSessionLength, // TODO: cookieOptions: { domain, httpOnly, path, secure, sameSite } })); } @@ -112,11 +112,11 @@ module.exports = function (params) { req.openIdTokens = tokenSet; - if (config.sessionSecret) { + if (config.appSessionSecret) { let identityClaims = tokenSet.claims(); // Remove validation claims to reduce stored size. ['aud', 'iss', 'exp', 'nonce', 'azp', 'auth_time'].forEach(claim => delete identityClaims[claim]); - req[config.sessionName].claims = tokenSet.claims(); + req[config.appSessionName].claims = tokenSet.claims(); } next(); From 01a35c2a0faa6b2fb21351e3118e219fd158dd5e Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 30 Dec 2019 13:41:55 -0800 Subject: [PATCH 10/19] Use single prop for property and cookie names --- lib/session.js | 24 ++++++++++++------------ middleware/auth.js | 3 +-- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/lib/session.js b/lib/session.js index be25538d..9206a67d 100644 --- a/lib/session.js +++ b/lib/session.js @@ -8,7 +8,7 @@ const hkdf = require('futoin-hkdf'); const deriveKey = (secret) => hkdf(secret, 32, { info: 'JWE CEK', hash: 'SHA-256' }); const epoch = () => Date.now() / 1000 | 0; -module.exports = ({ cookieName, propertyName, secret, duration, cookieOptions = {} }) => { +module.exports = ({ name, secret, duration, cookieOptions = {} }) => { let current; const { domain, httpOnly, path, secure, sameSite } = cookieOptions; @@ -44,16 +44,16 @@ module.exports = ({ cookieName, propertyName, secret, duration, cookieOptions = } function setCookie (req, res, { uat = epoch(), iat = uat, exp = uat + duration }) { - if ((!req[propertyName] || !Object.keys(req[propertyName]).length) && cookieName in req[COOKIES]) { - res.clearCookie(cookieName); + if ((!req[name] || !Object.keys(req[name]).length) && name in req[COOKIES]) { + res.clearCookie(name); return; } - if (req[propertyName] && Object.keys(req[propertyName]).length > 0) { - const value = encrypt(JSON.stringify(req[propertyName]), { iat, uat, exp }); + if (req[name] && Object.keys(req[name]).length > 0) { + const value = encrypt(JSON.stringify(req[name]), { iat, uat, exp }); res.cookie( - cookieName, + name, value, { domain, @@ -72,7 +72,7 @@ module.exports = ({ cookieName, propertyName, secret, duration, cookieOptions = req[COOKIES] = cookie.parse(req.get('cookie') || ''); } - if (propertyName in req) { + if (name in req) { return next(); } @@ -81,15 +81,15 @@ module.exports = ({ cookieName, propertyName, secret, duration, cookieOptions = try { // TODO: detect and join chunks - if (cookieName in req[COOKIES]) { - const { protected: header, cleartext } = decrypt(req[COOKIES][cookieName]) + if (name in req[COOKIES]) { + const { protected: header, cleartext } = decrypt(req[COOKIES][name]) ;({ iat, exp } = header); assert(exp > epoch()); - req[propertyName] = JSON.parse(cleartext); + req[name] = JSON.parse(cleartext); } } finally { - if (!(propertyName in req)) { - req[propertyName] = {}; + if (!(name in req)) { + req[name] = {}; } } diff --git a/middleware/auth.js b/middleware/auth.js index 6114d8f6..27df3994 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -50,8 +50,7 @@ module.exports = function (params) { // Only use the internal cookie-based session if appSessionSecret is provided. if (config.appSessionSecret) { router.use(idSession({ - cookieName: config.appSessionName, - propertyName: config.appSessionName, + name: config.appSessionName, secret: config.appSessionSecret, duration: config.appSessionLength, // TODO: cookieOptions: { domain, httpOnly, path, secure, sameSite } From 0d37cb7d1b66994e2f54a7dd5be4ba8862bfbf19 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 30 Dec 2019 13:55:48 -0800 Subject: [PATCH 11/19] Add appSessionCookie config key Added a config key for the application session cookie to allow cookie options to be passed to the session cookie. --- API.md | 1 + lib/config.js | 10 ++++++++++ middleware/auth.js | 2 +- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/API.md b/API.md index a76fedcc..533aabce 100644 --- a/API.md +++ b/API.md @@ -36,6 +36,7 @@ Additional configuration keys that can be passed to `auth()` on initialization: - **`routes`** - Boolean value to automatically install the login and logout routes. See [the examples](EXAMPLES.md) for more information on how this key is used. Default is `true`. - **`appSessionLength`** - Integer value, in seconds, indicating application session length. Set to `0` to indicate the cookie should be ephemeral (no expiration). Default is 7 days. - **`appSessionName`** - String value for the cookie name used for the internal session. This value must only include letters, numbers, and underscores. Default is `identity`. +- **`appSessionCookie`** - Object defining application session cookie attributes. Allowed keys are `domain`, `httpOnly`, `path`, `secure`, and `sameSite`. Defaults are `true` for `httpOnly` and `Lax` for `sameSite`. ### Authorization Params Key diff --git a/lib/config.js b/lib/config.js index f8fac667..2c64d0b2 100644 --- a/lib/config.js +++ b/lib/config.js @@ -16,6 +16,14 @@ const authorizationParamsSchema = Joi.object().keys({ scope: Joi.string().required() }).unknown(true); +const appSessionCookieSchema = Joi.object().keys({ + domain: Joi.string().optional(), + httpOnly: Joi.boolean().optional().default(true), + path: Joi.string().optional(), + secure: Joi.boolean().optional(), + sameSite: Joi.string().valid(['Lax', 'Strict', 'None']).optional().default('Lax') +}).unknown(false); + // const requiredParams = ['issuerBaseURL', 'baseURL', 'clientID']; const paramsSchema = Joi.object().keys({ httpOptions: Joi.object().optional(), @@ -46,6 +54,7 @@ const paramsSchema = Joi.object().keys({ Joi.boolean().valid([false]) ]).required(), appSessionLength: Joi.number().integer().optional().default(7 * 24 * 60 * 60), + appSessionCookie: Joi.object().optional(), idpLogout: Joi.boolean().optional().default(false) .when('auth0Logout', { is: true, then: Joi.boolean().optional().default(true) }) @@ -92,6 +101,7 @@ module.exports.get = function(params) { config = paramsValidation.value; config.authorizationParams = buildAuthorizeParams(config.authorizationParams); + config.appSessionCookie = Joi.validate(config.appSessionCookie, appSessionCookieSchema); // Code grant requires a client secret to exchange the code for tokens const responseTypeHasCode = config.authorizationParams.response_type.split(' ').includes('code'); diff --git a/middleware/auth.js b/middleware/auth.js index 27df3994..22f4fa96 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -53,7 +53,7 @@ module.exports = function (params) { name: config.appSessionName, secret: config.appSessionSecret, duration: config.appSessionLength, - // TODO: cookieOptions: { domain, httpOnly, path, secure, sameSite } + cookieOptions: config.appSessionCookie })); } From bdda90b4ec860deb5f71b3f0f3eddd7236b6729f Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 30 Dec 2019 14:15:22 -0800 Subject: [PATCH 12/19] Fix existing tests --- test/auth.tests.js | 5 +++++ test/callback_route_form_post.tests.js | 1 + test/client.tests.js | 20 +++++++++++--------- test/config.tests.js | 7 +++++++ test/custom_redirect_uri.tests.js | 1 + test/invalid_id_token_alg.tests.js | 5 +++-- test/invalid_params.tests.js | 16 ++++++++++++++++ test/invalid_response_mode.tests.js | 1 + test/invalid_response_type.tests.js | 1 + test/logout.tests.js | 2 ++ test/requiresAuth.tests.js | 2 ++ 11 files changed, 50 insertions(+), 11 deletions(-) diff --git a/test/auth.tests.js b/test/auth.tests.js index 2347fd33..fb21d202 100644 --- a/test/auth.tests.js +++ b/test/auth.tests.js @@ -36,6 +36,7 @@ describe('auth', function() { before(async function() { router = expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', @@ -86,6 +87,7 @@ describe('auth', function() { before(async function() { router = expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', @@ -127,6 +129,7 @@ describe('auth', function() { before(async function() { router = expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', clientSecret: '__test_client_secret__', baseURL: 'https://example.org', @@ -173,6 +176,7 @@ describe('auth', function() { before(async function() { router = expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', @@ -215,6 +219,7 @@ describe('auth', function() { before(async function() { router = expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', diff --git a/test/callback_route_form_post.tests.js b/test/callback_route_form_post.tests.js index e5802ca2..c7cfacd0 100644 --- a/test/callback_route_form_post.tests.js +++ b/test/callback_route_form_post.tests.js @@ -13,6 +13,7 @@ const clientID = '__test_client_id__'; function testCase(params) { return () => { const authOpts = Object.assign({}, { + appSessionSecret: '__test_session_secret__', clientID: clientID, baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', diff --git a/test/client.tests.js b/test/client.tests.js index 77738ed1..fd3ca5b1 100644 --- a/test/client.tests.js +++ b/test/client.tests.js @@ -16,6 +16,7 @@ describe('client initialization', function() { describe('default case', function() { const config = getConfig({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', clientSecret: '__test_client_secret__', issuerBaseURL: 'https://test.auth0.com', @@ -26,24 +27,24 @@ describe('client initialization', function() { before(async function() { client = await getClient(config); }); - + it('should save the passed values', async function() { assert.equal('__test_client_id__', client.client_id); assert.equal('__test_client_secret__', client.client_secret); }); - + it('should send the correct default headers', async function() { const headers = await client.introspect('__test_token__', '__test_hint__'); const headerProps = Object.getOwnPropertyNames(headers); - + assert.include(headerProps, 'auth0-client'); const decodedTelemetry = JSON.parse(Buffer.from(headers['auth0-client'], 'base64').toString('ascii')); - + assert.equal( 'express-oidc', decodedTelemetry.name ); assert.equal( pkg.version, decodedTelemetry.version ); assert.equal( process.version, decodedTelemetry.env.node ); - + assert.include( headerProps, 'user-agent'); assert.equal( `express-openid-connect/${pkg.version}`, headers['user-agent']); }); @@ -51,6 +52,7 @@ describe('client initialization', function() { describe('custom headers', function() { const config = getConfig({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', clientSecret: '__test_client_secret__', issuerBaseURL: 'https://test.auth0.com', @@ -68,19 +70,19 @@ describe('client initialization', function() { before(async function() { client = await getClient(config); }); - + it('should send the correct default headers', async function() { const headers = await client.introspect('__test_token__', '__test_hint__'); const headerProps = Object.getOwnPropertyNames(headers); - + // User agent header should be overridden. assert.include(headerProps, 'user-agent'); assert.equal('__test_custom_user_agent__', headers['user-agent']); - + // Custom header should be added. assert.include(headerProps, 'x-custom-header'); assert.equal('__test_custom_header__', headers['x-custom-header']); - + // Telemetry header should not be overridden. assert.include(headerProps, 'auth0-client'); assert.notEqual('__test_custom_telemetry__', headers['x-custom-header']); diff --git a/test/config.tests.js b/test/config.tests.js index 8ab254fd..59008d05 100644 --- a/test/config.tests.js +++ b/test/config.tests.js @@ -4,6 +4,7 @@ const { get: getConfig } = require('../lib/config'); describe('config', function() { describe('simple case', function() { const config = getConfig({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', issuerBaseURL: 'https://test.auth0.com', baseURL: 'https://example.org', @@ -28,6 +29,7 @@ describe('config', function() { describe('when authorizationParams is response_type=x', function() { const config = getConfig({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', clientSecret: '__test_client_secret__', issuerBaseURL: 'https://test.auth0.com', @@ -52,6 +54,7 @@ describe('config', function() { describe('with auth0Logout', function() { const config = getConfig({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', issuerBaseURL: 'https://test.auth0.com', baseURL: 'https://example.org', @@ -66,6 +69,7 @@ describe('config', function() { describe('without auth0Logout nor idpLogout', function() { const config = getConfig({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', issuerBaseURL: 'https://test.auth0.com', baseURL: 'https://example.org', @@ -79,6 +83,7 @@ describe('config', function() { describe('with idpLogout', function() { const config = getConfig({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', issuerBaseURL: 'https://test.auth0.com', baseURL: 'https://example.org', @@ -93,6 +98,7 @@ describe('config', function() { describe('default auth paths', function() { const config = getConfig({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', issuerBaseURL: 'https://test.auth0.com', baseURL: 'https://example.org', @@ -113,6 +119,7 @@ describe('config', function() { describe('custom auth paths', function() { const config = getConfig({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', issuerBaseURL: 'https://test.auth0.com', baseURL: 'https://example.org', diff --git a/test/custom_redirect_uri.tests.js b/test/custom_redirect_uri.tests.js index 4d8ce824..37484eb5 100644 --- a/test/custom_redirect_uri.tests.js +++ b/test/custom_redirect_uri.tests.js @@ -20,6 +20,7 @@ describe('auth with redirectUriPath', function() { before(async function() { router = expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', diff --git a/test/invalid_id_token_alg.tests.js b/test/invalid_id_token_alg.tests.js index 672d95f0..30f64412 100644 --- a/test/invalid_id_token_alg.tests.js +++ b/test/invalid_id_token_alg.tests.js @@ -9,9 +9,10 @@ const request = require('request-promise-native').defaults({ describe('with an invalid id token alg', function() { const router = expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', clientID: '123', - baseURL: 'https://myapp.com', - issuerBaseURL: 'https://flosser.auth0.com', + baseURL: 'https://example.org', + issuerBaseURL: 'https://test.auth0.com', idTokenAlg: '__invalid_alg__' }); diff --git a/test/invalid_params.tests.js b/test/invalid_params.tests.js index c1412522..870557f2 100644 --- a/test/invalid_params.tests.js +++ b/test/invalid_params.tests.js @@ -5,6 +5,7 @@ describe('invalid parameters', function() { it('should fail when the issuerBaseURL is invalid', function() { assert.throws(() => { expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', baseURL: 'https://example.org', issuerBaseURL: '__invalid_url__', clientID: '__test_client_id__' @@ -15,6 +16,7 @@ describe('invalid parameters', function() { it('should fail when the baseURL is invalid', function() { assert.throws(() => { expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', baseURL: '__invalid_url__', issuerBaseURL: 'https://test.auth0.com', clientID: '__test_client_id__' @@ -25,6 +27,7 @@ describe('invalid parameters', function() { it('should fail when the clientID is not provided', function() { assert.throws(() => { expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', }); @@ -34,15 +37,27 @@ describe('invalid parameters', function() { it('should fail when the baseURL is not provided', function() { assert.throws(() => { expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', issuerBaseURL: 'https://test.auth0.com', clientID: '__test_client_id__', }); }, '"baseURL" is required'); }); + it('should fail when the appSessionSecret is not provided', function() { + assert.throws(() => { + expressOpenid.auth({ + issuerBaseURL: 'https://test.auth0.com', + baseURL: 'https://example.org', + clientID: '__test_client_id__', + }); + }, '"appSessionSecret" is required'); + }); + it('should fail when client secret is not provided and using the response type code in mode query', function() { assert.throws(() => { expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', issuerBaseURL: 'https://test.auth0.com', baseURL: 'https://example.org', clientID: '__test_client_id__', @@ -56,6 +71,7 @@ describe('invalid parameters', function() { it('should fail when client secret is not provided and using an HS256 ID token algorithm', function() { assert.throws(() => { expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', issuerBaseURL: 'http://foobar.auth0.com', baseURL: 'http://foobar.com', clientID: 'asdas', diff --git a/test/invalid_response_mode.tests.js b/test/invalid_response_mode.tests.js index 6c606491..69a5f25c 100644 --- a/test/invalid_response_mode.tests.js +++ b/test/invalid_response_mode.tests.js @@ -9,6 +9,7 @@ const request = require('request-promise-native').defaults({ describe('with an invalid response_mode', function() { const router = expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', diff --git a/test/invalid_response_type.tests.js b/test/invalid_response_type.tests.js index 78086b3b..b444390a 100644 --- a/test/invalid_response_type.tests.js +++ b/test/invalid_response_type.tests.js @@ -9,6 +9,7 @@ const request = require('request-promise-native').defaults({ describe('with an invalid response type', function() { const router = expressOpenid.auth({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', diff --git a/test/logout.tests.js b/test/logout.tests.js index 7b7a0745..6332b508 100644 --- a/test/logout.tests.js +++ b/test/logout.tests.js @@ -21,6 +21,7 @@ describe('logout route', function() { clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', + appSessionSecret: '__test_session_secret__', required: false }); baseUrl = await server.create(middleware); @@ -59,6 +60,7 @@ describe('logout route', function() { clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', + appSessionSecret: '__test_session_secret__', required: false }); baseUrl = await server.create(middleware); diff --git a/test/requiresAuth.tests.js b/test/requiresAuth.tests.js index 4a8f9c70..2780f253 100644 --- a/test/requiresAuth.tests.js +++ b/test/requiresAuth.tests.js @@ -14,6 +14,7 @@ describe('requiresAuth middleware', function() { before(async function() { const router = auth({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', @@ -52,6 +53,7 @@ describe('requiresAuth middleware', function() { before(async function() { const router = auth({ + appSessionSecret: '__test_session_secret__', clientID: '__test_client_id__', baseURL: 'https://example.org', issuerBaseURL: 'https://test.auth0.com', From 048e259ac0956b70170688797de039f2ada7c89e Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 30 Dec 2019 15:12:22 -0800 Subject: [PATCH 13/19] Fix session cookie config and add tests --- lib/config.js | 24 ++++++++--- test/config.tests.js | 68 +++++++++++++++++++++++++++++- test/invalid_params.tests.js | 81 ++++++++++++++++++++++++++++++++---- 3 files changed, 159 insertions(+), 14 deletions(-) diff --git a/lib/config.js b/lib/config.js index 2c64d0b2..9feb0bb7 100644 --- a/lib/config.js +++ b/lib/config.js @@ -18,10 +18,10 @@ const authorizationParamsSchema = Joi.object().keys({ const appSessionCookieSchema = Joi.object().keys({ domain: Joi.string().optional(), - httpOnly: Joi.boolean().optional().default(true), + httpOnly: Joi.boolean().optional(), path: Joi.string().optional(), - secure: Joi.boolean().optional(), - sameSite: Joi.string().valid(['Lax', 'Strict', 'None']).optional().default('Lax') + sameSite: Joi.string().valid(['Lax', 'Strict', 'None']).optional(), + secure: Joi.boolean().optional() }).unknown(false); // const requiredParams = ['issuerBaseURL', 'baseURL', 'clientID']; @@ -46,10 +46,10 @@ const paramsSchema = Joi.object().keys({ legacySameSiteCookie: Joi.boolean().optional().default(true), appSessionName: Joi.string().token().optional().default('identity'), appSessionSecret: Joi.alternatives([ - // Array of keys to allow for rotation. - Joi.array().items(Joi.string()), // Single string key. Joi.string(), + // Array of keys to allow for rotation. + Joi.array().items(Joi.string()), // False to stop client session from being created. Joi.boolean().valid([false]) ]).required(), @@ -87,6 +87,18 @@ If the user provides authorizationParams then return authorizationParams; } +function buildAppSessionCookieConfig(cookieConfig) { + + cookieConfig = cookieConfig && Object.keys(cookieConfig).length > 0 ? cookieConfig : { httpOnly: true }; + const cookieConfigValidation = Joi.validate(cookieConfig, appSessionCookieSchema); + + if(cookieConfigValidation.error) { + throw new Error(cookieConfigValidation.error.details[0].message); + } + + return cookieConfig; +} + module.exports.get = function(params) { let config = typeof params == 'object' ? clone(params) : {}; @@ -101,7 +113,7 @@ module.exports.get = function(params) { config = paramsValidation.value; config.authorizationParams = buildAuthorizeParams(config.authorizationParams); - config.appSessionCookie = Joi.validate(config.appSessionCookie, appSessionCookieSchema); + config.appSessionCookie = buildAppSessionCookieConfig(config.appSessionCookie); // Code grant requires a client secret to exchange the code for tokens const responseTypeHasCode = config.authorizationParams.response_type.split(' ').includes('code'); diff --git a/test/config.tests.js b/test/config.tests.js index 59008d05..94ec7d8e 100644 --- a/test/config.tests.js +++ b/test/config.tests.js @@ -4,7 +4,7 @@ const { get: getConfig } = require('../lib/config'); describe('config', function() { describe('simple case', function() { const config = getConfig({ - appSessionSecret: '__test_session_secret__', + appSessionSecret: false, clientID: '__test_client_id__', issuerBaseURL: 'https://test.auth0.com', baseURL: 'https://example.org', @@ -141,4 +141,70 @@ describe('config', function() { }); }); + describe('app session default configuration', function() { + const config = getConfig({ + appSessionSecret: '__test_session_secret__', + clientID: '__test_client_id__', + issuerBaseURL: 'https://test.auth0.com', + baseURL: 'https://example.org' + }); + + it('should set the app session secret', function() { + assert.equal(config.appSessionSecret, '__test_session_secret__'); + }); + + it('should set the session length to 7 days by default', function() { + assert.equal(config.appSessionLength, 604800); + }); + + it('should set the session name to "identity" by default', function() { + assert.equal(config.appSessionName, 'identity'); + }); + + it('should set the session cookie attributes to correct defaults', function() { + assert.notExists(config.appSessionCookie.domain); + assert.notExists(config.appSessionCookie.path); + assert.notExists(config.appSessionCookie.secure); + assert.notExists(config.appSessionCookie.sameSite); + assert.equal(config.appSessionCookie.httpOnly, true); + }); + }); + + describe('app session cookie configuration', function() { + const config = getConfig({ + appSessionSecret: [ '__test_session_secret_1__', '__test_session_secret_2__' ], + appSessionName: '__test_custom_session_name__', + appSessionLength: 1234567890, + appSessionCookie: { + domain: '__test_custom_domain__', + path: '__test_custom_path__', + httpOnly: false, + secure: true, + sameSite: 'Lax', + }, + clientID: '__test_client_id__', + issuerBaseURL: 'https://test.auth0.com', + baseURL: 'https://example.org' + }); + + it('should set an array of secrets', function() { + assert.equal(config.appSessionSecret.length, 2); + assert.equal(config.appSessionSecret[0], '__test_session_secret_1__'); + assert.equal(config.appSessionSecret[1], '__test_session_secret_2__'); + }); + + it('should set the custom session values', function() { + assert.equal(config.appSessionLength, 1234567890); + assert.equal(config.appSessionName, '__test_custom_session_name__'); + }); + + it('should set the session cookie attributes to custom values', function() { + assert.equal(config.appSessionCookie.domain, '__test_custom_domain__'); + assert.equal(config.appSessionCookie.path, '__test_custom_path__'); + assert.equal(config.appSessionCookie.httpOnly, false); + assert.equal(config.appSessionCookie.secure, true); + assert.equal(config.appSessionCookie.sameSite, 'Lax'); + }); + }); + }); diff --git a/test/invalid_params.tests.js b/test/invalid_params.tests.js index 870557f2..ac06755a 100644 --- a/test/invalid_params.tests.js +++ b/test/invalid_params.tests.js @@ -1,6 +1,17 @@ const { assert } = require('chai'); const expressOpenid = require('..'); +const validConfiguration = { + appSessionSecret: '__test_session_secret__', + issuerBaseURL: 'https://test.auth0.com', + baseURL: 'https://example.org', + clientID: '__test_client_id__', +}; + +function getTestConfig(modify) { + return Object.assign({}, validConfiguration, modify); +} + describe('invalid parameters', function() { it('should fail when the issuerBaseURL is invalid', function() { assert.throws(() => { @@ -70,13 +81,69 @@ describe('invalid parameters', function() { it('should fail when client secret is not provided and using an HS256 ID token algorithm', function() { assert.throws(() => { - expressOpenid.auth({ - appSessionSecret: '__test_session_secret__', - issuerBaseURL: 'http://foobar.auth0.com', - baseURL: 'http://foobar.com', - clientID: 'asdas', - idTokenAlg: 'HS256' - }); + expressOpenid.auth(getTestConfig({idTokenAlg: 'HS256'})); }, '"clientSecret" is required for ID tokens with HS algorithms'); }); + + it('should fail when app session length is not an integer', function() { + assert.throws(() => { + expressOpenid.auth(getTestConfig({appSessionLength: 3.14159})); + }, '"appSessionLength" must be an integer'); + }); + + it('should fail when app session secret is invalid', function() { + assert.throws(() => { + expressOpenid.auth(getTestConfig({appSessionSecret: {key: '__test_session_secret__'}})); + }, '"appSessionSecret" must be a string'); + }); + + it('should fail when app session cookie httpOnly is not a boolean', function() { + assert.throws(() => { + expressOpenid.auth(getTestConfig({ + appSessionCookie: { + httpOnly: '__invalid_httponly__' + } + })); + }, '"httpOnly" must be a boolean'); + }); + + it('should fail when app session cookie secure is not a boolean', function() { + assert.throws(() => { + expressOpenid.auth(getTestConfig({ + appSessionCookie: { + secure: '__invalid_secure__' + } + })); + }, '"secure" must be a boolean'); + }); + + it('should fail when app session cookie sameSite is invalid', function() { + assert.throws(() => { + expressOpenid.auth(getTestConfig({ + appSessionCookie: { + sameSite: '__invalid_samesite__' + } + })); + }, '"sameSite" must be one of [Lax, Strict, None]'); + }); + + it('should fail when app session cookie domain is invalid', function() { + assert.throws(() => { + expressOpenid.auth(getTestConfig({ + appSessionCookie: { + domain: false + } + })); + }, '"domain" must be a string'); + }); + + it('should fail when app session cookie sameSite is an invalid value', function() { + assert.throws(() => { + expressOpenid.auth(getTestConfig({ + appSessionCookie: { + path: 123 + } + })); + }, '"path" must be a string'); + }); }); From ef888df7cc64f5519131053ef4cfd15c7fe79e16 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Mon, 30 Dec 2019 15:18:21 -0800 Subject: [PATCH 14/19] Rename appSession, light refacotring, and tests --- lib/{session.js => appSession.js} | 34 ++++-------- lib/config.js | 3 +- middleware/auth.js | 12 +++-- test/appSession.tests.js | 73 ++++++++++++++++++++++++++ test/callback_route_form_post.tests.js | 31 +++++------ 5 files changed, 107 insertions(+), 46 deletions(-) rename lib/{session.js => appSession.js} (77%) create mode 100644 test/appSession.tests.js diff --git a/lib/session.js b/lib/appSession.js similarity index 77% rename from lib/session.js rename to lib/appSession.js index 9206a67d..c2d55d97 100644 --- a/lib/session.js +++ b/lib/appSession.js @@ -11,9 +11,7 @@ const epoch = () => Date.now() / 1000 | 0; module.exports = ({ name, secret, duration, cookieOptions = {} }) => { let current; - const { domain, httpOnly, path, secure, sameSite } = cookieOptions; - - const COOKIES = Symbol(); + const COOKIES = Symbol('cookies'); const alg = 'dir'; const enc = 'A256GCM'; @@ -40,7 +38,7 @@ module.exports = ({ name, secret, duration, cookieOptions = {} }) => { } function decrypt (jwe) { - return JWE.decrypt(jwe, keystore, { complete: true, algorithms: ['A256GCM'] }); + return JWE.decrypt(jwe, keystore, { complete: true, algorithms: [enc] }); } function setCookie (req, res, { uat = epoch(), iat = uat, exp = uat + duration }) { @@ -51,28 +49,18 @@ module.exports = ({ name, secret, duration, cookieOptions = {} }) => { if (req[name] && Object.keys(req[name]).length > 0) { const value = encrypt(JSON.stringify(req[name]), { iat, uat, exp }); + const expires = !duration ? 0 : new Date(exp * 1000); - res.cookie( - name, - value, - { - domain, - httpOnly, - path, - secure, - sameSite, - expires: !duration ? 0 : new Date(exp * 1000) - } - ); + res.cookie(name, value, {expires, ...cookieOptions}); } } return (req, res, next) => { - if (!(COOKIES in req)) { + if (!req.hasOwnProperty(COOKIES)) { req[COOKIES] = cookie.parse(req.get('cookie') || ''); } - if (name in req) { + if (req.hasOwnProperty(name)) { return next(); } @@ -80,15 +68,15 @@ module.exports = ({ name, secret, duration, cookieOptions = {} }) => { let exp; try { - // TODO: detect and join chunks - if (name in req[COOKIES]) { - const { protected: header, cleartext } = decrypt(req[COOKIES][name]) - ;({ iat, exp } = header); + + if (req[COOKIES].hasOwnProperty(name)) { + const { protected: header, cleartext } = decrypt(req[COOKIES][name]); + ({ iat, exp } = header); assert(exp > epoch()); req[name] = JSON.parse(cleartext); } } finally { - if (!(name in req)) { + if (!req.hasOwnProperty(name) || !req[name]) { req[name] = {}; } } diff --git a/lib/config.js b/lib/config.js index 9feb0bb7..f7295038 100644 --- a/lib/config.js +++ b/lib/config.js @@ -44,7 +44,6 @@ const paramsSchema = Joi.object().keys({ loginPath: Joi.string().optional().default('/login'), logoutPath: Joi.string().optional().default('/logout'), legacySameSiteCookie: Joi.boolean().optional().default(true), - appSessionName: Joi.string().token().optional().default('identity'), appSessionSecret: Joi.alternatives([ // Single string key. Joi.string(), @@ -53,9 +52,9 @@ const paramsSchema = Joi.object().keys({ // False to stop client session from being created. Joi.boolean().valid([false]) ]).required(), + appSessionName: Joi.string().token().optional().default('identity'), appSessionLength: Joi.number().integer().optional().default(7 * 24 * 60 * 60), appSessionCookie: Joi.object().optional(), - idpLogout: Joi.boolean().optional().default(false) .when('auth0Logout', { is: true, then: Joi.boolean().optional().default(true) }) }); diff --git a/middleware/auth.js b/middleware/auth.js index 22f4fa96..ddd225ac 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -8,7 +8,7 @@ const { get: getClient } = require('../lib/client'); const requiresAuth = require('./requiresAuth'); const transient = require('../lib/transientHandler'); const { RequestContext, ResponseContext } = require('../lib/context'); -const idSession = require('../lib/session'); +const appSession = require('../lib/appSession'); /** * Returns a router with two routes /login and /callback @@ -49,7 +49,7 @@ module.exports = function (params) { // Only use the internal cookie-based session if appSessionSecret is provided. if (config.appSessionSecret) { - router.use(idSession({ + router.use(appSession({ name: config.appSessionName, secret: config.appSessionSecret, duration: config.appSessionLength, @@ -113,9 +113,13 @@ module.exports = function (params) { if (config.appSessionSecret) { let identityClaims = tokenSet.claims(); + // Remove validation claims to reduce stored size. - ['aud', 'iss', 'exp', 'nonce', 'azp', 'auth_time'].forEach(claim => delete identityClaims[claim]); - req[config.appSessionName].claims = tokenSet.claims(); + ['aud', 'iss', 'iat', 'exp', 'nonce', 'azp', 'auth_time'].forEach(claim => { + delete identityClaims[claim]; + }); + + req[config.appSessionName].claims = identityClaims; } next(); diff --git a/test/appSession.tests.js b/test/appSession.tests.js new file mode 100644 index 00000000..cfe42af8 --- /dev/null +++ b/test/appSession.tests.js @@ -0,0 +1,73 @@ +const assert = require('chai').assert; +const appSession = require('../lib/appSession'); + +const defaultConfig = { + name: 'identity', + secret: '__test_secret__', + duration: 1234567890, + cookieOptions: {} +}; + +let req = { + get: (key) => key +}; +let res = {}; +const next = () => true; + +describe('appSession', function() { + + describe('no session cookies, no session property', () => { + const appSessionMw = appSession(defaultConfig); + const result = appSessionMw(req, res, next); + + it('should call next', function() { + assert.ok(result); + }); + + it('should set an empty identity', function() { + assert.isEmpty(req.identity); + }); + }); + + describe('no session cookies, existing session property', () => { + const appSessionMw = appSession(defaultConfig); + const thisReq = Object.assign({}, req, {identity: {sub: '__test_existing_sub__'}}); + const result = appSessionMw(thisReq, res, next); + + it('should call next', function() { + assert.ok(result); + }); + + it('should keep existing identity', function() { + assert.equal(thisReq.identity.sub, '__test_existing_sub__'); + }); + }); + + describe('malformed session cookies', () => { + const appSessionMw = appSession(defaultConfig); + const thisReq = {get: () => 'identity=__invalid_identity__'}; + + it('should error with malformed identity', function() { + assert.throws(() => appSessionMw(thisReq, res, next), Error, 'JWE malformed or invalid serialization'); + }); + }); + + describe('existing session cookies', () => { + const appSessionMw = appSession(defaultConfig); + const thisReq = { + // Encypted '{sub:"__test_sub__"}' with '__test_secret__' + get: () => 'identity=eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIiwiemlwIjoiREVGIiwidWF0IjoxNTc3ODI2NzY5' + + 'LCJpYXQiOjE1Nzc4MjY3NjksImV4cCI6MTU3ODQzMTU2OX0..4XocWueShMw1cD_b.EhS_rNI4HeCFSlJTxKowE1SwLfsEfg' + + '.JKMnZOkBjwi-9Z5BSHliiw' + }; + const result = appSessionMw(thisReq, res, next); + + it('should call next', function() { + assert.ok(result); + }); + + it('should set the identity on req', function() { + assert.equal(thisReq.identity.sub, '__test_sub__'); + }); + }); +}); diff --git a/test/callback_route_form_post.tests.js b/test/callback_route_form_post.tests.js index c7cfacd0..9726cecb 100644 --- a/test/callback_route_form_post.tests.js +++ b/test/callback_route_form_post.tests.js @@ -35,22 +35,9 @@ function testCase(params) { baseUrl + '/callback', ); }); - }); - - before(async function() { - this.response = await request.post('/callback', { - baseUrl, - jar, - json: params.body - }); - }); - before(async function() { - this.currentSession = await request.get('/session', { - baseUrl, - jar, - json: true, - }).then(r => r.body); + this.response = await request.post('/callback', {baseUrl, jar, json: params.body}); + this.currentUser = await request.get('/user', {baseUrl, jar, json: true}).then(r => r.body); }); params.assertions(); @@ -61,8 +48,8 @@ function makeIdToken(payload) { if (typeof payload !== 'object' ) { payload = { 'nickname': '__test_nickname__', - 'iss': 'https://test.auth0.com/', 'sub': '__test_sub__', + 'iss': 'https://test.auth0.com/', 'aud': clientID, 'iat': Math.round(Date.now() / 1000), 'exp': Math.round(Date.now() / 1000) + 60000, @@ -228,7 +215,17 @@ describe('callback routes response_type: id_token, response_mode: form_post', fu }); it('should contain the claims in the current session', function() { - assert.ok(this.currentSession.claims); + assert.ok(this.currentUser); + assert.equal(this.currentUser.sub, '__test_sub__'); + assert.equal(this.currentUser.nickname, '__test_nickname__'); + }); + + it('should strip validation claims from the ID tokens', function() { + assert.notExists(this.currentUser.iat); + assert.notExists(this.currentUser.iss); + assert.notExists(this.currentUser.aud); + assert.notExists(this.currentUser.exp); + assert.notExists(this.currentUser.nonce); }); it('should expose the user in the request', async function() { From 4908210daaa4acd1ae40c9e9d1c1d3c233cdba37 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Thu, 2 Jan 2020 21:48:41 -0800 Subject: [PATCH 15/19] Improve examples wording for clarity --- EXAMPLES.md | 74 ++++++++++++++++++++++++++--------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index c169c334..1732d262 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -1,7 +1,7 @@ # Examples -## 1. Basic Setup +## 1. Basic setup The simplest use case for this middleware: @@ -65,9 +65,9 @@ app.use('/admin/users', (req, res) => res.render('admin-users')); app.use('/admin/posts', (req, res) => res.render('admin-posts')); ``` -## 3. Route Customization +## 3. Route customization -If you need to customize the routes, you can opt-out from the default routes and write your own route handler: +If you need to customize the provided routes, you can opt-out from the default routes and write your own route handler: ```js app.use(auth({ routes: false })); @@ -88,11 +88,11 @@ app.use(auth({ Please note that both of these routes are completely optional and not required. Trying to access any protected resource triggers a redirect directly to Auth0 to login. -## 4. Using access tokens +## 4. Custom user session handling -If your application needs to request and store access tokens, you must provide a method to store the incoming tokens during callback. We recommend to use a persistant store, like a database or Redis, to store these tokens directly associated with the user for which they were requested. +By default, this library uses an encrypted cookie to store the user identity claims used as a session. If the size of the user identity is too large or you're concerned about sensitive data being stored, you can provide your own session handling as part of the `getUser` function. -If the tokens only need to be used during the user's session, they can be stored using a session middleware like `express-session`. We recommend persisting the data in a session store other than in-memory (which is the default), otherwise all tokens will be lost when the server restarts. The basics of handling the tokens is below: +If, for example, you want the user session to be stored on the server, you can use a session middleware like `express-session`. We recommend persisting the data in a session store other than in-memory (which is the default), otherwise all sessions will be lost when the server restarts. The basics of handling the user identity server-side is below: ```js const session = require('express-session'); @@ -105,35 +105,24 @@ app.use(session({ })); app.use(auth({ - authorizationParams: { - response_type: 'code', - audience: process.env.API_URL, - scope: 'openid profile email read:reports' - }, + // Setting this configuration key to false will turn off internal session handling. + appSessionSecret: false, handleCallback: async function (req, res, next) { - req.session.openIdTokens = req.openIdTokens; + // This will store the user identity claims in the session + req.session.userIdentity = req.openIdTokens.claims(); next(); + }, + getUser: async function (req) { + return req.session.userIdentity; } })); ``` -On a route that needs to use the access token, pull the token data from the storage and initialize a new `TokenSet` using `makeTokenSet()` method exposed by this library: - -```js -app.get('/route-that-calls-an-api', async (req, res, next) => { - - const tokenSet = req.openid.makeTokenSet(req.session.openIdTokens); - let apiData = {}; - - // Check for and use tokenSet.access_token for the API call ... -}); -``` - -## 5. Custom user session handling +## 5. Obtaining and storing access tokens to call external APIs -By default, this library uses an encrypted cookie to store the user identity claims used as a session. If the size of the user identity is too large or you're concerned about sensitive data being stored, you can provide your own session handling as part of the `getUser` function. +If your application needs to request and store [access tokens](https://auth0.com/docs/tokens/access-tokens) for external APIs, you must provide a method to store the incoming tokens during callback. We recommend to use a persistant store, like a database or Redis, to store these tokens directly associated with the user for which they were requested. -If, for example, you want the user session to be stored on the server, you can use a session middleware like `express-session`. We recommend persisting the data in a session store other than in-memory (which is the default), otherwise all sessions will be lost when the server restarts. The basics of handling the user identity server-side is below: +If the tokens only need to be used during the user's session, they can be stored using a session middleware like `express-session`. We recommend persisting the data in a session store other than in-memory (which is the default), otherwise all tokens will be lost when the server restarts. The basics of handling the tokens is below: ```js const session = require('express-session'); @@ -146,22 +135,33 @@ app.use(session({ })); app.use(auth({ - // Setting this configuration key to false will turn off internal session handling. - appSessionSecret: false, + authorizationParams: { + response_type: 'code', + audience: process.env.API_URL, + scope: 'openid profile email read:reports' + }, handleCallback: async function (req, res, next) { - // This will store the user identity claims in the session - req.session.userIdentity = req.openIdTokens.claims(); + req.session.openIdTokens = req.openIdTokens; next(); - }, - getUser: async function (req) { - return req.session.userIdentity; } })); ``` -## 6. Using refresh tokens +On a route that needs to use the access token, pull the token data from the storage and initialize a new `TokenSet` using `makeTokenSet()` method exposed by this library: + +```js +app.get('/route-that-calls-an-api', async (req, res, next) => { + + const tokenSet = req.openid.makeTokenSet(req.session.openIdTokens); + let apiData = {}; + + // Check for and use tokenSet.access_token for the API call ... +}); +``` + +## 6. Obtaining and using refresh tokens -Refresh tokens can be requested along with access tokens using the `offline_access` scope during login: +[Refresh tokens](https://auth0.com/docs/tokens/refresh-token/current) can be requested along with access tokens using the `offline_access` scope during login. Please see the section on access tokens above for information on token storage. ```js app.use(auth({ @@ -211,7 +211,7 @@ app.get('/route-that-calls-an-api', async (req, res, next) => { ## 7. Calling userinfo -If your application needs to call the userinfo endpoint for the user's identity, add a `handleCallback` function during initialization that will make this call. To map the incoming claims to the user identity, also add a `getUser` function. +If your application needs to call the userinfo endpoint for the user's identity instead of the ID token used by default, add a `handleCallback` function during initialization that will make this call. To map the incoming claims to the user identity, also add a `getUser` function. ```js app.use(auth({ From b33476c909afc808868dcf06cd81f12c0d919307 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Thu, 2 Jan 2020 21:49:17 -0800 Subject: [PATCH 16/19] Rename appSessionLength to appSessionDuration --- API.md | 2 +- lib/config.js | 2 +- middleware/auth.js | 2 +- test/config.tests.js | 6 +++--- test/invalid_params.tests.js | 4 ++-- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/API.md b/API.md index 533aabce..6e8c33b4 100644 --- a/API.md +++ b/API.md @@ -34,7 +34,7 @@ Additional configuration keys that can be passed to `auth()` on initialization: - **`redirectUriPath`** - Relative path to the application callback to process the response from the authorization server. This value is combined with the `baseUrl` and sent to the authorize endpoint as the `redirectUri` parameter. Default is `/callback`. - **`required`** - Use a boolean value to require authentication for all routes. Pass a function instead to base this value on the request. Default is `true`. - **`routes`** - Boolean value to automatically install the login and logout routes. See [the examples](EXAMPLES.md) for more information on how this key is used. Default is `true`. -- **`appSessionLength`** - Integer value, in seconds, indicating application session length. Set to `0` to indicate the cookie should be ephemeral (no expiration). Default is 7 days. +- **`appSessionDuration`** - Integer value, in seconds, indicating application session length. Set to `0` to indicate the cookie should be ephemeral (no expiration). Default is 7 days. - **`appSessionName`** - String value for the cookie name used for the internal session. This value must only include letters, numbers, and underscores. Default is `identity`. - **`appSessionCookie`** - Object defining application session cookie attributes. Allowed keys are `domain`, `httpOnly`, `path`, `secure`, and `sameSite`. Defaults are `true` for `httpOnly` and `Lax` for `sameSite`. diff --git a/lib/config.js b/lib/config.js index f7295038..271b8cd4 100644 --- a/lib/config.js +++ b/lib/config.js @@ -53,7 +53,7 @@ const paramsSchema = Joi.object().keys({ Joi.boolean().valid([false]) ]).required(), appSessionName: Joi.string().token().optional().default('identity'), - appSessionLength: Joi.number().integer().optional().default(7 * 24 * 60 * 60), + appSessionDuration: Joi.number().integer().optional().default(7 * 24 * 60 * 60), appSessionCookie: Joi.object().optional(), idpLogout: Joi.boolean().optional().default(false) .when('auth0Logout', { is: true, then: Joi.boolean().optional().default(true) }) diff --git a/middleware/auth.js b/middleware/auth.js index ddd225ac..8956077a 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -52,7 +52,7 @@ module.exports = function (params) { router.use(appSession({ name: config.appSessionName, secret: config.appSessionSecret, - duration: config.appSessionLength, + duration: config.appSessionDuration, cookieOptions: config.appSessionCookie })); } diff --git a/test/config.tests.js b/test/config.tests.js index 94ec7d8e..b5e0b7c8 100644 --- a/test/config.tests.js +++ b/test/config.tests.js @@ -154,7 +154,7 @@ describe('config', function() { }); it('should set the session length to 7 days by default', function() { - assert.equal(config.appSessionLength, 604800); + assert.equal(config.appSessionDuration, 604800); }); it('should set the session name to "identity" by default', function() { @@ -174,7 +174,7 @@ describe('config', function() { const config = getConfig({ appSessionSecret: [ '__test_session_secret_1__', '__test_session_secret_2__' ], appSessionName: '__test_custom_session_name__', - appSessionLength: 1234567890, + appSessionDuration: 1234567890, appSessionCookie: { domain: '__test_custom_domain__', path: '__test_custom_path__', @@ -194,7 +194,7 @@ describe('config', function() { }); it('should set the custom session values', function() { - assert.equal(config.appSessionLength, 1234567890); + assert.equal(config.appSessionDuration, 1234567890); assert.equal(config.appSessionName, '__test_custom_session_name__'); }); diff --git a/test/invalid_params.tests.js b/test/invalid_params.tests.js index ac06755a..0261e415 100644 --- a/test/invalid_params.tests.js +++ b/test/invalid_params.tests.js @@ -87,8 +87,8 @@ describe('invalid parameters', function() { it('should fail when app session length is not an integer', function() { assert.throws(() => { - expressOpenid.auth(getTestConfig({appSessionLength: 3.14159})); - }, '"appSessionLength" must be an integer'); + expressOpenid.auth(getTestConfig({appSessionDuration: 3.14159})); + }, '"appSessionDuration" must be an integer'); }); it('should fail when app session secret is invalid', function() { From 29fda776b24f8161f6997b0cbfc361a449d25d33 Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Thu, 2 Jan 2020 22:00:55 -0800 Subject: [PATCH 17/19] Add filter to config for identity claims --- API.md | 1 + lib/config.js | 1 + middleware/auth.js | 4 +--- test/callback_route_form_post.tests.js | 23 +++++++++++++++++++++++ 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/API.md b/API.md index 6e8c33b4..a4392f8d 100644 --- a/API.md +++ b/API.md @@ -28,6 +28,7 @@ Additional configuration keys that can be passed to `auth()` on initialization: - **`errorOnRequiredAuth`** - Boolean value to throw a `Unauthorized 401` error instead of triggering the login process for routes that require authentication. Default is `false`. - **`httpOptions`** - Default options object used for all HTTP calls made by the library ([possible options](https://github.com/sindresorhus/got/tree/v9.6.0#options)). Default is empty. - **`idpLogout`** - Boolean value to log the user out from the identity provider on application logout. Requires the issuer to provide a `end_session_endpoint` value. Default is `false`. +- **`identityClaimFilter`** - Array value of claims to remove from the ID token before storing the cookie session. Default is `['aud', 'iss', 'iat', 'exp', 'nonce', 'azp', 'auth_time']`. - **`legacySameSiteCookie`** - Set a fallback cookie with no SameSite attribute when `authorizationParams.response_mode` is `form_post`. Default is `true`. - **`loginPath`** - Relative path to application login. Default is `/login`. - **`logoutPath`** - Relative path to application logout. Default is `/logout`. diff --git a/lib/config.js b/lib/config.js index 271b8cd4..d91d109d 100644 --- a/lib/config.js +++ b/lib/config.js @@ -44,6 +44,7 @@ const paramsSchema = Joi.object().keys({ loginPath: Joi.string().optional().default('/login'), logoutPath: Joi.string().optional().default('/logout'), legacySameSiteCookie: Joi.boolean().optional().default(true), + identityClaimFilter: Joi.array().optional().default(['aud', 'iss', 'iat', 'exp', 'nonce', 'azp', 'auth_time']), appSessionSecret: Joi.alternatives([ // Single string key. Joi.string(), diff --git a/middleware/auth.js b/middleware/auth.js index 8956077a..72a19047 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -113,9 +113,7 @@ module.exports = function (params) { if (config.appSessionSecret) { let identityClaims = tokenSet.claims(); - - // Remove validation claims to reduce stored size. - ['aud', 'iss', 'iat', 'exp', 'nonce', 'azp', 'auth_time'].forEach(claim => { + config.identityClaimFilter.forEach(claim => { delete identityClaims[claim]; }); diff --git a/test/callback_route_form_post.tests.js b/test/callback_route_form_post.tests.js index 9726cecb..e29f5f02 100644 --- a/test/callback_route_form_post.tests.js +++ b/test/callback_route_form_post.tests.js @@ -284,4 +284,27 @@ describe('callback routes response_type: id_token, response_mode: form_post', fu } })); + describe('uses custom claim filtering', testCase({ + authOpts: { + identityClaimFilter: [] + }, + cookies: { + _state: '__test_state__', + _nonce: '__test_nonce__' + }, + body: { + state: '__test_state__', + id_token: makeIdToken() + }, + assertions() { + it('should have previously-stripped claims', function() { + assert.equal(this.currentUser.iss, 'https://test.auth0.com/'); + assert.equal(this.currentUser.aud, clientID); + assert.equal(this.currentUser.nonce, '__test_nonce__'); + assert.exists(this.currentUser.iat); + assert.exists(this.currentUser.exp); + }); + } + })); + }); From 76f837758bdecf1066752a474b0a5535d5ee1d7f Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Thu, 2 Jan 2020 22:02:27 -0800 Subject: [PATCH 18/19] Keep httpOnly setting if session cooie opts are passed --- lib/config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/config.js b/lib/config.js index d91d109d..c448d296 100644 --- a/lib/config.js +++ b/lib/config.js @@ -89,7 +89,8 @@ If the user provides authorizationParams then function buildAppSessionCookieConfig(cookieConfig) { - cookieConfig = cookieConfig && Object.keys(cookieConfig).length > 0 ? cookieConfig : { httpOnly: true }; + cookieConfig = cookieConfig && Object.keys(cookieConfig).length ? cookieConfig : {}; + cookieConfig = Object.assign({ httpOnly: true }, cookieConfig); const cookieConfigValidation = Joi.validate(cookieConfig, appSessionCookieSchema); if(cookieConfigValidation.error) { From 370cf780aaab457b39ea43d719fb1a9964978c0f Mon Sep 17 00:00:00 2001 From: Josh Cunningham Date: Fri, 3 Jan 2020 07:27:57 -0800 Subject: [PATCH 19/19] Update example for userinfo --- EXAMPLES.md | 3 ++- lib/hooks/getUser.js | 3 +-- middleware/auth.js | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/EXAMPLES.md b/EXAMPLES.md index 1732d262..8108b9d3 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -211,12 +211,13 @@ app.get('/route-that-calls-an-api', async (req, res, next) => { ## 7. Calling userinfo -If your application needs to call the userinfo endpoint for the user's identity instead of the ID token used by default, add a `handleCallback` function during initialization that will make this call. To map the incoming claims to the user identity, also add a `getUser` function. +If your application needs to call the userinfo endpoint for the user's identity instead of the ID token used by default, add a `handleCallback` function during initialization that will make this call. Save the claims retrieved from the userinfo endpoint to the `appSessionName` on the request object (default is `identity`): ```js app.use(auth({ handleCallback: async function (req, res, next) { const client = req.openid.client; + req.identity = req.identity || {}; try { req.identity.claims = await client.userinfo(req.openidTokens); next(); diff --git a/lib/hooks/getUser.js b/lib/hooks/getUser.js index 26beb6d3..6cb80a7d 100644 --- a/lib/hooks/getUser.js +++ b/lib/hooks/getUser.js @@ -4,8 +4,7 @@ */ module.exports = function(req, config) { - // If there is no appSessionSecret, session handing is custom. - if (!config.appSessionSecret || !req[config.appSessionName] || !req[config.appSessionName].claims) { + if (!req[config.appSessionName] || !req[config.appSessionName].claims) { return null; } diff --git a/middleware/auth.js b/middleware/auth.js index 72a19047..8488a094 100644 --- a/middleware/auth.js +++ b/middleware/auth.js @@ -109,7 +109,7 @@ module.exports = function (params) { throw createError.BadRequest(err.message); } - req.openIdTokens = tokenSet; + req.openidTokens = tokenSet; if (config.appSessionSecret) { let identityClaims = tokenSet.claims();