Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEAT: Open ID Cconnect authentication #2630

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Next Next commit
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
marekful committed Feb 24, 2023
commit caeb2934f0dff0e6b7d73b9bbeddb74a2f31116d
46 changes: 46 additions & 0 deletions backend/internal/token.js
Original file line number Diff line number Diff line change
@@ -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]
4 changes: 3 additions & 1 deletion backend/lib/express/jwt-decode.js
Original file line number Diff line number Diff line change
@@ -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();
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
@@ -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",
1 change: 1 addition & 0 deletions backend/routes/api/main.js
Original file line number Diff line number Diff line change
@@ -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'));
132 changes: 132 additions & 0 deletions backend/routes/api/oidc.js
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions backend/routes/api/settings.js
Original file line number Diff line number Diff line change
@@ -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);
})
2 changes: 2 additions & 0 deletions backend/routes/api/tokens.js
Original file line number Diff line number Diff line change
@@ -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);
})
37 changes: 37 additions & 0 deletions backend/yarn.lock
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions frontend/js/app/api.js
Original file line number Diff line number Diff line change
@@ -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));
},

5 changes: 5 additions & 0 deletions frontend/js/app/controller.js
Original file line number Diff line number Diff line change
@@ -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}));
});
}
}
},

8 changes: 8 additions & 0 deletions frontend/js/app/settings/list/item.ejs
Original file line number Diff line number Diff line change
@@ -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">
47 changes: 47 additions & 0 deletions frontend/js/app/settings/oidc-config/main.ejs
Original file line number Diff line number Diff line change
@@ -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>
47 changes: 47 additions & 0 deletions frontend/js/app/settings/oidc-config/main.js
Original file line number Diff line number Diff line change
@@ -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');
});
}
}
});
1 change: 1 addition & 0 deletions frontend/js/i18n/messages.json
Original file line number Diff line number Diff line change
@@ -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",
9 changes: 8 additions & 1 deletion frontend/js/login/ui/login.ejs
Original file line number Diff line number Diff line change
@@ -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>
65 changes: 60 additions & 5 deletions frontend/js/login/ui/login.js
Original file line number Diff line number Diff line change
@@ -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();
}
},

30 changes: 30 additions & 0 deletions frontend/scss/custom.scss
Original file line number Diff line number Diff line change
@@ -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;
}