Merge branch 'feature/sql-attachments' of github.com:Budibase/budibase into fix/ts-convert-create-edit-column

This commit is contained in:
mike12345567 2025-03-18 15:31:59 +00:00
commit 6a2329e10a
30 changed files with 573 additions and 117 deletions

View File

@ -186,7 +186,7 @@ jobs:
id: dotenv
uses: falti/dotenv-action@v1.1.3
with:
path: ./packages/server/datasource-sha.env
path: ./packages/server/images-sha.env
- name: Pull testcontainers images
run: |
@ -213,6 +213,7 @@ jobs:
docker pull redis &
docker pull testcontainers/ryuk:0.5.1 &
docker pull budibase/couchdb:v3.3.3-sqs-v2.1.1 &
docker pull ${{ steps.dotenv.outputs.KEYCLOAK_IMAGE }} &
wait $(jobs -p)

View File

@ -47,6 +47,9 @@ export default [
parserOptions: {
allowImportExportEverywhere: true,
svelteFeatures: {
experimentalGenerics: true,
},
},
},

View File

@ -1,25 +1,26 @@
<script>
<script lang="ts">
import { setContext, getContext } from "svelte"
import Popover from "../Popover/Popover.svelte"
import Menu from "../Menu/Menu.svelte"
import type { PopoverAlignment } from "../constants"
export let disabled = false
export let align = "left"
export let portalTarget = undefined
export let openOnHover = false
export let animate = true
export let offset = undefined
export let disabled: boolean = false
export let align: `${PopoverAlignment}` = "left"
export let portalTarget: string | undefined = undefined
export let openOnHover: boolean = false
export let animate: boolean | undefined = true
export let offset: number | undefined = undefined
const actionMenuContext = getContext("actionMenu")
let anchor
let dropdown
let timeout
let anchor: HTMLElement | undefined
let dropdown: Popover
let timeout: ReturnType<typeof setTimeout>
// This is needed because display: contents is considered "invisible".
// It should only ever be an action button, so should be fine.
function getAnchor(node) {
anchor = node.firstChild
function getAnchor(node: HTMLDivElement) {
anchor = (node.firstChild as HTMLElement) ?? undefined
}
export const show = () => {
@ -37,7 +38,7 @@
actionMenuContext?.hide()
}
const openMenu = event => {
const openMenu = (event: Event) => {
if (!disabled) {
event.stopPropagation()
show()

View File

@ -89,7 +89,7 @@
/* Selection is only meant for standalone list items (non stacked) so we just set a fixed border radius */
.list-item.selected {
background-color: var(--spectrum-global-color-blue-100);
border-color: var(--spectrum-global-color-blue-100);
border: none;
}
.list-item.selected:after {
content: "";
@ -100,7 +100,7 @@
pointer-events: none;
top: 0;
left: 0;
border-radius: 4px;
border-radius: inherit;
box-sizing: border-box;
z-index: 1;
opacity: 0.5;

8
packages/bbui/src/context.d.ts vendored Normal file
View File

@ -0,0 +1,8 @@
import { ActionMenu } from "./types"
declare module "svelte" {
export function getContext(key: "actionMenu"): ActionMenu | undefined
}
export const Modal = "bbui-modal"
export const PopoverRoot = "bbui-popover-root"

View File

@ -0,0 +1,3 @@
export interface ActionMenu {
hide: () => void
}

View File

@ -1,16 +1,18 @@
<script>
<script lang="ts">
import { Modal, ModalContent, Body } from "@budibase/bbui"
export let title = ""
export let body = ""
export let okText = "Confirm"
export let cancelText = "Cancel"
export let onOk = undefined
export let onCancel = undefined
export let warning = true
export let disabled = false
export let title: string = ""
export let body: string = ""
export let okText: string = "Confirm"
export let cancelText: string = "Cancel"
export let size: "S" | "M" | "L" | "XL" | undefined = undefined
export let onOk: (() => void) | undefined = undefined
export let onCancel: (() => void) | undefined = undefined
export let onClose: (() => void) | undefined = undefined
export let warning: boolean = true
export let disabled: boolean = false
let modal
let modal: Modal
export const show = () => {
modal.show()
@ -20,14 +22,16 @@
}
</script>
<Modal bind:this={modal} on:hide={onCancel}>
<Modal bind:this={modal} on:hide={onClose ?? onCancel}>
<ModalContent
onConfirm={onOk}
{onCancel}
{title}
confirmText={okText}
{cancelText}
{warning}
{disabled}
{size}
>
<Body size="S">
{body}

View File

@ -1,5 +1,5 @@
<script>
import { goto, params } from "@roxi/routify"
import { beforeUrlChange, goto, params } from "@roxi/routify"
import { datasources, flags, integrations, queries } from "@/stores/builder"
import { environment } from "@/stores/portal"
import {
@ -25,7 +25,7 @@
EditorModes,
} from "@/components/common/CodeMirrorEditor.svelte"
import RestBodyInput from "./RestBodyInput.svelte"
import { capitalise } from "@/helpers"
import { capitalise, confirm } from "@/helpers"
import { onMount } from "svelte"
import restUtils from "@/helpers/data/utils"
import {
@ -50,6 +50,7 @@
toBindingsArray,
} from "@/dataBinding"
import ConnectedQueryScreens from "./ConnectedQueryScreens.svelte"
import AuthPicker from "./rest/AuthPicker.svelte"
export let queryId
@ -63,6 +64,7 @@
let nestedSchemaFields = {}
let saving
let queryNameLabel
let mounted = false
$: staticVariables = datasource?.config?.staticVariables || {}
@ -104,8 +106,10 @@
$: runtimeUrlQueries = readableToRuntimeMap(mergedBindings, breakQs)
$: originalQuery = originalQuery ?? cloneDeep(query)
$: builtQuery = buildQuery(query, runtimeUrlQueries, requestBindings)
$: originalQuery = mounted
? originalQuery ?? cloneDeep(builtQuery)
: undefined
$: isModified = JSON.stringify(originalQuery) !== JSON.stringify(builtQuery)
function getSelectedQuery() {
@ -208,11 +212,14 @@
originalQuery = null
queryNameLabel.disableEditingState()
return { ok: true }
} catch (err) {
notifications.error(`Error saving query`)
} finally {
saving = false
}
return { ok: false }
}
const validateQuery = async () => {
@ -474,6 +481,38 @@
staticVariables,
restBindings
)
mounted = true
})
$beforeUrlChange(async () => {
if (!isModified) {
return true
}
return await confirm({
title: "Some updates are not saved",
body: "Some of your changes are not yet saved. Do you want to save them before leaving?",
okText: "Save and continue",
cancelText: "Discard and continue",
size: "M",
onConfirm: async () => {
const saveResult = await saveQuery()
if (!saveResult.ok) {
// We can't leave as the query was not properly saved
return false
}
return true
},
onCancel: () => {
// Leave without saving anything
return true
},
onClose: () => {
return false
},
})
})
</script>
@ -642,15 +681,12 @@
<div class="auth-container">
<div />
<!-- spacer -->
<div class="auth-select">
<Select
label="Auth"
labelPosition="left"
placeholder="None"
bind:value={query.fields.authConfigId}
options={authConfigs}
/>
</div>
<AuthPicker
bind:authConfigId={query.fields.authConfigId}
{authConfigs}
datasourceId={datasource._id}
/>
</div>
</Tabs>
</Layout>
@ -853,10 +889,6 @@
justify-content: space-between;
}
.auth-select {
width: 200px;
}
.pagination {
display: grid;
grid-template-columns: 1fr 1fr;

View File

@ -0,0 +1,71 @@
<script lang="ts">
import {
ActionButton,
Body,
Button,
List,
ListItem,
PopoverAlignment,
} from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { appStore } from "@/stores/builder"
import DetailPopover from "@/components/common/DetailPopover.svelte"
export let authConfigId: string | undefined
export let authConfigs: { label: string; value: string }[]
export let datasourceId: string
let popover: DetailPopover
$: authConfig = authConfigs.find(c => c.value === authConfigId)
function addBasicConfiguration() {
$goto(
`/builder/app/${$appStore.appId}/data/datasource/${datasourceId}?&tab=Authentication`
)
}
function selectConfiguration(id: string) {
if (authConfigId === id) {
authConfigId = undefined
} else {
authConfigId = id
}
popover.hide()
}
$: title = !authConfig ? "Authentication" : `Auth: ${authConfig.label}`
</script>
<DetailPopover bind:this={popover} {title} align={PopoverAlignment.Right}>
<div slot="anchor">
<ActionButton icon="LockClosed" quiet selected>
{#if !authConfig}
Authentication
{:else}
Auth: {authConfig.label}
{/if}
</ActionButton>
</div>
<Body size="S" color="var(--spectrum-global-color-gray-700)">
Basic & Bearer Authentication
</Body>
{#if authConfigs.length}
<List>
{#each authConfigs as config}
<ListItem
title={config.label}
on:click={() => selectConfiguration(config.value)}
selected={config.value === authConfigId}
/>
{/each}
</List>
{/if}
<div>
<Button secondary icon="Add" on:click={addBasicConfiguration}
>Add config</Button
>
</div>
</DetailPopover>

View File

@ -0,0 +1,41 @@
import ConfirmDialog from "@/components/common/ConfirmDialog.svelte"
export enum ConfirmOutput {}
export async function confirm(props: {
title: string
body?: string
okText?: string
cancelText?: string
size?: "S" | "M" | "L" | "XL"
onConfirm?: () => void
onCancel?: () => void
onClose?: () => void
}) {
return await new Promise(resolve => {
const dialog = new ConfirmDialog({
target: document.body,
props: {
title: props.title,
body: props.body,
okText: props.okText,
cancelText: props.cancelText,
size: props.size,
warning: false,
onOk: () => {
dialog.$destroy()
resolve(props.onConfirm?.() || true)
},
onCancel: () => {
dialog.$destroy()
resolve(props.onCancel?.() || false)
},
onClose: () => {
dialog.$destroy()
resolve(props.onClose?.() || false)
},
},
})
dialog.show()
})
}

View File

@ -11,3 +11,4 @@ export {
} from "./helpers"
export * as featureFlag from "./featureFlags"
export * as bindings from "./bindings"
export * from "./confirm"

View File

@ -1,4 +1,5 @@
<script>
import { params } from "@roxi/routify"
import { Tabs, Tab, Heading, Body, Layout } from "@budibase/bbui"
import { datasources, integrations } from "@/stores/builder"
import ICONS from "@/components/backend/DatasourceNavigator/icons"
@ -15,7 +16,7 @@
import { admin } from "@/stores/portal"
import { IntegrationTypes } from "@/constants/backend"
let selectedPanel = null
let selectedPanel = $params.tab ?? null
let panelOptions = []
$: datasource = $datasources.selected

View File

@ -4,4 +4,5 @@ POSTGRES_SHA=sha256:bd0d8e485d1aca439d39e5ea99b931160bd28d862e74c786f7508e9d0053
MONGODB_SHA=sha256:afa36bca12295b5f9dae68a493c706113922bdab520e901bd5d6c9d7247a1d8d
MARIADB_SHA=sha256:e59ba8783bf7bc02a4779f103bb0d8751ac0e10f9471089709608377eded7aa8
ELASTICSEARCH_SHA=sha256:9a6443f55243f6acbfeb4a112d15eb3b9aac74bf25e0e39fa19b3ddd3a6879d0
DYNAMODB_SHA=sha256:cf8cebd061f988628c02daff10fdb950a54478feff9c52f6ddf84710fe3c3906
DYNAMODB_SHA=sha256:cf8cebd061f988628c02daff10fdb950a54478feff9c52f6ddf84710fe3c3906
KEYCLOAK_IMAGE=keycloak/keycloak@sha256:044a457e04987e1fff756be3d2fa325a4ef420fa356b7034ecc9f1b693c32761

View File

@ -1,5 +1,6 @@
import {
CreateOAuth2ConfigRequest,
CreateOAuth2ConfigResponse,
Ctx,
FetchOAuth2ConfigsResponse,
OAuth2Config,
@ -12,17 +13,26 @@ export async function fetch(ctx: Ctx<void, FetchOAuth2ConfigsResponse>) {
const response: FetchOAuth2ConfigsResponse = {
configs: (configs || []).map(c => ({
id: c.id,
name: c.name,
url: c.url,
})),
}
ctx.body = response
}
export async function create(ctx: Ctx<CreateOAuth2ConfigRequest, void>) {
const newConfig: RequiredKeys<OAuth2Config> = {
name: ctx.request.body.name,
export async function create(
ctx: Ctx<CreateOAuth2ConfigRequest, CreateOAuth2ConfigResponse>
) {
const { body } = ctx.request
const newConfig: RequiredKeys<Omit<OAuth2Config, "id">> = {
name: body.name,
url: body.url,
clientId: body.clientId,
clientSecret: body.clientSecret,
}
await sdk.oauth2.create(newConfig)
const config = await sdk.oauth2.create(newConfig)
ctx.status = 201
ctx.body = { config }
}

View File

@ -1,4 +1,4 @@
import { CreateOAuth2ConfigRequest } from "@budibase/types"
import { CreateOAuth2ConfigRequest, VirtualDocumentType } from "@budibase/types"
import * as setup from "./utilities"
import { generator } from "@budibase/backend-core/tests"
@ -8,6 +8,9 @@ describe("/oauth2", () => {
function makeOAuth2Config(): CreateOAuth2ConfigRequest {
return {
name: generator.guid(),
url: generator.url(),
clientId: generator.guid(),
clientSecret: generator.hash(),
}
}
@ -15,6 +18,10 @@ describe("/oauth2", () => {
beforeEach(async () => await config.newTenant())
const expectOAuth2ConfigId = expect.stringMatching(
`^${VirtualDocumentType.OAUTH2_CONFIG}_.+$`
)
describe("fetch", () => {
it("returns empty when no oauth are created", async () => {
const response = await config.api.oauth2.fetch()
@ -33,7 +40,9 @@ describe("/oauth2", () => {
expect(response).toEqual({
configs: [
{
id: expectOAuth2ConfigId,
name: oauth2Config.name,
url: oauth2Config.url,
},
],
})
@ -48,12 +57,17 @@ describe("/oauth2", () => {
const response = await config.api.oauth2.fetch()
expect(response.configs).toEqual([
{
id: expectOAuth2ConfigId,
name: oauth2Config.name,
url: oauth2Config.url,
},
{
id: expectOAuth2ConfigId,
name: oauth2Config2.name,
url: oauth2Config2.url,
},
])
expect(response.configs[0].id).not.toEqual(response.configs[1].id)
})
it("cannot create configurations with already existing names", async () => {
@ -71,7 +85,9 @@ describe("/oauth2", () => {
const response = await config.api.oauth2.fetch()
expect(response.configs).toEqual([
{
id: expectOAuth2ConfigId,
name: oauth2Config.name,
url: oauth2Config.url,
},
])
})

View File

@ -8,8 +8,6 @@ import {
PaginationValues,
QueryType,
RestAuthType,
RestBasicAuthConfig,
RestBearerAuthConfig,
RestConfig,
RestQueryFields as RestQuery,
} from "@budibase/types"
@ -28,6 +26,8 @@ import { parse } from "content-disposition"
import path from "path"
import { Builder as XmlBuilder } from "xml2js"
import { getAttachmentHeaders } from "./utils/restUtils"
import { utils } from "@budibase/shared-core"
import sdk from "../sdk"
const coreFields = {
path: {
@ -377,29 +377,41 @@ export class RestIntegration implements IntegrationBase {
return input
}
getAuthHeaders(authConfigId?: string): { [key: string]: any } {
let headers: any = {}
async getAuthHeaders(
authConfigId?: string,
authConfigType?: RestAuthType
): Promise<{ [key: string]: any }> {
if (!authConfigId) {
return {}
}
if (this.config.authConfigs && authConfigId) {
const authConfig = this.config.authConfigs.filter(
c => c._id === authConfigId
)[0]
// check the config still exists before proceeding
// if not - do nothing
if (authConfig) {
let config
switch (authConfig.type) {
case RestAuthType.BASIC:
config = authConfig.config as RestBasicAuthConfig
headers.Authorization = `Basic ${Buffer.from(
`${config.username}:${config.password}`
).toString("base64")}`
break
case RestAuthType.BEARER:
config = authConfig.config as RestBearerAuthConfig
headers.Authorization = `Bearer ${config.token}`
break
}
if (authConfigType === RestAuthType.OAUTH2) {
return { Authorization: await sdk.oauth2.generateToken(authConfigId) }
}
if (!this.config.authConfigs) {
return {}
}
let headers: any = {}
const authConfig = this.config.authConfigs.filter(
c => c._id === authConfigId
)[0]
// check the config still exists before proceeding
// if not - do nothing
if (authConfig) {
const { type, config } = authConfig
switch (type) {
case RestAuthType.BASIC:
headers.Authorization = `Basic ${Buffer.from(
`${config.username}:${config.password}`
).toString("base64")}`
break
case RestAuthType.BEARER:
headers.Authorization = `Bearer ${config.token}`
break
default:
throw utils.unreachable(type)
}
}
@ -416,10 +428,11 @@ export class RestIntegration implements IntegrationBase {
bodyType = BodyType.NONE,
requestBody,
authConfigId,
authConfigType,
pagination,
paginationValues,
} = query
const authHeaders = this.getAuthHeaders(authConfigId)
const authHeaders = await this.getAuthHeaders(authConfigId, authConfigType)
this.headers = {
...(this.config.defaultHeaders || {}),

View File

@ -1,10 +1,16 @@
import nock from "nock"
import { RestIntegration } from "../rest"
import { BodyType, RestAuthType } from "@budibase/types"
import { Response } from "node-fetch"
import TestConfiguration from "../../../src/tests/utilities/TestConfiguration"
import { RestIntegration } from "../rest"
import {
BasicRestAuthConfig,
BearerRestAuthConfig,
BodyType,
RestAuthType,
} from "@budibase/types"
import { Response } from "node-fetch"
import { createServer } from "http"
import { AddressInfo } from "net"
import { generator } from "@budibase/backend-core/tests"
const UUID_REGEX =
"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
@ -224,7 +230,7 @@ describe("REST Integration", () => {
})
describe("authentication", () => {
const basicAuth = {
const basicAuth: BasicRestAuthConfig = {
_id: "c59c14bd1898a43baa08da68959b24686",
name: "basic-1",
type: RestAuthType.BASIC,
@ -234,7 +240,7 @@ describe("REST Integration", () => {
},
}
const bearerAuth = {
const bearerAuth: BearerRestAuthConfig = {
_id: "0d91d732f34e4befabeff50b392a8ff3",
name: "bearer-1",
type: RestAuthType.BEARER,
@ -269,6 +275,38 @@ describe("REST Integration", () => {
const { data } = await integration.read({ authConfigId: bearerAuth._id })
expect(data).toEqual({ foo: "bar" })
})
it("adds OAuth2 auth", async () => {
const oauth2Url = generator.url()
const { config: oauthConfig } = await config.api.oauth2.create({
name: generator.guid(),
url: oauth2Url,
clientId: generator.guid(),
clientSecret: generator.hash(),
})
const token = generator.guid()
const url = new URL(oauth2Url)
nock(url.origin)
.post(url.pathname)
.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" })
})
})
describe("page based pagination", () => {

View File

@ -1,7 +1,7 @@
import dotenv from "dotenv"
import { join } from "path"
const path = join(__dirname, "..", "..", "..", "..", "datasource-sha.env")
const path = join(__dirname, "..", "..", "..", "..", "images-sha.env")
dotenv.config({
path,
})
@ -14,3 +14,4 @@ export const MONGODB_IMAGE = `mongo@${process.env.MONGODB_SHA}`
export const MARIADB_IMAGE = `mariadb@${process.env.MARIADB_SHA}`
export const ELASTICSEARCH_IMAGE = `elasticsearch@${process.env.ELASTICSEARCH_SHA}`
export const DYNAMODB_IMAGE = `amazon/dynamodb-local@${process.env.DYNAMODB_SHA}`
export const KEYCLOAK_IMAGE = process.env.KEYCLOAK_IMAGE || ""

View File

@ -1,28 +0,0 @@
import { context, HTTPError } from "@budibase/backend-core"
import { DocumentType, OAuth2Config, OAuth2Configs } from "@budibase/types"
export async function fetch(): Promise<OAuth2Config[]> {
const db = context.getAppDB()
const result = await db.tryGet<OAuth2Configs>(DocumentType.OAUTH2_CONFIG)
if (!result) {
return []
}
return Object.values(result.configs)
}
export async function create(config: OAuth2Config) {
const db = context.getAppDB()
const doc: OAuth2Configs = (await db.tryGet<OAuth2Configs>(
DocumentType.OAUTH2_CONFIG
)) ?? {
_id: DocumentType.OAUTH2_CONFIG,
configs: {},
}
if (doc.configs[config.name]) {
throw new HTTPError("Name already used", 400)
}
doc.configs[config.name] = config
await db.put(doc)
}

View File

@ -0,0 +1,50 @@
import { context, HTTPError, utils } from "@budibase/backend-core"
import {
Database,
DocumentType,
OAuth2Config,
OAuth2Configs,
SEPARATOR,
VirtualDocumentType,
} from "@budibase/types"
async function getDocument(db: Database = context.getAppDB()) {
const result = await db.tryGet<OAuth2Configs>(DocumentType.OAUTH2_CONFIG)
return result
}
export async function fetch(): Promise<OAuth2Config[]> {
const result = await getDocument()
if (!result) {
return []
}
return Object.values(result.configs)
}
export async function create(
config: Omit<OAuth2Config, "id">
): Promise<OAuth2Config> {
const db = context.getAppDB()
const doc: OAuth2Configs = (await getDocument(db)) ?? {
_id: DocumentType.OAUTH2_CONFIG,
configs: {},
}
if (Object.values(doc.configs).find(c => c.name === config.name)) {
throw new HTTPError("Name already used", 400)
}
const id = `${VirtualDocumentType.OAUTH2_CONFIG}${SEPARATOR}${utils.newid()}`
doc.configs[id] = {
id,
...config,
}
await db.put(doc)
return doc.configs[id]
}
export async function get(id: string): Promise<OAuth2Config | undefined> {
const doc = await getDocument()
return doc?.configs?.[id]
}

View File

@ -0,0 +1,2 @@
export * from "./crud"
export * from "./utils"

View File

@ -0,0 +1,13 @@
{
"id": "myrealm",
"realm": "myrealm",
"enabled": true,
"clients": [
{
"clientId": "my-client",
"secret": "my-secret",
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": true
}
]
}

View File

@ -0,0 +1,110 @@
import { generator } from "@budibase/backend-core/tests"
import { GenericContainer, Wait } from "testcontainers"
import sdk from "../../.."
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
import { generateToken } from "../utils"
import path from "path"
import { KEYCLOAK_IMAGE } from "../../../../integrations/tests/utils/images"
import { startContainer } from "../../../../integrations/tests/utils"
const config = new TestConfiguration()
const volumePath = path.resolve(__dirname, "docker-volume")
jest.setTimeout(60000)
describe("oauth2 utils", () => {
let keycloakUrl: string
beforeAll(async () => {
await config.init()
const ports = await startContainer(
new GenericContainer(KEYCLOAK_IMAGE)
.withName("keycloak_testcontainer")
.withExposedPorts(8080)
.withBindMounts([
{ source: volumePath, target: "/opt/keycloak/data/import/" },
])
.withCommand(["start-dev", "--import-realm"])
.withWaitStrategy(
Wait.forLogMessage("Listening on: http://0.0.0.0:8080")
)
.withStartupTimeout(60000)
)
const port = ports.find(x => x.container === 8080)?.host
if (!port) {
throw new Error("Keycloak port not found")
}
keycloakUrl = `http://127.0.0.1:${port}`
})
describe("generateToken", () => {
it("successfully generates tokens", async () => {
const response = await config.doInContext(config.appId, async () => {
const oauthConfig = await sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "my-client",
clientSecret: "my-secret",
})
const response = await generateToken(oauthConfig.id)
return response
})
expect(response).toEqual(expect.stringMatching(/^Bearer .+/))
})
it("handles wrong urls", async () => {
await expect(
config.doInContext(config.appId, async () => {
const oauthConfig = await sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/wrong/protocol/openid-connect/token`,
clientId: "my-client",
clientSecret: "my-secret",
})
await generateToken(oauthConfig.id)
})
).rejects.toThrow("Error fetching oauth2 token: Not Found")
})
it("handles wrong client ids", async () => {
await expect(
config.doInContext(config.appId, async () => {
const oauthConfig = await sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "wrong-client-id",
clientSecret: "my-secret",
})
await generateToken(oauthConfig.id)
})
).rejects.toThrow(
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
)
})
it("handles wrong secrets", async () => {
await expect(
config.doInContext(config.appId, async () => {
const oauthConfig = await sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "my-client",
clientSecret: "wrong-secret",
})
await generateToken(oauthConfig.id)
})
).rejects.toThrow(
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
)
})
})
})

View File

@ -0,0 +1,33 @@
import fetch from "node-fetch"
import { HttpError } from "koa"
import { get } from "../oauth2"
// TODO: check if caching is worth
export async function generateToken(id: string) {
const config = await get(id)
if (!config) {
throw new HttpError(`oAuth config ${id} count not be found`)
}
const resp = await fetch(config.url, {
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()
if (!resp.ok) {
const message = jsonResponse.error_description ?? resp.statusText
throw new Error(`Error fetching oauth2 token: ${message}`)
}
return `${jsonResponse.token_type} ${jsonResponse.access_token}`
}

View File

@ -1,5 +1,6 @@
import {
CreateOAuth2ConfigRequest,
CreateOAuth2ConfigResponse,
FetchOAuth2ConfigsResponse,
} from "@budibase/types"
import { Expectations, TestAPI } from "./base"
@ -15,9 +16,12 @@ export class OAuth2API extends TestAPI {
body: CreateOAuth2ConfigRequest,
expectations?: Expectations
) => {
return await this._post<CreateOAuth2ConfigRequest>("/api/oauth2", {
return await this._post<CreateOAuth2ConfigResponse>("/api/oauth2", {
body,
expectations,
expectations: {
status: expectations?.status ?? 201,
...expectations,
},
})
}
}

View File

@ -1,9 +1,19 @@
interface OAuth2Config {
interface OAuth2ConfigResponse {
id: string
name: string
}
export interface FetchOAuth2ConfigsResponse {
configs: OAuth2Config[]
configs: OAuth2ConfigResponse[]
}
export interface CreateOAuth2ConfigRequest extends OAuth2Config {}
export interface CreateOAuth2ConfigRequest {
name: string
url: string
clientId: string
clientSecret: string
}
export interface CreateOAuth2ConfigResponse {
config: OAuth2ConfigResponse
}

View File

@ -19,6 +19,7 @@ export interface Datasource extends Document {
export enum RestAuthType {
BASIC = "basic",
BEARER = "bearer",
OAUTH2 = "oauth2",
}
export interface RestBasicAuthConfig {
@ -30,13 +31,22 @@ export interface RestBearerAuthConfig {
token: string
}
export interface RestAuthConfig {
export interface BasicRestAuthConfig {
_id: string
name: string
type: RestAuthType
config: RestBasicAuthConfig | RestBearerAuthConfig
type: RestAuthType.BASIC
config: RestBasicAuthConfig
}
export interface BearerRestAuthConfig {
_id: string
name: string
type: RestAuthType.BEARER
config: RestBearerAuthConfig
}
export type RestAuthConfig = BasicRestAuthConfig | BearerRestAuthConfig
export interface DynamicVariable {
name: string
queryId: string

View File

@ -1,7 +1,11 @@
import { Document } from "../document"
export interface OAuth2Config {
id: string
name: string
url: string
clientId: string
clientSecret: string
}
export interface OAuth2Configs extends Document {

View File

@ -1,4 +1,5 @@
import { Document } from "../document"
import { RestAuthType } from "./datasource"
import { Row } from "./row"
export interface QuerySchema {
@ -56,6 +57,7 @@ export interface RestQueryFields {
bodyType?: BodyType
method?: string
authConfigId?: string
authConfigType?: RestAuthType
pagination?: PaginationConfig
paginationValues?: PaginationValues
}

View File

@ -82,6 +82,7 @@ export enum InternalTable {
export enum VirtualDocumentType {
VIEW = "view",
ROW_ACTION = "row_action",
OAUTH2_CONFIG = "oauth2",
}
// Because VirtualDocumentTypes can overlap, we need to make sure that we search