Add validation between sso config acivation and sso enforcement

This commit is contained in:
Rory Powell 2023-02-28 13:36:54 +00:00
parent ab69bbc299
commit 7ed0586673
6 changed files with 175 additions and 87 deletions

View File

@ -131,24 +131,25 @@
isEqual(providers.google?.config, originalGoogleDoc?.config) isEqual(providers.google?.config, originalGoogleDoc?.config)
? (googleSaveButtonDisabled = true) ? (googleSaveButtonDisabled = true)
: (googleSaveButtonDisabled = false) : (googleSaveButtonDisabled = false)
// delete the callback url which is never saved to the oidc
// config doc, to ensure an accurate comparison
delete providers.oidc?.config.configs[0].callbackURL
isEqual(providers.oidc?.config, originalOidcDoc?.config) isEqual(providers.oidc?.config, originalOidcDoc?.config)
? (oidcSaveButtonDisabled = true) ? (oidcSaveButtonDisabled = true)
: (oidcSaveButtonDisabled = false) : (oidcSaveButtonDisabled = false)
} }
// Create a flag so that it will only try to save completed forms $: googleComplete = !!(
$: partialGoogle =
providers.google?.config?.clientID || providers.google?.config?.clientSecret
$: 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?.clientID && providers.google?.config?.clientSecret
$: oidcComplete = )
$: oidcComplete = !!(
providers.oidc?.config?.configs[0].configUrl && providers.oidc?.config?.configs[0].configUrl &&
providers.oidc?.config?.configs[0].clientID && providers.oidc?.config?.configs[0].clientID &&
providers.oidc?.config?.configs[0].clientSecret providers.oidc?.config?.configs[0].clientSecret
)
const onFileSelected = e => { const onFileSelected = e => {
let fileName = e.target.files[0].name let fileName = e.target.files[0].name
@ -159,74 +160,88 @@
async function toggleIsSSOEnforced() { async function toggleIsSSOEnforced() {
const value = $organisation.isSSOEnforced const value = $organisation.isSSOEnforced
try {
await organisation.save({ isSSOEnforced: !value }) await organisation.save({ isSSOEnforced: !value })
} catch (e) {
notifications.error(e.message)
}
} }
async function save(docs) { async function saveConfig(config) {
let calls = [] // Delete unsupported fields
// Only if the user has provided an image, upload it delete config.createdAt
delete config.updatedAt
return API.saveConfig(config)
}
async function saveOIDCLogo() {
if (image) { if (image) {
let data = new FormData() let data = new FormData()
data.append("file", image) data.append("file", image)
calls.push( await API.uploadOIDCLogo({
API.uploadOIDCLogo({
name: image.name, name: image.name,
data, data,
}) })
)
} }
docs.forEach(element => { }
// Delete unsupported fields
delete element.createdAt
delete element.updatedAt
const { activated } = element.config async function saveOIDC() {
if (!oidcComplete) {
notifications.error(
`Please fill in all required ${ConfigTypes.OIDC} fields`
)
return
}
const oidc = providers.oidc
if (element.type === ConfigTypes.OIDC) {
// Add a UUID here so each config is distinguishable when it arrives at the login page // Add a UUID here so each config is distinguishable when it arrives at the login page
for (let config of element.config.configs) { for (let config of oidc.config.configs) {
if (!config.uuid) { if (!config.uuid) {
config.uuid = Helpers.uuid() config.uuid = Helpers.uuid()
} }
// Callback urls shouldn't be included // Callback urls shouldn't be included
delete config.callbackURL delete config.callbackURL
} }
if ((partialOidc || activated) && !oidcComplete) {
notifications.error( try {
`Please fill in all required ${ConfigTypes.OIDC} fields` const res = await saveConfig(oidc)
) providers[res.type]._rev = res._rev
} else if (oidcComplete || !activated) { providers[res.type]._id = res._id
calls.push(API.saveConfig(element)) await saveOIDCLogo()
notifications.success(`Settings saved`)
} catch (e) {
notifications.error(e.message)
return
}
// Turn the save button grey when clicked // Turn the save button grey when clicked
oidcSaveButtonDisabled = true oidcSaveButtonDisabled = true
originalOidcDoc = cloneDeep(providers.oidc) originalOidcDoc = cloneDeep(providers.oidc)
} }
}
if (element.type === ConfigTypes.Google) { async function saveGoogle() {
if ((partialGoogle || activated) && !googleComplete) { if (!googleComplete) {
notifications.error( notifications.error(
`Please fill in all required ${ConfigTypes.Google} fields` `Please fill in all required ${ConfigTypes.Google} fields`
) )
} else if (googleComplete || !activated) { return
calls.push(API.saveConfig(element))
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
} }
}
}) const google = providers.google
if (calls.length) {
Promise.all(calls) try {
.then(data => { const res = await saveConfig(google)
data.forEach(res => {
providers[res.type]._rev = res._rev providers[res.type]._rev = res._rev
providers[res.type]._id = res._id providers[res.type]._id = res._id
})
notifications.success(`Settings saved`) notifications.success(`Settings saved`)
}) } catch (e) {
.catch(() => { notifications.error(e.message)
notifications.error("Failed to update auth settings") return
})
} }
googleSaveButtonDisabled = true
originalGoogleDoc = cloneDeep(providers.google)
} }
let defaultScopes = ["profile", "email", "offline_access"] let defaultScopes = ["profile", "email", "offline_access"]
@ -266,7 +281,7 @@
if (!googleDoc?._id) { if (!googleDoc?._id) {
providers.google = { providers.google = {
type: ConfigTypes.Google, type: ConfigTypes.Google,
config: { activated: true }, config: { activated: false },
} }
originalGoogleDoc = cloneDeep(googleDoc) originalGoogleDoc = cloneDeep(googleDoc)
} else { } else {
@ -310,7 +325,7 @@
if (!oidcDoc?._id) { if (!oidcDoc?._id) {
providers.oidc = { providers.oidc = {
type: ConfigTypes.OIDC, type: ConfigTypes.OIDC,
config: { configs: [{ activated: true, scopes: defaultScopes }] }, config: { configs: [{ activated: false, scopes: defaultScopes }] },
} }
} else { } else {
originalOidcDoc = cloneDeep(oidcDoc) originalOidcDoc = cloneDeep(oidcDoc)
@ -413,7 +428,7 @@
<Button <Button
disabled={googleSaveButtonDisabled} disabled={googleSaveButtonDisabled}
cta cta
on:click={() => save([providers.google])} on:click={() => saveGoogle()}
> >
Save Save
</Button> </Button>
@ -575,11 +590,7 @@
</div> </div>
</Layout> </Layout>
<div> <div>
<Button <Button disabled={oidcSaveButtonDisabled} cta on:click={() => saveOIDC()}>
disabled={oidcSaveButtonDisabled}
cta
on:click={() => save([providers.oidc])}
>
Save Save
</Button> </Button>
</div> </div>

View File

@ -1,6 +1,7 @@
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import { API } from "api" import { API } from "api"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import _ from "lodash"
const DEFAULT_CONFIG = { const DEFAULT_CONFIG = {
platformUrl: "", platformUrl: "",
@ -26,14 +27,14 @@ export function createOrganisationStore() {
async function save(config) { async function save(config) {
// Delete non-persisted fields // Delete non-persisted fields
const storeConfig = get(store) const storeConfig = _.cloneDeep(get(store))
delete storeConfig.oidc delete storeConfig.oidc
delete storeConfig.google delete storeConfig.google
delete storeConfig.oidcCallbackUrl delete storeConfig.oidcCallbackUrl
delete storeConfig.googleCallbackUrl delete storeConfig.googleCallbackUrl
await API.saveConfig({ await API.saveConfig({
type: "settings", type: "settings",
config: { ...get(store), ...config }, config: { ...storeConfig, ...config },
}) })
await init() await init()
} }

View File

@ -36,6 +36,9 @@ export interface SettingsConfig extends Config {
config: SettingsInnerConfig config: SettingsInnerConfig
} }
export type SSOConfigType = ConfigType.GOOGLE | ConfigType.OIDC
export type SSOConfig = GoogleInnerConfig | OIDCInnerConfig
export interface GoogleInnerConfig { export interface GoogleInnerConfig {
clientID: string clientID: string
clientSecret: string clientSecret: string
@ -60,6 +63,10 @@ export interface OIDCStrategyConfiguration {
callbackURL: string callbackURL: string
} }
export interface OIDCConfigs {
configs: OIDCInnerConfig[]
}
export interface OIDCInnerConfig { export interface OIDCInnerConfig {
configUrl: string configUrl: string
clientID: string clientID: string
@ -72,9 +79,7 @@ export interface OIDCInnerConfig {
} }
export interface OIDCConfig extends Config { export interface OIDCConfig extends Config {
config: { config: OIDCConfigs
configs: OIDCInnerConfig[]
}
} }
export interface OIDCWellKnownConfig { export interface OIDCWellKnownConfig {

View File

@ -17,10 +17,15 @@ import {
Ctx, Ctx,
GetPublicOIDCConfigResponse, GetPublicOIDCConfigResponse,
GetPublicSettingsResponse, GetPublicSettingsResponse,
GoogleInnerConfig,
isGoogleConfig, isGoogleConfig,
isOIDCConfig, isOIDCConfig,
isSettingsConfig, isSettingsConfig,
isSMTPConfig, isSMTPConfig,
OIDCConfigs,
SettingsInnerConfig,
SSOConfig,
SSOConfigType,
UserCtx, UserCtx,
} from "@budibase/types" } from "@budibase/types"
import * as pro from "@budibase/pro" import * as pro from "@budibase/pro"
@ -119,6 +124,63 @@ const getEventFns = async (config: Config, existing?: Config) => {
return fns return fns
} }
type SSOConfigs = { [key in SSOConfigType]: SSOConfig | undefined }
async function getSSOConfigs(): Promise<SSOConfigs> {
const google = await configs.getGoogleConfig()
const oidc = await configs.getOIDCConfig()
return {
[ConfigType.GOOGLE]: google,
[ConfigType.OIDC]: oidc,
}
}
async function hasActivatedConfig(ssoConfigs?: SSOConfigs) {
if (!ssoConfigs) {
ssoConfigs = await getSSOConfigs()
}
return !!Object.values(ssoConfigs).find(c => c?.activated)
}
async function verifySettingsConfig(config: SettingsInnerConfig) {
if (config.isSSOEnforced) {
const valid = await hasActivatedConfig()
if (!valid) {
throw new Error(
"Cannot enforce SSO without an activated configuration"
)
}
}
}
async function verifySSOConfig(type: SSOConfigType, config: SSOConfig) {
const settings = await configs.getSettingsConfig()
if (settings.isSSOEnforced && !config.activated) {
// config is being saved as deactivated
// ensure there is at least one other activated sso config
const ssoConfigs = await getSSOConfigs()
// overwrite the config being updated
// to reflect the desired state
ssoConfigs[type] = config
const activated = await hasActivatedConfig(ssoConfigs)
if (!activated) {
throw new Error(
"Configuration cannot be deactivated while SSO is enforced"
)
}
}
}
async function verifyGoogleConfig(config: GoogleInnerConfig) {
await verifySSOConfig(ConfigType.GOOGLE, config)
}
async function verifyOIDCConfig(config: OIDCConfigs) {
await verifySSOConfig(ConfigType.OIDC, config.configs[0])
}
export async function save(ctx: UserCtx<Config>) { export async function save(ctx: UserCtx<Config>) {
const body = ctx.request.body const body = ctx.request.body
const type = body.type const type = body.type
@ -133,10 +195,19 @@ export async function save(ctx: UserCtx<Config>) {
try { try {
// verify the configuration // verify the configuration
switch (config.type) { switch (type) {
case ConfigType.SMTP: case ConfigType.SMTP:
await email.verifyConfig(config) await email.verifyConfig(config)
break break
case ConfigType.SETTINGS:
await verifySettingsConfig(config)
break
case ConfigType.GOOGLE:
await verifyGoogleConfig(config)
break
case ConfigType.OIDC:
await verifyOIDCConfig(config)
break
} }
} catch (err: any) { } catch (err: any) {
ctx.throw(400, err) ctx.throw(400, err)

View File

@ -34,8 +34,8 @@ function settingValidation() {
function googleValidation() { function googleValidation() {
// prettier-ignore // prettier-ignore
return Joi.object({ return Joi.object({
clientID: Joi.when('activated', { is: true, then: Joi.string().required() }), clientID: Joi.string().required(),
clientSecret: Joi.when('activated', { is: true, then: Joi.string().required() }), clientSecret: Joi.string().required(),
activated: Joi.boolean().required(), activated: Joi.boolean().required(),
}).unknown(true) }).unknown(true)
} }
@ -45,12 +45,12 @@ function oidcValidation() {
return Joi.object({ return Joi.object({
configs: Joi.array().items( configs: Joi.array().items(
Joi.object({ Joi.object({
clientID: Joi.when('activated', { is: true, then: Joi.string().required() }), clientID: Joi.string().required(),
clientSecret: Joi.when('activated', { is: true, then: Joi.string().required() }), clientSecret: Joi.string().required(),
configUrl: Joi.when('activated', { is: true, then: Joi.string().required() }), configUrl: Joi.string().required(),
logo: Joi.string().allow("", null), logo: Joi.string().allow("", null),
name: Joi.string().allow("", null), name: Joi.string().allow("", null),
uuid: Joi.when('activated', { is: true, then: Joi.string().required() }), uuid: Joi.string().required(),
activated: Joi.boolean().required(), activated: Joi.boolean().required(),
scopes: Joi.array().optional() scopes: Joi.array().optional()
}) })

View File

@ -203,7 +203,7 @@ export async function sendEmail(
* @param {object} config an SMTP configuration - this is based on the nodemailer API. * @param {object} config an SMTP configuration - this is based on the nodemailer API.
* @return {Promise<boolean>} returns true if the configuration is valid. * @return {Promise<boolean>} returns true if the configuration is valid.
*/ */
export async function verifyConfig(config: any) { export async function verifyConfig(config: SMTPInnerConfig) {
const transport = createSMTPTransport(config) const transport = createSMTPTransport(config)
await transport.verify() await transport.verify()
} }