From caeb2934f0dff0e6b7d73b9bbeddb74a2f31116d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marcell=20F=C3=9CL=C3=96P?= <marekful@domainloop.net>
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 a64b90105..8e04341d4 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 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/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..2f3ec6d71 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..e60949b3a
--- /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 d08b2bf5c..edb9edd81 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/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..b314b40be 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
+        <% } %>
     </div>
 </td>
 <td class="text-right">
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..6bd7c3420
--- /dev/null
+++ b/frontend/js/app/settings/oidc-config/main.ejs
@@ -0,0 +1,47 @@
+<div class="modal-content">
+    <div class="modal-header">
+        <h5 class="modal-title"><%- i18n('settings', id) %></h5>
+        <button type="button" class="close cancel" aria-label="Close" data-dismiss="modal">&nbsp;</button>
+    </div>
+    <div class="modal-body">
+        <form>
+            <div class="row">
+                <div class="col-sm-12 col-md-12">
+                    <div class="form-group">
+                        <div class="form-label"><%- description %></div>
+                        <div class="custom-controls-stacked">
+                            <div class="form-group">
+                                <div class="form-label">Name</div>
+                                <input class="form-control name-input" name="meta[name]" placeholder="" type="text" value="<%- meta && typeof meta.name !== 'undefined' ? meta.name : '' %>">
+                            </div>
+                            <div class="form-group">
+                                <div class="form-label">Client ID</div>
+                                <input class="form-control id-input" name="meta[clientID]" placeholder="" type="text" value="<%- meta && typeof meta.clientID !== 'undefined' ? meta.clientID : '' %>">
+                            </div>
+                            <div class="form-group">
+                                <div class="form-label">Client Secret</div>
+                                <input class="form-control secret-input" name="meta[clientSecret]" placeholder="" type="text" value="<%- meta && typeof meta.clientSecret !== 'undefined' ? meta.clientSecret : '' %>">
+                            </div>
+                            <div class="form-group">
+                                <div class="form-label">Issuer URL</div>
+                                <input class="form-control issuer-input" name="meta[issuerURL]" placeholder="https://" type="url" value="<%- meta && typeof meta.issuerURL !== 'undefined' ? meta.issuerURL : '' %>">
+                            </div>
+                            <div class="form-group">
+                                <div class="form-label">Redirect URL</div>
+                                <input class="form-control redirect-url-input" name="meta[redirectURL]" placeholder="https://" type="url" value="<%- meta && typeof meta.redirectURL !== 'undefined' ? meta.redirectURL : '' %>">
+                            </div>
+                            <div class="form-group">
+                                <div class="form-label">Enabled</div>
+                                <input class="form-check enabled-input" name="meta[enabled]" placeholder="" type="checkbox" <%- meta && typeof meta.enabled !== 'undefined' && meta.enabled === true ? 'checked="checked"' : '' %> >
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </form>
+    </div>
+    <div class="modal-footer">
+        <button type="button" class="btn btn-secondary cancel" data-dismiss="modal"><%- i18n('str', 'cancel') %></button>
+        <button type="button" class="btn btn-teal save"><%- i18n('str', 'save') %></button>
+    </div>
+</div>
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..8fffcd698
--- /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()) {
+                $('<input type="submit">').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..c6f90941a 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 693bc050c..84aa90a02 100644
--- a/frontend/js/login/ui/login.ejs
+++ b/frontend/js/login/ui/login.ejs
@@ -5,7 +5,7 @@
                 <div class="card-body p-6">
                     <div class="container">
                         <div class="row">
-                            <div class="col-sm-12 col-md-6">
+                            <div class="col-sm-12 col-md-6 margin-auto">
                                 <div class="text-center p-6">
                                     <img src="/images/logo-text-vertical-grey.png" alt="Logo" />
                                     <div class="text-center text-muted mt-5">
@@ -27,6 +27,13 @@
                                 <div class="form-footer">
                                     <button type="submit" class="btn btn-teal btn-block"><%- i18n('str', 'sign-in') %></button>
                                 </div>
+                                <div class="form-footer login-oidc">
+                                    <div class="separator"><slot>OR</slot></div>
+                                    <button type="button" id="login-oidc" class="btn btn-teal btn-block">
+                                        <%- i18n('str', 'sign-in-with') %> <span class="oidc-provider"></span>
+                                    </button>
+                                    <div class="invalid-feedback oidc-error"></div>
+                                </div>
                             </div>
                         </div>
                     </div>
diff --git a/frontend/js/login/ui/login.js b/frontend/js/login/ui/login.js
index 757eb4e31..50064f246 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 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

From 3e2a411dfbd53ca4be6c61a96fb0d322eff92cfc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marcell=20F=C3=9CL=C3=96P?= <marekful@domainloop.net>
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 47fd1e7b0..fff6cfaa1 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?= <marekful@domainloop.net>
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 6bd7c3420..1395a920e 100644
--- a/frontend/js/app/settings/oidc-config/main.ejs
+++ b/frontend/js/app/settings/oidc-config/main.ejs
@@ -9,26 +9,35 @@
                 <div class="col-sm-12 col-md-12">
                     <div class="form-group">
                         <div class="form-label"><%- description %></div>
+                        <div>
+                            <p><%- i18n('settings', 'oidc-config-hint-1') %></p>
+                            <p><%- i18n('settings', 'oidc-config-hint-2') %></p>
+                        </div>
                         <div class="custom-controls-stacked">
                             <div class="form-group">
-                                <div class="form-label">Name</div>
-                                <input class="form-control name-input" name="meta[name]" placeholder="" type="text" value="<%- meta && typeof meta.name !== 'undefined' ? meta.name : '' %>">
+                                <label class="form-label">Name <span class="form-required">*</span>
+                                    <input class="form-control name-input" name="meta[name]" required type="text" value="<%- meta && typeof meta.name !== 'undefined' ? meta.name : '' %>">
+                                </label>
                             </div>
                             <div class="form-group">
-                                <div class="form-label">Client ID</div>
-                                <input class="form-control id-input" name="meta[clientID]" placeholder="" type="text" value="<%- meta && typeof meta.clientID !== 'undefined' ? meta.clientID : '' %>">
+                                <label class="form-label">Client ID <span class="form-required">*</span>
+                                    <input class="form-control id-input" name="meta[clientID]" required type="text" value="<%- meta && typeof meta.clientID !== 'undefined' ? meta.clientID : '' %>">
+                                </label>
                             </div>
                             <div class="form-group">
-                                <div class="form-label">Client Secret</div>
-                                <input class="form-control secret-input" name="meta[clientSecret]" placeholder="" type="text" value="<%- meta && typeof meta.clientSecret !== 'undefined' ? meta.clientSecret : '' %>">
+                                <label class="form-label">Client Secret <span class="form-required">*</span>
+                                    <input class="form-control secret-input" name="meta[clientSecret]" required type="text" value="<%- meta && typeof meta.clientSecret !== 'undefined' ? meta.clientSecret : '' %>">
+                                </label>
                             </div>
                             <div class="form-group">
-                                <div class="form-label">Issuer URL</div>
-                                <input class="form-control issuer-input" name="meta[issuerURL]" placeholder="https://" type="url" value="<%- meta && typeof meta.issuerURL !== 'undefined' ? meta.issuerURL : '' %>">
+                                <label class="form-label">Issuer URL <span class="form-required">*</span>
+                                    <input class="form-control issuer-input" name="meta[issuerURL]" required placeholder="https://" type="url" value="<%- meta && typeof meta.issuerURL !== 'undefined' ? meta.issuerURL : '' %>">
+                                </label>
                             </div>
                             <div class="form-group">
-                                <div class="form-label">Redirect URL</div>
-                                <input class="form-control redirect-url-input" name="meta[redirectURL]" placeholder="https://" type="url" value="<%- meta && typeof meta.redirectURL !== 'undefined' ? meta.redirectURL : '' %>">
+                                <label class="form-label">Redirect URL <span class="form-required">*</span>
+                                    <input class="form-control redirect-url-input" name="meta[redirectURL]" required placeholder="https://" type="url" value="<%- meta && typeof meta.redirectURL !== 'undefined' ? meta.redirectURL : document.location.origin + '/api/oidc/callback' %>">
+                                </label>
                             </div>
                             <div class="form-group">
                                 <div class="form-label">Enabled</div>
diff --git a/frontend/js/app/settings/oidc-config/main.js b/frontend/js/app/settings/oidc-config/main.js
index 8fffcd698..b44997858 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?= <marekful@domainloop.net>
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 c6f90941a..29c4d6440 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?= <marekful@domainloop.net>
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 e60949b3a..e3da2caaf 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?= <marekful@domainloop.net>
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 e3da2caaf..b02c503f5 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 1395a920e..15eb3981b 100644
--- a/frontend/js/app/settings/oidc-config/main.ejs
+++ b/frontend/js/app/settings/oidc-config/main.ejs
@@ -41,7 +41,7 @@
                             </div>
                             <div class="form-group">
                                 <div class="form-label">Enabled</div>
-                                <input class="form-check enabled-input" name="meta[enabled]" placeholder="" type="checkbox" <%- meta && typeof meta.enabled !== 'undefined' && meta.enabled === true ? 'checked="checked"' : '' %> >
+                                <input class="form-check enabled-input" name="meta[enabled]" placeholder="" type="checkbox" <%- meta && (typeof meta.enabled !== 'undefined' && meta.enabled === true) || (JSON.stringify(meta) === '{}') ? 'checked="checked"' : '' %> >
                             </div>
                         </div>
                     </div>
diff --git a/frontend/js/app/settings/oidc-config/main.js b/frontend/js/app/settings/oidc-config/main.js
index b44997858..34b16b577 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?= <marekful@domainloop.net>
Date: Fri, 24 Feb 2023 21:09:21 +0000
Subject: [PATCH 07/25] refactor: satisfy linter requirements

---
 backend/internal/token.js                    | 43 +++++-----
 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, 83 insertions(+), 88 deletions(-)

diff --git a/backend/internal/token.js b/backend/internal/token.js
index 8e04341d4..27da42b44 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 2f3ec6d71..546cc7275 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 b02c503f5..6fd87c70e 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 edb9edd81..f04f3d7f7 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 b314b40be..207cb548a 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 34b16b577..b4eb6d1c8 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 50064f246..dc5605d8d 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?= <marekful@domainloop.net>
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 6fd87c70e..58d2c0623 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 dc5605d8d..0c1c25c61 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?= <marekful@domainloop.net>
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 000000000..bb37f7e6d
--- /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?= <marekful@domainloop.net>
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 bb37f7e6d..7af85c7bc 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?= <marekful@domainloop.net>
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 7af85c7bc..000000000
--- 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 fff6cfaa1..f36927c9a 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?= <marekful@domainloop.net>
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 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/routes/api/oidc.js b/backend/routes/api/oidc.js
index 58d2c0623..9c8030f9d 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?= <marekful@domainloop.net>
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 f36927c9a..68483525f 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 <samuel@oechsler.it>
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 26dd3f276..ec6b44fb1 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 4f32cd473..9afae5912 100644
--- a/frontend/js/app/settings/list/item.ejs
+++ b/frontend/js/app/settings/list/item.ejs
@@ -1,7 +1,19 @@
 <td>
-    <div><%- i18n('settings', 'default-site') %></div>
+    <div>
+        <% if (id === 'default-site') { %>
+            <%- i18n('settings', 'default-site') %>
+        <% } %>
+        <% if (id === 'oidc-config') { %>
+            <%- i18n('settings', 'oidc-config') %>
+        <% } %>
+    </div>
     <div class="small text-muted">
-        <%- i18n('settings', 'default-site-description') %>
+        <% if (id === 'default-site') { %>
+            <%- i18n('settings', 'default-site-description') %>
+        <% } %>
+        <% if (id === 'oidc-config') { %>
+            <%- i18n('settings', 'oidc-config-description') %>
+        <% } %>
     </div>
 </td>
 <td>
@@ -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') %>
         <% } %>
     </div>
 </td>
