diff --git a/backend/internal/token.js b/backend/internal/token.js index a64b90105..27da42b44 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -82,6 +82,47 @@ module.exports = { }); }, + /** + * @param {Object} data + * @param {String} data.identity + * @param {String} [issuer] + * @returns {Promise} + */ + getTokenFromOAuthClaim: (data) => { + let Token = new TokenModel(); + + data.scope = 'user'; + data.expiry = '1d'; + + return userModel + .query() + .where('email', data.identity) + .andWhere('is_deleted', 0) + .andWhere('is_disabled', 0) + .first() + .then((user) => { + if (!user) { + throw new error.AuthError('No relevant user found'); + } + + // Create a moment of the expiry expression + let expiry = helpers.parseDatePeriod(data.expiry); + if (expiry === null) { + throw new error.AuthError('Invalid expiry time: ' + data.expiry); + } + + let iss = 'api', + attrs = { id: user.id }, + scope = [ data.scope ], + expiresIn = data.expiry; + + return Token.create({ iss, attrs, scope, expiresIn }) + .then((signed) => { + return { token: signed.token, expires: expiry.toISOString() }; + }); + }); + }, + /** * @param {Access} access * @param {Object} [data] diff --git a/backend/lib/express/jwt-decode.js b/backend/lib/express/jwt-decode.js index 17edccec0..745763a74 100644 --- a/backend/lib/express/jwt-decode.js +++ b/backend/lib/express/jwt-decode.js @@ -4,7 +4,9 @@ module.exports = () => { return function (req, res, next) { res.locals.access = null; let access = new Access(res.locals.token || null); - access.load() + // allow unauthenticated access to OIDC configuration + let anon_access = req.url === '/oidc-config' && !access.token.getUserId(); + access.load(anon_access) .then(() => { res.locals.access = access; next(); diff --git a/backend/logger.js b/backend/logger.js index 680af6d51..3ece76fdc 100644 --- a/backend/logger.js +++ b/backend/logger.js @@ -9,5 +9,6 @@ module.exports = { ssl: new Signale({scope: 'SSL '}), import: new Signale({scope: 'Importer '}), setup: new Signale({scope: 'Setup '}), - ip_ranges: new Signale({scope: 'IP Ranges'}) + ip_ranges: new Signale({scope: 'IP Ranges'}), + oidc: new Signale({scope: 'OIDC '}) }; diff --git a/backend/package.json b/backend/package.json index bc682106b..f90c2640d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "node-rsa": "^1.0.8", "nodemon": "^2.0.2", "objection": "^2.2.16", + "openid-client": "^5.4.0", "path": "^0.12.7", "signale": "^1.4.0", "sqlite3": "^4.1.1", diff --git a/backend/routes/api/main.js b/backend/routes/api/main.js index 33cbbc21f..546cc7275 100644 --- a/backend/routes/api/main.js +++ b/backend/routes/api/main.js @@ -27,6 +27,7 @@ router.get('/', (req, res/*, next*/) => { router.use('/schema', require('./schema')); router.use('/tokens', require('./tokens')); +router.use('/oidc', require('./oidc')); router.use('/users', require('./users')); router.use('/audit-log', require('./audit-log')); router.use('/reports', require('./reports')); diff --git a/backend/routes/api/oidc.js b/backend/routes/api/oidc.js new file mode 100644 index 000000000..9c8030f9d --- /dev/null +++ b/backend/routes/api/oidc.js @@ -0,0 +1,168 @@ +const crypto = require('crypto'); +const error = require('../../lib/error'); +const express = require('express'); +const jwtdecode = require('../../lib/express/jwt-decode'); +const logger = require('../../logger').oidc; +const oidc = require('openid-client'); +const settingModel = require('../../models/setting'); +const internalToken = require('../../internal/token'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/oidc + * + * OAuth Authorization Code flow initialisation + */ + .get(jwtdecode(), async (req, res) => { + logger.info('Initializing OAuth flow'); + settingModel + .query() + .where({id: 'oidc-config'}) + .first() + .then((row) => getInitParams(req, row)) + .then((params) => redirectToAuthorizationURL(res, params)) + .catch((err) => redirectWithError(res, err)); + }); + + +router + .route('/callback') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/oidc/callback + * + * Oauth Authorization Code flow callback + */ + .get(jwtdecode(), async (req, res) => { + logger.info('Processing callback'); + settingModel + .query() + .where({id: 'oidc-config'}) + .first() + .then((settings) => validateCallback(req, settings)) + .then((token) => redirectWithJwtToken(res, token)) + .catch((err) => redirectWithError(res, err)); + }); + +/** + * Executes discovery and returns the configured `openid-client` client + * + * @param {Setting} row + * */ +let getClient = async (row) => { + let issuer; + try { + issuer = await oidc.Issuer.discover(row.meta.issuerURL); + } catch (err) { + throw new error.AuthError(`Discovery failed for the specified URL with message: ${err.message}`); + } + + return new issuer.Client({ + client_id: row.meta.clientID, + client_secret: row.meta.clientSecret, + redirect_uris: [row.meta.redirectURL], + response_types: ['code'], + }); +}; + +/** + * Generates state, nonce and authorization url. + * + * @param {Request} req + * @param {Setting} row + * @return { {String}, {String}, {String} } state, nonce and url + * */ +let getInitParams = async (req, row) => { + let client = await getClient(row), + state = crypto.randomUUID(), + nonce = crypto.randomUUID(), + url = client.authorizationUrl({ + scope: 'openid email profile', + resource: `${req.protocol}://${req.get('host')}${req.originalUrl}`, + state, + nonce, + }); + + return { state, nonce, url }; +}; + +/** + * Parses state and nonce from cookie during the callback phase. + * + * @param {Request} req + * @return { {String}, {String} } state and nonce + * */ +let parseStateFromCookie = (req) => { + let state, nonce; + let cookies = req.headers.cookie.split(';'); + for (let cookie of cookies) { + if (cookie.split('=')[0].trim() === 'npm_oidc') { + let raw = cookie.split('=')[1], + val = raw.split('--'); + state = val[0].trim(); + nonce = val[1].trim(); + break; + } + } + + return { state, nonce }; +}; + +/** + * Executes validation of callback parameters. + * + * @param {Request} req + * @param {Setting} settings + * @return {Promise} a promise resolving to a jwt token + * */ +let validateCallback = async (req, settings) => { + let client = await getClient(settings); + let { state, nonce } = parseStateFromCookie(req); + + const params = client.callbackParams(req); + const tokenSet = await client.callback(settings.meta.redirectURL, params, { state, nonce }); + let claims = tokenSet.claims(); + + if (!claims.email) { + throw new error.AuthError('The Identity Provider didn\'t send the \'email\' claim'); + } else { + logger.info('Successful authentication for email ' + claims.email); + } + + return internalToken.getTokenFromOAuthClaim({ identity: claims.email }); +}; + +let redirectToAuthorizationURL = (res, params) => { + logger.info('Authorization URL: ' + params.url); + res.cookie('npm_oidc', params.state + '--' + params.nonce); + res.redirect(params.url); +}; + +let redirectWithJwtToken = (res, token) => { + res.cookie('npm_oidc', token.token + '---' + token.expires); + res.redirect('/login'); +}; + +let redirectWithError = (res, error) => { + logger.error('Callback error: ' + error.message); + res.cookie('npm_oidc_error', error.message); + res.redirect('/login'); +}; + +module.exports = router; diff --git a/backend/routes/api/settings.js b/backend/routes/api/settings.js index d08b2bf5c..f04f3d7f7 100644 --- a/backend/routes/api/settings.js +++ b/backend/routes/api/settings.js @@ -69,6 +69,17 @@ router }); }) .then((row) => { + if (row.id === 'oidc-config') { + // redact oidc configuration via api + let m = row.meta; + row.meta = { + name: m.name, + enabled: m.enabled === true && !!(m.clientID && m.clientSecret && m.issuerURL && m.redirectURL && m.name) + }; + // remove these temporary cookies used during oidc authentication + res.clearCookie('npm_oidc'); + res.clearCookie('npm_oidc_error'); + } res.status(200) .send(row); }) diff --git a/backend/routes/api/tokens.js b/backend/routes/api/tokens.js index a21f998ae..29bfbbafe 100644 --- a/backend/routes/api/tokens.js +++ b/backend/routes/api/tokens.js @@ -28,6 +28,8 @@ router scope: (typeof req.query.scope !== 'undefined' ? req.query.scope : null) }) .then((data) => { + // clear this temporary cookie following a successful oidc authentication + res.clearCookie('npm_oidc'); res.status(200) .send(data); }) diff --git a/backend/setup.js b/backend/setup.js index 47fd1e7b0..68483525f 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -131,7 +131,7 @@ const setupDefaultUser = () => { * @returns {Promise} */ const setupDefaultSettings = () => { - return settingModel + return Promise.all([settingModel .query() .select(settingModel.raw('COUNT(`id`) as `count`')) .where({id: 'default-site'}) @@ -148,13 +148,37 @@ const setupDefaultSettings = () => { meta: {}, }) .then(() => { - logger.info('Default settings added'); + logger.info('Added default-site setting'); }); } if (debug_mode) { logger.debug('Default setting setup not required'); } - }); + }), + settingModel + .query() + .select(settingModel.raw('COUNT(`id`) as `count`')) + .where({id: 'oidc-config'}) + .first() + .then((row) => { + if (!row.count) { + settingModel + .query() + .insert({ + id: 'oidc-config', + name: 'Open ID Connect', + description: 'Sign in to Nginx Proxy Manager with an external Identity Provider', + value: 'metadata', + meta: {}, + }) + .then(() => { + logger.info('Added oidc-config setting'); + }); + } + if (debug_mode) { + logger.debug('Default setting setup not required'); + } + })]); }; /** diff --git a/backend/yarn.lock b/backend/yarn.lock index 396e11c98..e7deee42f 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1874,6 +1874,11 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= +jose@^4.10.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.12.0.tgz#7f00cd2f82499b91623cd413b7b5287fd52651ed" + integrity sha512-wW1u3cK81b+SFcHjGC8zw87yuyUweEFe0UJirrXEw1NasW00eF7sZjeG3SLBGz001ozxQ46Y9sofDvhBmWFtXQ== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -2142,6 +2147,13 @@ lowercase-keys@^2.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + make-dir@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -2487,6 +2499,11 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" +object-hash@^2.0.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" + integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" @@ -2527,6 +2544,11 @@ objection@^2.2.16: ajv "^6.12.6" db-errors "^0.2.3" +oidc-token-hash@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz#ae6beec3ec20f0fd885e5400d175191d6e2f10c6" + integrity sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ== + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" @@ -2553,6 +2575,16 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" +openid-client@^5.4.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.4.0.tgz#77f1cda14e2911446f16ea3f455fc7c405103eac" + integrity sha512-hgJa2aQKcM2hn3eyVtN12tEA45ECjTJPXCgUh5YzTzy9qwapCvmDTVPWOcWVL0d34zeQoQ/hbG9lJhl3AYxJlQ== + dependencies: + jose "^4.10.0" + lru-cache "^6.0.0" + object-hash "^2.0.1" + oidc-token-hash "^5.0.1" + optionator@^0.8.3: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -3719,6 +3751,11 @@ yallist@^3.0.0, yallist@^3.1.1: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" diff --git a/frontend/js/app/api.js b/frontend/js/app/api.js index 6e33a6dca..207cb548a 100644 --- a/frontend/js/app/api.js +++ b/frontend/js/app/api.js @@ -59,6 +59,8 @@ function fetch(verb, path, data, options) { }, beforeSend: function (xhr) { + // allow unauthenticated access to OIDC configuration + if (path === 'settings/oidc-config') return; xhr.setRequestHeader('Authorization', 'Bearer ' + (token ? token.t : null)); }, diff --git a/frontend/js/app/controller.js b/frontend/js/app/controller.js index ccb2978a8..a2c112b38 100644 --- a/frontend/js/app/controller.js +++ b/frontend/js/app/controller.js @@ -434,6 +434,11 @@ module.exports = { App.UI.showModalDialog(new View({model: model})); }); } + if (model.get('id') === 'oidc-config') { + require(['./main', './settings/oidc-config/main'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } } }, diff --git a/frontend/js/app/settings/list/item.ejs b/frontend/js/app/settings/list/item.ejs index 4f81b4509..21eae7edb 100644 --- a/frontend/js/app/settings/list/item.ejs +++ b/frontend/js/app/settings/list/item.ejs @@ -9,6 +9,14 @@ <% if (id === 'default-site') { %> <%- i18n('settings', 'default-site-' + value) %> <% } %> + <% if (id === 'oidc-config' && meta && meta.name && meta.clientID && meta.clientSecret && meta.issuerURL && meta.redirectURL) { %> + <%- meta.name %> + <% if (!meta.enabled) { %> + (Disabled) + <% } %> + <% } else if (id === 'oidc-config') { %> + Not configured + <% } %>