diff --git a/backend/internal/token.js b/backend/internal/token.js index a64b9010..8e04341d 100644 --- a/backend/internal/token.js +++ b/backend/internal/token.js @@ -82,6 +82,52 @@ module.exports = { }); }, + /** + * @param {Object} data + * @param {String} data.identity + * @param {String} [issuer] + * @returns {Promise} + */ + getTokenFromOAuthClaim: (data, issuer) => { + let Token = new TokenModel(); + + data.scope = 'user'; + data.expiry = '1d'; + + return userModel + .query() + .where('email', data.identity) + .andWhere('is_deleted', 0) + .andWhere('is_disabled', 0) + .first() + .then((user) => { + if (!user) { + throw new error.AuthError('No relevant user found'); + } + + // Create a moment of the expiry expression + let expiry = helpers.parseDatePeriod(data.expiry); + if (expiry === null) { + throw new error.AuthError('Invalid expiry time: ' + data.expiry); + } + + let iss = 'api', + attrs = { id: user.id }, + scope = [ data.scope ], + expiresIn = data.expiry; + + return Token.create({ iss, attrs, scope, expiresIn }) + .then((signed) => { + return { + token: signed.token, + expires: expiry.toISOString() + }; + }); + + } + ); + }, + /** * @param {Access} access * @param {Object} [data] diff --git a/backend/lib/express/jwt-decode.js b/backend/lib/express/jwt-decode.js index 17edccec..745763a7 100644 --- a/backend/lib/express/jwt-decode.js +++ b/backend/lib/express/jwt-decode.js @@ -4,7 +4,9 @@ module.exports = () => { return function (req, res, next) { res.locals.access = null; let access = new Access(res.locals.token || null); - access.load() + // allow unauthenticated access to OIDC configuration + let anon_access = req.url === '/oidc-config' && !access.token.getUserId(); + access.load(anon_access) .then(() => { res.locals.access = access; next(); diff --git a/backend/package.json b/backend/package.json index bc682106..f90c2640 100644 --- a/backend/package.json +++ b/backend/package.json @@ -24,6 +24,7 @@ "node-rsa": "^1.0.8", "nodemon": "^2.0.2", "objection": "^2.2.16", + "openid-client": "^5.4.0", "path": "^0.12.7", "signale": "^1.4.0", "sqlite3": "^4.1.1", diff --git a/backend/routes/api/main.js b/backend/routes/api/main.js index 33cbbc21..2f3ec6d7 100644 --- a/backend/routes/api/main.js +++ b/backend/routes/api/main.js @@ -27,6 +27,7 @@ router.get('/', (req, res/*, next*/) => { router.use('/schema', require('./schema')); router.use('/tokens', require('./tokens')); +router.use('/oidc', require('./oidc')) router.use('/users', require('./users')); router.use('/audit-log', require('./audit-log')); router.use('/reports', require('./reports')); diff --git a/backend/routes/api/oidc.js b/backend/routes/api/oidc.js new file mode 100644 index 00000000..e60949b3 --- /dev/null +++ b/backend/routes/api/oidc.js @@ -0,0 +1,132 @@ +const crypto = require('crypto'); +const express = require('express'); +const jwtdecode = require('../../lib/express/jwt-decode'); +const oidc = require('openid-client'); +const settingModel = require('../../models/setting'); +const internalToken = require('../../internal/token'); + +let router = express.Router({ + caseSensitive: true, + strict: true, + mergeParams: true +}); + +/** + * OAuth Authorization Code flow initialisation + * + * /api/oidc + */ +router + .route('/') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /api/users + * + * Retrieve all users + */ + .get(jwtdecode(), async (req, res, next) => { + console.log("oidc init >>>", res.locals.access, oidc); + + settingModel + .query() + .where({id: 'oidc-config'}) + .first() + .then( async row => { + console.log('oidc init > config > ', row); + + let issuer = await oidc.Issuer.discover(row.meta.issuerURL); + let client = new issuer.Client({ + client_id: row.meta.clientID, + client_secret: row.meta.clientSecret, + redirect_uris: [row.meta.redirectURL], + response_types: ['code'], + }) + let state = crypto.randomUUID(); + let nonce = crypto.randomUUID(); + let url = client.authorizationUrl({ + scope: 'openid email profile', + resource: 'http://rye.local:2081/api/oidc/callback', + state, + nonce, + }) + + console.log('oidc init > url > ', state, nonce, url); + + res.cookie("npm_oidc", state + '--' + nonce); + res.redirect(url); + }); + }); + + +/** + * Oauth Authorization Code flow callback + * + * /api/oidc/callback + */ +router + .route('/callback') + .options((req, res) => { + res.sendStatus(204); + }) + .all(jwtdecode()) + + /** + * GET /users/123 or /users/me + * + * Retrieve a specific user + */ + .get(jwtdecode(), async (req, res, next) => { + console.log("oidc callback >>>"); + + settingModel + .query() + .where({id: 'oidc-config'}) + .first() + .then( async row => { + console.log('oidc callback > config > ', row); + + let issuer = await oidc.Issuer.discover(row.meta.issuerURL); + let client = new issuer.Client({ + client_id: row.meta.clientID, + client_secret: row.meta.clientSecret, + redirect_uris: [row.meta.redirectURL], + response_types: ['code'], + }); + + let state, nonce; + let cookies = req.headers.cookie.split(';'); + for (cookie of cookies) { + if (cookie.split('=')[0].trim() === 'npm_oidc') { + let raw = cookie.split('=')[1]; + let val = raw.split('--'); + state = val[0].trim(); + nonce = val[1].trim(); + break; + } + } + + const params = client.callbackParams(req); + const tokenSet = await client.callback(row.meta.redirectURL, params, { /*code_verifier: verifier,*/ state, nonce }); + let claims = tokenSet.claims(); + console.log('validated ID Token claims %j', claims); + + return internalToken.getTokenFromOAuthClaim({ identity: claims.email }) + + }) + .then( response => { + console.log('oidc callback > signed token > >', response); + res.cookie('npm_oidc', response.token + '---' + response.expires); + res.redirect('/login'); + }) + .catch( err => { + console.log('oidc callback ERR > ', err); + res.cookie('npm_oidc_error', err.message); + res.redirect('/login'); + }); + }); + +module.exports = router; diff --git a/backend/routes/api/settings.js b/backend/routes/api/settings.js index d08b2bf5..edb9edd8 100644 --- a/backend/routes/api/settings.js +++ b/backend/routes/api/settings.js @@ -69,6 +69,17 @@ router }); }) .then((row) => { + if (row.id === 'oidc-config') { + // redact oidc configuration via api + let m = row.meta + row.meta = { + name: m.name, + enabled: m.enabled === true && !!(m.clientID && m.clientSecret && m.issuerURL && m.redirectURL && m.name) + }; + // remove these temporary cookies used during oidc authentication + res.clearCookie('npm_oidc') + res.clearCookie('npm_oidc_error') + } res.status(200) .send(row); }) diff --git a/backend/routes/api/tokens.js b/backend/routes/api/tokens.js index a21f998a..29bfbbaf 100644 --- a/backend/routes/api/tokens.js +++ b/backend/routes/api/tokens.js @@ -28,6 +28,8 @@ router scope: (typeof req.query.scope !== 'undefined' ? req.query.scope : null) }) .then((data) => { + // clear this temporary cookie following a successful oidc authentication + res.clearCookie('npm_oidc'); res.status(200) .send(data); }) diff --git a/backend/yarn.lock b/backend/yarn.lock index 396e11c9..e7deee42 100644 --- a/backend/yarn.lock +++ b/backend/yarn.lock @@ -1874,6 +1874,11 @@ isobject@^3.0.0, isobject@^3.0.1: resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8= +jose@^4.10.0: + version "4.12.0" + resolved "https://registry.yarnpkg.com/jose/-/jose-4.12.0.tgz#7f00cd2f82499b91623cd413b7b5287fd52651ed" + integrity sha512-wW1u3cK81b+SFcHjGC8zw87yuyUweEFe0UJirrXEw1NasW00eF7sZjeG3SLBGz001ozxQ46Y9sofDvhBmWFtXQ== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" @@ -2142,6 +2147,13 @@ lowercase-keys@^2.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== +lru-cache@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" + integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== + dependencies: + yallist "^4.0.0" + make-dir@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -2487,6 +2499,11 @@ object-copy@^0.1.0: define-property "^0.2.5" kind-of "^3.0.3" +object-hash@^2.0.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-2.2.0.tgz#5ad518581eefc443bd763472b8ff2e9c2c0d54a5" + integrity sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw== + object-visit@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/object-visit/-/object-visit-1.0.1.tgz#f79c4493af0c5377b59fe39d395e41042dd045bb" @@ -2527,6 +2544,11 @@ objection@^2.2.16: ajv "^6.12.6" db-errors "^0.2.3" +oidc-token-hash@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/oidc-token-hash/-/oidc-token-hash-5.0.1.tgz#ae6beec3ec20f0fd885e5400d175191d6e2f10c6" + integrity sha512-EvoOtz6FIEBzE+9q253HsLCVRiK/0doEJ2HCvvqMQb3dHZrP3WlJKYtJ55CRTw4jmYomzH4wkPuCj/I3ZvpKxQ== + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" @@ -2553,6 +2575,16 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" +openid-client@^5.4.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/openid-client/-/openid-client-5.4.0.tgz#77f1cda14e2911446f16ea3f455fc7c405103eac" + integrity sha512-hgJa2aQKcM2hn3eyVtN12tEA45ECjTJPXCgUh5YzTzy9qwapCvmDTVPWOcWVL0d34zeQoQ/hbG9lJhl3AYxJlQ== + dependencies: + jose "^4.10.0" + lru-cache "^6.0.0" + object-hash "^2.0.1" + oidc-token-hash "^5.0.1" + optionator@^0.8.3: version "0.8.3" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" @@ -3719,6 +3751,11 @@ yallist@^3.0.0, yallist@^3.1.1: resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== +yallist@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" + integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== + yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" diff --git a/frontend/js/app/api.js b/frontend/js/app/api.js index 6e33a6dc..b314b40b 100644 --- a/frontend/js/app/api.js +++ b/frontend/js/app/api.js @@ -59,6 +59,8 @@ function fetch(verb, path, data, options) { }, beforeSend: function (xhr) { + // allow unauthenticated access to OIDC configuration + if (path === "settings/oidc-config") return; xhr.setRequestHeader('Authorization', 'Bearer ' + (token ? token.t : null)); }, diff --git a/frontend/js/app/controller.js b/frontend/js/app/controller.js index ccb2978a..a2c112b3 100644 --- a/frontend/js/app/controller.js +++ b/frontend/js/app/controller.js @@ -434,6 +434,11 @@ module.exports = { App.UI.showModalDialog(new View({model: model})); }); } + if (model.get('id') === 'oidc-config') { + require(['./main', './settings/oidc-config/main'], function (App, View) { + App.UI.showModalDialog(new View({model: model})); + }); + } } }, diff --git a/frontend/js/app/settings/list/item.ejs b/frontend/js/app/settings/list/item.ejs index 4f81b450..21eae7ed 100644 --- a/frontend/js/app/settings/list/item.ejs +++ b/frontend/js/app/settings/list/item.ejs @@ -9,6 +9,14 @@ <% if (id === 'default-site') { %> <%- i18n('settings', 'default-site-' + value) %> <% } %> + <% if (id === 'oidc-config' && meta && meta.name && meta.clientID && meta.clientSecret && meta.issuerURL && meta.redirectURL) { %> + <%- meta.name %> + <% if (!meta.enabled) { %> + (Disabled) + <% } %> + <% } else if (id === 'oidc-config') { %> + Not configured + <% } %> diff --git a/frontend/js/app/settings/oidc-config/main.ejs b/frontend/js/app/settings/oidc-config/main.ejs new file mode 100644 index 00000000..6bd7c342 --- /dev/null +++ b/frontend/js/app/settings/oidc-config/main.ejs @@ -0,0 +1,47 @@ + diff --git a/frontend/js/app/settings/oidc-config/main.js b/frontend/js/app/settings/oidc-config/main.js new file mode 100644 index 00000000..8fffcd69 --- /dev/null +++ b/frontend/js/app/settings/oidc-config/main.js @@ -0,0 +1,47 @@ +const Mn = require('backbone.marionette'); +const App = require('../../main'); +const template = require('./main.ejs'); + +require('jquery-serializejson'); +require('selectize'); + +module.exports = Mn.View.extend({ + template: template, + className: 'modal-dialog', + + ui: { + form: 'form', + buttons: '.modal-footer button', + cancel: 'button.cancel', + save: 'button.save', + }, + + events: { + 'click @ui.save': function (e) { + e.preventDefault(); + + if (!this.ui.form[0].checkValidity()) { + $('').hide().appendTo(this.ui.form).click().remove(); + return; + } + + let view = this; + let data = this.ui.form.serializeJSON(); + data.id = this.model.get('id'); + if (data.meta.enabled) { + data.meta.enabled = data.meta.enabled === "on" || data.meta.enabled === "true"; + } + + this.ui.buttons.prop('disabled', true).addClass('btn-disabled'); + App.Api.Settings.update(data) + .then(result => { + view.model.set(result); + App.UI.closeModal(); + }) + .catch(err => { + alert(err.message); + this.ui.buttons.prop('disabled', false).removeClass('btn-disabled'); + }); + } + } +}); diff --git a/frontend/js/i18n/messages.json b/frontend/js/i18n/messages.json index aa544c7e..c6f90941 100644 --- a/frontend/js/i18n/messages.json +++ b/frontend/js/i18n/messages.json @@ -5,6 +5,7 @@ "username": "Username", "password": "Password", "sign-in": "Sign in", + "sign-in-with": "Sign in with", "sign-out": "Sign out", "try-again": "Try again", "name": "Name", diff --git a/frontend/js/login/ui/login.ejs b/frontend/js/login/ui/login.ejs index 693bc050..84aa90a0 100644 --- a/frontend/js/login/ui/login.ejs +++ b/frontend/js/login/ui/login.ejs @@ -5,7 +5,7 @@
-
+
Logo
@@ -27,6 +27,13 @@ +
diff --git a/frontend/js/login/ui/login.js b/frontend/js/login/ui/login.js index 757eb4e3..50064f24 100644 --- a/frontend/js/login/ui/login.js +++ b/frontend/js/login/ui/login.js @@ -3,17 +3,22 @@ const Mn = require('backbone.marionette'); const template = require('./login.ejs'); const Api = require('../../app/api'); const i18n = require('../../app/i18n'); +const Tokens = require('../../app/tokens'); module.exports = Mn.View.extend({ template: template, className: 'page-single', ui: { - form: 'form', - identity: 'input[name="identity"]', - secret: 'input[name="secret"]', - error: '.secret-error', - button: 'button' + form: 'form', + identity: 'input[name="identity"]', + secret: 'input[name="secret"]', + error: '.secret-error', + button: 'button[type=submit]', + oidcLogin: 'div.login-oidc', + oidcButton: 'button#login-oidc', + oidcError: '.oidc-error', + oidcProvider: 'span.oidc-provider' }, events: { @@ -30,6 +35,56 @@ module.exports = Mn.View.extend({ this.ui.error.text(err.message).show(); this.ui.button.removeClass('btn-loading').prop('disabled', false); }); + }, + 'click @ui.oidcButton': function(e) { + this.ui.identity.prop('disabled', true); + this.ui.secret.prop('disabled', true); + this.ui.button.prop('disabled', true); + this.ui.oidcButton.addClass('btn-loading').prop('disabled', true); + // redirect to initiate oauth flow + document.location.replace('/api/oidc/'); + }, + }, + + async onRender() { + // read oauth callback state cookies + let cookies = document.cookie.split(';'), + token, expiry, error; + for (cookie of cookies) { + let raw = cookie.split('='), + name = raw[0].trim(), + value = raw[1]; + if (name === 'npm_oidc') { + let v = value.split('---'); + token = v[0]; + expiry = v[1]; + } + if (name === 'npm_oidc_error') { + console.log(' ERROR 000 > ', value); + error = decodeURIComponent(value); + } + } + + console.log('login.js event > render', expiry, token); + // register a newly acquired jwt token following successful oidc authentication + if (token && expiry && (new Date(Date.parse(decodeURIComponent(expiry)))) > new Date() ) { + console.log('login.js event > render >>>'); + Tokens.addToken(token); + document.location.replace('/'); + } + + // show error message following a failed oidc authentication + if (error) { + console.log(' ERROR > ', error); + this.ui.oidcError.html(error); + } + + // fetch oidc configuration and show alternative action button if enabled + let response = await Api.Settings.getById("oidc-config"); + if (response && response.meta && response.meta.enabled === true) { + this.ui.oidcProvider.html(response.meta.name); + this.ui.oidcLogin.show(); + this.ui.oidcError.show(); } }, diff --git a/frontend/scss/custom.scss b/frontend/scss/custom.scss index 4037dcf6..30abfb8b 100644 --- a/frontend/scss/custom.scss +++ b/frontend/scss/custom.scss @@ -39,4 +39,34 @@ a:hover { .col-login { max-width: 48rem; +} + +.margin-auto { + margin: auto; +} + +.separator { + display: flex; + align-items: center; + text-align: center; + margin-bottom: 1em; +} + +.separator::before, .separator::after { + content: ""; + flex: 1 1 0%; + border-bottom: 1px solid #ccc; +} + +.separator:not(:empty)::before { + margin-right: 0.5em; +} + +.separator:not(:empty)::after { + margin-left: 0.5em; +} + +.login-oidc { + display: none; + margin-top: 1em; } \ No newline at end of file