Merge branch 'master' into feat/file-ts-conversion

This commit is contained in:
Peter Clement 2025-03-20 08:45:08 +00:00 committed by GitHub
commit 88d5dd48f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 646 additions and 239 deletions

View File

@ -1,9 +1,4 @@
<script lang="ts" context="module"> <script lang="ts" generics="O extends any,V">
type O = any
type V = any
</script>
<script lang="ts">
import Field from "./Field.svelte" import Field from "./Field.svelte"
import Select from "./Core/Select.svelte" import Select from "./Core/Select.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
@ -22,9 +17,11 @@
export let getOptionValue = (option: O, _index?: number) => export let getOptionValue = (option: O, _index?: number) =>
extractProperty(option, "value") extractProperty(option, "value")
export let getOptionSubtitle = (option: O, _index?: number) => export let getOptionSubtitle = (option: O, _index?: number) =>
option?.subtitle (option as any)?.subtitle
export let getOptionIcon = (option: O, _index?: number) => option?.icon export let getOptionIcon = (option: O, _index?: number) =>
export let getOptionColour = (option: O, _index?: number) => option?.colour (option as any)?.icon
export let getOptionColour = (option: O, _index?: number) =>
(option as any)?.colour
export let useOptionIconImage = false export let useOptionIconImage = false
export let isOptionEnabled: export let isOptionEnabled:
| ((_option: O, _index?: number) => boolean) | ((_option: O, _index?: number) => boolean)

View File

