Merge branch 'feature/oidc-support' of https://github.com/Budibase/budibase into oidc-config-management

This commit is contained in:
Peter Clement 2021-07-08 14:29:28 +01:00
commit 6be38bcefd
5 changed files with 148 additions and 115 deletions

View File

@ -11,14 +11,15 @@ async function authenticate(accessToken, refreshToken, profile, done) {
email: profile._json.email, email: profile._json.email,
oauth2: { oauth2: {
accessToken: accessToken, accessToken: accessToken,
refreshToken: refreshToken refreshToken: refreshToken,
} },
} }
return authenticateThirdParty( return authenticateThirdParty(
thirdPartyUser, thirdPartyUser,
true, // require local accounts to exist true, // require local accounts to exist
done) done
)
} }
/** /**

View File

@ -2,24 +2,6 @@ const fetch = require("node-fetch")
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
const { authenticateThirdParty } = require("./third-party-common") const { authenticateThirdParty } = require("./third-party-common")
/**
* @param {*} profile The structured profile created by passport using the user info endpoint
* @param {*} jwtClaims The claims returned in the id token
*/
function getEmail(profile, jwtClaims) {
// profile not guaranteed to contain email e.g. github connected azure ad account
if (profile._json.email) {
return profile._json.email
}
// fallback to id token
if (jwtClaims.email) {
return jwtClaims.email
}
return null;
}
/** /**
* @param {*} issuer The identity provider base URL * @param {*} issuer The identity provider base URL
* @param {*} sub The user ID * @param {*} sub The user ID
@ -30,7 +12,6 @@ function getEmail(profile, jwtClaims) {
* @param {*} idToken The id_token - always a JWT * @param {*} idToken The id_token - always a JWT
* @param {*} params The response body from requesting an access_token * @param {*} params The response body from requesting an access_token
* @param {*} done The passport callback: err, user, info * @param {*} done The passport callback: err, user, info
* @returns
*/ */
async function authenticate( async function authenticate(
issuer, issuer,
@ -52,14 +33,48 @@ async function authenticate(
email: getEmail(profile, jwtClaims), email: getEmail(profile, jwtClaims),
oauth2: { oauth2: {
accessToken: accessToken, accessToken: accessToken,
refreshToken: refreshToken refreshToken: refreshToken,
} },
} }
return authenticateThirdParty( return authenticateThirdParty(
thirdPartyUser, thirdPartyUser,
false, // don't require local accounts to exist false, // don't require local accounts to exist
done) done
)
}
/**
* @param {*} profile The structured profile created by passport using the user info endpoint
* @param {*} jwtClaims The claims returned in the id token
*/
function getEmail(profile, jwtClaims) {
// profile not guaranteed to contain email e.g. github connected azure ad account
if (profile._json.email) {
return profile._json.email
}
// fallback to id token email
if (jwtClaims.email) {
return jwtClaims.email
}
// fallback to id token preferred username
const username = jwtClaims.preferred_username
if (username && validEmail(username)) {
return username
}
return null
}
function validEmail(value) {
return (
value &&
!!value.match(
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
)
)
} }
/** /**
@ -67,23 +82,22 @@ async function authenticate(
* from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport. * from couchDB rather than environment variables, using this factory is necessary for dynamically configuring passport.
* @returns Dynamically configured Passport OIDC Strategy * @returns Dynamically configured Passport OIDC Strategy
*/ */
exports.strategyFactory = async function (callbackUrl) { exports.strategyFactory = async function (config, callbackUrl) {
try { try {
const configurationUrl = const { clientId, clientSecret, configUrl } = config
"https://login.microsoftonline.com/2668c0dd-7ed2-4db3-b387-05b6f9204a70/v2.0/.well-known/openid-configuration"
const clientSecret = "g-ty~2iW.bo.88xj_QI6~hdc-H8mP2Xbnd"
const clientId = "bed2017b-2f53-42a9-8ef9-e58918935e07"
if (!clientId || !clientSecret || !callbackUrl || !configurationUrl) { if (!clientId || !clientSecret || !callbackUrl || !configUrl) {
throw new Error( throw new Error(
"Configuration invalid. Must contain clientID, clientSecret, callbackUrl and configurationUrl" "Configuration invalid. Must contain clientID, clientSecret, callbackUrl and configUrl"
) )
} }
const response = await fetch(configurationUrl) const response = await fetch(configUrl)
if (!response.ok) { if (!response.ok) {
throw new Error(`Unexpected response when fetching openid-configuration: ${response.statusText}`) throw new Error(
`Unexpected response when fetching openid-configuration: ${response.statusText}`
)
} }
const body = await response.json() const body = await response.json()
@ -101,7 +115,6 @@ exports.strategyFactory = async function (callbackUrl) {
}, },
authenticate authenticate
) )
} catch (err) { } catch (err) {
console.error(err) console.error(err)
throw new Error("Error constructing OIDC authentication strategy", err) throw new Error("Error constructing OIDC authentication strategy", err)

View File

@ -12,70 +12,80 @@ const { authError } = require("./utils")
* Common authentication logic for third parties. e.g. OAuth, OIDC. * Common authentication logic for third parties. e.g. OAuth, OIDC.
*/ */
exports.authenticateThirdParty = async function ( exports.authenticateThirdParty = async function (
thirdPartyUser, thirdPartyUser,
requireLocalAccount = true, requireLocalAccount = true,
done done
) { ) {
if (!thirdPartyUser.provider) return authError(done, "third party user provider required") if (!thirdPartyUser.provider)
if (!thirdPartyUser.userId) return authError(done, "third party user id required") return authError(done, "third party user provider required")
if (!thirdPartyUser.email) return authError(done, "third party user email required") if (!thirdPartyUser.userId)
return authError(done, "third party user id required")
if (!thirdPartyUser.email)
return authError(done, "third party user email required")
const db = database.getDB(StaticDatabases.GLOBAL.name) const db = database.getDB(StaticDatabases.GLOBAL.name)
let dbUser let dbUser
// use the third party id // use the third party id
const userId = generateGlobalUserID(thirdPartyUser.userId) const userId = generateGlobalUserID(thirdPartyUser.userId)
try { try {
dbUser = await db.get(userId) dbUser = await db.get(userId)
} catch (err) { } catch (err) {
// abort when not 404 error // abort when not 404 error
if (!err.status || err.status !== 404) { if (!err.status || err.status !== 404) {
return authError(done, "Unexpected error when retrieving existing user", err) return authError(
} done,
"Unexpected error when retrieving existing user",
// check user already exists by email err
const users = await db.query(`database/${ViewNames.USER_BY_EMAIL}`, { )
key: thirdPartyUser.email,
include_docs: true,
})
let userExists = users.rows.length > 0
if (requireLocalAccount && !userExists) {
return authError(done, "Email does not yet exist. You must set up your local budibase account first.")
}
// create the user to save
let user
if (userExists) {
const existing = users.rows[0].doc
user = constructMergedUser(userId, existing, thirdPartyUser)
// remove the local account to avoid conflicts
await db.remove(existing._id, existing._rev)
} else {
user = constructNewUser(userId, thirdPartyUser)
}
// save the user
const response = await db.post(user)
dbUser = user
dbUser._rev = response.rev
} }
// authenticate // check user already exists by email
const payload = { const users = await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {
userId: dbUser._id, key: thirdPartyUser.email,
builder: dbUser.builder, include_docs: true,
email: dbUser.email,
}
dbUser.token = jwt.sign(payload, env.JWT_SECRET, {
expiresIn: "1 day",
}) })
const userExists = users.rows.length > 0
return done(null, dbUser) if (requireLocalAccount && !userExists) {
return authError(
done,
"Email does not yet exist. You must set up your local budibase account first."
)
}
// create the user to save
let user
if (userExists) {
const existing = users.rows[0].doc
user = constructMergedUser(userId, existing, thirdPartyUser)
// remove the local account to avoid conflicts
await db.remove(existing._id, existing._rev)
} else {
user = constructNewUser(userId, thirdPartyUser)
}
// save the user
const response = await db.post(user)
dbUser = user
dbUser._rev = response.rev
}
// authenticate
const payload = {
userId: dbUser._id,
builder: dbUser.builder,
email: dbUser.email,
}
dbUser.token = jwt.sign(payload, env.JWT_SECRET, {
expiresIn: "1 day",
})
return done(null, dbUser)
} }
/** /**
@ -100,7 +110,8 @@ function constructNewUser(userId, thirdPartyUser) {
_id: userId, _id: userId,
provider: thirdPartyUser.provider, provider: thirdPartyUser.provider,
providerType: thirdPartyUser.providerType, providerType: thirdPartyUser.providerType,
roles: {} email: thirdPartyUser.email,
roles: {},
} }
// persist profile information // persist profile information
@ -109,14 +120,14 @@ function constructNewUser(userId, thirdPartyUser) {
// Is this okay to change? // Is this okay to change?
if (thirdPartyUser.profile) { if (thirdPartyUser.profile) {
user.thirdPartyProfile = { user.thirdPartyProfile = {
...thirdPartyUser.profile._json ...thirdPartyUser.profile._json,
} }
} }
// persist oauth tokens for future use // persist oauth tokens for future use
if (thirdPartyUser.oauth2) { if (thirdPartyUser.oauth2) {
user.oauth2 = { user.oauth2 = {
...thirdPartyUser.oauth2 ...thirdPartyUser.oauth2,
} }
} }

View File

@ -6,9 +6,9 @@
* @param {*} err (Optional) error that will be logged * @param {*} err (Optional) error that will be logged
*/ */
exports.authError = function (done, message, err = null) { exports.authError = function (done, message, err = null) {
return done( return done(
err, err,
null, // never return a user null, // never return a user
{ message: message } { message: message }
) )
} }

