Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ Etherpad's `requireAuthentication` setting must be `true`.
with your identity provider, the redirect URL (a.k.a. callback URL) is this
base URL plus `/ep_openid_connect/callback`.
* `scope` (optional; defaults to `["openid"]`): List of OAuth2 scope strings.
* `CA` (optional): Path to a custom Certificate Authority for self-signed
deployments.
* `prohibited_usernames` (optional; defaults to `["admin", "guest"]`): List of
strings that will trigger an authentication error if any match the `sub`
(subject) claim from the identity provider. Use this to avoid conflicts with
Expand Down
82 changes: 49 additions & 33 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
'use strict';

const Ajv = require('ajv/dist/jtd');
const {URL} = require('url');
const {Issuer, generators} = require('openid-client');
const { URL } = require('url');
const { Issuer, generators, custom } = require('openid-client');
const { readFileSync } = require('fs');

let logger = {};
for (const level of ['debug', 'info', 'warn', 'error']) {
Expand All @@ -18,22 +19,25 @@ let oidcClient = null;

const validSettings = new Ajv().compile({
properties: {
base_url: {type: 'string'},
client_id: {type: 'string'},
client_secret: {type: 'string'},
base_url: { type: 'string' },
client_id: { type: 'string' },
client_secret: { type: 'string' },
},
optionalProperties: {
issuer: {type: 'string'},
CA: { type: 'string' },
issuer: { type: 'string' },
issuer_metadata: {},
prohibited_usernames: {elements: {type: 'string'}},
scope: {elements: {type: 'string'}},
user_properties: {values: {
optionalProperties: {
claim: {type: 'string'},
default: {type: 'string'},
},
nullable: true,
}},
prohibited_usernames: { elements: { type: 'string' } },
scope: { elements: { type: 'string' } },
user_properties: {
values: {
optionalProperties: {
claim: { type: 'string' },
default: { type: 'string' },
},
nullable: true,
}
},
},
});

Expand All @@ -42,9 +46,9 @@ const endpointUrl = (endpoint) => new URL(ep(endpoint).substr(1), settings.base_

const validateSubClaim = (sub) => {
if (typeof sub !== 'string' || // 'sub' claim must exist as a string per OIDC spec.
sub === '' || // Empty string doesn't make sense.
sub === '__proto__' || // Prevent prototype pollution.
settings.prohibited_usernames.includes(sub)) {
sub === '' || // Empty string doesn't make sense.
sub === '__proto__' || // Prevent prototype pollution.
settings.prohibited_usernames.includes(sub)) {
throw new Error('invalid sub claim');
}
};
Expand All @@ -68,12 +72,12 @@ const discoverIssuer = async (issuerUrl) => {
const discoveryUrl = new URL(issuerUrl);
if (!discoveryUrl.pathname.includes('/.well-known/')) {
discoveryUrl.pathname =
`${discoveryUrl.pathname.replace(/\/$/, '')}/.well-known/openid-configuration`;
`${discoveryUrl.pathname.replace(/\/$/, '')}/.well-known/openid-configuration`;
}
logger.error(
'Failed to discover issuer metadata via OpenID Connect Discovery ' +
'(https://openid.net/specs/openid-connect-discovery-1_0.html). ' +
`Does your issuer support Discovery? (hint: ${discoveryUrl})`);
'Failed to discover issuer metadata via OpenID Connect Discovery ' +
'(https://openid.net/specs/openid-connect-discovery-1_0.html). ' +
`Does your issuer support Discovery? (hint: ${discoveryUrl})`);
throw err;
}
logger.info('OpenID Connect Discovery complete.');
Expand All @@ -85,11 +89,11 @@ const getIssuer = async (settings) => {
return new Issuer(settings.issuer_metadata);
};

exports.init_ep_openid_connect = async (hookName, {logger: l}) => {
exports.init_ep_openid_connect = async (hookName, { logger: l }) => {
if (l != null) logger = l;
};

exports.loadSettings = async (hookName, {settings: {ep_openid_connect: s = {}}}) => {
exports.loadSettings = async (hookName, { settings: { ep_openid_connect: s = {} } }) => {
oidcClient = null;
settings = null;
if (!validSettings(s)) {
Expand All @@ -108,15 +112,27 @@ exports.loadSettings = async (hookName, {settings: {ep_openid_connect: s = {}}})
...defaultSettings,
...s,
user_properties: {
displayname: {claim: 'name'},
displayname: { claim: 'name' },
...s.user_properties,
// The username property must always match the key used in settings.users.
username: {claim: 'sub'},
username: { claim: 'sub' },
},
};
// Make sure base_url ends with '/' so that relative URLs are appended:
if (!settings.base_url.endsWith('/')) settings.base_url += '/';
logger.debug('Settings:', {...settings, client_secret: '********'});
// Configure custom CA if needed
if (settings.CA) {
var CAFile;
try {
CAFile = readFileSync(settings.CA);
custom.setHttpOptionsDefaults({
ca: [CAFile],
});
} catch (err) {
logger.error(`Cannot use custom CA: ${err.stack || err.message || String(err)}`)
}
}
logger.debug('Settings:', { ...settings, client_secret: '********' });
oidcClient = new (await getIssuer(settings)).Client({
client_id: settings.client_id,
client_secret: settings.client_secret,
Expand All @@ -126,7 +142,7 @@ exports.loadSettings = async (hookName, {settings: {ep_openid_connect: s = {}}})
logger.info('Configured.');
};

exports.expressCreateServer = (hookName, {app}) => {
exports.expressCreateServer = (hookName, { app }) => {
logger.debug('Configuring auth routes');
app.get(ep('callback'), async (req, res, next) => {
// This handler MUST NOT redirect to a page that requires authentication if there is a problem,
Expand All @@ -141,7 +157,7 @@ exports.expressCreateServer = (hookName, {app}) => {
const oidcSession = req.session.ep_openid_connect || {};
if (oidcSession.callbackChecks == null) throw new Error('missing authentication checks');
const tokenset =
await oidcClient.callback(endpointUrl('callback'), params, oidcSession.callbackChecks);
await oidcClient.callback(endpointUrl('callback'), params, oidcSession.callbackChecks);
const userinfo = await oidcClient.userinfo(tokenset);
validateSubClaim(userinfo.sub);
// The user has successfully authenticated, but don't set req.session.user here -- do it in
Expand Down Expand Up @@ -195,10 +211,10 @@ exports.expressCreateServer = (hookName, {app}) => {
});
};

exports.authenticate = (hookName, {req, res, users}) => {
exports.authenticate = (hookName, { req, res, users }) => {
if (oidcClient == null) return;
logger.debug('authenticate hook for', req.url);
const {ep_openid_connect: {userinfo} = {}} = req.session;
const { ep_openid_connect: { userinfo } = {} } = req.session;
if (userinfo == null) { // Nullish means the user isn't authenticated.
// Out of an abundance of caution, clear out the old state, nonce, and userinfo (if present) to
// force regeneration.
Expand All @@ -223,7 +239,7 @@ exports.authenticate = (hookName, {req, res, users}) => {
return true;
};

exports.authnFailure = (hookName, {req, res}) => {
exports.authnFailure = (hookName, { req, res }) => {
if (oidcClient == null) return;
// Normally the user is redirected to the login page which would then redirect the user back once
// authenticated. For non-GET requests, send a 401 instead because users can't be redirected back.
Expand Down Expand Up @@ -263,7 +279,7 @@ exports.authnFailure = (hookName, {req, res}) => {
return true;
};

exports.preAuthorize = (hookName, {req}) => {
exports.preAuthorize = (hookName, { req }) => {
if (oidcClient == null) return;
if (req.path.startsWith(ep(''))) return true;
return;
Expand Down