@ -11,6 +11,7 @@ export async function confirm(props: {
onConfirm?: () => void onConfirm?: () => void
onCancel?: () => void onCancel?: () => void
onClose?: () => void onClose?: () => void
warning?: boolean
}) { }) {
return await new Promise(resolve => { return await new Promise(resolve => {
const dialog = new ConfirmDialog({ const dialog = new ConfirmDialog({
@ -21,7 +22,7 @@ export async function confirm(props: {
okText: props.okText, okText: props.okText,
cancelText: props.cancelText, cancelText: props.cancelText,
size: props.size, size: props.size,
warning: false, warning: props.warning,
onOk: () => { onOk: () => {
dialog.$destroy() dialog.$destroy()
resolve(props.onConfirm?.() || true) resolve(props.onConfirm?.() || true)

View File

@ -1,140 +1,15 @@
<script lang="ts"> <script lang="ts">
import { oauth2 } from "@/stores/builder" import { Button, Modal } from "@budibase/bbui"
import type { CreateOAuth2Config } from "@/types" import OAuth2ConfigModalContent from "./OAuth2ConfigModalContent.svelte"
import {
Body,
Button,
Divider,
Heading,
Input,
keepOpen,
Link,
Modal,
ModalContent,
notifications,
} from "@budibase/bbui"
import type { ZodType } from "zod"
import { z } from "zod"
let modal: Modal let modal: Modal
function openModal() { function openModal() {
config = {}
errors = {}
hasBeenSubmitted = false
modal.show() modal.show()
} }
let config: Partial<CreateOAuth2Config> = {}
let errors: Record<string, string> = {}
let hasBeenSubmitted = false
const requiredString = (errorMessage: string) =>
z.string({ required_error: errorMessage }).trim().min(1, errorMessage)
const validateConfig = (config: Partial<CreateOAuth2Config>) => {
const validator = z.object({
name: requiredString("Name is required.").refine(
val =>
!$oauth2.configs
.map(c => c.name.toLowerCase())
.includes(val.toLowerCase()),
{
message: "This name is already taken.",
}
),
url: requiredString("Url is required.").url(),
clientId: requiredString("Client ID is required."),
clientSecret: requiredString("Client secret is required."),
}) satisfies ZodType<CreateOAuth2Config>
const validationResult = validator.safeParse(config)
errors = {}
if (!validationResult.success) {
errors = Object.entries(
validationResult.error.formErrors.fieldErrors
).reduce<Record<string, string>>((acc, [field, errors]) => {
if (errors[0]) {
acc[field] = errors[0]
}
return acc
}, {})
}
return validationResult
}
$: saveOAuth2Config = async () => {
hasBeenSubmitted = true
const validationResult = validateConfig(config)
if (validationResult.error) {
return keepOpen
}
try {
await oauth2.create(validationResult.data)
} catch (e: any) {
notifications.error(e.message)
return keepOpen
}
}
$: hasBeenSubmitted && validateConfig(config)
</script> </script>
<Button cta size="M" on:click={openModal}>Add OAuth2</Button> <Button cta size="M" on:click={openModal}>Add OAuth2</Button>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ModalContent onConfirm={saveOAuth2Config} size="M"> <OAuth2ConfigModalContent />
<Heading size="S">Create new OAuth2 connection</Heading>
<Body size="S">
The OAuth 2 authentication below uses the Client Credentials (machine to
machine) grant type.
</Body>
<Divider noGrid noMargin />
<Input
label="Name*"
placeholder="Type here..."
bind:value={config.name}
error={errors.name}
/>
<Input
label="Service URL*"
placeholder="E.g. www.google.com"
bind:value={config.url}
error={errors.url}
/>
<div class="field-info">
<Body size="XS" color="var(--spectrum-global-color-gray-700)">
The location where the flow sends the credentials. This field should be
a full URL.
</Body>
</div>
<Input
label="Client ID*"
placeholder="Type here..."
bind:value={config.clientId}
error={errors.clientId}
/>
<Input
type="password"
label="Client secret*"
placeholder="Type here..."
bind:value={config.clientSecret}
error={errors.clientSecret}
/>
<Body size="S"
>To learn how to configure OAuth2, our documentation <Link
href="TODO"
target="_blank"
size="M">our documentation.</Link
></Body
>
</ModalContent>
</Modal> </Modal>
<style>
.field-info {
margin-top: calc(var(--spacing-xl) * -1 + var(--spacing-s));
}
</style>

View File

@ -1,11 +1,42 @@
<script lang="ts"> <script lang="ts">
import { ActionMenu, Icon, MenuItem } from "@budibase/bbui" import { oauth2 } from "@/stores/builder"
import {
ActionMenu,
Icon,
MenuItem,
Modal,
notifications,
} from "@budibase/bbui"
import type { OAuth2Config } from "@budibase/types"
import OAuth2ConfigModalContent from "./OAuth2ConfigModalContent.svelte"
import { confirm } from "@/helpers"
export let row: OAuth2Config
let modal: Modal
function onEdit() { function onEdit() {
// TODO modal.show()
} }
function onDelete() { async function onDelete() {
// TODO await confirm({
title: "Confirm Deletion",
body: `Deleting "${row.name}" cannot be undone. Are you sure?`,
okText: "Delete Configuration",
warning: true,
onConfirm: async () => {
try {
await oauth2.delete(row.id)
notifications.success(`Config '${row.name}' deleted successfully`)
} catch (e: any) {
let message = "Error deleting config"
if (e.message) {
message += ` - ${e.message}`
}
notifications.error(message)
}
},
})
} }
</script> </script>
@ -16,3 +47,7 @@
<MenuItem on:click={onEdit} icon="Edit">Edit</MenuItem> <MenuItem on:click={onEdit} icon="Edit">Edit</MenuItem>
<MenuItem on:click={onDelete} icon="Delete">Delete</MenuItem> <MenuItem on:click={onDelete} icon="Delete">Delete</MenuItem>
</ActionMenu> </ActionMenu>
<Modal bind:this={modal}>
<OAuth2ConfigModalContent config={{ ...row }} />
</Modal>

View File

@ -0,0 +1,194 @@
<script lang="ts">
import { oauth2 } from "@/stores/builder"
import type { OAuth2Config, UpsertOAuth2Config } from "@/types"
import {
Body,
Divider,
Heading,
Input,
keepOpen,
Link,
ModalContent,
notifications,
Select,
} from "@budibase/bbui"
import {
OAuth2CredentialsMethod,
PASSWORD_REPLACEMENT,
} from "@budibase/types"
import type { ZodType } from "zod"
import { z } from "zod"
export let config: OAuth2Config | undefined = undefined
let errors: Record<string, string> = {}
let hasBeenSubmitted = false
$: data = (config as Partial<OAuth2Config>) ?? {}
$: isCreation = !config
$: title = isCreation
? "Create new OAuth2 connection"
: "Edit OAuth2 connection"
const methods = [
{
label: "Basic",
value: OAuth2CredentialsMethod.HEADER,
},
{
label: "POST",
value: OAuth2CredentialsMethod.BODY,
},
]
const requiredString = (errorMessage: string) =>
z.string({ required_error: errorMessage }).trim().min(1, errorMessage)
const validateConfig = (config: Partial<OAuth2Config>) => {
const validator = z.object({
name: requiredString("Name is required.").refine(
val =>
!$oauth2.configs
.filter(c => c.id !== config.id)
.map(c => c.name.toLowerCase())
.includes(val.toLowerCase()),
{
message: "This name is already taken.",
}
),
url: requiredString("Url is required.").url(),
clientId: requiredString("Client ID is required."),
clientSecret: requiredString("Client secret is required."),
method: z.nativeEnum(OAuth2CredentialsMethod, {
message: "Authentication method is required.",
}),
}) satisfies ZodType<UpsertOAuth2Config>
const validationResult = validator.safeParse(config)
errors = {}
if (!validationResult.success) {
errors = Object.entries(
validationResult.error.formErrors.fieldErrors
).reduce<Record<string, string>>((acc, [field, errors]) => {
if (errors[0]) {
acc[field] = errors[0]
}
return acc
}, {})
}
return validationResult
}
$: saveOAuth2Config = async () => {
hasBeenSubmitted = true
const validationResult = validateConfig(data)
if (validationResult.error) {
return keepOpen
}
const { data: configData } = validationResult
try {
const connectionValidation = await oauth2.validate({
id: config?.id,
url: configData.url,
clientId: configData.clientId,
clientSecret: configData.clientSecret,
method: configData.method,
})
if (!connectionValidation.valid) {
let message = "Connection settings could not be validated"
if (connectionValidation.message) {
message += `: ${connectionValidation.message}`
}
notifications.error(message)
return keepOpen
}
if (isCreation) {
await oauth2.create(configData)
notifications.success("Settings created.")
} else {
await oauth2.edit(config!.id, configData)
notifications.success("Settings saved.")
}
} catch (e: any) {
notifications.error(`Failed to save config - ${e.message}`)
return keepOpen
}
}
$: hasBeenSubmitted && validateConfig(data)
$: isProtectedPassword = config?.clientSecret === PASSWORD_REPLACEMENT
</script>
<ModalContent onConfirm={saveOAuth2Config} size="M">
<Heading size="S">{title}</Heading>
<Body size="S">
The OAuth 2 authentication below uses the Client Credentials (machine to
machine) grant type.
</Body>
<Divider noGrid noMargin />
<Input
label="Name*"
placeholder="Type here..."
bind:value={data.name}
error={errors.name}
/>
<Select
label="Authentication method*"
options={methods}
getOptionLabel={o => o.label}
getOptionValue={o => o.value}
bind:value={data.method}
error={errors.method}
/>
<div class="field-info">
<Body size="XS" color="var(--spectrum-global-color-gray-700)">
Basic will use the Authorisation Bearer header for each connection, while
POST will include the credentials in the body of the request under the
access_token property.
</Body>
</div>
<Input
label="Service URL*"
placeholder="E.g. www.google.com"
bind:value={data.url}
error={errors.url}
/>
<div class="field-info">
<Body size="XS" color="var(--spectrum-global-color-gray-700)">
The location where the flow sends the credentials. This field should be a
full URL.
</Body>
</div>
<Input
label="Client ID*"
placeholder="Type here..."
bind:value={data.clientId}
error={errors.clientId}
/>
<Input
type={!isProtectedPassword ? "password" : "text"}
label="Client secret*"
placeholder="Type here..."
bind:value={data.clientSecret}
error={errors.clientSecret}
/>
<Body size="S"
>To learn how to configure OAuth2, our documentation <Link
href="TODO"
target="_blank"
size="M">our documentation.</Link
></Body
>
</ModalContent>
<style>
.field-info {
margin-top: calc(var(--spacing-xl) * -1 + var(--spacing-s));
}
</style>

View File

@ -1,14 +1,10 @@
import { API } from "@/api" import { API } from "@/api"
import { BudiStore } from "@/stores/BudiStore" import { BudiStore } from "@/stores/BudiStore"
import { CreateOAuth2Config } from "@/types" import { OAuth2Config, UpsertOAuth2Config } from "@/types"
import { ValidateConfigRequest } from "@budibase/types"
interface Config {
id: string
name: string
}
interface OAuth2StoreState { interface OAuth2StoreState {
configs: Config[] configs: OAuth2Config[]
loading: boolean loading: boolean
error?: string error?: string
} }
@ -30,7 +26,14 @@ export class OAuth2Store extends BudiStore<OAuth2StoreState> {
const configs = await API.oauth2.fetch() const configs = await API.oauth2.fetch()
this.store.update(store => ({ this.store.update(store => ({
...store, ...store,
configs: configs.map(c => ({ id: c.id, name: c.name })), configs: configs.map(c => ({
id: c.id,
name: c.name,
url: c.url,
clientId: c.clientId,
clientSecret: c.clientSecret,
method: c.method,
})),
loading: false, loading: false,
})) }))
} catch (e: any) { } catch (e: any) {
@ -42,10 +45,24 @@ export class OAuth2Store extends BudiStore<OAuth2StoreState> {
} }
} }
async create(config: CreateOAuth2Config) { async create(config: UpsertOAuth2Config) {
await API.oauth2.create(config) await API.oauth2.create(config)
await this.fetch() await this.fetch()
} }
async edit(id: string, config: UpsertOAuth2Config) {
await API.oauth2.update(id, config)
await this.fetch()
}
async delete(id: string) {
await API.oauth2.delete(id)
await this.fetch()
}
async validate(config: ValidateConfigRequest) {
return await API.oauth2.validate(config)
}
} }
export const oauth2 = new OAuth2Store() export const oauth2 = new OAuth2Store()

View File

@ -1,3 +1,8 @@
import { UpsertOAuth2ConfigRequest } from "@budibase/types" import {
UpsertOAuth2ConfigRequest,
OAuth2ConfigResponse,
} from "@budibase/types"
export interface CreateOAuth2Config extends UpsertOAuth2ConfigRequest {} export interface OAuth2Config extends OAuth2ConfigResponse {}
export interface UpsertOAuth2Config extends UpsertOAuth2ConfigRequest {}

View File

@ -3,6 +3,8 @@ import {
OAuth2ConfigResponse, OAuth2ConfigResponse,
UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigRequest,
UpsertOAuth2ConfigResponse, UpsertOAuth2ConfigResponse,
ValidateConfigRequest,
ValidateConfigResponse,
} from "@budibase/types" } from "@budibase/types"
import { BaseAPIClient } from "./types" import { BaseAPIClient } from "./types"
@ -11,12 +13,17 @@ export interface OAuth2Endpoints {
create: ( create: (
config: UpsertOAuth2ConfigRequest config: UpsertOAuth2ConfigRequest
) => Promise<UpsertOAuth2ConfigResponse> ) => Promise<UpsertOAuth2ConfigResponse>
update: (
id: string,
config: UpsertOAuth2ConfigRequest
) => Promise<UpsertOAuth2ConfigResponse>
delete: (id: string) => Promise<void>
validate: (config: ValidateConfigRequest) => Promise<ValidateConfigResponse>
} }
export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({ export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({
/** /**
* Gets all OAuth2 configurations for the app. * Gets all OAuth2 configurations for the app.
* @param tableId the ID of the table
*/ */
fetch: async () => { fetch: async () => {
return ( return (
@ -28,8 +35,6 @@ export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({
/** /**
* Creates a OAuth2 configuration. * Creates a OAuth2 configuration.
* @param name the name of the row action
* @param tableId the ID of the table
*/ */
create: async config => { create: async config => {
return await API.post< return await API.post<
@ -42,4 +47,38 @@ export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({
}, },
}) })
}, },
/**
* Updates an existing OAuth2 configuration.
*/
update: async (id, config) => {
return await API.put<UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigResponse>(
{
url: `/api/oauth2/${id}`,
body: {
...config,
},
}
)
},
/**
* Deletes an OAuth2 configuration by its id.
* @param id the ID of the OAuth2 config
*/
delete: async id => {
return await API.delete<void, void>({
url: `/api/oauth2/${id}`,
})
},
validate: async function (
config: ValidateConfigRequest
): Promise<ValidateConfigResponse> {
return await API.post<ValidateConfigRequest, ValidateConfigResponse>({
url: `/api/oauth2/validate`,
body: {
...config,
},
})
},
}) })

View File

@ -5,18 +5,31 @@ import {
FetchOAuth2ConfigsResponse, FetchOAuth2ConfigsResponse,
OAuth2Config, OAuth2Config,
RequiredKeys, RequiredKeys,
OAuth2ConfigResponse,
PASSWORD_REPLACEMENT,
ValidateConfigResponse,
ValidateConfigRequest,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../sdk" import sdk from "../../sdk"
function toFetchOAuth2ConfigsResponse(
config: OAuth2Config
): OAuth2ConfigResponse {
return {
id: config.id,
name: config.name,
url: config.url,
clientId: config.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: config.method,
}
}
export async function fetch(ctx: Ctx<void, FetchOAuth2ConfigsResponse>) { export async function fetch(ctx: Ctx<void, FetchOAuth2ConfigsResponse>) {
const configs = await sdk.oauth2.fetch() const configs = await sdk.oauth2.fetch()
const response: FetchOAuth2ConfigsResponse = { const response: FetchOAuth2ConfigsResponse = {
configs: (configs || []).map(c => ({ configs: (configs || []).map(toFetchOAuth2ConfigsResponse),
id: c.id,
name: c.name,
url: c.url,
})),
} }
ctx.body = response ctx.body = response
} }
@ -30,11 +43,14 @@ export async function create(
url: body.url, url: body.url,
clientId: body.clientId, clientId: body.clientId,
clientSecret: body.clientSecret, clientSecret: body.clientSecret,
method: body.method,
} }
const config = await sdk.oauth2.create(newConfig) const config = await sdk.oauth2.create(newConfig)
ctx.status = 201 ctx.status = 201
ctx.body = { config } ctx.body = {
config: toFetchOAuth2ConfigsResponse(config),
}
} }
export async function edit( export async function edit(
@ -45,12 +61,15 @@ export async function edit(
id: ctx.params.id, id: ctx.params.id,
name: body.name, name: body.name,
url: body.url, url: body.url,
clientId: ctx.clientId, clientId: body.clientId,
clientSecret: ctx.clientSecret, clientSecret: body.clientSecret,
method: body.method,
} }
const config = await sdk.oauth2.update(toUpdate) const config = await sdk.oauth2.update(toUpdate)
ctx.body = { config } ctx.body = {
config: toFetchOAuth2ConfigsResponse(config),
}
} }
export async function remove( export async function remove(
@ -61,3 +80,28 @@ export async function remove(
await sdk.oauth2.remove(configToRemove) await sdk.oauth2.remove(configToRemove)
ctx.status = 204 ctx.status = 204
} }
export async function validate(
ctx: Ctx<ValidateConfigRequest, ValidateConfigResponse>
) {
const { body } = ctx.request
const config = {
url: body.url,
clientId: body.clientId,
clientSecret: body.clientSecret,
method: body.method,
}
if (config.clientSecret === PASSWORD_REPLACEMENT && body.id) {
const existingConfig = await sdk.oauth2.get(body.id)
if (!existingConfig) {
ctx.throw(`OAuth2 config with id '${body.id}' not found.`, 404)
}
config.clientSecret = existingConfig.clientSecret
}
const validation = await sdk.oauth2.validateConfig(config)
ctx.status = 201
ctx.body = validation
}

View File

@ -1,18 +1,35 @@
import Router from "@koa/router" import Router from "@koa/router"
import { PermissionType } from "@budibase/types" import { OAuth2CredentialsMethod, PermissionType } from "@budibase/types"
import { middleware } from "@budibase/backend-core" import { middleware } from "@budibase/backend-core"
import authorized from "../../middleware/authorized" import authorized from "../../middleware/authorized"
import * as controller from "../controllers/oauth2" import * as controller from "../controllers/oauth2"
import Joi from "joi" import Joi from "joi"
const baseValidation = {
url: Joi.string().required(),
clientId: Joi.string().required(),
clientSecret: Joi.string().required(),
method: Joi.string()
.required()
.valid(...Object.values(OAuth2CredentialsMethod)),
}
function oAuth2ConfigValidator() { function oAuth2ConfigValidator() {
return middleware.joiValidator.body( return middleware.joiValidator.body(
Joi.object({ Joi.object({
name: Joi.string().required(), name: Joi.string().required(),
url: Joi.string().required(), ...baseValidation,
clientId: Joi.string().required(), }),
clientSecret: Joi.string().required(), { allowUnknown: false }
)
}
function oAuth2ConfigValidationValidator() {
return middleware.joiValidator.body(
Joi.object({
id: Joi.string().required(),
...baseValidation,
}), }),
{ allowUnknown: false } { allowUnknown: false }
) )
@ -38,5 +55,11 @@ router.delete(
authorized(PermissionType.BUILDER), authorized(PermissionType.BUILDER),
controller.remove controller.remove
) )
router.post(
"/api/oauth2/validate",
authorized(PermissionType.BUILDER),
oAuth2ConfigValidationValidator(),
controller.validate
)
export default router export default router

View File

@ -1,5 +1,7 @@
import { import {
OAuth2Config, OAuth2Config,
OAuth2CredentialsMethod,
PASSWORD_REPLACEMENT,
UpsertOAuth2ConfigRequest, UpsertOAuth2ConfigRequest,
VirtualDocumentType, VirtualDocumentType,
} from "@budibase/types" } from "@budibase/types"
@ -16,6 +18,7 @@ describe("/oauth2", () => {
url: generator.url(), url: generator.url(),
clientId: generator.guid(), clientId: generator.guid(),
clientSecret: generator.hash(), clientSecret: generator.hash(),
method: generator.pickone(Object.values(OAuth2CredentialsMethod)),
} }
} }
@ -34,6 +37,30 @@ describe("/oauth2", () => {
configs: [], configs: [],
}) })
}) })
it("returns all created configs", async () => {
const existingConfigs = []
for (let i = 0; i < 10; i++) {
const oauth2Config = makeOAuth2Config()
const result = await config.api.oauth2.create(oauth2Config)
existingConfigs.push({ ...oauth2Config, id: result.config.id })
}
const response = await config.api.oauth2.fetch()
expect(response.configs).toHaveLength(existingConfigs.length)
expect(response).toEqual({
configs: expect.arrayContaining(
existingConfigs.map(c => ({
id: c.id,
name: c.name,
url: c.url,
clientId: c.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: c.method,
}))
),
})
})
}) })
describe("create", () => { describe("create", () => {
@ -48,6 +75,9 @@ describe("/oauth2", () => {
id: expectOAuth2ConfigId, id: expectOAuth2ConfigId,
name: oauth2Config.name, name: oauth2Config.name,
url: oauth2Config.url, url: oauth2Config.url,
clientId: oauth2Config.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config.method,
}, },
], ],
}) })
@ -65,11 +95,17 @@ describe("/oauth2", () => {
id: expectOAuth2ConfigId, id: expectOAuth2ConfigId,
name: oauth2Config.name, name: oauth2Config.name,
url: oauth2Config.url, url: oauth2Config.url,
clientId: oauth2Config.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config.method,
}, },
{ {
id: expectOAuth2ConfigId, id: expectOAuth2ConfigId,
name: oauth2Config2.name, name: oauth2Config2.name,
url: oauth2Config2.url, url: oauth2Config2.url,
clientId: oauth2Config2.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config2.method,
}, },
]) ])
expect(response.configs[0].id).not.toEqual(response.configs[1].id) expect(response.configs[0].id).not.toEqual(response.configs[1].id)
@ -93,6 +129,9 @@ describe("/oauth2", () => {
id: expectOAuth2ConfigId, id: expectOAuth2ConfigId,
name: oauth2Config.name, name: oauth2Config.name,
url: oauth2Config.url, url: oauth2Config.url,
clientId: oauth2Config.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config.method,
}, },
]) ])
}) })
@ -127,6 +166,9 @@ describe("/oauth2", () => {
id: configId, id: configId,
name: "updated name", name: "updated name",
url: configData.url, url: configData.url,
clientId: configData.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: configData.method,
}, },
]) ])
) )

