From caeb2934f0dff0e6b7d73b9bbeddb74a2f31116d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=9CL=C3=96P?= Date: Fri, 24 Feb 2023 08:39:21 +0000 Subject: [PATCH 01/25] FEAT: Add Open ID Connect authentication method * add `oidc-config` setting allowing an admin user to configure parameters * modify login page to show another button when oidc is configured * add dependency `openid-client` `v5.4.0` * add backend route to process "OAuth2 Authorization Code" flow initialisation * add backend route to process callback of above flow * sign in the authenticated user with internal jwt token if internal user with email matching the one retrieved from oauth claims exists Note: Only Open ID Connect Discovery is supported which most modern Identity Providers offer. Tested with Authentik 2023.2.2 and Keycloak 18.0.2 --- backend/internal/token.js | 46 ++++++ backend/lib/express/jwt-decode.js | 4 +- backend/package.json | 1 + backend/routes/api/main.js | 1 + backend/routes/api/oidc.js | 132 ++++++++++++++++++ backend/routes/api/settings.js | 11 ++ backend/routes/api/tokens.js | 2 + backend/yarn.lock | 37 +++++ frontend/js/app/api.js | 2 + frontend/js/app/controller.js | 5 + frontend/js/app/settings/list/item.ejs | 8 ++ frontend/js/app/settings/oidc-config/main.ejs | 47 +++++++ frontend/js/app/settings/oidc-config/main.js | 47 +++++++ frontend/js/i18n/messages.json | 1 + frontend/js/login/ui/login.ejs | 9 +- frontend/js/login/ui/login.js | 65 ++++++++- frontend/scss/custom.scss | 30 ++++ 17 files changed, 441 insertions(+), 7 deletions(-) create mode 100644 backend/routes/api/oidc.js create mode 100644 frontend/js/app/settings/oidc-config/main.ejs create mode 100644 frontend/js/app/settings/oidc-config/main.js diff --git a/backend/internal/token.js b/backend/internal/token.js index a64b9010..8e04341d 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -82,6 +82,52 @@ module.exports = { }); }, + /** + * @param {Object} data + * @param {String} data.identity + * @param {String} [issuer] + * @returns {Promise} + */ + getTokenFromOAuthClaim: (data, issuer) => { + 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 17edccec..745763a7 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/package.json b/backend/package.json index bc682106..f90c2640 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 33cbbc21..2f3ec6d7 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 00000000..e60949b3 --- /dev/null +++ b/backend/routes/api/oidc.js @@ -0,0 +1,132 @@ +const crypto = require('crypto'); +const express = require('express'); +const jwtdecode = require('../../lib/express/jwt-decode'); +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 +}); + +/** + * OAuth Authorization Code flow initialisation + * + * /api/oidc + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/users + * + * Retrieve all users + */ + .get(jwtdecode(), async (req, res, next) => { + console.log("oidc init >>>", res.locals.access, oidc); + + settingModel + .query() + .where({id: 'oidc-config'}) + .first() + .then( async row => { + console.log('oidc init > config > ', row); + + let issuer = await oidc.Issuer.discover(row.meta.issuerURL); + let client = new issuer.Client({ + client_id: row.meta.clientID, + client_secret: row.meta.clientSecret, + redirect_uris: [row.meta.redirectURL], + response_types: ['code'], + }) + let state = crypto.randomUUID(); + let nonce = crypto.randomUUID(); + let url = client.authorizationUrl({ + scope: 'openid email profile', + resource: 'http://rye.local:2081/api/oidc/callback', + state, + nonce, + }) + + console.log('oidc init > url > ', state, nonce, url); + + res.cookie("npm_oidc", state + '--' + nonce); + res.redirect(url); + }); + }); + + +/** + * Oauth Authorization Code flow callback + * + * /api/oidc/callback + */ +router + .route('/callback') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /users/123 or /users/me + * + * Retrieve a specific user + */ + .get(jwtdecode(), async (req, res, next) => { + console.log("oidc callback >>>"); + + settingModel + .query() + .where({id: 'oidc-config'}) + .first() + .then( async row => { + console.log('oidc callback > config > ', row); + + let issuer = await oidc.Issuer.discover(row.meta.issuerURL); + let client = new issuer.Client({ + client_id: row.meta.clientID, + client_secret: row.meta.clientSecret, + redirect_uris: [row.meta.redirectURL], + response_types: ['code'], + }); + + let state, nonce; + let cookies = req.headers.cookie.split(';'); + for (cookie of cookies) { + if (cookie.split('=')[0].trim() === 'npm_oidc') { + let raw = cookie.split('=')[1]; + let val = raw.split('--'); + state = val[0].trim(); + nonce = val[1].trim(); + break; + } + } + + const params = client.callbackParams(req); + const tokenSet = await client.callback(row.meta.redirectURL, params, { /*code_verifier: verifier,*/ state, nonce }); + let claims = tokenSet.claims(); + console.log('validated ID Token claims %j', claims); + + return internalToken.getTokenFromOAuthClaim({ identity: claims.email }) + + }) + .then( response => { + console.log('oidc callback > signed token > >', response); + res.cookie('npm_oidc', response.token + '---' + response.expires); + res.redirect('/login'); + }) + .catch( err => { + console.log('oidc callback ERR > ', err); + res.cookie('npm_oidc_error', err.message); + res.redirect('/login'); + }); + }); + +module.exports = router; diff --git a/backend/routes/api/settings.js b/backend/routes/api/settings.js index d08b2bf5..edb9edd8 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 a21f998a..29bfbbaf 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/yarn.lock b/backend/yarn.lock index 396e11c9..e7deee42 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 6e33a6dc..b314b40b 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 ccb2978a..a2c112b3 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 4f81b450..21eae7ed 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 00000000..6bd7c342 --- /dev/null +++ b/frontend/js/app/settings/oidc-config/main.ejs @@ -0,0 +1,47 @@ + 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 00000000..8fffcd69 --- /dev/null +++ b/frontend/js/app/settings/oidc-config/main.js @@ -0,0 +1,47 @@ +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./main.ejs'); + +require('jquery-serializejson'); +require('selectize'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + 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 aa544c7e..c6f90941 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", diff --git a/frontend/js/login/ui/login.ejs b/frontend/js/login/ui/login.ejs index 693bc050..84aa90a0 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 757eb4e3..50064f24 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: { @@ -30,6 +35,56 @@ module.exports = Mn.View.extend({ this.ui.error.text(err.message).show(); this.ui.button.removeClass('btn-loading').prop('disabled', false); }); + }, + 'click @ui.oidcButton': function(e) { + 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') { + console.log(' ERROR 000 > ', value); + error = decodeURIComponent(value); + } + } + + console.log('login.js event > render', expiry, token); + // register a newly acquired jwt token following successful oidc authentication + if (token && expiry && (new Date(Date.parse(decodeURIComponent(expiry)))) > new Date() ) { + console.log('login.js event > render >>>'); + Tokens.addToken(token); + document.location.replace('/'); + } + + // show error message following a failed oidc authentication + if (error) { + console.log(' ERROR > ', 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 4037dcf6..30abfb8b 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 From 3e2a411dfbd53ca4be6c61a96fb0d322eff92cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=9CL=C3=96P?= Date: Fri, 24 Feb 2023 15:05:57 +0000 Subject: [PATCH 02/25] chore: add oidc setting db entry during setup --- backend/setup.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/backend/setup.js b/backend/setup.js index 47fd1e7b..fff6cfaa 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -150,6 +150,18 @@ const setupDefaultSettings = () => { .then(() => { logger.info('Default settings added'); }); + 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('Default settings added'); + }); } if (debug_mode) { logger.debug('Default setting setup not required'); From 457d1a75ba6124ec499a49433f3232f4ceeb1536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=9CL=C3=96P?= Date: Fri, 24 Feb 2023 15:11:08 +0000 Subject: [PATCH 03/25] chore: improve oidc setting ui --- frontend/js/app/settings/oidc-config/main.ejs | 29 ++++++++++++------- frontend/js/app/settings/oidc-config/main.js | 2 +- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/frontend/js/app/settings/oidc-config/main.ejs b/frontend/js/app/settings/oidc-config/main.ejs index 6bd7c342..1395a920 100644 --- a/frontend/js/app/settings/oidc-config/main.ejs +++ b/frontend/js/app/settings/oidc-config/main.ejs @@ -9,26 +9,35 @@
<%- description %>
+
+