diff --git a/frontend/js/app/settings/oidc-config/main.ejs b/frontend/js/app/settings/oidc-config/main.ejs
index 15eb3981b..d8d767a0e 100644
--- a/frontend/js/app/settings/oidc-config/main.ejs
+++ b/frontend/js/app/settings/oidc-config/main.ejs
@@ -15,7 +15,7 @@
                         </div>
                         <div class="custom-controls-stacked">
                             <div class="form-group">
-                                <label class="form-label">Name <span class="form-required">*</span>
+                                <label class="form-label"><%- i18n('str', 'name') %> <span class="form-required">*</span>
                                     <input class="form-control name-input" name="meta[name]" required type="text" value="<%- meta && typeof meta.name !== 'undefined' ? meta.name : '' %>">
                                 </label>
                             </div>
@@ -40,7 +40,7 @@
                                 </label>
                             </div>
                             <div class="form-group">
-                                <div class="form-label">Enabled</div>
+                                <div class="form-label"><%- i18n('str', 'enable') %></div>
                                 <input class="form-check enabled-input" name="meta[enabled]" placeholder="" type="checkbox" <%- meta && (typeof meta.enabled !== 'undefined' && meta.enabled === true) || (JSON.stringify(meta) === '{}') ? 'checked="checked"' : '' %> >
                             </div>
                         </div>
diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json
index e7dca961e..3a3ec055e 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 <samuel@oechsler.it>
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 4ca624293..d205158f5 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 <samuel@oechsler.it>
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 725168e1b..eee0a79ff 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 <samuel@oechsler.it>
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 745763a74..193a3d0e7 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 9c8030f9d..751c04f5b 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 d870974fc..aa7d414e9 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 207cb548a..03e787d75 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 <samuel@oechsler.it>
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 a13d7aa5f..b29eff023 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 <chutch112292@gmail.com>
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 f949f1b24..436669bc2 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 <chutch112292@gmail.com>
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 ee8e9903d..8af828b21 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 <chutch112292@gmail.com>
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 3a3ec055e..e9a02f69e 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 <chutch112292@gmail.com>
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 6942760c7..bca5e3653 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 <chutch112292@gmail.com>
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 c00aa6d0f..94718a920 100644
--- a/frontend/js/app/dashboard/main.ejs
+++ b/frontend/js/app/dashboard/main.ejs
@@ -1,5 +1,5 @@
 <div class="page-header">
-    <h1 class="page-title"><%- i18n('dashboard', 'title', {name: getUserName()}) %></h1>
+    <h1 class="page-title" data-cy="page-title"><%- i18n('dashboard', 'title', {name: getUserName()}) %></h1>
 </div>
 
 <% if (columns) { %>
diff --git a/frontend/js/login/ui/login.ejs b/frontend/js/login/ui/login.ejs
index 84aa90a02..3cde0a6f2 100644
--- a/frontend/js/login/ui/login.ejs
+++ b/frontend/js/login/ui/login.ejs
@@ -17,19 +17,19 @@
                                 <div class="card-title"><%- i18n('login', 'title') %></div>
                                 <div class="form-group">
                                     <label class="form-label"><%- i18n('str', 'email-address') %></label>
-                                    <input name="identity" type="email" class="form-control" placeholder="<%- i18n('str', 'email-address') %>" required autofocus>
+                                    <input name="identity" type="email" class="form-control" placeholder="<%- i18n('str', 'email-address') %>" data-cy="identity" required autofocus>
                                 </div>
                                 <div class="form-group">
                                     <label class="form-label"><%- i18n('str', 'password') %></label>
-                                    <input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" required>
-                                    <div class="invalid-feedback secret-error"></div>
+                                    <input name="secret" type="password" class="form-control" placeholder="<%- i18n('str', 'password') %>" data-cy="password" required>
+                                    <div class="invalid-feedback secret-error" data-cy="password-error"></div>
                                 </div>
                                 <div class="form-footer">
-                                    <button type="submit" class="btn btn-teal btn-block"><%- i18n('str', 'sign-in') %></button>
+                                    <button type="submit" class="btn btn-teal btn-block" data-cy="sign-in"><%- i18n('str', 'sign-in') %></button>
                                 </div>
                                 <div class="form-footer login-oidc">
                                     <div class="separator"><slot>OR</slot></div>
-                                    <button type="button" id="login-oidc" class="btn btn-teal btn-block">
+                                    <button type="button" id="login-oidc" class="btn btn-teal btn-block" data-cy="oidc-login">
                                         <%- i18n('str', 'sign-in-with') %> <span class="oidc-provider"></span>
                                     </button>
                                     <div class="invalid-feedback oidc-error"></div>
diff --git a/test/cypress/e2e/ui/Login.cy.js b/test/cypress/e2e/ui/Login.cy.js
new file mode 100644
index 000000000..2b17ffa87
--- /dev/null
+++ b/test/cypress/e2e/ui/Login.cy.js
@@ -0,0 +1,185 @@
+/// <reference types="cypress" />
+
+import {TEST_USER_EMAIL, TEST_USER_NICKNAME, TEST_USER_PASSWORD} from "../../support/constants";
+
+describe('Login', () => {
+    beforeEach(() => {
+        // Clear all cookies and local storage so we start fresh
+        cy.clearCookies();
+        cy.clearLocalStorage();
+    });
+
+    describe('when OIDC is not enabled', () => {
+        beforeEach(() => {
+            cy.configureOidc(false);
+            cy.visit('/');
+        })
+
+        it('should show the login form', () => {
+            cy.get('input[data-cy="identity"]').should('exist');
+            cy.get('input[data-cy="password"]').should('exist');
+            cy.get('button[data-cy="sign-in"]').should('exist');
+        });
+
+        it('should NOT show the button to sign in with an identity provider', () => {
+            cy.get('button[data-cy="oidc-login"]').should('not.exist');
+        });
+
+        describe('logging in with a username and password', () => {
+            // These tests are duplicated below. The difference is that OIDC is disabled here.
+            beforeEach(() => {
+                // Delete and recreate the test user
+                cy.deleteTestUser();
+                cy.createTestUser();
+            });
+
+            it('should log the user in when the credentials are correct', () => {
+                // Fill in the form with the test user's email and the correct password
+                cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
+                cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
+
+                // Intercept the POST request to /api/tokens, so we can wait for it to complete before proceeding
+                cy.intercept('POST', '/api/tokens').as('login');
+
+                // Click the sign-in button
+                cy.get('button[data-cy="sign-in"]').click();
+                cy.wait('@login');
+
+                // Expect a 200 from the backend
+                cy.get('@login').its('response.statusCode').should('eq', 200);
+
+                // Expect the user to be redirected to the dashboard with a welcome message
+                cy.get('h1[data-cy="page-title"]').should('contain.text', `Hi ${TEST_USER_NICKNAME}`);
+            });
+
+            it('should show an error message if the password is incorrect', () => {
+                // Fill in the form with the test user's email and an incorrect password
+                cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
+                cy.get('input[data-cy="password"]').type(`${TEST_USER_PASSWORD}_obviously_not_correct`);
+
+                // Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
+                cy.intercept('POST', '/api/tokens').as('login');
+
+                // Click the sign-in button
+                cy.get('button[data-cy="sign-in"]').click();
+                cy.wait('@login');
+
+                // Expect a 401 from the backend
+                cy.get('@login').its('response.statusCode').should('eq', 401);
+                // Expect an error message on the UI
+                cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid password');
+            });
+
+            it('should show an error message if the email is incorrect', () => {
+                // Fill in the form with the test user's email and an incorrect password
+                cy.get('input[data-cy="identity"]').type(`definitely_not_${TEST_USER_EMAIL}`);
+                cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
+
+                // Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
+                cy.intercept('POST', '/api/tokens').as('login');
+
+                // Click the sign-in button
+                cy.get('button[data-cy="sign-in"]').click();
+                cy.wait('@login');
+
+                // Expect a 401 from the backend
+                cy.get('@login').its('response.statusCode').should('eq', 401);
+                // Expect an error message on the UI
+                cy.get('div[data-cy="password-error"]').should('contain.text', 'No relevant user found');
+            });
+        });
+    });
+
+    describe('when OIDC is enabled', () => {
+        beforeEach(() => {
+            cy.configureOidc(true);
+            cy.visit('/');
+        });
+
+        it('should show the login form', () => {
+            cy.get('input[data-cy="identity"]').should('exist');
+            cy.get('input[data-cy="password"]').should('exist');
+            cy.get('button[data-cy="sign-in"]').should('exist');
+        });
+
+        it('should show the button to sign in with the configured identity provider', () => {
+            cy.get('button[data-cy="oidc-login"]').should('exist');
+            cy.get('button[data-cy="oidc-login"]').should('contain.text', 'Sign in with ACME OIDC Provider');
+        });
+
+        describe('logging in with a username and password', () => {
+            // These tests are the same as the ones above, but we need to repeat them here because the OIDC configuration
+            beforeEach(() => {
+                // Delete and recreate the test user
+                cy.deleteTestUser();
+                cy.createTestUser();
+            });
+
+            it('should log the user in when the credentials are correct', () => {
+                // Fill in the form with the test user's email and the correct password
+                cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
+                cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
+
+                // Intercept the POST request to /api/tokens, so we can wait for it to complete before proceeding
+                cy.intercept('POST', '/api/tokens').as('login');
+
+                // Click the sign-in button
+                cy.get('button[data-cy="sign-in"]').click();
+                cy.wait('@login');
+
+                // Expect a 200 from the backend
+                cy.get('@login').its('response.statusCode').should('eq', 200);
+
+                // Expect the user to be redirected to the dashboard with a welcome message
+                cy.get('h1[data-cy="page-title"]').should('contain.text', `Hi ${TEST_USER_NICKNAME}`);
+
+            });
+
+            it('should show an error message if the password is incorrect', () => {
+                // Fill in the form with the test user's email and an incorrect password
+                cy.get('input[data-cy="identity"]').type(TEST_USER_EMAIL);
+                cy.get('input[data-cy="password"]').type(`${TEST_USER_PASSWORD}_obviously_not_correct`);
+
+                // Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
+                cy.intercept('POST', '/api/tokens').as('login');
+
+                // Click the sign-in button
+                cy.get('button[data-cy="sign-in"]').click();
+                cy.wait('@login');
+
+                // Expect a 401 from the backend
+                cy.get('@login').its('response.statusCode').should('eq', 401);
+                // Expect an error message on the UI
+                cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid password');
+            });
+
+            it('should show an error message if the email is incorrect', () => {
+                // Fill in the form with the test user's email and an incorrect password
+                cy.get('input[data-cy="identity"]').type(`definitely_not_${TEST_USER_EMAIL}`);
+                cy.get('input[data-cy="password"]').type(TEST_USER_PASSWORD);
+
+                // Intercept the POST request to /api/tokens, so we can wait for it to complete before checking the error message
+                cy.intercept('POST', '/api/tokens').as('login');
+
+                // Click the sign-in button
+                cy.get('button[data-cy="sign-in"]').click();
+                cy.wait('@login');
+
+                // Expect a 401 from the backend
+                cy.get('@login').its('response.statusCode').should('eq', 401);
+                // Expect an error message on the UI
+                cy.get('div[data-cy="password-error"]').should('contain.text', 'No relevant user found');
+            });
+        });
+
+        describe('logging in with OIDC', () => {
+           beforeEach(() => {
+                // Delete and recreate the test user
+                cy.deleteTestUser();
+                cy.createTestUser();
+           });
+
+           // TODO: Create a dummy OIDC provider that we can use for testing so we can test this fully.
+        });
+    });
+});
diff --git a/test/cypress/support/commands.js b/test/cypress/support/commands.js
index 7d602ecb0..10c30a764 100644
--- a/test/cypress/support/commands.js
+++ b/test/cypress/support/commands.js
@@ -10,6 +10,13 @@
 //
 
 import 'cypress-wait-until';
+import {
+	DEFAULT_ADMIN_EMAIL,
+	DEFAULT_ADMIN_PASSWORD,
+	TEST_USER_EMAIL,
+	TEST_USER_NAME,
+	TEST_USER_NICKNAME, TEST_USER_PASSWORD
+} from "./constants";
 
 Cypress.Commands.add('randomString', (length) => {
 	var result           = '';
@@ -40,13 +47,118 @@ Cypress.Commands.add('validateSwaggerSchema', (method, code, path, data) => {
 	}).should('equal', null);
 });
 
+/**
+ * Configure OIDC settings in the backend, so we can test scenarios around OIDC being enabled or disabled.
+ */
+Cypress.Commands.add('configureOidc', (enabled) => {
+	cy.getToken().then((token) => {
+		if (enabled) {
+			cy.task('backendApiPut', {
+				token: token,
+				path: '/api/settings/oidc-config',
+				data: {
+					meta: {
+						name: 'ACME OIDC Provider',
+						clientID: 'clientID',
+						clientSecret: 'clientSecret',
+						// TODO: Create dummy OIDC provider for testing
+						issuerURL: 'https://oidc.example.com',
+						redirectURL: 'https://redirect.example.com/api/oidc/callback',
+						enabled: true,
+					}
+				},
+			})
+		} else {
+			cy.task('backendApiPut', {
+				token: token,
+				path: '/api/settings/oidc-config',
+				data: {
+					meta: {
+						name: '',
+						clientID: '',
+						clientSecret: '',
+						issuerURL: '',
+						redirectURL: '',
+						enabled: false,
+					}
+				},
+			})
+		}
+	});
+});
+
+/**
+ * Create a new user in the backend for testing purposes.
+ *
+ * The created user will have a name, nickname, email, and password as defined in the constants file (TEST_USER_*).
+ *
+ * @param {boolean} withPassword Whether to create the user with a password or not (default: true)
+ */
+Cypress.Commands.add('createTestUser', (withPassword) => {
+	if (withPassword === undefined) {
+		withPassword = true;
+	}
+
+	cy.getToken().then((token) => {
+		cy.task('backendApiPost', {
+			token: token,
+			path: '/api/users',
+			data: {
+				name: TEST_USER_NAME,
+				nickname: TEST_USER_NICKNAME,
+				email: TEST_USER_EMAIL,
+				roles: ['admin'],
+				is_disabled: false,
+				auth: withPassword ? {
+					type: 'password',
+					secret: TEST_USER_PASSWORD
+				} : {}
+			}
+		})
+	});
+});
+
+
+/**
+ * Delete the test user from the backend.
+ * The test user is identified by the email address defined in the constants file (TEST_USER_EMAIL).
+ *
+ * This command will only attempt to delete the test user if it exists.
+ */
+Cypress.Commands.add('deleteTestUser', () => {
+	cy.getToken().then((token) => {
+		cy.task('backendApiGet', {
+			token: token,
+			path: '/api/users',
+		}).then((data) => {
+			// Find the test user
+			const testUser = data.find(user => user.email === TEST_USER_EMAIL);
+
+			// If the test user doesn't exist, we don't need to delete it
+			if (!testUser) {
+				return;
+			}
+
+			// Delete the test user
+			cy.task('backendApiDelete', {
+				token: token,
+				path: `/api/users/${testUser.id}`,
+			});
+		});
+	});
+});
+
+/**
+ * Get a new token from the backend.
+ * The token will be created using the default admin email and password defined in the constants file (DEFAULT_ADMIN_*).
+ */
 Cypress.Commands.add('getToken', () => {
 	// login with existing user
 	cy.task('backendApiPost', {
 		path: '/api/tokens',
 		data: {
-			identity: 'admin@example.com',
-			secret:   'changeme'
+			identity: DEFAULT_ADMIN_EMAIL,
+			secret: DEFAULT_ADMIN_PASSWORD
 		}
 	}).then(res => {
 		cy.wrap(res.token);
diff --git a/test/cypress/support/constants.js b/test/cypress/support/constants.js
new file mode 100644
index 000000000..9e2edcab6
--- /dev/null
+++ b/test/cypress/support/constants.js
@@ -0,0 +1,16 @@
+// Description: Constants used in the tests.
+
+/**
+ *  The default admin user is used to get tokens from the backend API to make requests.
+ *  It is also used to create the test user.
+ */
+export const DEFAULT_ADMIN_EMAIL = Cypress.env('DEFAULT_ADMIN_EMAIL') || 'admin@example.com';
+export const DEFAULT_ADMIN_PASSWORD =  Cypress.env('DEFAULT_ADMIN_PASSWORD') || 'changeme';
+
+/**
+ * The test user is created and deleted by the tests using `cy.createTestUser()` and `cy.deleteTestUser()`.
+ */
+export const TEST_USER_NAME = 'Robert Ross';
+export const TEST_USER_NICKNAME = 'Bob';
+export const TEST_USER_EMAIL = 'bob@ross.com';
+export const TEST_USER_PASSWORD = 'changeme';

From 6e41d7b51e43b665a51762970fa2e3eaf8f7df94 Mon Sep 17 00:00:00 2001
From: Cameron Hutchison <chutch112292@gmail.com>
Date: Wed, 11 Dec 2024 13:25:46 -0600
Subject: [PATCH 24/25] Update warning in documentation to be consistent with
 the rest of the page

---
 docs/src/setup/index.md | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/docs/src/setup/index.md b/docs/src/setup/index.md
index 8af828b21..b7aac170f 100644
--- a/docs/src/setup/index.md
+++ b/docs/src/setup/index.md
@@ -149,7 +149,12 @@ Immediately after logging in with this default user you will be asked to modify
 ## 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.*
+
+::: warning
+
+Please note, that 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.

From 46f0b5250972443a32b5d74b366af9b37e72162e Mon Sep 17 00:00:00 2001
From: Cameron Hutchison <chutch112292@gmail.com>
Date: Wed, 11 Dec 2024 16:46:20 -0600
Subject: [PATCH 25/25] Update error messages for login tests

---
 test/cypress/e2e/ui/Login.cy.js | 8 ++++----
 1 file changed, 4 insertions(+), 4 deletions(-)

diff --git a/test/cypress/e2e/ui/Login.cy.js b/test/cypress/e2e/ui/Login.cy.js
index 2b17ffa87..3b765920c 100644
--- a/test/cypress/e2e/ui/Login.cy.js
+++ b/test/cypress/e2e/ui/Login.cy.js
@@ -67,7 +67,7 @@ describe('Login', () => {
                 // Expect a 401 from the backend
                 cy.get('@login').its('response.statusCode').should('eq', 401);
                 // Expect an error message on the UI
-                cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid password');
+                cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid email or password');
             });
 
             it('should show an error message if the email is incorrect', () => {
@@ -85,7 +85,7 @@ describe('Login', () => {
                 // Expect a 401 from the backend
                 cy.get('@login').its('response.statusCode').should('eq', 401);
                 // Expect an error message on the UI
-                cy.get('div[data-cy="password-error"]').should('contain.text', 'No relevant user found');
+                cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid email or password');
             });
         });
     });
@@ -150,7 +150,7 @@ describe('Login', () => {
                 // Expect a 401 from the backend
                 cy.get('@login').its('response.statusCode').should('eq', 401);
                 // Expect an error message on the UI
-                cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid password');
+                cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid email or password');
             });
 
             it('should show an error message if the email is incorrect', () => {
@@ -168,7 +168,7 @@ describe('Login', () => {
                 // Expect a 401 from the backend
                 cy.get('@login').its('response.statusCode').should('eq', 401);
                 // Expect an error message on the UI
-                cy.get('div[data-cy="password-error"]').should('contain.text', 'No relevant user found');
+                cy.get('div[data-cy="password-error"]').should('contain.text', 'Invalid email or password');
             });
         });