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 Connect authentication #4010

Open
wants to merge 30 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
caeb293
FEAT: Add Open ID Connect authentication method
marekful Feb 24, 2023
3e2a411
chore: add oidc setting db entry during setup
marekful Feb 24, 2023
457d1a7
chore: improve oidc setting ui
marekful Feb 24, 2023
8350271
chore: add message texts
marekful Feb 24, 2023
bc0b466
refactor: improve code structure
marekful Feb 24, 2023
baee464
chore: improve error handling
marekful Feb 24, 2023
6f98fa6
refactor: satisfy linter requirements
marekful Feb 24, 2023
df5ab36
chore: update comments, remove debug logging
marekful Feb 24, 2023
ef64edd
fix: add database migration for oidc-config setting
marekful Feb 26, 2023
fd49644
fix: linter
marekful Feb 26, 2023
d0d36a9
fix: add oidc-config setting via setup.js rather than migrations
marekful Mar 6, 2023
6ed6415
fix: add oidc logger and replace console logging
marekful Mar 6, 2023
0f588ba
fix: indentation
marekful Mar 9, 2023
0b09f03
Merge remote-tracking branch 'origin/develop' into FEAT/open-id-conne…
oechsler Sep 19, 2024
8b84117
Fix configuration template
oechsler Sep 19, 2024
7196dfa
Merge remote-tracking branch 'origin/develop' into FEAT/open-id-conne…
oechsler Oct 30, 2024
0b126ca
Add oidc-config to OpenAPI schema
oechsler Oct 30, 2024
7ef52d8
Update yarn.lock
oechsler Oct 30, 2024
1a030a6
Enforce token auth for odic config PUT call
oechsler Oct 30, 2024
eb312cc
Remove nodemon dependency in package.json
oechsler Oct 31, 2024
637b773
Make the error message for when a user does not exist when attempting…
chutch1122 Dec 10, 2024
e4b87d0
Add documentation for configuring SSO with OIDC
chutch1122 Dec 10, 2024
81aa8a4
Make 'Redirect URL' match the name of the field.
chutch1122 Dec 10, 2024
1ed15b3
Add Cypress tests for updating the OIDC configuration
chutch1122 Dec 10, 2024
529c84f
Add UI E2E tests for the login page for OIDC being enabled and when i…
chutch1122 Dec 11, 2024
6e41d7b
Update warning in documentation to be consistent with the rest of the…
chutch1122 Dec 11, 2024
2cae60d
Merge pull request #1 from chutch1122/FEAT/open-id-connect-authentica…
oechsler Dec 11, 2024
d714fee
Merge remote-tracking branch 'upstream/develop' into FEAT/open-id-con…
chutch1122 Dec 11, 2024
46f0b52
Update error messages for login tests
chutch1122 Dec 11, 2024
03fbebc
Merge pull request #2 from chutch1122/FEAT/open-id-connect-authentica…
oechsler Dec 12, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
9 changes: 8 additions & 1 deletion backend/lib/express/jwt-decode.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
3 changes: 2 additions & 1 deletion backend/logger.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 '})
};
4 changes: 3 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
"moment": "^2.29.4",
"mysql2": "^3.11.1",
"node-rsa": "^1.0.8",
"nodemon": "^2.0.2",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nodemon is already in devDependencies - adding it here is unnecessary and will add bloat to the final image

"openid-client": "^5.4.0",
"objection": "3.0.1",
"path": "^0.12.7",
"signale": "1.4.0",
Expand All @@ -44,4 +46,4 @@
"scripts": {
"validate-schema": "node validate-schema.js"
}
}
}
1 change: 1 addition & 0 deletions backend/routes/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/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;
12 changes: 12 additions & 0 deletions backend/routes/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
})
Expand Down
2 changes: 2 additions & 0 deletions backend/routes/tokens.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
})
Expand Down
73 changes: 57 additions & 16 deletions backend/schema/paths/settings/settingID/put.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"schema": {
"type": "string",
"minLength": 1,
"enum": ["default-site"]
"enum": ["default-site", "oidc-config"]
},
"required": true,
"description": "Setting ID",
Expand All @@ -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"
}
}
}
}
}
}
]
}
}
}
Expand Down
Loading