Merge branch 'feature/oidc-support' of https://github.com/Budibase/budibase into oidc-config-management
This commit is contained in:
commit
6be38bcefd
|
@ -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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
)
|
)
|
||||||
}
|
}
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
Loading…
Reference in New Issue