This commit is contained in:
Samuel Oechsler
2024-11-25 18:30:01 -05:00
committed by GitHub
20 changed files with 1124 additions and 1304 deletions

View File

@@ -84,6 +84,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]

View File

@@ -4,7 +4,14 @@ 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 get the oidc configuration
let oidc_access =
req.url === '/oidc-config' &&
req.method === 'GET' &&
!access.token.getUserId();
access.load(oidc_access)
.then(() => {
res.locals.access = access;
next();

View File

@@ -10,5 +10,6 @@ module.exports = {
certbot: new Signale({scope: 'Certbot '}),
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 '})
};

View File

@@ -21,6 +21,7 @@
"moment": "^2.29.4",
"mysql2": "^3.11.1",
"node-rsa": "^1.0.8",
"openid-client": "^5.4.0",
"objection": "3.0.1",
"path": "^0.12.7",
"signale": "1.4.0",
@@ -44,4 +45,4 @@
"scripts": {
"validate-schema": "node validate-schema.js"
}
}
}

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

168
backend/routes/oidc.js Normal file
View File

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

View File

@@ -71,6 +71,18 @@ router
});
})
.then((row) => {
if (row.id === 'oidc-config') {
// Redact oidc configuration via api (unauthenticated get call)
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

@@ -29,6 +29,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

@@ -14,7 +14,7 @@
"schema": {
"type": "string",
"minLength": 1,
"enum": ["default-site"]
"enum": ["default-site", "oidc-config"]
},
"required": true,
"description": "Setting ID",
@@ -27,28 +27,69 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"value": {
"type": "string",
"minLength": 1,
"enum": ["congratulations", "404", "444", "redirect", "html"]
},
"meta": {
"oneOf": [
{
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"redirect": {
"type": "string"
"value": {
"type": "string",
"minLength": 1,
"enum": [
"congratulations",
"404",
"444",
"redirect",
"html"
]
},
"html": {
"type": "string"
"meta": {
"type": "object",
"additionalProperties": false,
"properties": {
"redirect": {
"type": "string"
},
"html": {
"type": "string"
}
}
}
}
},
{
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"meta": {
"type": "object",
"additionalProperties": false,
"properties": {
"clientID": {
"type": "string"
},
"clientSecret": {
"type": "string"
},
"enabled": {
"type": "boolean"
},
"issuerURL": {
"type": "string"
},
"name": {
"type": "string"
},
"redirectURL": {
"type": "string"
}
}
}
}
}
}
]
}
}
}

View File

@@ -75,30 +75,56 @@ const setupDefaultUser = () => {
* @returns {Promise}
*/
const setupDefaultSettings = () => {
return settingModel
.query()
.select(settingModel.raw('COUNT(`id`) as `count`'))
.where({id: 'default-site'})
.first()
.then((row) => {
if (!row.count) {
settingModel
.query()
.insert({
id: 'default-site',
name: 'Default Site',
description: 'What to show when Nginx is hit with an unknown Host',
value: 'congratulations',
meta: {},
})
.then(() => {
logger.info('Default settings added');
});
}
if (config.debug()) {
logger.info('Default setting setup not required');
}
});
return Promise.all([
settingModel
.query()
.select(settingModel.raw('COUNT(`id`) as `count`'))
.where({id: 'default-site'})
.first()
.then((row) => {
if (!row.count) {
settingModel
.query()
.insert({
id: 'default-site',
name: 'Default Site',
description: 'What to show when Nginx is hit with an unknown Host',
value: 'congratulations',
meta: {},
})
.then(() => {
logger.info('Added default-site setting');
});
}
if (config.debug()) {
logger.info('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 (config.debug()) {
logger.info('Default setting setup not required');
}
})
]);
};
/**

File diff suppressed because it is too large Load Diff