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 + <% } %> diff --git a/frontend/js/app/settings/oidc-config/main.ejs b/frontend/js/app/settings/oidc-config/main.ejs new file mode 100644 index 000000000..15eb3981b --- /dev/null +++ b/frontend/js/app/settings/oidc-config/main.ejs @@ -0,0 +1,56 @@ + diff --git a/frontend/js/app/settings/oidc-config/main.js b/frontend/js/app/settings/oidc-config/main.js new file mode 100644 index 000000000..b4eb6d1c8 --- /dev/null +++ b/frontend/js/app/settings/oidc-config/main.js @@ -0,0 +1,46 @@ +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./main.ejs'); + +require('jquery-serializejson'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog wide', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + }, + + events: { + 'click @ui.save': function (e) { + e.preventDefault(); + + if (!this.ui.form[0].checkValidity()) { + $('').hide().appendTo(this.ui.form).click().remove(); + return; + } + + let view = this; + let data = this.ui.form.serializeJSON(); + data.id = this.model.get('id'); + if (data.meta.enabled) { + data.meta.enabled = data.meta.enabled === 'on' || data.meta.enabled === 'true'; + } + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + App.Api.Settings.update(data) + .then((result) => { + view.model.set(result); + App.UI.closeModal(); + }) + .catch((err) => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index aa544c7e0..29c4d6440 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -5,6 +5,7 @@ "username": "Username", "password": "Password", "sign-in": "Sign in", + "sign-in-with": "Sign in with", "sign-out": "Sign out", "try-again": "Try again", "name": "Name", @@ -288,7 +289,10 @@ "default-site-congratulations": "Congratulations Page", "default-site-404": "404 Page", "default-site-html": "Custom Page", - "default-site-redirect": "Redirect" + "default-site-redirect": "Redirect", + "oidc-config": "Open ID Conncect Configuration", + "oidc-config-hint-1": "Provide configuration for an IdP that supports Open ID Connect Discovery.", + "oidc-config-hint-2": "The 'RedirectURL' must be set to '[base URL]/api/oidc/callback', the IdP must send the 'email' claim and a user with matching email address must exist in Nginx Proxy Manager." } } } diff --git a/frontend/js/login/ui/login.ejs b/frontend/js/login/ui/login.ejs index 693bc050c..84aa90a02 100644 --- a/frontend/js/login/ui/login.ejs +++ b/frontend/js/login/ui/login.ejs @@ -5,7 +5,7 @@
-
+
Logo
@@ -27,6 +27,13 @@ +
diff --git a/frontend/js/login/ui/login.js b/frontend/js/login/ui/login.js index 757eb4e31..0c1c25c61 100644 --- a/frontend/js/login/ui/login.js +++ b/frontend/js/login/ui/login.js @@ -3,17 +3,22 @@ const Mn = require('backbone.marionette'); const template = require('./login.ejs'); const Api = require('../../app/api'); const i18n = require('../../app/i18n'); +const Tokens = require('../../app/tokens'); module.exports = Mn.View.extend({ template: template, className: 'page-single', ui: { - form: 'form', - identity: 'input[name="identity"]', - secret: 'input[name="secret"]', - error: '.secret-error', - button: 'button' + form: 'form', + identity: 'input[name="identity"]', + secret: 'input[name="secret"]', + error: '.secret-error', + button: 'button[type=submit]', + oidcLogin: 'div.login-oidc', + oidcButton: 'button#login-oidc', + oidcError: '.oidc-error', + oidcProvider: 'span.oidc-provider' }, events: { @@ -26,10 +31,56 @@ module.exports = Mn.View.extend({ .then(() => { window.location = '/'; }) - .catch(err => { + .catch((err) => { this.ui.error.text(err.message).show(); this.ui.button.removeClass('btn-loading').prop('disabled', false); }); + }, + 'click @ui.oidcButton': function() { + this.ui.identity.prop('disabled', true); + this.ui.secret.prop('disabled', true); + this.ui.button.prop('disabled', true); + this.ui.oidcButton.addClass('btn-loading').prop('disabled', true); + // redirect to initiate oauth flow + document.location.replace('/api/oidc/'); + }, + }, + + async onRender() { + // read oauth callback state cookies + let cookies = document.cookie.split(';'), + token, expiry, error; + for (cookie of cookies) { + let raw = cookie.split('='), + name = raw[0].trim(), + value = raw[1]; + if (name === 'npm_oidc') { + let v = value.split('---'); + token = v[0]; + expiry = v[1]; + } + if (name === 'npm_oidc_error') { + error = decodeURIComponent(value); + } + } + + // register a newly acquired jwt token following successful oidc authentication + if (token && expiry && (new Date(Date.parse(decodeURIComponent(expiry)))) > new Date() ) { + Tokens.addToken(token); + document.location.replace('/'); + } + + // show error message following a failed oidc authentication + if (error) { + this.ui.oidcError.html(error); + } + + // fetch oidc configuration and show alternative action button if enabled + let response = await Api.Settings.getById('oidc-config'); + if (response && response.meta && response.meta.enabled === true) { + this.ui.oidcProvider.html(response.meta.name); + this.ui.oidcLogin.show(); + this.ui.oidcError.show(); } }, diff --git a/frontend/scss/custom.scss b/frontend/scss/custom.scss index 4037dcf6c..30abfb8b3 100644 --- a/frontend/scss/custom.scss +++ b/frontend/scss/custom.scss @@ -39,4 +39,34 @@ a:hover { .col-login { max-width: 48rem; +} + +.margin-auto { + margin: auto; +} + +.separator { + display: flex; + align-items: center; + text-align: center; + margin-bottom: 1em; +} + +.separator::before, .separator::after { + content: ""; + flex: 1 1 0%; + border-bottom: 1px solid #ccc; +} + +.separator:not(:empty)::before { + margin-right: 0.5em; +} + +.separator:not(:empty)::after { + margin-left: 0.5em; +} + +.login-oidc { + display: none; + margin-top: 1em; } \ No newline at end of file