328 lines
8.6 KiB
JavaScript
328 lines
8.6 KiB
JavaScript
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
|
|
})
|
|
|
|
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'
|
|
])
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
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)
|
|
})
|
|
})
|
|
}
|