@flowfuse/flowfuse
Version:
An open source low-code development platform
213 lines (197 loc) • 9.93 kB
JavaScript
const crypto = require('crypto')
const { Authenticator } = require('@fastify/passport')
const { MultiSamlStrategy } = require('@node-saml/passport-saml')
const fp = require('fastify-plugin')
const { completeUserSignup } = require('../../../lib/userTeam')
const generatePassword = () => {
const charList = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz~!@-#$'
return Array.from(crypto.randomFillSync(new Uint32Array(8))).map(x => charList[x % charList.length]).join('')
}
module.exports = fp(async function (app, opts) {
app.addHook('onRequest', async (request, reply) => {
if (!request.session) {
// passport expects request.session to exist and to be able to store
// state. We don't need to use that, but we need to ensure we have
// the api in place otherwise passport will complain.
request.session = {
get: key => { return null },
set: (key, value) => { return null }
}
}
})
const fastifyPassport = new Authenticator()
// We don't use @fastify/session, but this is needed to let passport think
// we are using it
fastifyPassport.registerUserSerializer(async (user, request) => user)
app.register(require('@fastify/formbody'))
// app.register(fastifySession, { secret: 'secret with minimum length of 32 characters' })
app.register(fastifyPassport.initialize())
app.register(fastifyPassport.secureSession())
app.setErrorHandler(function (error, request, reply) {
// TODO: how to surface errors properly
app.log.error(`SAML Login error: ${error.toString()}`)
reply.redirect('/')
})
fastifyPassport.use(new MultiSamlStrategy({
passReqToCallback: true, // makes req available in callback,
disableRequestedAuthnContext: true, // Helps make things work with Entra
wantAssertionsSigned: false, // TODO: allow this to be set per provider
async getSamlOptions (request, done) {
if (request.body?.RelayState) {
// This is an in-flight request. We previously stored the SAML provider
// id in RelayState - which should get returned to us here.
// Use that to get back the right SAML options
const state = JSON.parse(request.body.RelayState)
const opts = await app.sso.getProviderOptions(state.provider)
if (opts) {
done(null, opts)
} else {
done(new Error(`Unknown SAML provider: ${state.provider}`))
}
return
}
if (request.query.u) {
// This is an initial request to start a SAML flow. The user
// email is provided as a query parameter 'u'
const providerId = await app.sso.getProviderForEmail(request.query.u)
const opts = await app.sso.getProviderOptions(providerId)
if (opts) {
request.query.RelayState = JSON.stringify({
provider: providerId,
redirectTo: decodeURIComponent(request.query.r || '/')
})
done(null, opts)
return
} else {
done(new Error(`No matching SAML provider for email ${request.query.u}`))
return
}
}
done(new Error('Missing u query parameter'))
}
}, async (request, samlUser, done) => {
if (samlUser.nameID) {
const user = await app.db.models.User.byUsernameOrEmail(samlUser.nameID)
if (user) {
const state = JSON.parse(request.body.RelayState)
const providerOpts = await app.sso.getProviderOptions(state.provider)
if (providerOpts.groupMapping) {
// This SSO provider is configured to manage team membership.
try {
await app.sso.updateTeamMembership(samlUser, user, providerOpts)
} catch (err) {
done(err)
return
}
}
done(null, user)
} else {
const state = JSON.parse(request.body.RelayState)
const providerOpts = await app.sso.getProviderOptions(state.provider)
if (providerOpts.provisionNewUsers) {
// create new user from content of samlUser if available
const userProperties = {
// username: request.body.username,
// name: request.body.name,
email: samlUser.nameID,
// email_verified: true,
// password: request.body.password,
admin: false,
// sso_enabled: true,
tcs_accepted: new Date()
}
if (samlUser['http://schemas.microsoft.com/identity/claims/displayname']) {
userProperties.name = samlUser['http://schemas.microsoft.com/identity/claims/displayname']
} else {
userProperties.name = samlUser.nameID.split('@')[0]
}
userProperties.password = generatePassword()
userProperties.username = samlUser.nameID.replace('@', '-').replaceAll('.', '_').toLowerCase()
try {
// create user
const newUser = await app.db.models.User.create(userProperties)
// check if we need to add teams from SSO
if (providerOpts.groupMapping) {
// This SSO provider is configured to manage team membership.
try {
await app.sso.updateTeamMembership(samlUser, newUser, providerOpts)
} catch (err) {
done(err)
return
}
} else {
// no SSO Group mapping so create team
await completeUserSignup(app, newUser)
}
request.session.newSSOUser = true
done(null, newUser)
} catch (err) {
// console.log(err)
done(err)
}
} else {
const unknownError = new Error(`Unknown user: ${samlUser.nameID}`)
unknownError.code = 'unknown_sso_user'
const userInfo = app.auditLog.formatters.userObject({ email: samlUser.nameID })
const resp = { code: 'unknown_sso_user', error: 'unauthorized' }
await app.auditLog.User.account.login(userInfo, resp, userInfo)
done(unknownError)
}
}
} else {
const missingNameIDError = new Error('SAML response missing nameID')
missingNameIDError.code = 'unknown_sso_user'
done(missingNameIDError)
}
}))
app.get('/ee/sso/login', {
config: { allowAnonymous: true },
preValidation: fastifyPassport.authenticate('saml', { session: false })
}, async (request, reply, err, user, info, status) => {
// Should never get here as passport will trigger the saml flow
// and either result in the error handler, or a POST to /sso/login/callback below
reply.redirect('/')
})
app.post('/ee/sso/login/callback', {
config: { allowAnonymous: true },
preValidation: fastifyPassport.authenticate('saml', { session: false })
}, async (request, reply, err, user, info, status) => {
if (request.user) {
const userInfo = app.auditLog.formatters.userObject(request.user)
// They have completed authentication and we know who they are.
const sessionInfo = await app.createSessionCookie(request.user.email)
if (sessionInfo) {
request.user.sso_enabled = true
request.user.email_verified = true
if (request.user.mfa_enabled) {
// They are mfa_enabled - but have authenticated via SSO
// so we will let them in without further challenge
sessionInfo.session.mfa_verified = true
await sessionInfo.session.save()
}
await request.user.save()
userInfo.id = sessionInfo.session.UserId
reply.setCookie('sid', sessionInfo.session.sid, sessionInfo.cookieOptions)
await app.auditLog.User.account.login(userInfo, null)
let redirectTo = '/'
if (request.body?.RelayState) {
const state = JSON.parse(request.body.RelayState)
redirectTo = /^\/.*/.test(state.redirectTo) ? state.redirectTo : '/'
}
if (request.session.newSSOUser) {
delete request.session.newSSOUser
redirectTo = '/account/settings'
}
reply.redirect(redirectTo)
return
} else {
const resp = { code: 'user_suspended', error: 'User Suspended' }
await app.auditLog.User.account.login(userInfo, resp, userInfo)
// TODO: how to surface errors
reply.redirect('/')
return
}
}
throw new Error('Invalid SAML response')
})
}, { name: 'app.ee.routes.sso.auth' })