<%- i18n('settings', 'oidc-config-hint-1') %>

+

<%- i18n('settings', 'oidc-config-hint-2') %>

+
-
Name
- +
-
Client ID
- +
-
Client Secret
- +
-
Issuer URL
- +
-
Redirect URL
- +
Enabled
diff --git a/frontend/js/app/settings/oidc-config/main.js b/frontend/js/app/settings/oidc-config/main.js index 8fffcd69..b4499785 100644 --- a/frontend/js/app/settings/oidc-config/main.js +++ b/frontend/js/app/settings/oidc-config/main.js @@ -7,7 +7,7 @@ require('selectize'); module.exports = Mn.View.extend({ template: template, - className: 'modal-dialog', + className: 'modal-dialog wide', ui: { form: 'form', From 8350271e6f83dd7c581d5d935b058df7be9828fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=9CL=C3=96P?= Date: Fri, 24 Feb 2023 15:12:03 +0000 Subject: [PATCH 04/25] chore: add message texts --- frontend/js/i18n/messages.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index c6f90941..29c4d644 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -289,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." } } } From bc0b466a8e79911022ce38937e0dc832154d5b47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=BCl=C3=B6p?= Date: Fri, 24 Feb 2023 16:30:45 +0000 Subject: [PATCH 05/25] refactor: improve code structure --- backend/routes/api/oidc.js | 171 ++++++++++++++++++++++--------------- 1 file changed, 102 insertions(+), 69 deletions(-) diff --git a/backend/routes/api/oidc.js b/backend/routes/api/oidc.js index e60949b3..e3da2caa 100644 --- a/backend/routes/api/oidc.js +++ b/backend/routes/api/oidc.js @@ -29,36 +29,13 @@ router * Retrieve all users */ .get(jwtdecode(), async (req, res, next) => { - console.log("oidc init >>>", res.locals.access, oidc); - + console.log("oidc: init flow"); settingModel .query() .where({id: 'oidc-config'}) .first() - .then( async row => { - console.log('oidc init > config > ', row); - - let issuer = await oidc.Issuer.discover(row.meta.issuerURL); - let client = new issuer.Client({ - client_id: row.meta.clientID, - client_secret: row.meta.clientSecret, - redirect_uris: [row.meta.redirectURL], - response_types: ['code'], - }) - let state = crypto.randomUUID(); - let nonce = crypto.randomUUID(); - let url = client.authorizationUrl({ - scope: 'openid email profile', - resource: 'http://rye.local:2081/api/oidc/callback', - state, - nonce, - }) - - console.log('oidc init > url > ', state, nonce, url); - - res.cookie("npm_oidc", state + '--' + nonce); - res.redirect(url); - }); + .then( row => getInitParams(req, row)) + .then( params => redirectToAuthorizationURL(res, params)); }); @@ -80,53 +57,109 @@ router * Retrieve a specific user */ .get(jwtdecode(), async (req, res, next) => { - console.log("oidc callback >>>"); - + console.log("oidc: callback"); settingModel .query() .where({id: 'oidc-config'}) .first() - .then( async row => { - console.log('oidc callback > config > ', row); - - let issuer = await oidc.Issuer.discover(row.meta.issuerURL); - let client = new issuer.Client({ - client_id: row.meta.clientID, - client_secret: row.meta.clientSecret, - redirect_uris: [row.meta.redirectURL], - response_types: ['code'], - }); - - let state, nonce; - let cookies = req.headers.cookie.split(';'); - for (cookie of cookies) { - if (cookie.split('=')[0].trim() === 'npm_oidc') { - let raw = cookie.split('=')[1]; - let val = raw.split('--'); - state = val[0].trim(); - nonce = val[1].trim(); - break; - } - } - - const params = client.callbackParams(req); - const tokenSet = await client.callback(row.meta.redirectURL, params, { /*code_verifier: verifier,*/ state, nonce }); - let claims = tokenSet.claims(); - console.log('validated ID Token claims %j', claims); - - return internalToken.getTokenFromOAuthClaim({ identity: claims.email }) - - }) - .then( response => { - console.log('oidc callback > signed token > >', response); - res.cookie('npm_oidc', response.token + '---' + response.expires); - res.redirect('/login'); - }) - .catch( err => { - console.log('oidc callback ERR > ', err); - res.cookie('npm_oidc_error', err.message); - res.redirect('/login'); - }); + .then( settings => validateCallback(req, settings)) + .then( token => redirectWithJwtToken(res, token)) + .catch( err => redirectWithError(res, err)); }); +/** + * Executed discovery and returns the configured `openid-client` client + * + * @param {Setting} row + * */ +let getClient = async row => { + let issuer = await oidc.Issuer.discover(row.meta.issuerURL); + + 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); + let state = crypto.randomUUID(); + let nonce = crypto.randomUUID(); + let 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 (cookie of cookies) { + if (cookie.split('=')[0].trim() === 'npm_oidc') { + let raw = cookie.split('=')[1]; + let 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(); + console.log('oidc: authentication successful for email', claims.email); + + return internalToken.getTokenFromOAuthClaim({ identity: claims.email }) +} + +let redirectToAuthorizationURL = (res, params) => { + console.log('oidc: init flow > 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) => { + console.log('oidc: callback error: ', error); + res.cookie('npm_oidc_error', error.message); + res.redirect('/login'); +} + module.exports = router; From baee4641db474ce15ba9d5a64d9265193ea708c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=BCl=C3=B6p?= Date: Fri, 24 Feb 2023 18:54:38 +0000 Subject: [PATCH 06/25] chore: improve error handling --- backend/routes/api/oidc.js | 11 +++++++++-- frontend/js/app/settings/oidc-config/main.ejs | 2 +- frontend/js/app/settings/oidc-config/main.js | 1 - 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/backend/routes/api/oidc.js b/backend/routes/api/oidc.js index e3da2caa..b02c503f 100644 --- a/backend/routes/api/oidc.js +++ b/backend/routes/api/oidc.js @@ -1,4 +1,5 @@ const crypto = require('crypto'); +const error = require('../../lib/error'); const express = require('express'); const jwtdecode = require('../../lib/express/jwt-decode'); const oidc = require('openid-client'); @@ -35,7 +36,8 @@ router .where({id: 'oidc-config'}) .first() .then( row => getInitParams(req, row)) - .then( params => redirectToAuthorizationURL(res, params)); + .then( params => redirectToAuthorizationURL(res, params)) + .catch( err => redirectWithError(res, err)); }); @@ -73,7 +75,12 @@ router * @param {Setting} row * */ let getClient = async row => { - let issuer = await oidc.Issuer.discover(row.meta.issuerURL); + 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, diff --git a/frontend/js/app/settings/oidc-config/main.ejs b/frontend/js/app/settings/oidc-config/main.ejs index 1395a920..15eb3981 100644 --- a/frontend/js/app/settings/oidc-config/main.ejs +++ b/frontend/js/app/settings/oidc-config/main.ejs @@ -41,7 +41,7 @@
Enabled
- > + >
diff --git a/frontend/js/app/settings/oidc-config/main.js b/frontend/js/app/settings/oidc-config/main.js index b4499785..34b16b57 100644 --- a/frontend/js/app/settings/oidc-config/main.js +++ b/frontend/js/app/settings/oidc-config/main.js @@ -3,7 +3,6 @@ const App = require('../../main'); const template = require('./main.ejs'); require('jquery-serializejson'); -require('selectize'); module.exports = Mn.View.extend({ template: template, From 6f98fa61e4991ed7b8bdbf177bea87c97c9238e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=BCl=C3=B6p?= Date: Fri, 24 Feb 2023 21:09:21 +0000 Subject: [PATCH 07/25] refactor: satisfy linter requirements --- backend/internal/token.js | 45 +++++----- backend/routes/api/main.js | 2 +- backend/routes/api/oidc.js | 88 ++++++++++---------- backend/routes/api/settings.js | 8 +- frontend/js/app/api.js | 2 +- frontend/js/app/settings/oidc-config/main.js | 14 ++-- frontend/js/login/ui/login.js | 14 ++-- 7 files changed, 84 insertions(+), 89 deletions(-) diff --git a/backend/internal/token.js b/backend/internal/token.js index 8e04341d..27da42b4 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -88,7 +88,7 @@ module.exports = { * @param {String} [issuer] * @returns {Promise} */ - getTokenFromOAuthClaim: (data, issuer) => { + getTokenFromOAuthClaim: (data) => { let Token = new TokenModel(); data.scope = 'user'; @@ -101,31 +101,26 @@ module.exports = { .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() - }; - }); - + 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() }; + }); + }); }, /** diff --git a/backend/routes/api/main.js b/backend/routes/api/main.js index 2f3ec6d7..546cc727 100644 --- a/backend/routes/api/main.js +++ b/backend/routes/api/main.js @@ -27,7 +27,7 @@ router.get('/', (req, res/*, next*/) => { router.use('/schema', require('./schema')); router.use('/tokens', require('./tokens')); -router.use('/oidc', require('./oidc')) +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 index b02c503f..6fd87c70 100644 --- a/backend/routes/api/oidc.js +++ b/backend/routes/api/oidc.js @@ -3,7 +3,7 @@ const error = require('../../lib/error'); const express = require('express'); const jwtdecode = require('../../lib/express/jwt-decode'); const oidc = require('openid-client'); -const settingModel = require('../../models/setting'); +const settingModel = require('../../models/setting'); const internalToken = require('../../internal/token'); let router = express.Router({ @@ -29,15 +29,15 @@ router * * Retrieve all users */ - .get(jwtdecode(), async (req, res, next) => { - console.log("oidc: init flow"); + .get(jwtdecode(), async (req, res) => { + console.log('oidc: init flow'); settingModel .query() .where({id: 'oidc-config'}) .first() - .then( row => getInitParams(req, row)) - .then( params => redirectToAuthorizationURL(res, params)) - .catch( err => redirectWithError(res, err)); + .then((row) => getInitParams(req, row)) + .then((params) => redirectToAuthorizationURL(res, params)) + .catch((err) => redirectWithError(res, err)); }); @@ -58,15 +58,15 @@ router * * Retrieve a specific user */ - .get(jwtdecode(), async (req, res, next) => { - console.log("oidc: callback"); + .get(jwtdecode(), async (req, res) => { + console.log('oidc: callback'); settingModel .query() .where({id: 'oidc-config'}) .first() - .then( settings => validateCallback(req, settings)) - .then( token => redirectWithJwtToken(res, token)) - .catch( err => redirectWithError(res, err)); + .then((settings) => validateCallback(req, settings)) + .then((token) => redirectWithJwtToken(res, token)) + .catch((err) => redirectWithError(res, err)); }); /** @@ -74,21 +74,21 @@ router * * @param {Setting} row * */ -let getClient = async row => { +let getClient = async (row) => { let issuer; try { issuer = await oidc.Issuer.discover(row.meta.issuerURL); - } catch(err) { + } 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], + client_id: row.meta.clientID, + client_secret: row.meta.clientSecret, + redirect_uris: [row.meta.redirectURL], response_types: ['code'], }); -} +}; /** * Generates state, nonce and authorization url. @@ -98,18 +98,18 @@ let getClient = async row => { * @return { {String}, {String}, {String} } state, nonce and url * */ let getInitParams = async (req, row) => { - let client = await getClient(row); - let state = crypto.randomUUID(); - let nonce = crypto.randomUUID(); - let url = client.authorizationUrl({ - scope: 'openid email profile', - resource: `${req.protocol}://${req.get('host')}${req.originalUrl}`, - state, - nonce, - }) + 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. @@ -117,21 +117,21 @@ let getInitParams = async (req, row) => { * @param {Request} req * @return { {String}, {String} } state and nonce * */ -let parseStateFromCookie = req => { +let parseStateFromCookie = (req) => { let state, nonce; let cookies = req.headers.cookie.split(';'); - for (cookie of cookies) { + for (let cookie of cookies) { if (cookie.split('=')[0].trim() === 'npm_oidc') { - let raw = cookie.split('=')[1]; - let val = raw.split('--'); - state = val[0].trim(); - nonce = val[1].trim(); + 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. @@ -140,33 +140,33 @@ let parseStateFromCookie = req => { * @param {Setting} settings * @return {Promise} a promise resolving to a jwt token * */ -let validateCallback = async (req, settings) => { - let client = await getClient(settings); +let validateCallback = async (req, settings) => { + let client = await getClient(settings); let { state, nonce } = parseStateFromCookie(req); - const params = client.callbackParams(req); + const params = client.callbackParams(req); const tokenSet = await client.callback(settings.meta.redirectURL, params, { state, nonce }); - let claims = tokenSet.claims(); + let claims = tokenSet.claims(); console.log('oidc: authentication successful for email', claims.email); - return internalToken.getTokenFromOAuthClaim({ identity: claims.email }) -} + return internalToken.getTokenFromOAuthClaim({ identity: claims.email }); +}; let redirectToAuthorizationURL = (res, params) => { console.log('oidc: init flow > url > ', params.url); - res.cookie("npm_oidc", params.state + '--' + params.nonce); + 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) => { console.log('oidc: callback error: ', error); 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 edb9edd8..f04f3d7f 100644 --- a/backend/routes/api/settings.js +++ b/backend/routes/api/settings.js @@ -71,14 +71,14 @@ router .then((row) => { if (row.id === 'oidc-config') { // redact oidc configuration via api - let m = row.meta + let m = row.meta; row.meta = { - name: m.name, + 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.clearCookie('npm_oidc'); + res.clearCookie('npm_oidc_error'); } res.status(200) .send(row); diff --git a/frontend/js/app/api.js b/frontend/js/app/api.js index b314b40b..207cb548 100644 --- a/frontend/js/app/api.js +++ b/frontend/js/app/api.js @@ -60,7 +60,7 @@ function fetch(verb, path, data, options) { beforeSend: function (xhr) { // allow unauthenticated access to OIDC configuration - if (path === "settings/oidc-config") return; + if (path === 'settings/oidc-config') return; xhr.setRequestHeader('Authorization', 'Bearer ' + (token ? token.t : null)); }, diff --git a/frontend/js/app/settings/oidc-config/main.js b/frontend/js/app/settings/oidc-config/main.js index 34b16b57..b4eb6d1c 100644 --- a/frontend/js/app/settings/oidc-config/main.js +++ b/frontend/js/app/settings/oidc-config/main.js @@ -9,10 +9,10 @@ module.exports = Mn.View.extend({ className: 'modal-dialog wide', ui: { - form: 'form', - buttons: '.modal-footer button', - cancel: 'button.cancel', - save: 'button.save', + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', }, events: { @@ -28,16 +28,16 @@ module.exports = Mn.View.extend({ 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"; + 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 => { + .then((result) => { view.model.set(result); App.UI.closeModal(); }) - .catch(err => { + .catch((err) => { alert(err.message); this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); }); diff --git a/frontend/js/login/ui/login.js b/frontend/js/login/ui/login.js index 50064f24..dc5605d8 100644 --- a/frontend/js/login/ui/login.js +++ b/frontend/js/login/ui/login.js @@ -31,12 +31,12 @@ 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(e) { + 'click @ui.oidcButton': function() { this.ui.identity.prop('disabled', true); this.ui.secret.prop('disabled', true); this.ui.button.prop('disabled', true); @@ -51,12 +51,12 @@ module.exports = Mn.View.extend({ let cookies = document.cookie.split(';'), token, expiry, error; for (cookie of cookies) { - let raw = cookie.split('='), - name = raw[0].trim(), + let raw = cookie.split('='), + name = raw[0].trim(), value = raw[1]; if (name === 'npm_oidc') { - let v = value.split('---'); - token = v[0]; + let v = value.split('---'); + token = v[0]; expiry = v[1]; } if (name === 'npm_oidc_error') { @@ -80,7 +80,7 @@ module.exports = Mn.View.extend({ } // fetch oidc configuration and show alternative action button if enabled - let response = await Api.Settings.getById("oidc-config"); + 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(); From df5ab361e30fae36cba8b4dd2b419a0911746b7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=BCl=C3=B6p?= Date: Fri, 24 Feb 2023 22:27:27 +0000 Subject: [PATCH 08/25] chore: update comments, remove debug logging --- backend/routes/api/oidc.js | 20 +++++--------------- frontend/js/login/ui/login.js | 4 ---- 2 files changed, 5 insertions(+), 19 deletions(-) diff --git a/backend/routes/api/oidc.js b/backend/routes/api/oidc.js index 6fd87c70..58d2c062 100644 --- a/backend/routes/api/oidc.js +++ b/backend/routes/api/oidc.js @@ -12,11 +12,6 @@ let router = express.Router({ mergeParams: true }); -/** - * OAuth Authorization Code flow initialisation - * - * /api/oidc - */ router .route('/') .options((req, res) => { @@ -25,9 +20,9 @@ router .all(jwtdecode()) /** - * GET /api/users + * GET /api/oidc * - * Retrieve all users + * OAuth Authorization Code flow initialisation */ .get(jwtdecode(), async (req, res) => { console.log('oidc: init flow'); @@ -41,11 +36,6 @@ router }); -/** - * Oauth Authorization Code flow callback - * - * /api/oidc/callback - */ router .route('/callback') .options((req, res) => { @@ -54,9 +44,9 @@ router .all(jwtdecode()) /** - * GET /users/123 or /users/me + * GET /api/oidc/callback * - * Retrieve a specific user + * Oauth Authorization Code flow callback */ .get(jwtdecode(), async (req, res) => { console.log('oidc: callback'); @@ -70,7 +60,7 @@ router }); /** - * Executed discovery and returns the configured `openid-client` client + * Executes discovery and returns the configured `openid-client` client * * @param {Setting} row * */ diff --git a/frontend/js/login/ui/login.js b/frontend/js/login/ui/login.js index dc5605d8..0c1c25c6 100644 --- a/frontend/js/login/ui/login.js +++ b/frontend/js/login/ui/login.js @@ -60,22 +60,18 @@ module.exports = Mn.View.extend({ expiry = v[1]; } if (name === 'npm_oidc_error') { - console.log(' ERROR 000 > ', value); error = decodeURIComponent(value); } } - console.log('login.js event > render', expiry, token); // register a newly acquired jwt token following successful oidc authentication if (token && expiry && (new Date(Date.parse(decodeURIComponent(expiry)))) > new Date() ) { - console.log('login.js event > render >>>'); Tokens.addToken(token); document.location.replace('/'); } // show error message following a failed oidc authentication if (error) { - console.log(' ERROR > ', error); this.ui.oidcError.html(error); } From ef64edd9432b05f39063f790188df495dca04c84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=BCl=C3=B6p?= Date: Sun, 26 Feb 2023 13:24:47 +0000 Subject: [PATCH 09/25] fix: add database migration for oidc-config setting --- .../20230226135501_add_oidc_config_segging.js | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 backend/migrations/20230226135501_add_oidc_config_segging.js diff --git a/backend/migrations/20230226135501_add_oidc_config_segging.js b/backend/migrations/20230226135501_add_oidc_config_segging.js new file mode 100644 index 00000000..bb37f7e6 --- /dev/null +++ b/backend/migrations/20230226135501_add_oidc_config_segging.js @@ -0,0 +1,42 @@ +const migrate_name = 'oidc_config_setting'; +const logger = require('../logger').migrate; +const settingModel = require('../models/setting'); + +/** + * Migrate + * + * @see http://knexjs.org/#Schema + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.up = function (knex) { + logger.info('[' + migrate_name + '] Migrating Up...'); + + return 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: {}, + }); +}; + +/** + * Undo Migrate + * + * @param {Object} knex + * @param {Promise} Promise + * @returns {Promise} + */ +exports.down = function (knex) { + logger.info('[' + migrate_name + '] Migrating Down...'); + + return settingModel + .query() + .delete() + .where('setting_id', 'oidc-config'); +}; \ No newline at end of file From fd49644f212399cb2f6c27e75fcd176fe1baa89f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=BCl=C3=B6p?= Date: Sun, 26 Feb 2023 13:34:58 +0000 Subject: [PATCH 10/25] fix: linter --- .../20230226135501_add_oidc_config_segging.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/backend/migrations/20230226135501_add_oidc_config_segging.js b/backend/migrations/20230226135501_add_oidc_config_segging.js index bb37f7e6..7af85c7b 100644 --- a/backend/migrations/20230226135501_add_oidc_config_segging.js +++ b/backend/migrations/20230226135501_add_oidc_config_segging.js @@ -1,17 +1,16 @@ -const migrate_name = 'oidc_config_setting'; -const logger = require('../logger').migrate; -const settingModel = require('../models/setting'); +const migrate_name = 'oidc_config_setting'; +const settingModel = require('../models/setting'); +const logger = require('../logger').migrate; /** * Migrate * * @see http://knexjs.org/#Schema * - * @param {Object} knex * @param {Promise} Promise * @returns {Promise} */ -exports.up = function (knex) { +exports.up = function () { logger.info('[' + migrate_name + '] Migrating Up...'); return settingModel @@ -28,11 +27,10 @@ exports.up = function (knex) { /** * Undo Migrate * - * @param {Object} knex * @param {Promise} Promise * @returns {Promise} */ -exports.down = function (knex) { +exports.down = function () { logger.info('[' + migrate_name + '] Migrating Down...'); return settingModel From d0d36a95ec96396b81aa428c6332d3b6546c1cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=BCl=C3=B6p?= Date: Mon, 6 Mar 2023 09:33:01 +0000 Subject: [PATCH 11/25] fix: add oidc-config setting via setup.js rather than migrations --- .../20230226135501_add_oidc_config_segging.js | 40 ------------------ backend/setup.js | 42 ++++++++++++------- 2 files changed, 27 insertions(+), 55 deletions(-) delete mode 100644 backend/migrations/20230226135501_add_oidc_config_segging.js diff --git a/backend/migrations/20230226135501_add_oidc_config_segging.js b/backend/migrations/20230226135501_add_oidc_config_segging.js deleted file mode 100644 index 7af85c7b..00000000 --- a/backend/migrations/20230226135501_add_oidc_config_segging.js +++ /dev/null @@ -1,40 +0,0 @@ -const migrate_name = 'oidc_config_setting'; -const settingModel = require('../models/setting'); -const logger = require('../logger').migrate; - -/** - * Migrate - * - * @see http://knexjs.org/#Schema - * - * @param {Promise} Promise - * @returns {Promise} - */ -exports.up = function () { - logger.info('[' + migrate_name + '] Migrating Up...'); - - return 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: {}, - }); -}; - -/** - * Undo Migrate - * - * @param {Promise} Promise - * @returns {Promise} - */ -exports.down = function () { - logger.info('[' + migrate_name + '] Migrating Down...'); - - return settingModel - .query() - .delete() - .where('setting_id', 'oidc-config'); -}; \ No newline at end of file diff --git a/backend/setup.js b/backend/setup.js index fff6cfaa..f36927c9 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,25 +148,37 @@ const setupDefaultSettings = () => { meta: {}, }) .then(() => { - logger.info('Default settings added'); - }); - 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('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'); + } + })]); }; /** From 6ed64153e76e7e0a562f73e103239d6bd7431e09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=BCl=C3=B6p?= Date: Mon, 6 Mar 2023 12:27:51 +0000 Subject: [PATCH 12/25] fix: add oidc logger and replace console logging --- backend/logger.js | 3 ++- backend/routes/api/oidc.js | 16 +++++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/backend/logger.js b/backend/logger.js index 680af6d5..3ece76fd 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/routes/api/oidc.js b/backend/routes/api/oidc.js index 58d2c062..9c8030f9 100644 --- a/backend/routes/api/oidc.js +++ b/backend/routes/api/oidc.js @@ -2,6 +2,7 @@ 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'); @@ -25,7 +26,7 @@ router * OAuth Authorization Code flow initialisation */ .get(jwtdecode(), async (req, res) => { - console.log('oidc: init flow'); + logger.info('Initializing OAuth flow'); settingModel .query() .where({id: 'oidc-config'}) @@ -49,7 +50,7 @@ router * Oauth Authorization Code flow callback */ .get(jwtdecode(), async (req, res) => { - console.log('oidc: callback'); + logger.info('Processing callback'); settingModel .query() .where({id: 'oidc-config'}) @@ -137,13 +138,18 @@ let validateCallback = async (req, settings) => { const params = client.callbackParams(req); const tokenSet = await client.callback(settings.meta.redirectURL, params, { state, nonce }); let claims = tokenSet.claims(); - console.log('oidc: authentication successful for email', claims.email); + + 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) => { - console.log('oidc: init flow > url > ', params.url); + logger.info('Authorization URL: ' + params.url); res.cookie('npm_oidc', params.state + '--' + params.nonce); res.redirect(params.url); }; @@ -154,7 +160,7 @@ let redirectWithJwtToken = (res, token) => { }; let redirectWithError = (res, error) => { - console.log('oidc: callback error: ', error); + logger.error('Callback error: ' + error.message); res.cookie('npm_oidc_error', error.message); res.redirect('/login'); }; From 0f588baa3e8a8d56d1ce0c3e167dd9168979952b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcell=20F=C3=BCl=C3=B6p?= Date: Thu, 9 Mar 2023 21:24:12 +0000 Subject: [PATCH 13/25] fix: indentation --- backend/setup.js | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/backend/setup.js b/backend/setup.js index f36927c9..68483525 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -155,30 +155,30 @@ const setupDefaultSettings = () => { 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'); - } - })]); + 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'); + } + })]); }; /** From 8b841176fad5d99e3ce178224f058dba0150f6cc Mon Sep 17 00:00:00 2001 From: Samuel Oechsler Date: Thu, 19 Sep 2024 19:39:17 +0200 Subject: [PATCH 14/25] Fix configuration template --- backend/setup.js | 98 ++++++++++--------- frontend/js/app/settings/list/item.ejs | 20 +++- frontend/js/app/settings/oidc-config/main.ejs | 4 +- frontend/js/i18n/messages.json | 4 +- 4 files changed, 71 insertions(+), 55 deletions(-) diff --git a/backend/setup.js b/backend/setup.js index 26dd3f27..ec6b44fb 100644 --- a/backend/setup.js +++ b/backend/setup.js @@ -75,54 +75,56 @@ const setupDefaultUser = () => { * @returns {Promise} */ const setupDefaultSettings = () => { - return Promise.all([settingModel - .query() - .select(settingModel.raw('COUNT(`id`) as `count`')) - .where({id: 'default-site'}) - .first() - .then((row) => { - if (!row.count) { - settingModel - .query() - .insert({ - id: 'default-site', - name: 'Default Site', - description: 'What to show when Nginx is hit with an unknown Host', - value: 'congratulations', - meta: {}, - }) - .then(() => { - logger.info('Added default-site setting'); - }); - } - if (config.debug()) { - logger.info('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'); - } - })]); + return Promise.all([ + settingModel + .query() + .select(settingModel.raw('COUNT(`id`) as `count`')) + .where({id: 'default-site'}) + .first() + .then((row) => { + if (!row.count) { + settingModel + .query() + .insert({ + id: 'default-site', + name: 'Default Site', + description: 'What to show when Nginx is hit with an unknown Host', + value: 'congratulations', + meta: {}, + }) + .then(() => { + logger.info('Added default-site setting'); + }); + } + if (config.debug()) { + logger.info('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 (config.debug()) { + logger.info('Default setting setup not required'); + } + }) + ]); }; /** diff --git a/frontend/js/app/settings/list/item.ejs b/frontend/js/app/settings/list/item.ejs index 4f32cd47..9afae591 100644 --- a/frontend/js/app/settings/list/item.ejs +++ b/frontend/js/app/settings/list/item.ejs @@ -1,7 +1,19 @@ -
<%- i18n('settings', 'default-site') %>
+
+ <% if (id === 'default-site') { %> + <%- i18n('settings', 'default-site') %> + <% } %> + <% if (id === 'oidc-config') { %> + <%- i18n('settings', 'oidc-config') %> + <% } %> +
- <%- i18n('settings', 'default-site-description') %> + <% if (id === 'default-site') { %> + <%- i18n('settings', 'default-site-description') %> + <% } %> + <% if (id === 'oidc-config') { %> + <%- i18n('settings', 'oidc-config-description') %> + <% } %>
@@ -12,10 +24,10 @@ <% if (id === 'oidc-config' && meta && meta.name && meta.clientID && meta.clientSecret && meta.issuerURL && meta.redirectURL) { %> <%- meta.name %> <% if (!meta.enabled) { %> - (Disabled) + (<%- i18n('str', 'disabled') %>) <% } %> <% } else if (id === 'oidc-config') { %> - Not configured + <%- i18n('settings', 'oidc-not-configured') %> <% } %>
diff --git a/frontend/js/app/settings/oidc-config/main.ejs b/frontend/js/app/settings/oidc-config/main.ejs index 15eb3981..d8d767a0 100644 --- a/frontend/js/app/settings/oidc-config/main.ejs +++ b/frontend/js/app/settings/oidc-config/main.ejs @@ -15,7 +15,7 @@
-
@@ -40,7 +40,7 @@
-
Enabled
+
<%- i18n('str', 'enable') %>
>
diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index e7dca961..3a3ec055 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -293,8 +293,10 @@ "default-site-html": "Custom Page", "default-site-redirect": "Redirect", "oidc-config": "Open ID Conncect Configuration", + "oidc-config-description": "Sign in to Nginx Proxy Manager with an external Identity Provider", + "oidc-not-configured": "Not configured", "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." } } -} +} \ No newline at end of file From 0b126ca5466fb30875bb4a59dc80a912667a937f Mon Sep 17 00:00:00 2001 From: Samuel Oechsler Date: Wed, 30 Oct 2024 20:33:26 +0100 Subject: [PATCH 15/25] Add oidc-config to OpenAPI schema --- .../schema/paths/settings/settingID/put.json | 73 +++++++++++++++---- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/backend/schema/paths/settings/settingID/put.json b/backend/schema/paths/settings/settingID/put.json index 4ca62429..d205158f 100644 --- a/backend/schema/paths/settings/settingID/put.json +++ b/backend/schema/paths/settings/settingID/put.json @@ -14,7 +14,7 @@ "schema": { "type": "string", "minLength": 1, - "enum": ["default-site"] + "enum": ["default-site", "oidc-config"] }, "required": true, "description": "Setting ID", @@ -27,28 +27,69 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": false, - "minProperties": 1, - "properties": { - "value": { - "type": "string", - "minLength": 1, - "enum": ["congratulations", "404", "444", "redirect", "html"] - }, - "meta": { + "oneOf": [ + { "type": "object", "additionalProperties": false, + "minProperties": 1, "properties": { - "redirect": { - "type": "string" + "value": { + "type": "string", + "minLength": 1, + "enum": [ + "congratulations", + "404", + "444", + "redirect", + "html" + ] }, - "html": { - "type": "string" + "meta": { + "type": "object", + "additionalProperties": false, + "properties": { + "redirect": { + "type": "string" + }, + "html": { + "type": "string" + } + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "properties": { + "meta": { + "type": "object", + "additionalProperties": false, + "properties": { + "clientID": { + "type": "string" + }, + "clientSecret": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "issuerURL": { + "type": "string" + }, + "name": { + "type": "string" + }, + "redirectURL": { + "type": "string" + } + } } } } - } + ] } } } From 7ef52d8ed4de49579783ddd5c30a59e4e0f50c2d Mon Sep 17 00:00:00 2001 From: Samuel Oechsler Date: Wed, 30 Oct 2024 20:34:16 +0100 Subject: [PATCH 16/25] Update yarn.lock --- backend/yarn.lock | 38 ++++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/backend/yarn.lock b/backend/yarn.lock index 725168e1..eee0a79f 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1100,13 +1100,13 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" -finalhandler@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.1.tgz#0c575f1d1d324ddd1da35ad7ece3df7d19088019" - integrity sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ== +finalhandler@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" + integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== dependencies: debug "2.6.9" - encodeurl "~2.0.0" + encodeurl "~1.0.2" escape-html "~1.0.3" on-finished "2.4.1" parseurl "~1.3.3" @@ -2342,6 +2342,13 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + qs@6.13.0: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" @@ -2510,6 +2517,25 @@ semver@~7.0.0: resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== +send@0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" + integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== + dependencies: + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + encodeurl "~1.0.2" + escape-html "~1.0.3" + etag "~1.8.1" + fresh "0.5.2" + http-errors "2.0.0" + mime "1.6.0" + ms "2.1.3" + on-finished "2.4.1" + range-parser "~1.2.1" + statuses "2.0.1" + send@0.19.0: version "0.19.0" resolved "https://registry.yarnpkg.com/send/-/send-0.19.0.tgz#bbc5a388c8ea6c048967049dbeac0e4a3f09d7f8" @@ -2578,7 +2604,7 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -side-channel@^1.0.6: +side-channel@^1.0.4, side-channel@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== From 1a030a6ddd022a64165a4ba5b5bf76370b968114 Mon Sep 17 00:00:00 2001 From: Samuel Oechsler Date: Wed, 30 Oct 2024 20:35:01 +0100 Subject: [PATCH 17/25] Enforce token auth for odic config PUT call --- backend/lib/express/jwt-decode.js | 11 ++++++++--- backend/routes/oidc.js | 10 +++++----- backend/routes/settings.js | 5 +++-- frontend/js/app/api.js | 7 +++++-- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/backend/lib/express/jwt-decode.js b/backend/lib/express/jwt-decode.js index 745763a7..193a3d0e 100644 --- a/backend/lib/express/jwt-decode.js +++ b/backend/lib/express/jwt-decode.js @@ -4,9 +4,14 @@ module.exports = () => { return function (req, res, next) { res.locals.access = null; let access = new Access(res.locals.token || null); - // allow unauthenticated access to OIDC configuration - let anon_access = req.url === '/oidc-config' && !access.token.getUserId(); - access.load(anon_access) + + // Allow unauthenticated access to get the oidc configuration + let oidc_access = + req.url === '/oidc-config' && + req.method === 'GET' && + !access.token.getUserId(); + + access.load(oidc_access) .then(() => { res.locals.access = access; next(); diff --git a/backend/routes/oidc.js b/backend/routes/oidc.js index 9c8030f9..751c04f5 100644 --- a/backend/routes/oidc.js +++ b/backend/routes/oidc.js @@ -1,11 +1,11 @@ const crypto = require('crypto'); -const error = require('../../lib/error'); +const error = require('../lib/error'); const express = require('express'); -const jwtdecode = require('../../lib/express/jwt-decode'); -const logger = require('../../logger').oidc; +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'); +const settingModel = require('../models/setting'); +const internalToken = require('../internal/token'); let router = express.Router({ caseSensitive: true, diff --git a/backend/routes/settings.js b/backend/routes/settings.js index d870974f..aa7d414e 100644 --- a/backend/routes/settings.js +++ b/backend/routes/settings.js @@ -72,13 +72,14 @@ router }) .then((row) => { if (row.id === 'oidc-config') { - // redact oidc configuration via api + // Redact oidc configuration via api (unauthenticated get call) 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 + + // Remove these temporary cookies used during oidc authentication res.clearCookie('npm_oidc'); res.clearCookie('npm_oidc_error'); } diff --git a/frontend/js/app/api.js b/frontend/js/app/api.js index 207cb548..03e787d7 100644 --- a/frontend/js/app/api.js +++ b/frontend/js/app/api.js @@ -59,8 +59,11 @@ function fetch(verb, path, data, options) { }, beforeSend: function (xhr) { - // allow unauthenticated access to OIDC configuration - if (path === 'settings/oidc-config') return; + // Allow unauthenticated access to get the oidc configuration + if (path === 'settings/oidc-config' && verb === "get") { + return; + } + xhr.setRequestHeader('Authorization', 'Bearer ' + (token ? token.t : null)); }, From eb312cc61d122a84cf0647b2b0c100a3dfc1cf99 Mon Sep 17 00:00:00 2001 From: Samuel Oechsler Date: Thu, 31 Oct 2024 21:23:45 +0100 Subject: [PATCH 18/25] Remove nodemon dependency in package.json as it is already in devDependencies --- backend/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/package.json b/backend/package.json index a13d7aa5..b29eff02 100644 --- a/backend/package.json +++ b/backend/package.json @@ -21,7 +21,6 @@ "moment": "^2.29.4", "mysql2": "^3.11.1", "node-rsa": "^1.0.8", - "nodemon": "^2.0.2", "openid-client": "^5.4.0", "objection": "3.0.1", "path": "^0.12.7", From 637b773fd6884cfa4079beddb361dd2abb469899 Mon Sep 17 00:00:00 2001 From: Cameron Hutchison Date: Tue, 10 Dec 2024 16:06:56 -0600 Subject: [PATCH 19/25] Make the error message for when a user does not exist when attempting to login via OIDC more user-friendly. --- backend/internal/token.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/internal/token.js b/backend/internal/token.js index f949f1b2..436669bc 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -102,7 +102,7 @@ module.exports = { .first() .then((user) => { if (!user) { - throw new error.AuthError('No relevant user found'); + throw new error.AuthError(`A user with the email ${data.identity} does not exist. Please contact your administrator.`); } // Create a moment of the expiry expression From e4b87d01f1ca2bc498d85a395ed4fd8802657b84 Mon Sep 17 00:00:00 2001 From: Cameron Hutchison Date: Tue, 10 Dec 2024 16:07:36 -0600 Subject: [PATCH 20/25] Add documentation for configuring SSO with OIDC --- docs/src/setup/index.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/src/setup/index.md b/docs/src/setup/index.md index ee8e9903..8af828b2 100644 --- a/docs/src/setup/index.md +++ b/docs/src/setup/index.md @@ -146,4 +146,38 @@ Immediately after logging in with this default user you will be asked to modify INITIAL_ADMIN_PASSWORD: mypassword1 ``` +## OpenID Connect - Single Sign-On (SSO) + +Nginx Proxy Manager supports single sign-on (SSO) with OpenID Connect. This feature allows you to use an external OpenID Connect provider log in. +*Note: This feature requires a user to have an existing account to have been created via the "Users" page in the admin interface.* + +### Provider Configuration +However, before you configure this feature, you need to have an OpenID Connect provider. +If you don't have one, you can use Authentik, which is an open-source OpenID Connect provider. Auth0 is another popular OpenID Connect provider that offers a free tier. + +Each provider is a little different, so you will need to refer to the provider's documentation to get the necessary information to configure a new application. +You will need the `Client ID`, `Client Secret`, and `Issuer URL` from the provider. When you create the application in the provider, you will also need to include the `Redirect URL` in the list of allowed redirect URLs for the application. +Nginx Proxy Manager uses the `/api/oidc/callback` endpoint for the redirect URL. +The scopes requested by Nginx Proxy Manager are `openid`, `email`, and `profile` - make sure your auth provider supports these scopes. + +We have confirmed that the following providers work with Nginx Proxy Manager. If you have success with another provider, make a pull request to add it to the list! +- Authentik +- Authelia +- Auth0 + +### Nginx Proxy Manager Configuration +To enable SSO, log into the management interface as an Administrator and navigate to the "Settings" page. +The setting to configure OpenID Connect is named "OpenID Connect Configuration". +Click the 3 dots on the far right side of the table and then click "Edit". +In the modal that appears, you will see a form with the following fields: + +| Field | Description | Example Value | Notes | +|---------------|-----------------------------------------------------------|---------------------------------------------|---------------------------------------------------------------------| +| Name | The name of the OpenID Connect provider | Authentik | This will be shown on the login page (eg: "Sign in with Authentik") | +| Client ID | The client ID provided by the OpenID Connect provider | `xyz...456` | | +| Client Secret | The client secret provided by the OpenID Connect provider | `abc...123` | +| Issuer URL | The issuer URL provided by the OpenID Connect provider | `https://authentik.example.com` | This is the URL that the provider uses to identify itself | +| Redirect URL | The redirect URL to use for the OpenID Connect provider | `https://npm.example.com/api/oidc/callback` | | + +After filling in the fields, click "Save" to save the settings. You can now use the "Sign in with Authentik" button on the login page to sign in with your OpenID Connect provider. From 81aa8a4fe6b8ff66b63eeb758131c2fa93edf07e Mon Sep 17 00:00:00 2001 From: Cameron Hutchison Date: Tue, 10 Dec 2024 16:23:19 -0600 Subject: [PATCH 21/25] Make 'Redirect URL' match the name of the field. --- frontend/js/i18n/messages.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index 3a3ec055..e9a02f69 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -296,7 +296,7 @@ "oidc-config-description": "Sign in to Nginx Proxy Manager with an external Identity Provider", "oidc-not-configured": "Not configured", "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." + "oidc-config-hint-2": "The 'Redirect URL' 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." } } -} \ No newline at end of file +} From 1ed15b3dd9d5fe5fc046fc0bb6d253b1f3b49039 Mon Sep 17 00:00:00 2001 From: Cameron Hutchison Date: Tue, 10 Dec 2024 17:03:40 -0600 Subject: [PATCH 22/25] Add Cypress tests for updating the OIDC configuration --- test/cypress/e2e/api/Settings.cy.js | 45 +++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/cypress/e2e/api/Settings.cy.js b/test/cypress/e2e/api/Settings.cy.js index 6942760c..bca5e365 100644 --- a/test/cypress/e2e/api/Settings.cy.js +++ b/test/cypress/e2e/api/Settings.cy.js @@ -19,6 +19,51 @@ describe('Settings endpoints', () => { }); }); + it('Get oidc-config setting', function() { + cy.task('backendApiGet', { + token: token, + path: '/api/settings/oidc-config', + }).then((data) => { + cy.validateSwaggerSchema('get', 200, '/settings/{settingID}', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.equal('oidc-config'); + }); + }); + + it('OIDC settings can be updated', function() { + cy.task('backendApiPut', { + token: token, + path: '/api/settings/oidc-config', + data: { + meta: { + name: 'Some OIDC Provider', + clientID: 'clientID', + clientSecret: 'clientSecret', + issuerURL: 'https://oidc.example.com', + redirectURL: 'https://redirect.example.com/api/oidc/callback', + enabled: true, + } + }, + }).then((data) => { + cy.validateSwaggerSchema('put', 200, '/settings/{settingID}', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.equal('oidc-config'); + expect(data).to.have.property('meta'); + expect(data.meta).to.have.property('name'); + expect(data.meta.name).to.be.equal('Some OIDC Provider'); + expect(data.meta).to.have.property('clientID'); + expect(data.meta.clientID).to.be.equal('clientID'); + expect(data.meta).to.have.property('clientSecret'); + expect(data.meta.clientSecret).to.be.equal('clientSecret'); + expect(data.meta).to.have.property('issuerURL'); + expect(data.meta.issuerURL).to.be.equal('https://oidc.example.com'); + expect(data.meta).to.have.property('redirectURL'); + expect(data.meta.redirectURL).to.be.equal('https://redirect.example.com/api/oidc/callback'); + expect(data.meta).to.have.property('enabled'); + expect(data.meta.enabled).to.be.true; + }); + }); + it('Get default-site setting', function() { cy.task('backendApiGet', { token: token, From 529c84f0fdb2e41b95bd6c2395241cffd059a9f1 Mon Sep 17 00:00:00 2001 From: Cameron Hutchison Date: Wed, 11 Dec 2024 13:23:31 -0600 Subject: [PATCH 23/25] Add UI E2E tests for the login page for OIDC being enabled and when it is disabled --- frontend/js/app/dashboard/main.ejs | 2 +- frontend/js/login/ui/login.ejs | 10 +- test/cypress/e2e/ui/Login.cy.js | 185 +++++++++++++++++++++++++++++ test/cypress/support/commands.js | 116 +++++++++++++++++- test/cypress/support/constants.js | 16 +++ 5 files changed, 321 insertions(+), 8 deletions(-) create mode 100644 test/cypress/e2e/ui/Login.cy.js create mode 100644 test/cypress/support/constants.js diff --git a/frontend/js/app/dashboard/main.ejs b/frontend/js/app/dashboard/main.ejs index c00aa6d0..94718a92 100644 --- a/frontend/js/app/dashboard/main.ejs +++ b/frontend/js/app/dashboard/main.ejs @@ -1,5 +1,5 @@ <% if (columns) { %> diff --git a/frontend/js/login/ui/login.ejs b/frontend/js/login/ui/login.ejs index 84aa90a0..3cde0a6f 100644 --- a/frontend/js/login/ui/login.ejs +++ b/frontend/js/login/ui/login.ejs @@ -17,19 +17,19 @@
<%- i18n('login', 'title') %>
- +
- -
+ +