View File

@ -14,14 +14,14 @@ const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
function authInternal(ctx, user, err = null, info = null) { function authInternal(ctx, user, err = null, info = null) {
if (err) { if (err) {
console.error("Authentication error", err) console.error("Authentication error", err)
return ctx.throw(403, info? info : "Unauthorized") return ctx.throw(403, info ? info : "Unauthorized")
} }
const expires = new Date() const expires = new Date()
expires.setDate(expires.getDate() + 1) expires.setDate(expires.getDate() + 1)
if (!user) { if (!user) {
return ctx.throw(403, info? info : "Unauthorized") return ctx.throw(403, info ? info : "Unauthorized")
} }
ctx.cookies.set(Cookies.Auth, user.token, { ctx.cookies.set(Cookies.Auth, user.token, {
@ -133,8 +133,16 @@ exports.googleAuth = async (ctx, next) => {
} }
async function oidcStrategyFactory(ctx) { async function oidcStrategyFactory(ctx) {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.getScopedConfig(db, {
type: Configs.OIDC,
group: ctx.query.group,
})
const callbackUrl = `${ctx.protocol}://${ctx.host}/api/admin/auth/oidc/callback` const callbackUrl = `${ctx.protocol}://${ctx.host}/api/admin/auth/oidc/callback`
return oidc.strategyFactory(callbackUrl)
return oidc.strategyFactory(config, callbackUrl)
} }
/** /**