Merge branch 'master' into feat/file-ts-conversion
This commit is contained in:
commit
88d5dd48f4
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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()
|
||||||
|
|
|
@ -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 {}
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
|
|
|
@ -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", {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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,70 +42,77 @@ describe("oauth2 utils", () => {
|
||||||
keycloakUrl = `http://127.0.0.1:${port}`
|
keycloakUrl = `http://127.0.0.1:${port}`
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("generateToken", () => {
|
describe.each(Object.values(OAuth2CredentialsMethod))(
|
||||||
it("successfully generates tokens", async () => {
|
"generateToken (in %s)",
|
||||||
const response = await config.doInContext(config.appId, async () => {
|
method => {
|
||||||
const oauthConfig = await sdk.oauth2.create({
|
it("successfully generates tokens", async () => {
|
||||||
name: generator.guid(),
|
const response = await config.doInContext(config.appId, async () => {
|
||||||
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
|
const oauthConfig = await sdk.oauth2.create({
|
||||||
clientId: "my-client",
|
name: generator.guid(),
|
||||||
clientSecret: "my-secret",
|
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)
|
expect(response).toEqual(expect.stringMatching(/^Bearer .+/))
|
||||||
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",
|
||||||
|
method,
|
||||||
|
})
|
||||||
|
|
||||||
it("handles wrong urls", async () => {
|
await generateToken(oauthConfig.id)
|
||||||
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",
|
|
||||||
})
|
})
|
||||||
|
).rejects.toThrow("Error fetching oauth2 token: Not Found")
|
||||||
|
})
|
||||||
|
|
||||||
await generateToken(oauthConfig.id)
|
it("handles wrong client ids", async () => {
|
||||||
})
|
await expect(
|
||||||
).rejects.toThrow("Error fetching oauth2 token: Not Found")
|
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 generateToken(oauthConfig.id)
|
||||||
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",
|
|
||||||
})
|
})
|
||||||
|
).rejects.toThrow(
|
||||||
|
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
await generateToken(oauthConfig.id)
|
it("handles wrong secrets", async () => {
|
||||||
})
|
await expect(
|
||||||
).rejects.toThrow(
|
config.doInContext(config.appId, async () => {
|
||||||
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
|
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 generateToken(oauthConfig.id)
|
||||||
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",
|
|
||||||
})
|
})
|
||||||
|
).rejects.toThrow(
|
||||||
await generateToken(oauthConfig.id)
|
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
|
||||||
})
|
)
|
||||||
).rejects.toThrow(
|
})
|
||||||
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
|
}
|
||||||
)
|
)
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
|
@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
Loading…
Reference in New Issue