Merge branch 'develop' of github.com:Budibase/budibase into drawer-improvements

This commit is contained in:
Andrew Kingston 2021-07-20 14:39:55 +01:00
commit b00452cc8d
64 changed files with 5565 additions and 223 deletions

View File

@ -1,5 +1,5 @@
{ {
"version": "0.9.73", "version": "0.9.78",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*" "packages/*"

View File

@ -1,10 +1,14 @@
{ {
"name": "@budibase/auth", "name": "@budibase/auth",
"version": "0.9.73", "version": "0.9.78",
"description": "Authentication middlewares for budibase builder and apps", "description": "Authentication middlewares for budibase builder and apps",
"main": "src/index.js", "main": "src/index.js",
"author": "Budibase", "author": "Budibase",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": {
"test": "jest",
"test:watch": "jest --watchAll"
},
"dependencies": { "dependencies": {
"aws-sdk": "^2.901.0", "aws-sdk": "^2.901.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@ -13,6 +17,7 @@
"koa-passport": "^4.1.4", "koa-passport": "^4.1.4",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"@techpass/passport-openidconnect": "^0.3.0",
"passport-google-auth": "^1.0.2", "passport-google-auth": "^1.0.2",
"passport-google-oauth": "^2.0.0", "passport-google-oauth": "^2.0.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
@ -22,8 +27,17 @@
"uuid": "^8.3.2", "uuid": "^8.3.2",
"zlib": "^1.0.5" "zlib": "^1.0.5"
}, },
"jest": {
"setupFiles": [
"./scripts/jestSetup.js"
]
},
"devDependencies": { "devDependencies": {
"ioredis-mock": "^5.5.5" "ioredis-mock": "^5.5.5",
"jest": "^26.6.3",
"pouchdb-adapter-memory": "^7.2.2",
"pouchdb": "^7.2.1",
"pouchdb-all-dbs": "^1.0.2"
}, },
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc" "gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc"
} }

View File

@ -0,0 +1,5 @@
const env = require("../src/environment")
env._set("NODE_ENV", "jest")
env._set("JWT_SECRET", "test-jwtsecret")
env._set("LOG_LEVEL", "silent")

View File

@ -6,6 +6,7 @@ exports.UserStatus = {
exports.Cookies = { exports.Cookies = {
CurrentApp: "budibase:currentapp", CurrentApp: "budibase:currentapp",
Auth: "budibase:auth", Auth: "budibase:auth",
OIDC_CONFIG: "budibase:oidc:config",
} }
exports.GlobalRoles = { exports.GlobalRoles = {
@ -20,4 +21,6 @@ exports.Configs = {
ACCOUNT: "account", ACCOUNT: "account",
SMTP: "smtp", SMTP: "smtp",
GOOGLE: "google", GOOGLE: "google",
OIDC: "oidc",
OIDC_LOGOS: "logos_oidc",
} }

View File

@ -17,4 +17,8 @@ module.exports = {
MINIO_URL: process.env.MINIO_URL, MINIO_URL: process.env.MINIO_URL,
INTERNAL_API_KEY: process.env.INTERNAL_API_KEY, INTERNAL_API_KEY: process.env.INTERNAL_API_KEY,
isTest, isTest,
_set(key, value) {
process.env[key] = value
module.exports[key] = value
},
} }

View File

@ -2,7 +2,14 @@ const passport = require("koa-passport")
const LocalStrategy = require("passport-local").Strategy const LocalStrategy = require("passport-local").Strategy
const JwtStrategy = require("passport-jwt").Strategy const JwtStrategy = require("passport-jwt").Strategy
const { StaticDatabases } = require("./db/utils") const { StaticDatabases } = require("./db/utils")
const { jwt, local, authenticated, google, auditLog } = require("./middleware") const {
jwt,
local,
authenticated,
google,
oidc,
auditLog,
} = require("./middleware")
const { setDB, getDB } = require("./db") const { setDB, getDB } = require("./db")
const userCache = require("./cache/user") const userCache = require("./cache/user")
@ -45,6 +52,7 @@ module.exports = {
buildAuthMiddleware: authenticated, buildAuthMiddleware: authenticated,
passport, passport,
google, google,
oidc,
jwt: require("jsonwebtoken"), jwt: require("jsonwebtoken"),
auditLog, auditLog,
}, },

View File

@ -1,11 +1,13 @@
const jwt = require("./passport/jwt") const jwt = require("./passport/jwt")
const local = require("./passport/local") const local = require("./passport/local")
const google = require("./passport/google") const google = require("./passport/google")
const oidc = require("./passport/oidc")
const authenticated = require("./authenticated") const authenticated = require("./authenticated")
const auditLog = require("./auditLog") const auditLog = require("./auditLog")
module.exports = { module.exports = {
google, google,
oidc,
jwt, jwt,
local, local,
authenticated, authenticated,

View File

@ -1,78 +1,25 @@
const env = require("../../environment")
const jwt = require("jsonwebtoken")
const database = require("../../db")
const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy const GoogleStrategy = require("passport-google-oauth").OAuth2Strategy
const {
StaticDatabases,
generateGlobalUserID,
ViewNames,
} = require("../../db/utils")
const { newid } = require("../../hashing")
const { createASession } = require("../../security/sessions")
async function authenticate(token, tokenSecret, profile, done) { const { authenticateThirdParty } = require("./third-party-common")
// Check the user exists in the instance DB by email
const db = database.getDB(StaticDatabases.GLOBAL.name)
let dbUser async function authenticate(accessToken, refreshToken, profile, done) {
const thirdPartyUser = {
const userId = generateGlobalUserID(profile.id) provider: profile.provider, // should always be 'google'
providerType: "google",
try { userId: profile.id,
// use the google profile id profile: profile,
dbUser = await db.get(userId) email: profile._json.email,
} catch (err) { oauth2: {
const user = { accessToken: accessToken,
_id: userId, refreshToken: refreshToken,
provider: profile.provider, },
roles: {},
...profile._json,
}
// check if an account with the google email address exists locally
const users = await db.query(`database/${ViewNames.USER_BY_EMAIL}`, {
key: profile._json.email,
include_docs: true,
})
// Google user already exists by email
if (users.rows.length > 0) {
const existing = users.rows[0].doc
// remove the local account to avoid conflicts
await db.remove(existing._id, existing._rev)
// merge with existing account
user.roles = existing.roles
user.builder = existing.builder
user.admin = existing.admin
const response = await db.post(user)
dbUser = user
dbUser._rev = response.rev
} else {
return done(
new Error(
"email does not yet exist. You must set up your local budibase account first."
),
false
)
}
} }
// authenticate return authenticateThirdParty(
const sessionId = newid() thirdPartyUser,
await createASession(dbUser._id, sessionId) true, // require local accounts to exist
done
dbUser.token = jwt.sign(
{
userId: dbUser._id,
sessionId,
},
env.JWT_SECRET
) )
return done(null, dbUser)
} }
/** /**
@ -103,3 +50,5 @@ exports.strategyFactory = async function (config) {
throw new Error("Error constructing google authentication strategy", err) throw new Error("Error constructing google authentication strategy", err)
} }
} }
// expose for testing
exports.authenticate = authenticate

View File

@ -1,5 +1,6 @@
const { Cookies } = require("../../constants") const { Cookies } = require("../../constants")
const env = require("../../environment") const env = require("../../environment")
const { authError } = require("./utils")
exports.options = { exports.options = {
secretOrKey: env.JWT_SECRET, secretOrKey: env.JWT_SECRET,
@ -12,6 +13,6 @@ exports.authenticate = async function (jwt, done) {
try { try {
return done(null, jwt) return done(null, jwt)
} catch (err) { } catch (err) {
return done(new Error("JWT invalid."), false) return authError(done, "JWT invalid", err)
} }
} }

View File

@ -3,6 +3,7 @@ const { UserStatus } = require("../../constants")
const { compare } = require("../../hashing") const { compare } = require("../../hashing")
const env = require("../../environment") const env = require("../../environment")
const { getGlobalUserByEmail } = require("../../utils") const { getGlobalUserByEmail } = require("../../utils")
const { authError } = require("./utils")
const { newid } = require("../../hashing") const { newid } = require("../../hashing")
const { createASession } = require("../../security/sessions") const { createASession } = require("../../security/sessions")
@ -18,17 +19,17 @@ exports.options = {}
* @returns The authenticated user, or errors if they occur * @returns The authenticated user, or errors if they occur
*/ */
exports.authenticate = async function (email, password, done) { exports.authenticate = async function (email, password, done) {
if (!email) return done(null, false, "Email Required.") if (!email) return authError(done, "Email Required")
if (!password) return done(null, false, "Password Required.") if (!password) return authError(done, "Password Required")
const dbUser = await getGlobalUserByEmail(email) const dbUser = await getGlobalUserByEmail(email)
if (dbUser == null) { if (dbUser == null) {
return done(null, false, { message: "User not found" }) return authError(done, "User not found")
} }
// check that the user is currently inactive, if this is the case throw invalid // check that the user is currently inactive, if this is the case throw invalid
if (dbUser.status === UserStatus.INACTIVE) { if (dbUser.status === UserStatus.INACTIVE) {
return done(null, false, { message: INVALID_ERR }) return authError(done, INVALID_ERR)
} }
// authenticate // authenticate
@ -48,6 +49,6 @@ exports.authenticate = async function (email, password, done) {
return done(null, dbUser) return done(null, dbUser)
} else { } else {
done(new Error(INVALID_ERR), false) return authError(done, INVALID_ERR)
} }
} }

View File

@ -0,0 +1,128 @@
const fetch = require("node-fetch")
const OIDCStrategy = require("@techpass/passport-openidconnect").Strategy
const { authenticateThirdParty } = require("./third-party-common")
/**
* @param {*} issuer The identity provider base URL
* @param {*} sub The user ID
* @param {*} profile The user profile information. Created by passport from the /userinfo response
* @param {*} jwtClaims The parsed id_token claims
* @param {*} accessToken The access_token for contacting the identity provider - may or may not be a JWT
* @param {*} refreshToken The refresh_token for obtaining a new access_token - usually not a JWT
* @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
*/
async function authenticate(
issuer,
sub,
profile,
jwtClaims,
accessToken,
refreshToken,
idToken,
params,
done
) {
const thirdPartyUser = {
// store the issuer info to enable sync in future
provider: issuer,
providerType: "oidc",
userId: profile.id,
profile: profile,
email: getEmail(profile, jwtClaims),
oauth2: {
accessToken: accessToken,
refreshToken: refreshToken,
},
}
return authenticateThirdParty(
thirdPartyUser,
false, // don't require local accounts to exist
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
}
throw new Error(
`Could not determine user email from profile ${JSON.stringify(
profile
)} and claims ${JSON.stringify(jwtClaims)}`
)
}
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,}))$/
)
)
}
/**
* Create an instance of the oidc passport strategy. This wrapper fetches the configuration
* 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 (config, callbackUrl) {
try {
const { clientID, clientSecret, configUrl } = config
if (!clientID || !clientSecret || !callbackUrl || !configUrl) {
throw new Error(
"Configuration invalid. Must contain clientID, clientSecret, callbackUrl and configUrl"
)
}
const response = await fetch(configUrl)
if (!response.ok) {
throw new Error(
`Unexpected response when fetching openid-configuration: ${response.statusText}`
)
}
const body = await response.json()
return new OIDCStrategy(
{
issuer: body.issuer,
authorizationURL: body.authorization_endpoint,
tokenURL: body.token_endpoint,
userInfoURL: body.userinfo_endpoint,
clientID: clientID,
clientSecret: clientSecret,
callbackURL: callbackUrl,
},
authenticate
)
} catch (err) {
console.error(err)
throw new Error("Error constructing OIDC authentication strategy", err)
}
}
// expose for testing
exports.authenticate = authenticate

View File

@ -0,0 +1,74 @@
// Mock data
const { data } = require("./utilities/mock-data")
const googleConfig = {
callbackURL: "http://somecallbackurl",
clientID: data.clientID,
clientSecret: data.clientSecret,
}
const profile = {
id: "mockId",
_json: {
email : data.email
},
provider: "google"
}
const user = data.buildThirdPartyUser("google", "google", profile)
describe("google", () => {
describe("strategyFactory", () => {
// mock passport strategy factory
jest.mock("passport-google-oauth")
const mockStrategy = require("passport-google-oauth").OAuth2Strategy
it("should create successfully create a google strategy", async () => {
const google = require("../google")
await google.strategyFactory(googleConfig)
const expectedOptions = {
clientID: googleConfig.clientID,
clientSecret: googleConfig.clientSecret,
callbackURL: googleConfig.callbackURL,
}
expect(mockStrategy).toHaveBeenCalledWith(
expectedOptions,
expect.anything()
)
})
})
describe("authenticate", () => {
afterEach(() => {
jest.clearAllMocks();
});
// mock third party common authentication
jest.mock("../third-party-common")
const authenticateThirdParty = require("../third-party-common").authenticateThirdParty
// mock the passport callback
const mockDone = jest.fn()
it("delegates authentication to third party common", async () => {
const google = require("../google")
await google.authenticate(
data.accessToken,
data.refreshToken,
profile,
mockDone
)
expect(authenticateThirdParty).toHaveBeenCalledWith(
user,
true,
mockDone)
})
})
})

View File

@ -0,0 +1,143 @@
// Mock data
const { data } = require("./utilities/mock-data")
const issuer = "mockIssuer"
const sub = "mockSub"
const profile = {
id: "mockId",
_json: {
email : data.email
}
}
let jwtClaims = {}
const idToken = "mockIdToken"
const params = {}
const callbackUrl = "http://somecallbackurl"
// response from .well-known/openid-configuration
const oidcConfigUrlResponse = {
issuer: issuer,
authorization_endpoint: "mockAuthorizationEndpoint",
token_endpoint: "mockTokenEndpoint",
userinfo_endpoint: "mockUserInfoEndpoint"
}
const oidcConfig = {
configUrl: "http://someconfigurl",
clientID: data.clientID,
clientSecret: data.clientSecret,
}
const user = data.buildThirdPartyUser(issuer, "oidc", profile)
describe("oidc", () => {
describe("strategyFactory", () => {
// mock passport strategy factory
jest.mock("@techpass/passport-openidconnect")
const mockStrategy = require("@techpass/passport-openidconnect").Strategy
// mock the request to retrieve the oidc configuration
jest.mock("node-fetch")
const mockFetch = require("node-fetch")
mockFetch.mockReturnValue({
ok: true,
json: () => oidcConfigUrlResponse
})
it("should create successfully create an oidc strategy", async () => {
const oidc = require("../oidc")
await oidc.strategyFactory(oidcConfig, callbackUrl)
expect(mockFetch).toHaveBeenCalledWith(oidcConfig.configUrl)
const expectedOptions = {
issuer: oidcConfigUrlResponse.issuer,
authorizationURL: oidcConfigUrlResponse.authorization_endpoint,
tokenURL: oidcConfigUrlResponse.token_endpoint,
userInfoURL: oidcConfigUrlResponse.userinfo_endpoint,
clientID: oidcConfig.clientID,
clientSecret: oidcConfig.clientSecret,
callbackURL: callbackUrl,
}
expect(mockStrategy).toHaveBeenCalledWith(
expectedOptions,
expect.anything()
)
})
})
describe("authenticate", () => {
afterEach(() => {
jest.clearAllMocks();
});
// mock third party common authentication
jest.mock("../third-party-common")
const authenticateThirdParty = require("../third-party-common").authenticateThirdParty
// mock the passport callback
const mockDone = jest.fn()
async function doAuthenticate() {
const oidc = require("../oidc")
await oidc.authenticate(
issuer,
sub,
profile,
jwtClaims,
data.accessToken,
data.refreshToken,
idToken,
params,
mockDone
)
}
async function doTest() {
await doAuthenticate()
expect(authenticateThirdParty).toHaveBeenCalledWith(
user,
false,
mockDone)
}
it("delegates authentication to third party common", async () => {
doTest()
})
it("uses JWT email to get email", async () => {
delete profile._json.email
jwtClaims = {
email : "mock@budibase.com"
}
doTest()
})
it("uses JWT username to get email", async () => {
delete profile._json.email
jwtClaims = {
preferred_username : "mock@budibase.com"
}
doTest()
})
it("uses JWT invalid username to get email", async () => {
delete profile._json.email
jwtClaims = {
preferred_username : "invalidUsername"
}
await expect(doAuthenticate()).rejects.toThrow("Could not determine user email from profile");
})
})
})

View File

@ -0,0 +1,152 @@
// Mock data
require("./utilities/test-config")
const database = require("../../../db")
const { authenticateThirdParty } = require("../third-party-common")
const { data } = require("./utilities/mock-data")
const {
StaticDatabases,
generateGlobalUserID
} = require("../../../db/utils")
const { newid } = require("../../../hashing")
let db
const done = jest.fn()
const getErrorMessage = () => {
return done.mock.calls[0][2].message
}
describe("third party common", () => {
describe("authenticateThirdParty", () => {
let thirdPartyUser
beforeEach(() => {
db = database.getDB(StaticDatabases.GLOBAL.name)
thirdPartyUser = data.buildThirdPartyUser()
})
afterEach(async () => {
jest.clearAllMocks()
await db.destroy()
})
describe("validation", () => {
const testValidation = async (message) => {
await authenticateThirdParty(thirdPartyUser, false, done)
expect(done.mock.calls.length).toBe(1)
expect(getErrorMessage()).toContain(message)
}
it("provider fails", async () => {
delete thirdPartyUser.provider
testValidation("third party user provider required")
})
it("user id fails", async () => {
delete thirdPartyUser.userId
testValidation("third party user id required")
})
it("email fails", async () => {
delete thirdPartyUser.email
testValidation("third party user email required")
})
})
const expectUserIsAuthenticated = () => {
const user = done.mock.calls[0][1]
expect(user).toBeDefined()
expect(user._id).toBeDefined()
expect(user._rev).toBeDefined()
expect(user.token).toBeDefined()
return user
}
const expectUserIsSynced = (user, thirdPartyUser) => {
expect(user.provider).toBe(thirdPartyUser.provider)
expect(user.email).toBe(thirdPartyUser.email)
expect(user.firstName).toBe(thirdPartyUser.profile.name.givenName)
expect(user.lastName).toBe(thirdPartyUser.profile.name.familyName)
expect(user.thirdPartyProfile).toStrictEqual(thirdPartyUser.profile._json)
expect(user.oauth2).toStrictEqual(thirdPartyUser.oauth2)
}
describe("when the user doesn't exist", () => {
describe("when a local account is required", () => {
it("returns an error message", async () => {
await authenticateThirdParty(thirdPartyUser, true, done)
expect(done.mock.calls.length).toBe(1)
expect(getErrorMessage()).toContain("Email does not yet exist. You must set up your local budibase account first.")
})
})
describe("when a local account isn't required", () => {
it("creates and authenticates the user", async () => {
await authenticateThirdParty(thirdPartyUser, false, done)
const user = expectUserIsAuthenticated()
expectUserIsSynced(user, thirdPartyUser)
expect(user.roles).toStrictEqual({})
})
})
})
describe("when the user exists", () => {
let dbUser
let id
let email
const createUser = async () => {
dbUser = {
_id: id,
email: email,
}
const response = await db.post(dbUser)
dbUser._rev = response.rev
}
const expectUserIsUpdated = (user) => {
// id is unchanged
expect(user._id).toBe(id)
// user is updated
expect(user._rev).not.toBe(dbUser._rev)
}
describe("exists by email", () => {
beforeEach(async () => {
id = generateGlobalUserID(newid()) // random id
email = thirdPartyUser.email // matching email
await createUser()
})
it("syncs and authenticates the user", async () => {
await authenticateThirdParty(thirdPartyUser, true, done)
const user = expectUserIsAuthenticated()
expectUserIsSynced(user, thirdPartyUser)
expectUserIsUpdated(user)
})
})
describe("exists by id", () => {
beforeEach(async () => {
id = generateGlobalUserID(thirdPartyUser.userId) // matching id
email = "test@test.com" // random email
await createUser()
})
it("syncs and authenticates the user", async () => {
await authenticateThirdParty(thirdPartyUser, true, done)
const user = expectUserIsAuthenticated()
expectUserIsSynced(user, thirdPartyUser)
expectUserIsUpdated(user)
})
})
})
})
})

View File

@ -0,0 +1,20 @@
const PouchDB = require("pouchdb")
const allDbs = require("pouchdb-all-dbs")
const env = require("../../../../environment")
let POUCH_DB_DEFAULTS
// should always be test but good to do the sanity check
if (env.isTest()) {
PouchDB.plugin(require("pouchdb-adapter-memory"))
POUCH_DB_DEFAULTS = {
prefix: undefined,
adapter: "memory",
}
}
const Pouch = PouchDB.defaults(POUCH_DB_DEFAULTS)
allDbs(Pouch)
module.exports = Pouch

View File

@ -0,0 +1,54 @@
// Mock Data
const mockClientID = "mockClientID"
const mockClientSecret = "mockClientSecret"
const mockEmail = "mock@budibase.com"
const mockAccessToken = "mockAccessToken"
const mockRefreshToken = "mockRefreshToken"
const mockProvider = "mockProvider"
const mockProviderType = "mockProviderType"
const mockProfile = {
id: "mockId",
name: {
givenName: "mockGivenName",
familyName: "mockFamilyName",
},
_json: {
email: mockEmail,
},
}
const buildOauth2 = (
accessToken = mockAccessToken,
refreshToken = mockRefreshToken
) => ({
accessToken: accessToken,
refreshToken: refreshToken,
})
const buildThirdPartyUser = (
provider = mockProvider,
providerType = mockProviderType,
profile = mockProfile,
email = mockEmail,
oauth2 = buildOauth2()
) => ({
provider: provider,
providerType: providerType,
userId: profile.id,
profile: profile,
email: email,
oauth2: oauth2,
})
exports.data = {
clientID: mockClientID,
clientSecret: mockClientSecret,
email: mockEmail,
accessToken: mockAccessToken,
refreshToken: mockRefreshToken,
buildThirdPartyUser,
}

View File

@ -0,0 +1,3 @@
const packageConfiguration = require("../../../../index")
const CouchDB = require("./db")
packageConfiguration.init(CouchDB)

View File

@ -0,0 +1,129 @@
const env = require("../../environment")
const jwt = require("jsonwebtoken")
const database = require("../../db")
const { StaticDatabases, generateGlobalUserID } = require("../../db/utils")
const { authError } = require("./utils")
const { newid } = require("../../hashing")
const { createASession } = require("../../security/sessions")
const { getGlobalUserByEmail } = require("../../utils")
/**
* 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 to load by id
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
)
}
}
// fallback to loading by email
if (!dbUser) {
dbUser = await getGlobalUserByEmail(thirdPartyUser.email)
}
// exit early if there is still no user and auto creation is disabled
if (!dbUser && requireLocalAccount) {
return authError(
done,
"Email does not yet exist. You must set up your local budibase account first."
)
}
// first time creation
if (!dbUser) {
// setup a blank user using the third party id
dbUser = {
_id: userId,
roles: {},
}
}
dbUser = syncUser(dbUser, thirdPartyUser)
// create or sync the user
const response = await db.post(dbUser)
dbUser._rev = response.rev
// authenticate
const sessionId = newid()
await createASession(dbUser._id, sessionId)
dbUser.token = jwt.sign(
{
userId: dbUser._id,
sessionId,
},
env.JWT_SECRET
)
return done(null, dbUser)
}
/**
* @returns a user that has been sync'd with third party information
*/
function syncUser(user, thirdPartyUser) {
// provider
user.provider = thirdPartyUser.provider
user.providerType = thirdPartyUser.providerType
// email
user.email = thirdPartyUser.email
if (thirdPartyUser.profile) {
const profile = thirdPartyUser.profile
if (profile.name) {
const name = profile.name
// first name
if (name.givenName) {
user.firstName = name.givenName
}
// last name
if (name.familyName) {
user.lastName = name.familyName
}
}
// profile
user.thirdPartyProfile = {
...profile._json,
}
}
// oauth tokens for future use
if (thirdPartyUser.oauth2) {
user.oauth2 = {
...thirdPartyUser.oauth2,
}
}
return user
}

