Skip to content

FEAT: Open ID Cconnect authentication #2630

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

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
41 changes: 41 additions & 0 deletions backend/internal/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,47 @@ module.exports = {
});
},

/**
* @param {Object} data
* @param {String} data.identity
* @param {String} [issuer]
* @returns {Promise}
*/
getTokenFromOAuthClaim: (data) => {
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]
Expand Down
4 changes: 3 additions & 1 deletion backend/lib/express/jwt-decode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion backend/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '})
};
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions backend/routes/api/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
168 changes: 168 additions & 0 deletions backend/routes/api/oidc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
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');

let router = express.Router({
caseSensitive: true,
strict: true,
mergeParams: true
});

router
.route('/')
.options((req, res) => {
res.sendStatus(204);
})
.all(jwtdecode())

/**
* GET /api/oidc
*
* OAuth Authorization Code flow initialisation
*/
.get(jwtdecode(), async (req, res) => {
logger.info('Initializing OAuth flow');
settingModel
.query()
.where({id: 'oidc-config'})
.first()
.then((row) => getInitParams(req, row))
.then((params) => redirectToAuthorizationURL(res, params))
.catch((err) => redirectWithError(res, err));
});


router
.route('/callback')
.options((req, res) => {
res.sendStatus(204);
})
.all(jwtdecode())

/**
* GET /api/oidc/callback
*
* Oauth Authorization Code flow callback
*/
.get(jwtdecode(), async (req, res) => {
logger.info('Processing callback');
settingModel
.query()
.where({id: 'oidc-config'})
.first()
.then((settings) => validateCallback(req, settings))
.then((token) => redirectWithJwtToken(res, token))
.catch((err) => redirectWithError(res, err));
});

/**
* Executes discovery and returns the configured `openid-client` client
*
* @param {Setting} row
* */
let getClient = async (row) => {
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,
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),
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.
*
* @param {Request} req
* @return { {String}, {String} } state and nonce
* */
let parseStateFromCookie = (req) => {
let state, nonce;
let cookies = req.headers.cookie.split(';');
for (let cookie of cookies) {
if (cookie.split('=')[0].trim() === 'npm_oidc') {
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.
*
* @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();

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) => {
logger.info('Authorization 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) => {
logger.error('Callback error: ' + error.message);
res.cookie('npm_oidc_error', error.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
Expand Up @@ -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);
})
Expand Down
2 changes: 2 additions & 0 deletions backend/routes/api/tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
})
Expand Down
30 changes: 27 additions & 3 deletions backend/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'})
Expand All @@ -148,13 +148,37 @@ const setupDefaultSettings = () => {
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');
}
})]);
};

/**
Expand Down
Loading