338 lines
9.0 KiB
JavaScript
338 lines
9.0 KiB
JavaScript
const saml2 = require('@peertube/saml2-js')
|
|
const crypto = require('crypto')
|
|
|
|
const store = {
|
|
assertUrl: null,
|
|
authDisplayName: 'SAML 2',
|
|
serviceProvider: null,
|
|
identityProvider: null
|
|
}
|
|
|
|
async function register ({
|
|
registerExternalAuth,
|
|
unregisterExternalAuth,
|
|
registerSetting,
|
|
settingsManager,
|
|
storageManager,
|
|
peertubeHelpers,
|
|
getRouter
|
|
}) {
|
|
const { logger } = peertubeHelpers
|
|
|
|
const metadataUrl = peertubeHelpers.config.getWebserverUrl() + '/plugins/auth-saml2/router/metadata.xml'
|
|
|
|
registerSetting({
|
|
name: 'client-id',
|
|
label: 'Client ID',
|
|
type: 'input',
|
|
private: true,
|
|
default: metadataUrl
|
|
})
|
|
|
|
registerSetting({
|
|
name: 'auth-display-name',
|
|
label: 'Auth display name',
|
|
type: 'input',
|
|
private: true,
|
|
default: 'SAML 2'
|
|
})
|
|
|
|
registerSetting({
|
|
name: 'login-url',
|
|
label: 'SSO login URL',
|
|
type: 'input',
|
|
private: true
|
|
})
|
|
|
|
registerSetting({
|
|
name: 'logout-url',
|
|
label: 'SSO logout URL (needs PeerTube >= 3.0.0)',
|
|
type: 'input',
|
|
private: true
|
|
})
|
|
|
|
registerSetting({
|
|
name: 'provider-certificate',
|
|
label: 'Identity provider certificate (PEM format)',
|
|
type: 'input-textarea',
|
|
private: true
|
|
})
|
|
|
|
registerSetting({
|
|
name: 'service-certificate',
|
|
label: 'Service certificate (PEM format)',
|
|
type: 'input-textarea',
|
|
private: true
|
|
})
|
|
|
|
registerSetting({
|
|
name: 'service-private-key',
|
|
label: 'Service private key (PEM format)',
|
|
type: 'input-textarea',
|
|
private: true
|
|
})
|
|
|
|
registerSetting({
|
|
name: 'sign-get-request',
|
|
label: 'Sign get request',
|
|
type: 'input-checkbox',
|
|
private: true,
|
|
default: false
|
|
})
|
|
|
|
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()
|
|
|
|
store.assertUrl = peertubeHelpers.config.getWebserverUrl() + '/plugins/auth-saml2/router/assert'
|
|
router.post('/assert', (req, res) => handleAssert(peertubeHelpers, settingsManager, req, res))
|
|
router.get('/assert', (req, res) => handleAssert(peertubeHelpers, settingsManager, req, res))
|
|
|
|
router.get('/metadata.xml', (req, res) => {
|
|
if (!store.serviceProvider) {
|
|
logger.warn('Cannot get SAML 2 metadata: service provider not created.')
|
|
return res.sendStatus(400)
|
|
}
|
|
|
|
res.type('application/xml').send(store.serviceProvider.create_metadata())
|
|
})
|
|
|
|
await loadSettingsAndCreateProviders(registerExternalAuth, unregisterExternalAuth, peertubeHelpers, settingsManager, storageManager)
|
|
store.authDisplayName = await settingsManager.getSetting('auth-display-name')
|
|
|
|
settingsManager.onSettingsChange(settings => {
|
|
loadSettingsAndCreateProviders(registerExternalAuth, unregisterExternalAuth, peertubeHelpers, settingsManager, storageManager)
|
|
.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']
|
|
})
|
|
}
|
|
|
|
async function unregister () {
|
|
return
|
|
}
|
|
|
|
module.exports = {
|
|
register,
|
|
unregister
|
|
}
|
|
|
|
// ############################################################################
|
|
|
|
async function loadSettingsAndCreateProviders (
|
|
registerExternalAuth,
|
|
unregisterExternalAuth,
|
|
peertubeHelpers,
|
|
settingsManager,
|
|
storageManager
|
|
) {
|
|
const { logger } = peertubeHelpers
|
|
|
|
if (store.serviceProvider || store.identityProvider) {
|
|
unregisterExternalAuth('saml2')
|
|
}
|
|
|
|
store.serviceProvider = null
|
|
store.identityProvider = null
|
|
|
|
const settings = await settingsManager.getSettings([
|
|
'client-id',
|
|
'sign-get-request',
|
|
'login-url',
|
|
'logout-url',
|
|
'provider-certificate',
|
|
'service-certificate',
|
|
'service-private-key'
|
|
])
|
|
|
|
if (!settings['login-url']) {
|
|
logger.info('Do not register external saml2 auth because login URL is not set.')
|
|
return
|
|
}
|
|
|
|
if (!settings['provider-certificate']) {
|
|
logger.info('Do not register external saml2 auth because provider certificate is not set.')
|
|
return
|
|
}
|
|
|
|
logger.debug('Creating SAML service/identity instances.', { settings })
|
|
|
|
const serviceOptions = {
|
|
entity_id: settings['client-id'],
|
|
private_key: settings['service-private-key'],
|
|
certificate: settings['service-certificate'],
|
|
assert_endpoint: store.assertUrl
|
|
}
|
|
store.serviceProvider = new saml2.ServiceProvider(serviceOptions)
|
|
|
|
const identityOptions = {
|
|
sso_login_url: settings['login-url'],
|
|
sso_logout_url: settings['logout-url'],
|
|
certificates: [
|
|
settings['provider-certificate']
|
|
],
|
|
sign_get_request: settings['sign-get-request'],
|
|
allow_unencrypted_assertion: true
|
|
}
|
|
store.identityProvider = new saml2.IdentityProvider(identityOptions)
|
|
|
|
const result = registerExternalAuth({
|
|
authName: 'saml2',
|
|
authDisplayName: () => store.authDisplayName,
|
|
onAuthRequest: async (req, res) => {
|
|
try {
|
|
store.serviceProvider.create_login_request_url(store.identityProvider, {}, (err, loginUrl, requestId) => {
|
|
if (err) {
|
|
logger.error('Cannot SAML 2 authenticate.', { err })
|
|
return redirectOnError(res)
|
|
}
|
|
|
|
res.redirect(loginUrl)
|
|
})
|
|
} catch (err) {
|
|
logger.error('Cannot create login request url.', { err })
|
|
return redirectOnError(res)
|
|
}
|
|
},
|
|
onLogout: (user, req) => {
|
|
// Return silently if logout-url is not specified
|
|
if (!settings['logout-url']) {
|
|
return
|
|
}
|
|
|
|
return new Promise(async (resolve, reject) => {
|
|
try {
|
|
const options = await storageManager.getData(`saml_session_${req.cookies.saml_session}`)
|
|
|
|
// Include nameid format so the SLO can be accepted.
|
|
// See xmlbuilder for the JS object format.
|
|
options.name_id = {
|
|
"@Format": "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
|
|
"#text": options.name_id
|
|
}
|
|
|
|
store.serviceProvider.create_logout_request_url(store.identityProvider, options, (err, logoutUrl) => {
|
|
if (err) {
|
|
reject('Cannot SAML 2 logout.', { err })
|
|
return
|
|
}
|
|
|
|
resolve(logoutUrl)
|
|
})
|
|
} catch (err) {
|
|
reject('Cannot create logout request url.', { err })
|
|
}
|
|
|
|
return
|
|
})
|
|
}
|
|
})
|
|
|
|
store.userAuthenticated = result.userAuthenticated
|
|
store.storageManager = storageManager
|
|
}
|
|
|
|
function handleAssert(peertubeHelpers, settingsManager, req, res) {
|
|
const { logger } = peertubeHelpers
|
|
|
|
if (req.query.SAMLResponse) {
|
|
// This is a HTTP-redirect for a LogoutResponse and not a SamlResponse after a login request.
|
|
// So we do not want to assert it with post_assert as it will throw an error.
|
|
return res.redirect(peertubeHelpers.config.getWebserverUrl())
|
|
}
|
|
|
|
const options = { request_body: req.body }
|
|
|
|
store.serviceProvider.post_assert(store.identityProvider, options, async (err, samlResponse) => {
|
|
if (err) {
|
|
logger.error('Error SAML 2 assert.', { err })
|
|
return redirectOnError(res)
|
|
}
|
|
|
|
logger.debug('User authenticated by SAML 2.', { samlResponse })
|
|
|
|
try {
|
|
const user = await buildUser(settingsManager, samlResponse.user)
|
|
|
|
// Store the nameid and session_index in the plugin database.
|
|
// Create a cookie called 'saml_session' so we can match later.
|
|
const session_id = crypto.randomBytes(10).toString("hex")
|
|
res.cookie('saml_session', session_id, { httpOnly: true, secure: true })
|
|
store.storageManager.storeData(`saml_session_${session_id}`, {
|
|
name_id: samlResponse.user.name_id,
|
|
session_index: samlResponse.user.session_index
|
|
})
|
|
|
|
return store.userAuthenticated({
|
|
req,
|
|
res,
|
|
...user
|
|
})
|
|
} catch (err) {
|
|
logger.error('Error SAML 2 build user.', { err })
|
|
return redirectOnError(res)
|
|
}
|
|
});
|
|
}
|
|
|
|
function redirectOnError (res) {
|
|
res.redirect('/login?externalAuthError=true')
|
|
}
|
|
|
|
function findInUser (samlUser, key) {
|
|
if (!key) return undefined
|
|
|
|
if (samlUser[key]) return samlUser[key]
|
|
|
|
if (samlUser.attributes[key]) return samlUser.attributes[key][0]
|
|
|
|
return undefined
|
|
}
|
|
|
|
async function buildUser (settingsManager, samlUser) {
|
|
const settings = await settingsManager.getSettings([
|
|
'mail-property',
|
|
'username-property',
|
|
'display-name-property',
|
|
'role-property'
|
|
])
|
|
|
|
let username = findInUser(samlUser, settings['username-property']) || ''
|
|
username = username.replace(/[^a-z0-9._]/g, '_')
|
|
|
|
return {
|
|
username,
|
|
email: findInUser(samlUser, settings['mail-property']),
|
|
displayName: findInUser(samlUser, settings['display-name-property']),
|
|
role: findInUser(samlUser, settings['role-property'])
|
|
}
|
|
}
|