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
This commit is contained in:
Marcell FÜLÖP 2023-02-24 08:39:21 +00:00 committed by Marcell Fülöp
parent 5920b0cf5e
commit caeb2934f0
17 changed files with 441 additions and 7 deletions

View File

@ -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]

View File

@ -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();

View File

@ -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",

View File

@ -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
backend/routes/api/oidc.js Normal file
View File

@ -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;

View File

@ -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);
})

View File

@ -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);
})

View File

@ -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"

View File

@ -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));
},

View File

@ -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}));
});
}
}
},

View File

@ -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">

View File

@ -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>

View File

@ -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');
});
}
}
});

View File

@ -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",

View File

@ -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>

View File

@ -3,6 +3,7 @@ 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,
@ -13,7 +14,11 @@ module.exports = Mn.View.extend({
identity: 'input[name="identity"]',
secret: 'input[name="secret"]',
error: '.secret-error',
button: 'button'
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();
}
},

View File

@ -40,3 +40,33 @@ 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;
}