const openidModule = require('openid-client') const crypto = require('crypto') const store = { client: null, userAuthenticated: null, secretKey: null, redirectUrl: null, authDisplayName: 'OpenID Connect' } const encryptionOptions = { algorithm: 'aes256', inputEncoding: 'utf8', outputEncoding: 'hex' } const cookieNamePrefix = 'plugin-auth-openid-code-verifier-' async function register ({ registerExternalAuth, unregisterExternalAuth, registerSetting, settingsManager, peertubeHelpers, getRouter }) { const { logger } = peertubeHelpers registerSetting({ name: 'auth-display-name', label: 'Auth display name', type: 'input', private: true, default: 'OpenID Connect' }) registerSetting({ name: 'discover-url', label: 'Discover URL', type: 'input', private: true }) registerSetting({ name: 'client-id', label: 'Client ID', type: 'input', private: true }) registerSetting({ name: 'client-secret', label: 'Client secret', type: 'input', private: true }) registerSetting({ name: 'scope', label: 'Scope', type: 'input', private: true, default: 'openid email profile' }) registerSetting({ name: 'username-property', label: 'Username property', type: 'input', private: true, default: 'preferred_username' }) registerSetting({ name: 'mail-property', label: 'Email property', type: 'input', private: true, default: 'email' }) registerSetting({ name: 'display-name-property', label: 'Display name property', type: 'input', private: true }) registerSetting({ name: 'role-property', label: 'Role property', type: 'input', private: true }) registerSetting({ name: 'group-property', label: 'Group property', type: 'input', private: true, descriptionHTML: 'Property/claim that contains a users groups' }) registerSetting({ name: 'allowed-group', label: 'Allowed group', type: 'input', private: true, descriptionHTML: 'Will only allow login for users whose group array contains this group' }) const router = getRouter() router.use('/code-cb', (req, res) => handleCb(peertubeHelpers, settingsManager, req, res)) store.redirectUrl = peertubeHelpers.config.getWebserverUrl() + '/plugins/auth-openid-connect/router/code-cb' const secretKeyBuf = await getRandomBytes(16) store.secretKey = secretKeyBuf.toString('hex') settingsManager.onSettingsChange(settings => { loadSettingsAndCreateClient(registerExternalAuth, unregisterExternalAuth, peertubeHelpers, settingsManager) .catch(err => logger.error('Cannot load settings and create client after settings changes.', { err })) if (settings['auth-display-name']) store.authDisplayName = settings['auth-display-name'] }) await loadSettingsAndCreateClient(registerExternalAuth, unregisterExternalAuth, peertubeHelpers, settingsManager) store.authDisplayName = await settingsManager.getSetting('auth-display-name') } async function unregister () { return } module.exports = { register, unregister } // ############################################################################ async function loadSettingsAndCreateClient (registerExternalAuth, unregisterExternalAuth, peertubeHelpers, settingsManager) { const { logger, config } = peertubeHelpers if (store.client) { unregisterExternalAuth('openid-connect') } store.client = null store.userAuthenticated = null const settings = await settingsManager.getSettings([ 'scope', 'discover-url', 'client-id', 'client-secret' ]) if (!settings['discover-url']) { logger.info('Do not register external openid auth because discover URL is not set.') return } if (!settings['client-id']) { logger.info('Do not register external openid auth because client ID is not set.') return } const discoverUrl = settings['discover-url'] const issuer = await openidModule.Issuer.discover(discoverUrl) logger.debug('Discovered issuer %s.', discoverUrl) const clientOptions = { client_id: settings['client-id'], redirect_uris: [ store.redirectUrl ], response_types: [ 'code' ] } if (settings['client-secret']) { clientOptions.client_secret = settings['client-secret'] } else { clientOptions.token_endpoint_auth_method = 'none' } store.client = new issuer.Client(clientOptions) const webserverUrl = config.getWebserverUrl() const result = registerExternalAuth({ authName: 'openid-connect', authDisplayName: () => store.authDisplayName, onAuthRequest: async (req, res) => { try { const codeVerifier = openidModule.generators.codeVerifier() const codeChallenge = openidModule.generators.codeChallenge(codeVerifier) const state = openidModule.generators.state() const redirectUrl = store.client.authorizationUrl({ scope: settings['scope'], response_mode: 'form_post', code_challenge: codeChallenge, code_challenge_method: 'S256', state, }) const cookieOptions = { secure: webserverUrl.startsWith('https://'), httpOnly: true, sameSite: 'none', maxAge: 1000 * 60 * 10 // 10 minutes } const encryptedCodeVerifier = await encrypt(codeVerifier) res.cookie(cookieNamePrefix + 'code-verifier', encryptedCodeVerifier, cookieOptions) const encryptedState = await encrypt(state) res.cookie(cookieNamePrefix + 'state', encryptedState, cookieOptions) return res.redirect(redirectUrl) } catch (err) { logger.error('Cannot handle auth request.', { err }) } } }) store.userAuthenticated = result.userAuthenticated } async function handleCb (peertubeHelpers, settingsManager, req, res) { const { logger } = peertubeHelpers if (!store.userAuthenticated) { logger.info('Received callback but cannot userAuthenticated function does not exist.') return onCBError(res) } const encryptedCodeVerifier = req.cookies[cookieNamePrefix + 'code-verifier'] if (!encryptedCodeVerifier) { logger.error('Received callback but code verifier not found in request cookies.') return onCBError(res) } const encryptedState = req.cookies[cookieNamePrefix + 'state'] if (!encryptedState) { logger.error('Received callback but state not found in request cookies.') return onCBError(res) } try { const codeVerifier = await decrypt(encryptedCodeVerifier) const state = await decrypt(encryptedState) const params = store.client.callbackParams(req) const tokenSet = await store.client.callback(store.redirectUrl, params, { code_verifier: codeVerifier, state, }) const accessToken = tokenSet.access_token const userInfo = await store.client.userinfo(accessToken) const settings = await settingsManager.getSettings([ 'mail-property', 'username-property', 'display-name-property', 'role-property', 'group-property', 'allowed-group' ]) logger.debug('Got userinfo from openid auth.', { userInfo, settings }) let role if (settings['role-property']) { let roleToParse = userInfo[settings['role-property']] if (Array.isArray(roleToParse)) roleToParse = roleToParse[0] role = parseInt('' + roleToParse, 10) if (isNaN(role)) { logger.error('Cannot load role ' + roleToParse + ' from OpenID: not a number.') role = null } } if (settings['group-property'] && settings['allowed-group']) { let roles = userInfo[settings['group-property']] if (!roles.includes(settings['allowed-group'])) { throw { name: "AllowedGroupNotFound", message: "User is not in allowed group" } } } let displayName if (settings['display-name-property']) { displayName = userInfo[settings['display-name-property']] } let username = userInfo[settings['username-property']] || '' username = username.replace(/[^a-z0-9._]/g, '_') store.userAuthenticated({ res, req, username, email: userInfo[settings['mail-property']], displayName, role }) } catch (err) { logger.error('Error in handle callback.', { err }) onCBError(res) } } function onCBError (res) { res.redirect('/login?externalAuthError=true') } async function encrypt (data) { const { algorithm, inputEncoding, outputEncoding } = encryptionOptions const iv = await getRandomBytes(16) const cipher = crypto.createCipheriv(algorithm, store.secretKey, iv) let encrypted = cipher.update(data, inputEncoding, outputEncoding) encrypted += cipher.final(outputEncoding) return iv.toString(outputEncoding) + ':' + encrypted } async function decrypt (data) { const { algorithm, inputEncoding, outputEncoding } = encryptionOptions const encryptedArray = data.split(':') const iv = Buffer.from(encryptedArray[0], outputEncoding) const encrypted = Buffer.from(encryptedArray[1], outputEncoding) const decipher = crypto.createDecipheriv(algorithm, store.secretKey, iv) return decipher.update(encrypted, outputEncoding, inputEncoding) + decipher.final(inputEncoding) } function getRandomBytes (size) { return new Promise((res, rej) => { crypto.randomBytes(size, (err, buf) => { if (err) return rej(err) return res(buf) }) }) }