View File

@ -5,6 +5,7 @@ import {
BasicRestAuthConfig, BasicRestAuthConfig,
BearerRestAuthConfig, BearerRestAuthConfig,
BodyType, BodyType,
OAuth2CredentialsMethod,
RestAuthType, RestAuthType,
} from "@budibase/types" } from "@budibase/types"
import { Response } from "node-fetch" import { Response } from "node-fetch"
@ -276,20 +277,67 @@ describe("REST Integration", () => {
expect(data).toEqual({ foo: "bar" }) expect(data).toEqual({ foo: "bar" })
}) })
it("adds OAuth2 auth", async () => { it("adds OAuth2 auth (via header)", async () => {
const oauth2Url = generator.url() const oauth2Url = generator.url()
const secret = generator.hash()
const { config: oauthConfig } = await config.api.oauth2.create({ const { config: oauthConfig } = await config.api.oauth2.create({
name: generator.guid(), name: generator.guid(),
url: oauth2Url, url: oauth2Url,
clientId: generator.guid(), clientId: generator.guid(),
clientSecret: generator.hash(), clientSecret: secret,
method: OAuth2CredentialsMethod.HEADER,
}) })
const token = generator.guid() const token = generator.guid()
const url = new URL(oauth2Url) const url = new URL(oauth2Url)
nock(url.origin) nock(url.origin)
.post(url.pathname) .post(url.pathname, {
grant_type: "client_credentials",
})
.basicAuth({ user: oauthConfig.clientId, pass: secret })
.reply(200, { token_type: "Bearer", access_token: token })
nock("https://example.com", {
reqheaders: { Authorization: `Bearer ${token}` },
})
.get("/")
.reply(200, { foo: "bar" })
const { data } = await config.doInContext(
config.appId,
async () =>
await integration.read({
authConfigId: oauthConfig.id,
authConfigType: RestAuthType.OAUTH2,
})
)
expect(data).toEqual({ foo: "bar" })
})
it("adds OAuth2 auth (via body)", async () => {
const oauth2Url = generator.url()
const secret = generator.hash()
const { config: oauthConfig } = await config.api.oauth2.create({
name: generator.guid(),
url: oauth2Url,
clientId: generator.guid(),
clientSecret: secret,
method: OAuth2CredentialsMethod.BODY,
})
const token = generator.guid()
const url = new URL(oauth2Url)
nock(url.origin, {
reqheaders: {
"content-Type": "application/x-www-form-urlencoded",
},
})
.post(url.pathname, {
grant_type: "client_credentials",
client_id: oauthConfig.clientId,
client_secret: secret,
})
.reply(200, { token_type: "Bearer", access_token: token }) .reply(200, { token_type: "Bearer", access_token: token })
nock("https://example.com", { nock("https://example.com", {

View File

@ -4,6 +4,7 @@ import {
DocumentType, DocumentType,
OAuth2Config, OAuth2Config,
OAuth2Configs, OAuth2Configs,
PASSWORD_REPLACEMENT,
SEPARATOR, SEPARATOR,
VirtualDocumentType, VirtualDocumentType,
} from "@budibase/types" } from "@budibase/types"
@ -73,6 +74,10 @@ export async function update(config: OAuth2Config): Promise<OAuth2Config> {
doc.configs[config.id] = { doc.configs[config.id] = {
...config, ...config,
clientSecret:
config.clientSecret === PASSWORD_REPLACEMENT
? doc.configs[config.id].clientSecret
: config.clientSecret,
} }
await db.put(doc) await db.put(doc)

View File

@ -6,6 +6,7 @@ import { generateToken } from "../utils"
import path from "path" import path from "path"
import { KEYCLOAK_IMAGE } from "../../../../integrations/tests/utils/images" import { KEYCLOAK_IMAGE } from "../../../../integrations/tests/utils/images"
import { startContainer } from "../../../../integrations/tests/utils" import { startContainer } from "../../../../integrations/tests/utils"
import { OAuth2CredentialsMethod } from "@budibase/types"
const config = new TestConfiguration() const config = new TestConfiguration()
@ -41,7 +42,9 @@ describe("oauth2 utils", () => {
keycloakUrl = `http://127.0.0.1:${port}` keycloakUrl = `http://127.0.0.1:${port}`
}) })
describe("generateToken", () => { describe.each(Object.values(OAuth2CredentialsMethod))(
"generateToken (in %s)",
method => {
it("successfully generates tokens", async () => { it("successfully generates tokens", async () => {
const response = await config.doInContext(config.appId, async () => { const response = await config.doInContext(config.appId, async () => {
const oauthConfig = await sdk.oauth2.create({ const oauthConfig = await sdk.oauth2.create({
@ -49,6 +52,7 @@ describe("oauth2 utils", () => {
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`, url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "my-client", clientId: "my-client",
clientSecret: "my-secret", clientSecret: "my-secret",
method,
}) })
const response = await generateToken(oauthConfig.id) const response = await generateToken(oauthConfig.id)
@ -66,6 +70,7 @@ describe("oauth2 utils", () => {
url: `${keycloakUrl}/realms/wrong/protocol/openid-connect/token`, url: `${keycloakUrl}/realms/wrong/protocol/openid-connect/token`,
clientId: "my-client", clientId: "my-client",
clientSecret: "my-secret", clientSecret: "my-secret",
method,
}) })
await generateToken(oauthConfig.id) await generateToken(oauthConfig.id)
@ -81,6 +86,7 @@ describe("oauth2 utils", () => {
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`, url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "wrong-client-id", clientId: "wrong-client-id",
clientSecret: "my-secret", clientSecret: "my-secret",
method,
}) })
await generateToken(oauthConfig.id) await generateToken(oauthConfig.id)
@ -98,6 +104,7 @@ describe("oauth2 utils", () => {
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`, url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "my-client", clientId: "my-client",
clientSecret: "wrong-secret", clientSecret: "wrong-secret",
method,
}) })
await generateToken(oauthConfig.id) await generateToken(oauthConfig.id)
@ -106,5 +113,6 @@ describe("oauth2 utils", () => {
"Error fetching oauth2 token: Invalid client or Invalid client credentials" "Error fetching oauth2 token: Invalid client or Invalid client credentials"
) )
}) })
}) }
)
}) })

View File

@ -1,6 +1,44 @@
import fetch from "node-fetch" import fetch, { RequestInit } from "node-fetch"
import { HttpError } from "koa" import { HttpError } from "koa"
import { get } from "../oauth2" import { get } from "../oauth2"
import { OAuth2CredentialsMethod } from "@budibase/types"
async function fetchToken(config: {
url: string
clientId: string
clientSecret: string
method: OAuth2CredentialsMethod
}) {
const fetchConfig: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
}),
redirect: "follow",
}
if (config.method === OAuth2CredentialsMethod.HEADER) {
fetchConfig.headers = {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(
`${config.clientId}:${config.clientSecret}`,
"utf-8"
).toString("base64")}`,
}
} else {
fetchConfig.body = new URLSearchParams({
grant_type: "client_credentials",
client_id: config.clientId,
client_secret: config.clientSecret,
})
}
const resp = await fetch(config.url, fetchConfig)
return resp
}
// TODO: check if caching is worth // TODO: check if caching is worth
export async function generateToken(id: string) { export async function generateToken(id: string) {
@ -9,18 +47,7 @@ export async function generateToken(id: string) {
throw new HttpError(`oAuth config ${id} count not be found`) throw new HttpError(`oAuth config ${id} count not be found`)
} }
const resp = await fetch(config.url, { const resp = await fetchToken(config)
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: config.clientId,
client_secret: config.clientSecret,
}),
redirect: "follow",
})
const jsonResponse = await resp.json() const jsonResponse = await resp.json()
if (!resp.ok) { if (!resp.ok) {
@ -31,3 +58,24 @@ export async function generateToken(id: string) {
return `${jsonResponse.token_type} ${jsonResponse.access_token}` return `${jsonResponse.token_type} ${jsonResponse.access_token}`
} }
export async function validateConfig(config: {
url: string
clientId: string
clientSecret: string
method: OAuth2CredentialsMethod
}): Promise<{ valid: boolean; message?: string }> {
try {
const resp = await fetchToken(config)
const jsonResponse = await resp.json()
if (!resp.ok) {
const message = jsonResponse.error_description ?? resp.statusText
return { valid: false, message }
}
return { valid: true }
} catch (e: any) {
return { valid: false, message: e.message }
}
}

View File

@ -1,6 +1,12 @@
import { OAuth2CredentialsMethod } from "@budibase/types"
export interface OAuth2ConfigResponse { export interface OAuth2ConfigResponse {
id: string id: string
name: string name: string
url: string
clientId: string
clientSecret: string
method: OAuth2CredentialsMethod
} }
export interface FetchOAuth2ConfigsResponse { export interface FetchOAuth2ConfigsResponse {
@ -12,8 +18,22 @@ export interface UpsertOAuth2ConfigRequest {
url: string url: string
clientId: string clientId: string
clientSecret: string clientSecret: string
method: OAuth2CredentialsMethod
} }
export interface UpsertOAuth2ConfigResponse { export interface UpsertOAuth2ConfigResponse {
config: OAuth2ConfigResponse config: OAuth2ConfigResponse
} }
export interface ValidateConfigRequest {
id?: string
url: string
clientId: string
clientSecret: string
method: OAuth2CredentialsMethod
}
export interface ValidateConfigResponse {
valid: boolean
message?: string
}

View File

@ -1,11 +1,17 @@
import { Document } from "../document" import { Document } from "../document"
export enum OAuth2CredentialsMethod {
HEADER = "HEADER",
BODY = "BODY",
}
export interface OAuth2Config { export interface OAuth2Config {
id: string id: string
name: string name: string
url: string url: string
clientId: string clientId: string
clientSecret: string clientSecret: string
method: OAuth2CredentialsMethod
} }
export interface OAuth2Configs extends Document { export interface OAuth2Configs extends Document {