View File

@ -0,0 +1,14 @@
/**
* 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 }
)
}

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/bbui", "name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.", "description": "A UI solution used in the different Budibase projects.",
"version": "0.9.73", "version": "0.9.78",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"svelte": "src/index.js", "svelte": "src/index.js",
"module": "dist/bbui.es.js", "module": "dist/bbui.es.js",

View File

@ -10,6 +10,7 @@
export let disabled = false export let disabled = false
export let error = null export let error = null
export let fieldText = "" export let fieldText = ""
export let fieldIcon = ""
export let isPlaceholder = false export let isPlaceholder = false
export let placeholderOption = null export let placeholderOption = null
export let options = [] export let options = []
@ -17,11 +18,11 @@
export let onSelectOption = () => {} export let onSelectOption = () => {}
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let getOptionIcon = null
export let open = false export let open = false
export let readonly = false export let readonly = false
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onClick = () => { const onClick = () => {
dispatch("click") dispatch("click")
@ -42,6 +43,12 @@
aria-haspopup="listbox" aria-haspopup="listbox"
on:mousedown={onClick} on:mousedown={onClick}
> >
{#if fieldIcon}
<span class="icon-Placeholder-Padding">
<img src={fieldIcon} alt="Picker Icon" width="20" height="15" />
</span>
{/if}
<span <span
class="spectrum-Picker-label" class="spectrum-Picker-label"
class:is-placeholder={isPlaceholder} class:is-placeholder={isPlaceholder}
@ -104,6 +111,16 @@
tabindex="0" tabindex="0"
on:click={() => onSelectOption(getOptionValue(option, idx))} on:click={() => onSelectOption(getOptionValue(option, idx))}
> >
{#if getOptionIcon(option, idx)}
<span class="icon-Padding">
<img
src={getOptionIcon(option, idx)}
alt="test"
width="20"
height="15"
/>
</span>
{/if}
<span class="spectrum-Menu-itemLabel" <span class="spectrum-Menu-itemLabel"
>{getOptionLabel(option, idx)}</span >{getOptionLabel(option, idx)}</span
> >
@ -148,4 +165,12 @@
.spectrum-Picker-label.auto-width.is-placeholder { .spectrum-Picker-label.auto-width.is-placeholder {
padding-right: 2px; padding-right: 2px;
} }
.icon-Padding {
padding-right: 10px;
}
.icon-Placeholder-Padding {
padding-top: 5px;
padding-right: 10px;
}
</style> </style>

View File

@ -10,6 +10,7 @@
export let options = [] export let options = []
export let getOptionLabel = option => option export let getOptionLabel = option => option
export let getOptionValue = option => option export let getOptionValue = option => option
export let getOptionIcon = null
export let readonly = false export let readonly = false
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
@ -17,6 +18,7 @@
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
$: fieldText = getFieldText(value, options, placeholder) $: fieldText = getFieldText(value, options, placeholder)
$: fieldIcon = getFieldIcon(value, options, placeholder)
const getFieldText = (value, options, placeholder) => { const getFieldText = (value, options, placeholder) => {
// Always use placeholder if no value // Always use placeholder if no value
@ -36,6 +38,17 @@
return index !== -1 ? getOptionLabel(options[index], index) : value return index !== -1 ? getOptionLabel(options[index], index) : value
} }
const getFieldIcon = (value, options) => {
// Wait for options to load if there is a value but no options
if (!options?.length) {
return ""
}
const index = options.findIndex(
(option, idx) => getOptionValue(option, idx) === value
)
return index !== -1 ? getOptionIcon(options[index], index) : null
}
const selectOption = value => { const selectOption = value => {
dispatch("change", value) dispatch("change", value)
open = false open = false
@ -55,6 +68,8 @@
{autoWidth} {autoWidth}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{getOptionIcon}
{fieldIcon}
isPlaceholder={value == null || value === ""} isPlaceholder={value == null || value === ""}
placeholderOption={placeholder} placeholderOption={placeholder}
isOptionSelected={option => option === value} isOptionSelected={option => option === value}

View File

@ -13,6 +13,7 @@
export let options = [] export let options = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionIcon = option => option?.icon
export let quiet = false export let quiet = false
export let autoWidth = false export let autoWidth = false
@ -21,6 +22,7 @@
value = e.detail value = e.detail
dispatch("change", e.detail) dispatch("change", e.detail)
} }
const extractProperty = (value, property) => { const extractProperty = (value, property) => {
if (value && typeof value === "object") { if (value && typeof value === "object") {
return value[property] return value[property]
@ -41,6 +43,7 @@
{autoWidth} {autoWidth}
{getOptionLabel} {getOptionLabel}
{getOptionValue} {getOptionValue}
{getOptionIcon}
on:change={onChange} on:change={onChange}
on:click on:click
/> />

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/builder", "name": "@budibase/builder",
"version": "0.9.73", "version": "0.9.78",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
"scripts": { "scripts": {
@ -65,10 +65,10 @@
} }
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^0.9.73", "@budibase/bbui": "^0.9.78",
"@budibase/client": "^0.9.73", "@budibase/client": "^0.9.78",
"@budibase/colorpicker": "1.1.2", "@budibase/colorpicker": "1.1.2",
"@budibase/string-templates": "^0.9.73", "@budibase/string-templates": "^0.9.78",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1", "@spectrum-css/vars": "^3.0.1",

View File

@ -12,7 +12,7 @@
apps: "Create your first app", apps: "Create your first app",
smtp: "Set up email", smtp: "Set up email",
adminUser: "Create your first user", adminUser: "Create your first user",
oauth: "Set up OAuth", sso: "Set up single sign-on",
} }
</script> </script>

View File

@ -1,11 +1,9 @@
<script> <script>
import { ActionButton } from "@budibase/bbui" import { ActionButton } from "@budibase/bbui"
import GoogleLogo from "assets/google-logo.png" import GoogleLogo from "assets/google-logo.png"
import { admin } from "stores/portal" import { organisation } from "stores/portal"
let show = false $: show = $organisation.google
$: show = $admin.checklist?.oauth
</script> </script>
{#if show} {#if show}

View File

@ -0,0 +1,59 @@
<script>
import { ActionButton } from "@budibase/bbui"
import OidcLogo from "assets/oidc-logo.png"
import Auth0Logo from "assets/auth0-logo.png"
import MicrosoftLogo from "assets/microsoft-logo.png"
import OktaLogo from "assets/okta-logo.png"
import OneLoginLogo from "assets/onelogin-logo.png"
import { oidc, organisation } from "stores/portal"
import { onMount } from "svelte"
$: show = $organisation.oidc
let preDefinedIcons = {
Oidc: OidcLogo,
Auth0: Auth0Logo,
Microsoft: MicrosoftLogo,
Okta: OktaLogo,
OneLogin: OneLoginLogo,
}
onMount(async () => {
await oidc.init()
})
$: src = !$oidc.logo
? OidcLogo
: preDefinedIcons[$oidc.logo] || `/global/logos_oidc/${$oidc.logo}`
</script>
{#if show}
<ActionButton
on:click={() =>
window.open(`/api/admin/auth/oidc/configs/${$oidc.uuid}`, "_blank")}
>
<div class="inner">
<img {src} alt="oidc icon" />
<p>{`Sign in with ${$oidc.name || "OIDC"}`}</p>
</div>
</ActionButton>
{/if}
<style>
.inner {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding-top: var(--spacing-xs);
padding-bottom: var(--spacing-xs);
}
.inner img {
width: 18px;
margin: 3px 10px 3px 3px;
}
.inner p {
margin: 0;
}
</style>

View File

@ -10,8 +10,9 @@
notifications, notifications,
} from "@budibase/bbui" } from "@budibase/bbui"
import { goto, params } from "@roxi/routify" import { goto, params } from "@roxi/routify"
import { auth, organisation } from "stores/portal" import { auth, organisation, oidc } from "stores/portal"
import GoogleButton from "./_components/GoogleButton.svelte" import GoogleButton from "./_components/GoogleButton.svelte"
import OIDCButton from "./_components/OIDCButton.svelte"
import Logo from "assets/bb-emblem.svg" import Logo from "assets/bb-emblem.svg"
import { onMount } from "svelte" import { onMount } from "svelte"
@ -61,6 +62,7 @@
<Heading>Sign in to {company}</Heading> <Heading>Sign in to {company}</Heading>
</Layout> </Layout>
<GoogleButton /> <GoogleButton />
<OIDCButton oidcIcon={$oidc.logo} oidcName={$oidc.name} />
<Divider noGrid /> <Divider noGrid />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Body size="S" textAlign="center">Sign in with email</Body> <Body size="S" textAlign="center">Sign in with email</Body>

View File

@ -0,0 +1,15 @@
<svg
width="25"
height="25 "
viewBox="0 0 100 100"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0)">
<path
d="M43,90c-88,-16,-21,-86,41,-51l9,-6v17h-26l8,-5c-55,-25,-86,29,-32,36z"
fill="#ccc"
/>
<path d="M43,90v-75l14,-9v75z" fill="#f60" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@ -1,5 +1,13 @@
<script> <script>
import GoogleLogo from "./_logos/Google.svelte" import GoogleLogo from "./_logos/Google.svelte"
import OidcLogo from "./_logos/OIDC.svelte"
import MicrosoftLogo from "assets/microsoft-logo.png"
import Auth0Logo from "assets/auth0-logo.png"
import OktaLogo from "assets/okta-logo.png"
import OneLoginLogo from "assets/onelogin-logo.png"
import OidcLogoPng from "assets/oidc-logo.png"
import { isEqual, cloneDeep } from "lodash/fp"
import { import {
Button, Button,
Heading, Heading,
@ -9,20 +17,25 @@
Layout, Layout,
Input, Input,
Body, Body,
Select,
Toggle,
} from "@budibase/bbui" } from "@budibase/bbui"
import { onMount } from "svelte" import { onMount } from "svelte"
import api from "builderStore/api" import api from "builderStore/api"
import { organisation } from "stores/portal"
import { uuid } from "builderStore/uuid"
const ConfigTypes = { const ConfigTypes = {
Google: "google", Google: "google",
OIDC: "oidc",
// Github: "github", // Github: "github",
// AzureAD: "ad", // AzureAD: "ad",
} }
const ConfigFields = { const GoogleConfigFields = {
Google: ["clientID", "clientSecret", "callbackURL"], Google: ["clientID", "clientSecret", "callbackURL"],
} }
const ConfigLabels = { const GoogleConfigLabels = {
Google: { Google: {
clientID: "Client ID", clientID: "Client ID",
clientSecret: "Client secret", clientSecret: "Client secret",
@ -30,24 +43,167 @@
}, },
} }
const OIDCConfigFields = {
Oidc: ["configUrl", "clientID", "clientSecret"],
}
const OIDCConfigLabels = {
Oidc: {
configUrl: "Config URL",
clientID: "Client ID",
clientSecret: "Client Secret",
},
}
let iconDropdownOptions = [
{
label: "Microsoft",
value: "Microsoft",
icon: MicrosoftLogo,
},
{
label: "Okta",
value: "Okta",
icon: OktaLogo,
},
{
label: "OneLogin",
value: "OneLogin",
icon: OneLoginLogo,
},
{
label: "Auth0",
value: "Auth0",
icon: Auth0Logo,
},
{
label: "OIDC",
value: "Oidc",
icon: OidcLogoPng,
},
{
label: "Upload your own",
value: "Upload",
},
]
let fileinput
let image
let google let google
let oidc
const providers = { google, oidc }
async function save(doc) { // control the state of the save button depending on whether form has changed
try { let originalGoogleDoc
// Save an oauth config let originalOidcDoc
const response = await api.post(`/api/admin/configs`, doc) let googleSaveButtonDisabled
const json = await response.json() let oidcSaveButtonDisabled
if (response.status !== 200) throw new Error(json.message) $: {
google._rev = json._rev isEqual(providers.google?.config, originalGoogleDoc?.config)
google._id = json._id ? (googleSaveButtonDisabled = true)
: (googleSaveButtonDisabled = false)
isEqual(providers.oidc?.config, originalOidcDoc?.config)
? (oidcSaveButtonDisabled = true)
: (oidcSaveButtonDisabled = false)
}
notifications.success(`Settings saved.`) // Create a flag so that it will only try to save completed forms
} catch (err) { $: partialGoogle =
notifications.error(`Failed to update OAuth settings. ${err}`) providers.google?.config?.clientID ||
} providers.google?.config?.clientSecret ||
providers.google?.config?.callbackURL
$: partialOidc =
providers.oidc?.config?.configs[0].configUrl ||
providers.oidc?.config?.configs[0].clientID ||
providers.oidc?.config?.configs[0].clientSecret
$: googleComplete =
providers.google?.config?.clientID &&
providers.google?.config?.clientSecret &&
providers.google?.config?.callbackURL
$: oidcComplete =
providers.oidc?.config?.configs[0].configUrl &&
providers.oidc?.config?.configs[0].clientID &&
providers.oidc?.config?.configs[0].clientSecret
async function uploadLogo(file) {
let data = new FormData()
data.append("file", file)
const res = await api.post(
`/api/admin/configs/upload/logos_oidc/${file.name}`,
data,
{}
)
return await res.json()
}
const onFileSelected = e => {
let fileName = e.target.files[0].name
image = e.target.files[0]
providers.oidc.config.configs[0].logo = fileName
iconDropdownOptions.unshift({ label: fileName, value: fileName })
}
async function save(docs) {
// only if the user has provided an image, upload it.
image && uploadLogo(image)
let calls = []
docs.forEach(element => {
if (element.type === ConfigTypes.OIDC) {
//Add a UUID here so each config is distinguishable when it arrives at the login page.
element.config.configs.forEach(config => {
!config.uuid && (config.uuid = uuid())
})
if (partialOidc) {
if (!oidcComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.OIDC} fields`
)
} else {
calls.push(api.post(`/api/admin/configs`, element))
// turn the save button grey when clicked
oidcSaveButtonDisabled = true
originalOidcDoc = cloneDeep(providers.oidc)
}
}
}
if (element.type === ConfigTypes.Google) {
if (partialGoogle) {
if (!googleComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.Google} fields`
)
} else {
calls.push(api.post(`/api/admin/configs`, element))
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
}
}
}
})
calls.length &&
Promise.all(calls)
.then(responses => {
return Promise.all(
responses.map(response => {
return response.json()
})
)
})
.then(data => {
data.forEach(res => {
providers[res.type]._rev = res._rev
providers[res.type]._id = res._id
})
notifications.success(`Settings saved.`)
})
.catch(err => {
notifications.error(`Failed to update auth settings. ${err}`)
throw new Error(err.message)
})
} }
onMount(async () => { onMount(async () => {
await organisation.init()
// fetch the configs for oauth // fetch the configs for oauth
const googleResponse = await api.get( const googleResponse = await api.get(
`/api/admin/configs/${ConfigTypes.Google}` `/api/admin/configs/${ConfigTypes.Google}`
@ -55,33 +211,77 @@
const googleDoc = await googleResponse.json() const googleDoc = await googleResponse.json()
if (!googleDoc._id) { if (!googleDoc._id) {
google = { providers.google = {
type: ConfigTypes.Google, type: ConfigTypes.Google,
config: {}, config: { activated: true },
}
originalGoogleDoc = cloneDeep(googleDoc)
} else {
originalGoogleDoc = cloneDeep(googleDoc)
providers.google = googleDoc
}
//Get the list of user uploaded logos and push it to the dropdown options.
//This needs to be done before the config call so they're available when the dropdown renders
const res = await api.get(`/api/admin/configs/logos_oidc`)
const configSettings = await res.json()
if (configSettings.config) {
const logoKeys = Object.keys(configSettings.config)
logoKeys.map(logoKey => {
const logoUrl = configSettings.config[logoKey]
iconDropdownOptions.unshift({
label: logoKey,
value: logoKey,
icon: logoUrl,
})
})
}
const oidcResponse = await api.get(`/api/admin/configs/${ConfigTypes.OIDC}`)
const oidcDoc = await oidcResponse.json()
if (!oidcDoc._id) {
console.log("hi")
providers.oidc = {
type: ConfigTypes.OIDC,
config: { configs: [{ activated: true }] },
} }
} else { } else {
google = googleDoc console.log("hello")
originalOidcDoc = cloneDeep(oidcDoc)
providers.oidc = oidcDoc
} }
}) })
</script> </script>
<Layout> <Layout>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="M">OAuth</Heading> <Heading size="M">Authentication</Heading>
<Body> <Body>
Every budibase app comes with basic authentication (email/password) Every budibase app comes with basic authentication (email/password)
included. You can add additional authentication methods from the options included. You can add additional authentication methods from the options
below. below.
</Body> </Body>
</Layout> </Layout>
{#if google} {#if providers.google}
<Divider /> <Divider />
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
<Heading size="S"> <Heading size="S">
<span> <div>
<GoogleLogo /> <GoogleLogo />
Google Google
</span> <div class="google-save-button">
<div>
<Button
disabled={googleSaveButtonDisabled}
size="s"
cta
on:click={() => save([providers.google])}>Save</Button
>
</div>
</div>
</div>
</Heading> </Heading>
<Body size="S"> <Body size="S">
To allow users to authenticate using their Google accounts, fill out the To allow users to authenticate using their Google accounts, fill out the
@ -89,30 +289,127 @@
</Body> </Body>
</Layout> </Layout>
<Layout gap="XS" noPadding> <Layout gap="XS" noPadding>
{#each ConfigFields.Google as field} {#each GoogleConfigFields.Google as field}
<div class="form-row"> <div class="form-row">
<Label size="L">{ConfigLabels.Google[field]}</Label> <Label size="L">{GoogleConfigLabels.Google[field]}</Label>
<Input bind:value={google.config[field]} /> <Input bind:value={providers.google.config[field]} />
</div> </div>
{/each} {/each}
<div class="form-row">
<div class="field">
<Label size="L">Activated</Label>
<span class="alignedToggle">
<Toggle text="" bind:value={providers.google.config.activated} />
</span>
</div>
</div>
</Layout> </Layout>
<div> {/if}
<Button cta on:click={() => save(google)}>Save</Button> {#if providers.oidc}
<Divider />
<Layout gap="XS" noPadding>
<Heading size="S">
<div>
<OidcLogo />
OpenID Connect
<div class="oidc-save-button">
<div>
<Button
disabled={oidcSaveButtonDisabled}
size="s"
cta
on:click={() => save([providers.oidc])}>Save</Button
>
</div>
</div>
</div></Heading
>
<Body size="S">
To allow users to authenticate using OIDC, fill out the fields below.
</Body>
</Layout>
<Layout gap="XS" noPadding>
{#each OIDCConfigFields.Oidc as field}
<div class="form-row">
<Label size="L">{OIDCConfigLabels.Oidc[field]}</Label>
<Input bind:value={providers.oidc.config.configs[0][field]} />
</div>
{/each}
<div class="form-row">
<Label size="L">Callback URL</Label>
<Input readonly placeholder="/api/admin/auth/oidc/callback" />
</div>
<br />
<Body size="S">
To customize your login button, fill out the fields below.
</Body>
<div class="form-row">
<Label size="L">Name</Label>
<Input bind:value={providers.oidc.config.configs[0].name} />
</div>
<div class="form-row">
<Label size="L">Icon</Label>
<Select
label=""
bind:value={providers.oidc.config.configs[0].logo}
options={iconDropdownOptions}
on:change={e => e.detail === "Upload" && fileinput.click()}
/>
</div>
<input
type="file"
accept=".jpg, .jpeg, .png"
on:change={e => onFileSelected(e)}
bind:this={fileinput}
/>
</Layout>
<div class="form-row">
<div class="field">
<Label size="L">Activated</Label>
<span class="alignedToggle">
<Toggle
text=""
bind:value={providers.oidc.config.configs[0].activated}
/>
</span>
</div>
</div> </div>
{/if} {/if}
</Layout> </Layout>
<style> <style>
.field {
display: flex;
align-items: center;
}
.alignedToggle {
margin-left: 63%;
}
.form-row { .form-row {
display: grid; display: grid;
grid-template-columns: 20% 1fr; grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l); grid-gap: var(--spacing-l);
align-items: center; align-items: center;
} }
span { span {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-s); gap: var(--spacing-s);
} }
input {
display: none;
}
.google-save-button {
display: inline-block;
margin-left: 400px;
}
.oidc-save-button {
display: inline-block;
margin-left: 320px;
}
</style> </style>

View File

@ -16,6 +16,7 @@
import Editor from "components/integration/QueryEditor.svelte" import Editor from "components/integration/QueryEditor.svelte"
import TemplateBindings from "./_components/TemplateBindings.svelte" import TemplateBindings from "./_components/TemplateBindings.svelte"
// this is the email purpose
export let template export let template
let htmlEditor let htmlEditor
@ -24,9 +25,11 @@
$: selectedTemplate = $email.templates?.find( $: selectedTemplate = $email.templates?.find(
({ purpose }) => purpose === template ({ purpose }) => purpose === template
) )
$: name = $email.definitions?.info[template]?.name
$: description = $email.definitions?.info[template]?.description
$: baseTemplate = $email.templates?.find(({ purpose }) => purpose === "base") $: baseTemplate = $email.templates?.find(({ purpose }) => purpose === "base")
$: templateBindings = $: templateBindings =
$email.definitions?.bindings?.[selectedTemplate.purpose] || [] $email.definitions?.bindings?.[selectedTemplate?.purpose] || []
$: previewContent = makePreviewContent(baseTemplate, selectedTemplate) $: previewContent = makePreviewContent(baseTemplate, selectedTemplate)
async function saveTemplate() { async function saveTemplate() {
@ -81,10 +84,12 @@
</div> </div>
<header> <header>
<Heading> <Heading>
Email Template: {template} Email Template: {name}
</Heading> </Heading>
<Button cta on:click={saveTemplate}>Save</Button> <Button cta on:click={saveTemplate}>Save</Button>
</header> </header>
<Detail>Description</Detail>
<Body>{description}</Body>
<Body <Body
>Change the email template here. Add dynamic content by using the bindings >Change the email template here. Add dynamic content by using the bindings
menu on the right.</Body menu on the right.</Body

View File

@ -1,13 +0,0 @@
<script>
import { goto } from "@roxi/routify"
export let value
</script>
<span on:click={() => $goto(`./${value}`)}>{value}</span>
<style>
span {
text-transform: capitalize;
}
</style>

View File

@ -14,7 +14,6 @@
Checkbox, Checkbox,
} from "@budibase/bbui" } from "@budibase/bbui"
import { email } from "stores/portal" import { email } from "stores/portal"
import TemplateLink from "./_components/TemplateLink.svelte"
import api from "builderStore/api" import api from "builderStore/api"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
@ -23,23 +22,30 @@
} }
const templateSchema = { const templateSchema = {
purpose: { name: {
displayName: "Email", displayName: "Name",
editable: false,
},
category: {
displayName: "Category",
editable: false, editable: false,
}, },
} }
const customRenderers = [ $: emailInfo = getEmailInfo($email.definitions)
{
column: "purpose",
component: TemplateLink,
},
]
let smtpConfig let smtpConfig
let loading let loading
let requireAuth = false let requireAuth = false
function getEmailInfo(definitions) {
if (!definitions) {
return []
}
const entries = Object.entries(definitions.info)
return entries.map(([key, value]) => ({ purpose: key, ...value }))
}
async function saveSmtp() { async function saveSmtp() {
// clone it so we can remove stuff if required // clone it so we can remove stuff if required
const smtp = cloneDeep(smtpConfig) const smtp = cloneDeep(smtpConfig)
@ -159,8 +165,7 @@
</Body> </Body>
</Layout> </Layout>
<Table <Table
{customRenderers} data={emailInfo}
data={$email.templates}
schema={templateSchema} schema={templateSchema}
{loading} {loading}
on:click={({ detail }) => $goto(`./${detail.purpose}`)} on:click={({ detail }) => $goto(`./${detail.purpose}`)}

View File

@ -4,3 +4,4 @@ export { admin } from "./admin"
export { apps } from "./apps" export { apps } from "./apps"
export { email } from "./email" export { email } from "./email"
export { auth } from "./auth" export { auth } from "./auth"
export { oidc } from "./oidc"

View File

@ -0,0 +1,33 @@
import { writable } from "svelte/store"
import api from "builderStore/api"
const OIDC_CONFIG = {
logo: undefined,
name: undefined,
uuid: undefined,
}
export function createOidcStore() {
const store = writable(OIDC_CONFIG)
const { set, subscribe } = store
async function init() {
const res = await api.get(`/api/admin/configs/publicOidc`)
const json = await res.json()
if (json.status === 400) {
set(OIDC_CONFIG)
} else {
// Just use the first config for now. We will be support multiple logins buttons later on.
set(...json)
}
}
return {
subscribe,
set,
init,
}
}
export const oidc = createOidcStore()

View File

@ -6,6 +6,8 @@ const DEFAULT_CONFIG = {
logoUrl: undefined, logoUrl: undefined,
docsUrl: undefined, docsUrl: undefined,
company: "Budibase", company: "Budibase",
oidc: undefined,
google: undefined,
} }
export function createOrganisationStore() { export function createOrganisationStore() {

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/cli", "name": "@budibase/cli",
"version": "0.9.73", "version": "0.9.78",
"description": "Budibase CLI, for developers, self hosting and migrations.", "description": "Budibase CLI, for developers, self hosting and migrations.",
"main": "src/index.js", "main": "src/index.js",
"bin": { "bin": {

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/client", "name": "@budibase/client",
"version": "0.9.73", "version": "0.9.78",
"license": "MPL-2.0", "license": "MPL-2.0",
"module": "dist/budibase-client.js", "module": "dist/budibase-client.js",
"main": "dist/budibase-client.js", "main": "dist/budibase-client.js",
@ -18,9 +18,9 @@
"dev:builder": "rollup -cw" "dev:builder": "rollup -cw"
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^0.9.73", "@budibase/bbui": "^0.9.78",
"@budibase/standard-components": "^0.9.73", "@budibase/standard-components": "^0.9.78",
"@budibase/string-templates": "^0.9.73", "@budibase/string-templates": "^0.9.78",
"regexparam": "^1.3.0", "regexparam": "^1.3.0",
"shortid": "^2.2.15", "shortid": "^2.2.15",
"svelte-spa-router": "^3.0.5" "svelte-spa-router": "^3.0.5"

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/server", "name": "@budibase/server",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "0.9.73", "version": "0.9.78",
"description": "Budibase Web Server", "description": "Budibase Web Server",
"main": "src/index.js", "main": "src/index.js",
"repository": { "repository": {
@ -60,9 +60,9 @@
"author": "Budibase", "author": "Budibase",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@budibase/auth": "^0.9.73", "@budibase/auth": "^0.9.78",
"@budibase/client": "^0.9.73", "@budibase/client": "^0.9.78",
"@budibase/string-templates": "^0.9.73", "@budibase/string-templates": "^0.9.78",
"@elastic/elasticsearch": "7.10.0", "@elastic/elasticsearch": "7.10.0",
"@koa/router": "8.0.0", "@koa/router": "8.0.0",
"@sendgrid/mail": "7.1.1", "@sendgrid/mail": "7.1.1",
@ -115,7 +115,7 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.14.3", "@babel/core": "^7.14.3",
"@babel/preset-env": "^7.14.4", "@babel/preset-env": "^7.14.4",
"@budibase/standard-components": "^0.9.73", "@budibase/standard-components": "^0.9.78",
"@jest/test-sequencer": "^24.8.0", "@jest/test-sequencer": "^24.8.0",
"@types/bull": "^3.15.1", "@types/bull": "^3.15.1",
"@types/jest": "^26.0.23", "@types/jest": "^26.0.23",

View File

@ -148,12 +148,20 @@ class QueryBuilder {
} }
if (this.query.equal) { if (this.query.equal) {
build(this.query.equal, (key, value) => { build(this.query.equal, (key, value) => {
return value ? `${key}:${luceneEscape(value.toLowerCase())}` : null const escapedValue = luceneEscape(value.toLowerCase())
// have to do the or to manage straight values, or strings
return value
? `(${key}:${escapedValue} OR ${key}:"${escapedValue}")`
: null
}) })
} }
if (this.query.notEqual) { if (this.query.notEqual) {
build(this.query.notEqual, (key, value) => { build(this.query.notEqual, (key, value) => {
return value ? `!${key}:${luceneEscape(value.toLowerCase())}` : null const escapedValue = luceneEscape(value.toLowerCase())
// have to do the or to manage straight values, or strings
return value
? `(!${key}:${escapedValue} OR !${key}:"${escapedValue}")`
: null
}) })
} }
if (this.query.empty) { if (this.query.empty) {

View File

@ -76,7 +76,10 @@ module.exports.run = async function ({ inputs, appId, emitter }) {
rowId: inputs.rowId, rowId: inputs.rowId,
}, },
request: { request: {
body: inputs.row, body: {
...inputs.row,
_id: inputs.rowId,
},
}, },
appId, appId,
eventEmitter: emitter, eventEmitter: emitter,

View File

@ -129,9 +129,11 @@ export interface QueryJson {
export interface SqlQuery { export interface SqlQuery {
sql: string sql: string
bindings?: { bindings?:
[key: string]: any | string[]
} | {
[key: string]: any
}
} }
export interface QueryOptions { export interface QueryOptions {

View File

@ -92,7 +92,7 @@ module PostgresModule {
async function internalQuery(client: any, query: SqlQuery) { async function internalQuery(client: any, query: SqlQuery) {
try { try {
return await client.query(query.sql, query.bindings || {}) return await client.query(query.sql, query.bindings || [])
} catch (err) { } catch (err) {
throw new Error(err) throw new Error(err)
} }

View File

@ -20,7 +20,7 @@ describe("Postgres Integration", () => {
await config.integration.create({ await config.integration.create({
sql sql
}) })
expect(pg.queryMock).toHaveBeenCalledWith(sql, {}) expect(pg.queryMock).toHaveBeenCalledWith(sql, [])
}) })
it("calls the read method with the correct params", async () => { it("calls the read method with the correct params", async () => {
@ -28,7 +28,7 @@ describe("Postgres Integration", () => {
await config.integration.read({ await config.integration.read({
sql sql
}) })
expect(pg.queryMock).toHaveBeenCalledWith(sql, {}) expect(pg.queryMock).toHaveBeenCalledWith(sql, [])
}) })
it("calls the update method with the correct params", async () => { it("calls the update method with the correct params", async () => {
@ -36,7 +36,7 @@ describe("Postgres Integration", () => {
const response = await config.integration.update({ const response = await config.integration.update({
sql sql
}) })
expect(pg.queryMock).toHaveBeenCalledWith(sql, {}) expect(pg.queryMock).toHaveBeenCalledWith(sql, [])
}) })
it("calls the delete method with the correct params", async () => { it("calls the delete method with the correct params", async () => {
@ -44,7 +44,7 @@ describe("Postgres Integration", () => {
await config.integration.delete({ await config.integration.delete({
sql sql
}) })
expect(pg.queryMock).toHaveBeenCalledWith(sql, {}) expect(pg.queryMock).toHaveBeenCalledWith(sql, [])
}) })
describe("no rows returned", () => { describe("no rows returned", () => {

View File

@ -29,12 +29,12 @@
"keywords": [ "keywords": [
"svelte" "svelte"
], ],
"version": "0.9.73", "version": "0.9.78",
"license": "MIT", "license": "MIT",
"gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc", "gitHead": "d1836a898cab3f8ab80ee6d8f42be1a9eed7dcdc",
"dependencies": { "dependencies": {
"@budibase/bbui": "^0.9.73",
"@spectrum-css/card": "^3.0.3", "@spectrum-css/card": "^3.0.3",
"@budibase/bbui": "^0.9.78",
"@spectrum-css/link": "^3.1.3", "@spectrum-css/link": "^3.1.3",
"@spectrum-css/page": "^3.0.1", "@spectrum-css/page": "^3.0.1",
"@spectrum-css/typography": "^3.0.2", "@spectrum-css/typography": "^3.0.2",

View File

@ -1,6 +1,6 @@
{ {
"name": "@budibase/string-templates", "name": "@budibase/string-templates",
"version": "0.9.73", "version": "0.9.78",
"description": "Handlebars wrapper for Budibase templating.", "description": "Handlebars wrapper for Budibase templating.",
"main": "src/index.cjs", "main": "src/index.cjs",
"module": "dist/bundle.mjs", "module": "dist/bundle.mjs",

View File

@ -1,7 +1,7 @@
{ {
"name": "@budibase/worker", "name": "@budibase/worker",
"email": "hi@budibase.com", "email": "hi@budibase.com",
"version": "0.9.73", "version": "0.9.78",
"description": "Budibase background service", "description": "Budibase background service",
"main": "src/index.js", "main": "src/index.js",
"repository": { "repository": {
@ -21,8 +21,8 @@
"author": "Budibase", "author": "Budibase",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@budibase/auth": "^0.9.73", "@budibase/auth": "^0.9.78",
"@budibase/string-templates": "^0.9.73", "@budibase/string-templates": "^0.9.78",
"@koa/router": "^8.0.0", "@koa/router": "^8.0.0",
"aws-sdk": "^2.811.0", "aws-sdk": "^2.811.0",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
@ -39,6 +39,7 @@
"koa-static": "^5.0.0", "koa-static": "^5.0.0",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"nodemailer": "^6.5.0", "nodemailer": "^6.5.0",
"@techpass/passport-openidconnect": "^0.3.0",
"passport-google-oauth": "^2.0.0", "passport-google-oauth": "^2.0.0",
"passport-jwt": "^4.0.0", "passport-jwt": "^4.0.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",

View File

@ -1,25 +1,28 @@
const authPkg = require("@budibase/auth") const authPkg = require("@budibase/auth")
const { google } = require("@budibase/auth/src/middleware") const { google } = require("@budibase/auth/src/middleware")
const { oidc } = require("@budibase/auth/src/middleware")
const { Configs, EmailTemplatePurpose } = require("../../../constants") const { Configs, EmailTemplatePurpose } = require("../../../constants")
const CouchDB = require("../../../db") const CouchDB = require("../../../db")
const { sendEmail, isEmailConfigured } = require("../../../utilities/email") const { sendEmail, isEmailConfigured } = require("../../../utilities/email")
const { clearCookie, getGlobalUserByEmail, hash } = authPkg.utils const { setCookie, getCookie, clearCookie, getGlobalUserByEmail, hash } =
authPkg.utils
const { Cookies } = authPkg.constants const { Cookies } = authPkg.constants
const { passport } = authPkg.auth const { passport } = authPkg.auth
const { checkResetPasswordCode } = require("../../../utilities/redis") const { checkResetPasswordCode } = require("../../../utilities/redis")
const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name const GLOBAL_DB = authPkg.StaticDatabases.GLOBAL.name
async function authInternal(ctx, user, err = null) { async function authInternal(ctx, user, err = null, info = null) {
if (err) { if (err) {
return ctx.throw(403, "Unauthorized") console.error("Authentication error", err)
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, "Unauthorized") return ctx.throw(403, info ? info : "Unauthorized")
} }
// just store the user ID // just store the user ID
@ -32,8 +35,8 @@ async function authInternal(ctx, user, err = null) {
} }
exports.authenticate = async (ctx, next) => { exports.authenticate = async (ctx, next) => {
return passport.authenticate("local", async (err, user) => { return passport.authenticate("local", async (err, user, info) => {
await authInternal(ctx, user, err) await authInternal(ctx, user, err, info)
delete user.token delete user.token
@ -123,8 +126,54 @@ exports.googleAuth = async (ctx, next) => {
return passport.authenticate( return passport.authenticate(
strategy, strategy,
{ successRedirect: "/", failureRedirect: "/error" }, { successRedirect: "/", failureRedirect: "/error" },
async (err, user) => { async (err, user, info) => {
await authInternal(ctx, user, err) await authInternal(ctx, user, err, info)
ctx.redirect("/")
}
)(ctx, next)
}
async function oidcStrategyFactory(ctx, configId) {
const db = new CouchDB(GLOBAL_DB)
const config = await authPkg.db.getScopedConfig(db, {
type: Configs.OIDC,
group: ctx.query.group,
})
const chosenConfig = config.configs.filter(c => c.uuid === configId)[0]
const callbackUrl = `${ctx.protocol}://${ctx.host}/api/admin/auth/oidc/callback`
return oidc.strategyFactory(chosenConfig, callbackUrl)
}
/**
* The initial call that OIDC authentication makes to take you to the configured OIDC login screen.
* On a successful login, you will be redirected to the oidcAuth callback route.
*/
exports.oidcPreAuth = async (ctx, next) => {
const { configId } = ctx.params
const strategy = await oidcStrategyFactory(ctx, configId)
setCookie(ctx, configId, Cookies.OIDC_CONFIG)
return passport.authenticate(strategy, {
// required 'openid' scope is added by oidc strategy factory
scope: ["profile", "email"],
})(ctx, next)
}
exports.oidcAuth = async (ctx, next) => {
const configId = getCookie(ctx, Cookies.OIDC_CONFIG)
const strategy = await oidcStrategyFactory(ctx, configId)
return passport.authenticate(
strategy,
{ successRedirect: "/", failureRedirect: "/error" },
async (err, user, info) => {
await authInternal(ctx, user, err, info)
ctx.redirect("/") ctx.redirect("/")
} }

View File

@ -98,23 +98,73 @@ exports.find = async function (ctx) {
} }
} }
exports.publicSettings = async function (ctx) { exports.publicOidc = async function (ctx) {
const db = new CouchDB(GLOBAL_DB) const db = new CouchDB(GLOBAL_DB)
try { try {
// Find the config with the most granular scope based on context // Find the config with the most granular scope based on context
const config = await getScopedFullConfig(db, { const oidcConfig = await getScopedFullConfig(db, {
type: Configs.SETTINGS, type: Configs.OIDC,
}) })
if (!config) {
if (!oidcConfig) {
ctx.body = {} ctx.body = {}
} else { } else {
ctx.body = config const partialOidcCofig = oidcConfig.config.configs.map(config => {
return {
logo: config.logo,
name: config.name,
uuid: config.uuid,
}
})
ctx.body = partialOidcCofig
} }
} catch (err) { } catch (err) {
ctx.throw(err.status, err) ctx.throw(err.status, err)
} }
} }
exports.publicSettings = async function (ctx) {
const db = new CouchDB(GLOBAL_DB)
try {
// Find the config with the most granular scope based on context
const publicConfig = await getScopedFullConfig(db, {
type: Configs.SETTINGS,
})
const googleConfig = await getScopedFullConfig(db, {
type: Configs.GOOGLE,
})
const oidcConfig = await getScopedFullConfig(db, {
type: Configs.OIDC,
})
let config = {}
if (!publicConfig) {
config = {
config: {},
}
} else {
config = publicConfig
}
config.config.google = !googleConfig
? !!googleConfig
: !googleConfig.config.activated
? false
: true
config.config.oidc = !oidcConfig
? !!oidcConfig
: !oidcConfig.config.configs[0].activated
? false
: true
ctx.body = config
} catch (err) {
ctx.throw(err.status, err)
}
}
exports.upload = async function (ctx) { exports.upload = async function (ctx) {
if (ctx.request.files == null || ctx.request.files.file.length > 1) { if (ctx.request.files == null || ctx.request.files.file.length > 1) {
ctx.throw(400, "One file must be uploaded.") ctx.throw(400, "One file must be uploaded.")
@ -122,12 +172,8 @@ exports.upload = async function (ctx) {
const file = ctx.request.files.file const file = ctx.request.files.file
const { type, name } = ctx.params const { type, name } = ctx.params
const fileExtension = [...file.name.split(".")].pop()
// filenames converted to UUIDs so they are unique
const processedFileName = `${name}.${fileExtension}`
const bucket = ObjectStoreBuckets.GLOBAL const bucket = ObjectStoreBuckets.GLOBAL
const key = `${type}/${processedFileName}` const key = `${type}/${name}`
await upload({ await upload({
bucket, bucket,
filename: key, filename: key,
@ -146,7 +192,7 @@ exports.upload = async function (ctx) {
} }
} }
const url = `/${bucket}/${key}` const url = `/${bucket}/${key}`
cfgStructure.config[`${name}Url`] = url cfgStructure.config[`${name}`] = url
// write back to db with url updated // write back to db with url updated
await db.put(cfgStructure) await db.put(cfgStructure)
@ -184,10 +230,14 @@ exports.configChecklist = async function (ctx) {
}) })
// They have set up Google Auth // They have set up Google Auth
const oauthConfig = await getScopedFullConfig(db, { const googleConfig = await getScopedFullConfig(db, {
type: Configs.GOOGLE, type: Configs.GOOGLE,
}) })
// They have set up OIDC
const oidcConfig = await getScopedFullConfig(db, {
type: Configs.OIDC,
})
// They have set up an admin user // They have set up an admin user
const users = await db.allDocs( const users = await db.allDocs(
getGlobalUserParams(null, { getGlobalUserParams(null, {
@ -200,7 +250,7 @@ exports.configChecklist = async function (ctx) {
apps: appDbNames.length, apps: appDbNames.length,
smtp: !!smtpConfig, smtp: !!smtpConfig,
adminUser, adminUser,
oauth: !!oauthConfig, sso: !!googleConfig || !!oidcConfig,
} }
} catch (err) { } catch (err) {
ctx.throw(err.status, err) ctx.throw(err.status, err)

View File

@ -28,12 +28,18 @@ exports.save = async ctx => {
exports.definitions = async ctx => { exports.definitions = async ctx => {
const bindings = {} const bindings = {}
const info = {}
for (let template of TemplateMetadata.email) { for (let template of TemplateMetadata.email) {
bindings[template.purpose] = template.bindings bindings[template.purpose] = template.bindings
info[template.purpose] = {
name: template.name,
description: template.description,
category: template.category,
}
} }
ctx.body = { ctx.body = {
info,
bindings: { bindings: {
...bindings, ...bindings,
common: Object.values(TemplateBindings), common: Object.values(TemplateBindings),

View File

@ -25,6 +25,14 @@ const PUBLIC_ENDPOINTS = [
route: "/api/admin/auth/google/callback", route: "/api/admin/auth/google/callback",
method: "GET", method: "GET",
}, },
{
route: "/api/admin/auth/oidc",
method: "GET",
},
{
route: "/api/admin/auth/oidc/callback",
method: "GET",
},
{ {
route: "/api/admin/auth/reset", route: "/api/admin/auth/reset",
method: "POST", method: "POST",
@ -41,6 +49,10 @@ const PUBLIC_ENDPOINTS = [
route: "/api/admin/configs/public", route: "/api/admin/configs/public",
method: "GET", method: "GET",
}, },
{
route: "/api/admin/configs/publicOidc",
method: "GET",
},
] ]
const router = new Router() const router = new Router()

View File

@ -39,5 +39,7 @@ router
.post("/api/admin/auth/logout", authController.logout) .post("/api/admin/auth/logout", authController.logout)
.get("/api/admin/auth/google", authController.googlePreAuth) .get("/api/admin/auth/google", authController.googlePreAuth)
.get("/api/admin/auth/google/callback", authController.googleAuth) .get("/api/admin/auth/google/callback", authController.googleAuth)
.get("/api/admin/auth/oidc/configs/:configId", authController.oidcPreAuth)
.get("/api/admin/auth/oidc/callback", authController.oidcAuth)
module.exports = router module.exports = router

View File

@ -3,7 +3,7 @@ const controller = require("../../controllers/admin/configs")
const joiValidator = require("../../../middleware/joi-validator") const joiValidator = require("../../../middleware/joi-validator")
const adminOnly = require("../../../middleware/adminOnly") const adminOnly = require("../../../middleware/adminOnly")
const Joi = require("joi") const Joi = require("joi")
const { Configs, ConfigUploads } = require("../../../constants") const { Configs } = require("../../../constants")
const router = Router() const router = Router()
@ -38,6 +38,24 @@ function googleValidation() {
clientID: Joi.string().required(), clientID: Joi.string().required(),
clientSecret: Joi.string().required(), clientSecret: Joi.string().required(),
callbackURL: Joi.string().required(), callbackURL: Joi.string().required(),
activated: Joi.boolean().required(),
}).unknown(true)
}
function oidcValidation() {
// prettier-ignore
return Joi.object({
configs: Joi.array().items(
Joi.object({
clientID: Joi.string().required(),
clientSecret: Joi.string().required(),
configUrl: Joi.string().required(),
logo: Joi.string().allow("", null),
name: Joi.string().allow("", null),
uuid: Joi.string().required(),
activated: Joi.boolean().required(),
})
).required(true)
}).unknown(true) }).unknown(true)
} }
@ -54,7 +72,8 @@ function buildConfigSaveValidation() {
{ is: Configs.SMTP, then: smtpValidation() }, { is: Configs.SMTP, then: smtpValidation() },
{ is: Configs.SETTINGS, then: settingValidation() }, { is: Configs.SETTINGS, then: settingValidation() },
{ is: Configs.ACCOUNT, then: Joi.object().unknown(true) }, { is: Configs.ACCOUNT, then: Joi.object().unknown(true) },
{ is: Configs.GOOGLE, then: googleValidation() } { is: Configs.GOOGLE, then: googleValidation() },
{ is: Configs.OIDC, then: oidcValidation() }
], ],
}), }),
}).required(), }).required(),
@ -65,7 +84,7 @@ function buildUploadValidation() {
// prettier-ignore // prettier-ignore
return joiValidator.params(Joi.object({ return joiValidator.params(Joi.object({
type: Joi.string().valid(...Object.values(Configs)).required(), type: Joi.string().valid(...Object.values(Configs)).required(),
name: Joi.string().valid(...Object.values(ConfigUploads)).required(), name: Joi.string().required(),
}).required()) }).required())
} }
@ -83,7 +102,7 @@ router
buildConfigSaveValidation(), buildConfigSaveValidation(),
controller.save controller.save
) )
.delete("/api/admin/configs/:id", adminOnly, controller.destroy) .delete("/api/admin/configs/:id/:rev", adminOnly, controller.destroy)
.get("/api/admin/configs", controller.fetch) .get("/api/admin/configs", controller.fetch)
.get("/api/admin/configs/checklist", controller.configChecklist) .get("/api/admin/configs/checklist", controller.configChecklist)
.get( .get(
@ -92,6 +111,7 @@ router
controller.fetch controller.fetch
) )
.get("/api/admin/configs/public", controller.publicSettings) .get("/api/admin/configs/public", controller.publicSettings)
.get("/api/admin/configs/publicOidc", controller.publicOidc)
.get("/api/admin/configs/:type", buildConfigGetValidation(), controller.find) .get("/api/admin/configs/:type", buildConfigGetValidation(), controller.find)
.post( .post(
"/api/admin/configs/upload/:type/:name", "/api/admin/configs/upload/:type/:name",

View File

@ -1,4 +1,5 @@
const setup = require("./utilities") const setup = require("./utilities")
const { Cookies } = require("@budibase/auth").constants
jest.mock("nodemailer") jest.mock("nodemailer")
const sendMailMock = setup.emailMock() const sendMailMock = setup.emailMock()
@ -14,6 +15,10 @@ describe("/api/admin/auth", () => {
afterAll(setup.afterAll) afterAll(setup.afterAll)
afterEach(() => {
jest.clearAllMocks()
})
it("should be able to generate password reset email", async () => { it("should be able to generate password reset email", async () => {
// initially configure settings // initially configure settings
await config.saveSmtpConfig() await config.saveSmtpConfig()
@ -46,4 +51,56 @@ describe("/api/admin/auth", () => {
.expect(200) .expect(200)
expect(res.body).toEqual({ message: "password reset successfully." }) expect(res.body).toEqual({ message: "password reset successfully." })
}) })
describe("oidc", () => {
const auth = require("@budibase/auth").auth
// mock the oidc strategy implementation and return value
strategyFactory = jest.fn()
mockStrategyReturn = jest.fn()
strategyFactory.mockReturnValue(mockStrategyReturn)
auth.oidc.strategyFactory = strategyFactory
const passportSpy = jest.spyOn(auth.passport, "authenticate")
let oidcConf
let chosenConfig
let configId
beforeEach(async () => {
oidcConf = await config.saveOIDCConfig()
chosenConfig = oidcConf.config.configs[0]
configId = chosenConfig.uuid
})
afterEach(() => {
expect(strategyFactory).toBeCalledWith(
chosenConfig,
`http://127.0.0.1:4003/api/admin/auth/oidc/callback` // calculated url
)
})
describe("/api/admin/auth/oidc/configs", () => {
it("should load strategy and delegate to passport", async () => {
await request.get(`/api/admin/auth/oidc/configs/${configId}`)
expect(passportSpy).toBeCalledWith(mockStrategyReturn, {
scope: ["profile", "email"],
})
expect(passportSpy.mock.calls.length).toBe(1);
})
})
describe("/api/admin/auth/oidc/callback", () => {
it("should load strategy and delegate to passport", async () => {
await request.get(`/api/admin/auth/oidc/callback`)
.set(config.getOIDConfigCookie(configId))
expect(passportSpy).toBeCalledWith(mockStrategyReturn, {
successRedirect: "/", failureRedirect: "/error"
}, expect.anything())
expect(passportSpy.mock.calls.length).toBe(1);
})
})
})
}) })

View File

@ -6,6 +6,7 @@ const { Cookies } = require("@budibase/auth").constants
const { Configs, LOGO_URL } = require("../../../../constants") const { Configs, LOGO_URL } = require("../../../../constants")
const { getGlobalUserByEmail } = require("@budibase/auth").utils const { getGlobalUserByEmail } = require("@budibase/auth").utils
const { createASession } = require("@budibase/auth/sessions") const { createASession } = require("@budibase/auth/sessions")
const { newid } = require("../../../../../../auth/src/hashing")
class TestConfiguration { class TestConfiguration {
constructor(openServer = true) { constructor(openServer = true) {
@ -67,6 +68,12 @@ class TestConfiguration {
} }
} }
cookieHeader(cookies) {
return {
Cookie: [cookies],
}
}
defaultHeaders() { defaultHeaders() {
const user = { const user = {
_id: "us_uuid1", _id: "us_uuid1",
@ -76,7 +83,7 @@ class TestConfiguration {
const authToken = jwt.sign(user, env.JWT_SECRET) const authToken = jwt.sign(user, env.JWT_SECRET)
return { return {
Accept: "application/json", Accept: "application/json",
Cookie: [`${Cookies.Auth}=${authToken}`], ...this.cookieHeader([`${Cookies.Auth}=${authToken}`]),
} }
} }
@ -155,6 +162,33 @@ class TestConfiguration {
) )
} }
getOIDConfigCookie(configId) {
const token = jwt.sign(configId, env.JWT_SECRET)
return this.cookieHeader([[`${Cookies.OIDC_CONFIG}=${token}`]])
}
async saveOIDCConfig() {
await this.deleteConfig(Configs.OIDC)
const config = {
type: Configs.OIDC,
config: {
configs: [
{
configUrl: "http://someconfigurl",
clientID: "clientId",
clientSecret: "clientSecret",
logo: "Microsoft",
name: "Active Directory",
uuid: newid(),
},
],
},
}
await this._req(config, null, controllers.config.save)
return config
}
async saveSmtpConfig() { async saveSmtpConfig() {
await this.deleteConfig(Configs.SMTP) await this.deleteConfig(Configs.SMTP)
await this._req( await this._req(

View File

@ -16,6 +16,7 @@ exports.Configs = Configs
exports.ConfigUploads = { exports.ConfigUploads = {
LOGO: "logo", LOGO: "logo",
OIDC_LOGO: "oidc_logo",
} }
const TemplateTypes = { const TemplateTypes = {
@ -96,7 +97,10 @@ const TemplateBindings = {
const TemplateMetadata = { const TemplateMetadata = {
[TemplateTypes.EMAIL]: [ [TemplateTypes.EMAIL]: [
{ {
name: "Base Format", name: "Base format",
description:
"This is the base template, all others are based on it. The {{ body }} will be replaced with another email template.",
category: "miscellaneous",
purpose: EmailTemplatePurpose.BASE, purpose: EmailTemplatePurpose.BASE,
bindings: [ bindings: [
{ {
@ -110,7 +114,10 @@ const TemplateMetadata = {
], ],
}, },
{ {
name: "Password Recovery", name: "Password recovery",
description:
"When a user requests a password reset they will receive an email built with this template.",
category: "user management",
purpose: EmailTemplatePurpose.PASSWORD_RECOVERY, purpose: EmailTemplatePurpose.PASSWORD_RECOVERY,
bindings: [ bindings: [
{ {
@ -126,7 +133,18 @@ const TemplateMetadata = {
], ],
}, },
{ {
name: "New User Invitation", name: "User welcome",
description:
"When a new user is added they will be sent a welcome email using this template.",
category: "user management",
purpose: EmailTemplatePurpose.WELCOME,
bindings: [],
},
{
name: "User invitation",
description:
"When inviting a user via the email on-boarding this template will be used.",
category: "user management",
purpose: EmailTemplatePurpose.INVITATION, purpose: EmailTemplatePurpose.INVITATION,
bindings: [ bindings: [
{ {
@ -143,6 +161,9 @@ const TemplateMetadata = {
}, },
{ {
name: "Custom", name: "Custom",
description:
"A custom template, this is currently used for SMTP email actions in automations.",
category: "automations",
purpose: EmailTemplatePurpose.CUSTOM, purpose: EmailTemplatePurpose.CUSTOM,
bindings: [ bindings: [
{ {

View File

@ -5,6 +5,7 @@ require("@budibase/auth").init(CouchDB)
const Koa = require("koa") const Koa = require("koa")
const destroyable = require("server-destroy") const destroyable = require("server-destroy")
const koaBody = require("koa-body") const koaBody = require("koa-body")
const koaSession = require("koa-session")
const { passport } = require("@budibase/auth").auth const { passport } = require("@budibase/auth").auth
const logger = require("koa-pino-logger") const logger = require("koa-pino-logger")
const http = require("http") const http = require("http")
@ -13,8 +14,11 @@ const redis = require("./utilities/redis")
const app = new Koa() const app = new Koa()
app.keys = ["secret", "key"]
// set up top level koa middleware // set up top level koa middleware
app.use(koaBody({ multipart: true })) app.use(koaBody({ multipart: true }))
app.use(koaSession(app))
app.use( app.use(
logger({ logger({

View File

@ -566,6 +566,17 @@
dependencies: dependencies:
defer-to-connect "^2.0.0" defer-to-connect "^2.0.0"
"@techpass/passport-openidconnect@^0.3.0":
version "0.3.0"
resolved "https://registry.yarnpkg.com/@techpass/passport-openidconnect/-/passport-openidconnect-0.3.0.tgz#a60b2bbf3f262649a5a02d5d186219944acc3010"
integrity sha512-bVsPwl66s7J7GHxTPlW/RJYhZol9SshNznQsx83OOh9G+JWFGoeWxh+xbX+FTdJNoUvGIGbJnpWPY2wC6NOHPw==
dependencies:
base64url "^3.0.1"
oauth "^0.9.15"
passport-strategy "^1.0.0"
request "^2.88.0"
webfinger "^0.4.2"
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7": "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
version "7.1.14" version "7.1.14"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.14.tgz#faaeefc4185ec71c389f4501ee5ec84b170cc402" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.14.tgz#faaeefc4185ec71c389f4501ee5ec84b170cc402"
@ -1058,7 +1069,7 @@ base64-js@^1.0.2, base64-js@^1.3.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
base64url@3.x.x: base64url@3.x.x, base64url@^3.0.1:
version "3.0.1" version "3.0.1"
resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d" resolved "https://registry.yarnpkg.com/base64url/-/base64url-3.0.1.tgz#6399d572e2bc3f90a9a8b22d5dbb0a32d33f788d"
integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==
@ -4183,7 +4194,7 @@ oauth-sign@~0.9.0:
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455"
integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ== integrity sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==
oauth@0.9.x: oauth@0.9.x, oauth@^0.9.15:
version "0.9.15" version "0.9.15"
resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1" resolved "https://registry.yarnpkg.com/oauth/-/oauth-0.9.15.tgz#bd1fefaf686c96b75475aed5196412ff60cfb9c1"
integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE= integrity sha1-vR/vr2hslrdUda7VGWQS/2DPucE=
@ -4933,7 +4944,7 @@ request-promise-native@^1.0.9:
stealthy-require "^1.1.1" stealthy-require "^1.1.1"
tough-cookie "^2.3.3" tough-cookie "^2.3.3"
request@^2.88.2: request@^2.88.0, request@^2.88.2:
version "2.88.2" version "2.88.2"
resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3" resolved "https://registry.yarnpkg.com/request/-/request-2.88.2.tgz#d73c918731cb5a87da047e207234146f664d12b3"
integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw== integrity sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==
@ -5080,7 +5091,7 @@ sax@1.2.1:
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o= integrity sha1-e45lYZCyKOgaZq6nSEgNgozS03o=
sax@>=0.6.0: sax@>=0.1.1, sax@>=0.6.0:
version "1.2.4" version "1.2.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
@ -5390,6 +5401,11 @@ stealthy-require@^1.1.1:
resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b" resolved "https://registry.yarnpkg.com/stealthy-require/-/stealthy-require-1.1.1.tgz#35b09875b4ff49f26a777e509b3090a3226bf24b"
integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks= integrity sha1-NbCYdbT/SfJqd35QmzCQoyJr8ks=
step@0.0.x:
version "0.0.6"
resolved "https://registry.yarnpkg.com/step/-/step-0.0.6.tgz#143e7849a5d7d3f4a088fe29af94915216eeede2"
integrity sha1-FD54SaXX0/SgiP4pr5SRUhbu7eI=
string-length@^4.0.1: string-length@^4.0.1:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a"
@ -5923,6 +5939,14 @@ walker@^1.0.7, walker@~1.0.5:
dependencies: dependencies:
makeerror "1.0.x" makeerror "1.0.x"
webfinger@^0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/webfinger/-/webfinger-0.4.2.tgz#3477a6d97799461896039fcffc650b73468ee76d"
integrity sha1-NHem2XeZRhiWA5/P/GULc0aO520=
dependencies:
step "0.0.x"
xml2js "0.1.x"
webidl-conversions@^5.0.0: webidl-conversions@^5.0.0:
version "5.0.0" version "5.0.0"
resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff"
@ -6031,6 +6055,13 @@ xml-name-validator@^3.0.0:
resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a" resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-3.0.0.tgz#6ae73e06de4d8c6e47f9fb181f78d648ad457c6a"
integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw== integrity sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==
xml2js@0.1.x:
version "0.1.14"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.1.14.tgz#5274e67f5a64c5f92974cd85139e0332adc6b90c"
integrity sha1-UnTmf1pkxfkpdM2FE54DMq3GuQw=
dependencies:
sax ">=0.1.1"
xml2js@0.4.19: xml2js@0.4.19:
version "0.4.19" version "0.4.19"
resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.19.tgz#686c20f213209e94abf0d1bcf1efaa291c7827a7"