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,
oauth2: {
accessToken: accessToken,
refreshToken: refreshToken
}
refreshToken: refreshToken,
},
}
return authenticateThirdParty(
thirdPartyUser,
thirdPartyUser,
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 { 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 {*} sub The user ID
@ -30,7 +12,6 @@ function getEmail(profile, jwtClaims) {
* @param {*} idToken The id_token - always a JWT
* @param {*} params The response body from requesting an access_token
* @param {*} done The passport callback: err, user, info
* @returns
*/
async function authenticate(
issuer,
@ -45,21 +26,55 @@ async function authenticate(
) {
const thirdPartyUser = {
// store the issuer info to enable sync in future
provider: issuer,
provider: issuer,
providerType: "oidc",
userId: profile.id,
profile: profile,
email: getEmail(profile, jwtClaims),
oauth2: {
accessToken: accessToken,
refreshToken: refreshToken
}
refreshToken: refreshToken,
},
}
return authenticateThirdParty(
thirdPartyUser,
thirdPartyUser,
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.
* @returns Dynamically configured Passport OIDC Strategy
*/
exports.strategyFactory = async function (callbackUrl) {
exports.strategyFactory = async function (config, callbackUrl) {
try {
const configurationUrl =
"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"
const { clientId, clientSecret, configUrl } = config
if (!clientId || !clientSecret || !callbackUrl || !configurationUrl) {
if (!clientId || !clientSecret || !callbackUrl || !configUrl) {
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) {
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()
@ -101,7 +115,6 @@ exports.strategyFactory = async function (callbackUrl) {
},
authenticate
)
} catch (err) {
console.error(err)
throw new Error("Error constructing OIDC authentication strategy", err)

View File

@ -9,73 +9,83 @@ const {
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 (
thirdPartyUser,
requireLocalAccount = true,
done
) {
if (!thirdPartyUser.provider) return authError(done, "third party user provider 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)
let dbUser
// use the third party id
const userId = generateGlobalUserID(thirdPartyUser.userId)
try {
dbUser = await db.get(userId)
} catch (err) {
// abort when not 404 error
if (!err.status || err.status !== 404) {
return authError(done, "Unexpected error when retrieving existing user", err)
}
// check user already exists by email
const users = await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {
key: thirdPartyUser.email,
include_docs: true,
})
let userExists = users.rows.length > 0
thirdPartyUser,
requireLocalAccount = true,
done
) {
if (!thirdPartyUser.provider)
return authError(done, "third party user provider required")
if (!thirdPartyUser.userId)
return authError(done, "third party user id required")
if (!thirdPartyUser.email)
return authError(done, "third party user email required")
if (requireLocalAccount && !userExists) {
return authError(done, "Email does not yet exist. You must set up your local budibase account first.")
}
const db = database.getDB(StaticDatabases.GLOBAL.name)
// 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)
}
let dbUser
// save the user
const response = await db.post(user)
dbUser = user
dbUser._rev = response.rev
// use the third party id
const userId = generateGlobalUserID(thirdPartyUser.userId)
try {
dbUser = await db.get(userId)
} catch (err) {
// abort when not 404 error
if (!err.status || err.status !== 404) {
return authError(
done,
"Unexpected error when retrieving existing user",
err
)
}
// authenticate
const payload = {
userId: dbUser._id,
builder: dbUser.builder,
email: dbUser.email,
}
dbUser.token = jwt.sign(payload, env.JWT_SECRET, {
expiresIn: "1 day",
// check user already exists by email
const users = await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {
key: thirdPartyUser.email,
include_docs: true,
})
return done(null, dbUser)
const 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
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,23 +110,24 @@ function constructNewUser(userId, thirdPartyUser) {
_id: userId,
provider: thirdPartyUser.provider,
providerType: thirdPartyUser.providerType,
roles: {}
email: thirdPartyUser.email,
roles: {},
}
// persist profile information
// @reviewers: Historically stored at the root level of the user
// Nest to prevent conflicts with future fields
// Is this okay to change?
// Is this okay to change?
if (thirdPartyUser.profile) {
user.thirdPartyProfile = {
...thirdPartyUser.profile._json
...thirdPartyUser.profile._json,
}
}
// persist oauth tokens for future use
// persist oauth tokens for future use
if (thirdPartyUser.oauth2) {
user.oauth2 = {
...thirdPartyUser.oauth2
...thirdPartyUser.oauth2,
}
}

View File

@ -1,14 +1,14 @@
/**
* Utility to handle authentication errors.
*
* @param {*} done The passport callback.
* Utility to handle authentication errors.
*
* @param {*} done The passport callback.
* @param {*} message Message that will be returned in the response body
* @param {*} err (Optional) error that will be logged
*/
exports.authError = function (done, message, err = null) {
return done(
err,
null, // never return a user
{ message: message }
)
}
return done(
err,
null, // never return a user
{ message: message }
)
}

View File

@ -14,14 +14,14 @@ const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
function authInternal(ctx, user, err = null, info = null) {
if (err) {
console.error("Authentication error", err)
return ctx.throw(403, info? info : "Unauthorized")
return ctx.throw(403, info ? info : "Unauthorized")
}
const expires = new Date()
expires.setDate(expires.getDate() + 1)
if (!user) {
return ctx.throw(403, info? info : "Unauthorized")
return ctx.throw(403, info ? info : "Unauthorized")
}
ctx.cookies.set(Cookies.Auth, user.token, {
@ -133,8 +133,16 @@ exports.googleAuth = async (ctx, next) => {
}
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`
return oidc.strategyFactory(callbackUrl)
return oidc.strategyFactory(config, callbackUrl)
}
/**