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 + <% } %>