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

View File

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

View File

@ -1,140 +1,15 @@
<script lang="ts">
import { oauth2 } from "@/stores/builder"
import type { CreateOAuth2Config } from "@/types"
import {
Body,
Button,
Divider,
Heading,
Input,
keepOpen,
Link,
Modal,
ModalContent,
notifications,
} from "@budibase/bbui"
import type { ZodType } from "zod"
import { z } from "zod"
import { Button, Modal } from "@budibase/bbui"
import OAuth2ConfigModalContent from "./OAuth2ConfigModalContent.svelte"
let modal: Modal
function openModal() {
config = {}
errors = {}
hasBeenSubmitted = false
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>
<Button cta size="M" on:click={openModal}>Add OAuth2</Button>
<Modal bind:this={modal}>
<ModalContent onConfirm={saveOAuth2Config} size="M">
<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>
<OAuth2ConfigModalContent />
</Modal>
<style>
.field-info {
margin-top: calc(var(--spacing-xl) * -1 + var(--spacing-s));
}
</style>

View File

@ -1,11 +1,42 @@
<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() {
// TODO
modal.show()
}
function onDelete() {
// TODO
async function onDelete() {
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>
@ -16,3 +47,7 @@
<MenuItem on:click={onEdit} icon="Edit">Edit</MenuItem>
<MenuItem on:click={onDelete} icon="Delete">Delete</MenuItem>
</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 { BudiStore } from "@/stores/BudiStore"
import { CreateOAuth2Config } from "@/types"
interface Config {
id: string
name: string
}
import { OAuth2Config, UpsertOAuth2Config } from "@/types"
import { ValidateConfigRequest } from "@budibase/types"
interface OAuth2StoreState {
configs: Config[]
configs: OAuth2Config[]
loading: boolean
error?: string
}
@ -30,7 +26,14 @@ export class OAuth2Store extends BudiStore<OAuth2StoreState> {
const configs = await API.oauth2.fetch()
this.store.update(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,
}))
} 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 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()

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,
UpsertOAuth2ConfigRequest,
UpsertOAuth2ConfigResponse,
ValidateConfigRequest,
ValidateConfigResponse,
} from "@budibase/types"
import { BaseAPIClient } from "./types"
@ -11,12 +13,17 @@ export interface OAuth2Endpoints {
create: (
config: UpsertOAuth2ConfigRequest
) => 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 => ({
/**
* Gets all OAuth2 configurations for the app.
* @param tableId the ID of the table
*/
fetch: async () => {
return (
@ -28,8 +35,6 @@ export const buildOAuth2Endpoints = (API: BaseAPIClient): OAuth2Endpoints => ({
/**
* Creates a OAuth2 configuration.
* @param name the name of the row action
* @param tableId the ID of the table
*/
create: async config => {
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,
OAuth2Config,
RequiredKeys,
OAuth2ConfigResponse,
PASSWORD_REPLACEMENT,
ValidateConfigResponse,
ValidateConfigRequest,
} from "@budibase/types"
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>) {
const configs = await sdk.oauth2.fetch()
const response: FetchOAuth2ConfigsResponse = {
configs: (configs || []).map(c => ({
id: c.id,
name: c.name,
url: c.url,
})),
configs: (configs || []).map(toFetchOAuth2ConfigsResponse),
}
ctx.body = response
}
@ -30,11 +43,14 @@ export async function create(
url: body.url,
clientId: body.clientId,
clientSecret: body.clientSecret,
method: body.method,
}
const config = await sdk.oauth2.create(newConfig)
ctx.status = 201
ctx.body = { config }
ctx.body = {
config: toFetchOAuth2ConfigsResponse(config),
}
}
export async function edit(
@ -45,12 +61,15 @@ export async function edit(
id: ctx.params.id,
name: body.name,
url: body.url,
clientId: ctx.clientId,
clientSecret: ctx.clientSecret,
clientId: body.clientId,
clientSecret: body.clientSecret,
method: body.method,
}
const config = await sdk.oauth2.update(toUpdate)
ctx.body = { config }
ctx.body = {
config: toFetchOAuth2ConfigsResponse(config),
}
}
export async function remove(
@ -61,3 +80,28 @@ export async function remove(
await sdk.oauth2.remove(configToRemove)
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 { PermissionType } from "@budibase/types"
import { OAuth2CredentialsMethod, PermissionType } from "@budibase/types"
import { middleware } from "@budibase/backend-core"
import authorized from "../../middleware/authorized"
import * as controller from "../controllers/oauth2"
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() {
return middleware.joiValidator.body(
Joi.object({
name: Joi.string().required(),
url: Joi.string().required(),
clientId: Joi.string().required(),
clientSecret: Joi.string().required(),
...baseValidation,
}),
{ allowUnknown: false }
)
}
function oAuth2ConfigValidationValidator() {
return middleware.joiValidator.body(
Joi.object({
id: Joi.string().required(),
...baseValidation,
}),
{ allowUnknown: false }
)
@ -38,5 +55,11 @@ router.delete(
authorized(PermissionType.BUILDER),
controller.remove
)
router.post(
"/api/oauth2/validate",
authorized(PermissionType.BUILDER),
oAuth2ConfigValidationValidator(),
controller.validate
)
export default router

View File

@ -1,5 +1,7 @@
import {
OAuth2Config,
OAuth2CredentialsMethod,
PASSWORD_REPLACEMENT,
UpsertOAuth2ConfigRequest,
VirtualDocumentType,
} from "@budibase/types"
@ -16,6 +18,7 @@ describe("/oauth2", () => {
url: generator.url(),
clientId: generator.guid(),
clientSecret: generator.hash(),
method: generator.pickone(Object.values(OAuth2CredentialsMethod)),
}
}
@ -34,6 +37,30 @@ describe("/oauth2", () => {
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", () => {
@ -48,6 +75,9 @@ describe("/oauth2", () => {
id: expectOAuth2ConfigId,
name: oauth2Config.name,
url: oauth2Config.url,
clientId: oauth2Config.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config.method,
},
],
})
@ -65,11 +95,17 @@ describe("/oauth2", () => {
id: expectOAuth2ConfigId,
name: oauth2Config.name,
url: oauth2Config.url,
clientId: oauth2Config.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config.method,
},
{
id: expectOAuth2ConfigId,
name: oauth2Config2.name,
url: oauth2Config2.url,
clientId: oauth2Config2.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config2.method,
},
])
expect(response.configs[0].id).not.toEqual(response.configs[1].id)
@ -93,6 +129,9 @@ describe("/oauth2", () => {
id: expectOAuth2ConfigId,
name: oauth2Config.name,
url: oauth2Config.url,
clientId: oauth2Config.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config.method,
},
])
})
@ -127,6 +166,9 @@ describe("/oauth2", () => {
id: configId,
name: "updated name",
url: configData.url,
clientId: configData.clientId,
clientSecret: PASSWORD_REPLACEMENT,
method: configData.method,
},
])
)

View File

@ -5,6 +5,7 @@ import {
BasicRestAuthConfig,
BearerRestAuthConfig,
BodyType,
OAuth2CredentialsMethod,
RestAuthType,
} from "@budibase/types"
import { Response } from "node-fetch"
@ -276,20 +277,67 @@ describe("REST Integration", () => {
expect(data).toEqual({ foo: "bar" })
})
it("adds OAuth2 auth", async () => {
it("adds OAuth2 auth (via header)", 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: generator.hash(),
clientSecret: secret,
method: OAuth2CredentialsMethod.HEADER,
})
const token = generator.guid()
const url = new URL(oauth2Url)
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 })
nock("https://example.com", {

View File

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

View File

@ -6,6 +6,7 @@ import { generateToken } from "../utils"
import path from "path"
import { KEYCLOAK_IMAGE } from "../../../../integrations/tests/utils/images"
import { startContainer } from "../../../../integrations/tests/utils"
import { OAuth2CredentialsMethod } from "@budibase/types"
const config = new TestConfiguration()
@ -41,70 +42,77 @@ describe("oauth2 utils", () => {
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",
describe.each(Object.values(OAuth2CredentialsMethod))(
"generateToken (in %s)",
method => {
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",
method,
})
const response = await generateToken(oauthConfig.id)
return response
})
const response = await generateToken(oauthConfig.id)
return response
expect(response).toEqual(expect.stringMatching(/^Bearer .+/))
})
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",
method,
})
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")
})
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",
method,
})
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"
)
})
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",
method,
})
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)
})
await generateToken(oauthConfig.id)
})
).rejects.toThrow(
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
)
})
})
).rejects.toThrow(
"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 { 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
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`)
}
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 resp = await fetchToken(config)
const jsonResponse = await resp.json()
if (!resp.ok) {
@ -31,3 +58,24 @@ export async function generateToken(id: string) {
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 {
id: string
name: string
url: string
clientId: string
clientSecret: string
method: OAuth2CredentialsMethod
}
export interface FetchOAuth2ConfigsResponse {
@ -12,8 +18,22 @@ export interface UpsertOAuth2ConfigRequest {
url: string
clientId: string
clientSecret: string
method: OAuth2CredentialsMethod
}
export interface UpsertOAuth2ConfigResponse {
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"
export enum OAuth2CredentialsMethod {
HEADER = "HEADER",
BODY = "BODY",
}
export interface OAuth2Config {
id: string
name: string
url: string
clientId: string
clientSecret: string
method: OAuth2CredentialsMethod
}
export interface OAuth2Configs extends Document {