Merge pull request #3772 from Budibase/feature/query-auth

REST Query Authentication
This commit is contained in:
Rory Powell 2021-12-14 12:24:06 +00:00 committed by GitHub
commit bc2c4a6ccd
17 changed files with 526 additions and 28 deletions

View File

@ -21,6 +21,7 @@
export let showSecondaryButton = false
export let secondaryButtonText = undefined
export let secondaryAction = undefined
export let secondaryButtonWarning = false
const { hide, cancel } = getContext(Context.Modal)
let loading = false
@ -88,8 +89,11 @@
{#if showSecondaryButton && secondaryButtonText && secondaryAction}
<div class="secondary-action">
<Button group secondary on:click={secondary}
>{secondaryButtonText}</Button
<Button
group
secondary
warning={secondaryButtonWarning}
on:click={secondary}>{secondaryButtonText}</Button
>
</div>
{/if}

View File

@ -12,7 +12,7 @@
import { datasources, integrations, tables } from "stores/backend"
import CreateEditRelationship from "components/backend/Datasources/CreateEditRelationship.svelte"
import CreateExternalTableModal from "./CreateExternalTableModal.svelte"
import ArrayRenderer from "components/common/ArrayRenderer.svelte"
import ArrayRenderer from "components/common/renderers/ArrayRenderer.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { goto } from "@roxi/routify"

View File

@ -1,6 +1,7 @@
<script>
import { Divider, Heading, ActionButton, Badge, Body } from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import RestAuthenticationBuilder from "./auth/RestAuthenticationBuilder.svelte"
export let datasource
@ -8,7 +9,7 @@
</script>
<Divider size="S" />
<div class="query-header">
<div class="section-header">
<div class="badge">
<Heading size="S">Headers</Heading>
<Badge quiet grey>Optional</Badge>
@ -30,8 +31,20 @@
</ActionButton>
</div>
<Divider size="S" />
<div class="section-header">
<div class="badge">
<Heading size="S">Authentication</Heading>
<Badge quiet grey>Optional</Badge>
</div>
</div>
<Body size="S">
Create an authentication config that can be shared with queries.
</Body>
<RestAuthenticationBuilder bind:configs={datasource.config.authConfigs} />
<style>
.query-header {
.section-header {
display: flex;
flex-direction: row;
justify-content: space-between;

View File

@ -0,0 +1,13 @@
<script>
import { AUTH_TYPE_LABELS } from "./authTypes"
export let value
const renderAuthType = value => {
return AUTH_TYPE_LABELS.filter(type => type.value === value).map(
type => type.label
)
}
</script>
{renderAuthType(value)}

View File

@ -0,0 +1,65 @@
<script>
import { Table, Modal, Layout, ActionButton } from "@budibase/bbui"
import AuthTypeRenderer from "./AuthTypeRenderer.svelte"
import RestAuthenticationModal from "./RestAuthenticationModal.svelte"
import { uuid } from "builderStore/uuid"
export let configs = []
let currentConfig = null
let modal
const schema = {
name: "",
type: "",
}
const openConfigModal = config => {
currentConfig = config
modal.show()
}
const onConfirm = config => {
if (currentConfig) {
configs = configs.map(c => {
// replace the current config with the new one
if (c._id === currentConfig._id) {
return config
}
return c
})
} else {
config._id = uuid()
configs = [...configs, config]
}
}
const onRemove = () => {
configs = configs.filter(c => {
return c._id !== currentConfig._id
})
}
</script>
<Modal bind:this={modal}>
<RestAuthenticationModal {configs} {currentConfig} {onConfirm} {onRemove} />
</Modal>
<Layout gap="S" noPadding>
{#if configs && configs.length > 0}
<Table
on:click={({ detail }) => openConfigModal(detail)}
{schema}
data={configs}
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "type", component: AuthTypeRenderer }]}
/>
{/if}
<div>
<ActionButton on:click={() => openConfigModal()} con="Add"
>Add authentication</ActionButton
>
</div>
</Layout>

View File

@ -0,0 +1,218 @@
<script>
import { onMount } from "svelte"
import { ModalContent, Layout, Select, Body, Input } from "@budibase/bbui"
import { AUTH_TYPE_LABELS, AUTH_TYPES } from "./authTypes"
export let configs
export let currentConfig
export let onConfirm
export let onRemove
let form = {
basic: {},
bearer: {},
}
let errors = {
basic: {},
bearer: {},
}
let blurred = {
basic: {},
bearer: {},
}
let hasErrors = false
let hasChanged = false
onMount(() => {
if (currentConfig) {
deconstructConfig()
}
})
/**
* map the current config's data into the form by type
*/
const deconstructConfig = () => {
form.name = currentConfig.name
form.type = currentConfig.type
if (currentConfig.type === AUTH_TYPES.BASIC) {
form.basic = {
...currentConfig.config,
}
} else if (currentConfig.type === AUTH_TYPES.BEARER) {
form.bearer = {
...currentConfig.config,
}
}
}
/**
* map the form into a new config to save by type
*/
const constructConfig = () => {
const newConfig = {
name: form.name,
type: form.type,
}
if (currentConfig) {
newConfig._id = currentConfig._id
}
if (form.type === AUTH_TYPES.BASIC) {
newConfig.config = {
...form.basic,
}
} else if (form.type === AUTH_TYPES.BEARER) {
newConfig.config = {
...form.bearer,
}
}
return newConfig
}
/**
* compare the existing config with the new config to see if there are any changes
*/
const checkChanged = () => {
if (currentConfig) {
hasChanged =
JSON.stringify(currentConfig) !== JSON.stringify(constructConfig())
} else {
hasChanged = true
}
}
const checkErrors = () => {
hasErrors = false
// NAME
const nameError = () => {
// Unique name
if (form.name) {
errors.name =
// check for duplicate excluding the current config
configs.find(
c => c.name === form.name && c.name !== currentConfig?.name
) !== undefined
? "Name must be unique"
: null
}
// Name required
else {
errors.name = "Name is required"
}
return !!errors.name
}
// TYPE
const typeError = () => {
errors.type = form.type ? null : "Type is required"
return !!errors.type
}
// BASIC AUTH
const basicAuthErrors = () => {
errors.basic.username = form.basic.username
? null
: "Username is required"
errors.basic.password = form.basic.password
? null
: "Password is required"
return !!(errors.basic.username || errors.basic.password || commonError)
}
// BEARER TOKEN
const bearerTokenErrors = () => {
errors.bearer.token = form.bearer.token ? null : "Token is required"
return !!(errors.bearer.token || commonError)
}
const commonError = nameError() || typeError()
if (form.type === AUTH_TYPES.BASIC) {
hasErrors = basicAuthErrors() || commonError
} else if (form.type === AUTH_TYPES.BEARER) {
hasErrors = bearerTokenErrors() || commonError
} else {
hasErrors = !!commonError
}
}
const onFieldChange = () => {
checkErrors()
checkChanged()
}
const onConfirmInternal = () => {
onConfirm(constructConfig())
}
</script>
<ModalContent
title={currentConfig ? "Update Authentication" : "Add Authentication"}
onConfirm={onConfirmInternal}
confirmText={currentConfig ? "Update" : "Add"}
disabled={hasErrors || !hasChanged}
cancelText={"Cancel"}
size="M"
showSecondaryButton={!!currentConfig}
secondaryButtonText={"Remove"}
secondaryAction={onRemove}
secondaryButtonWarning={true}
>
<Layout gap="S">
<Body size="S">
The authorization header will be automatically generated when you send the
request.
</Body>
<Input
label="Name"
bind:value={form.name}
on:change={onFieldChange}
on:blur={() => (blurred.name = true)}
error={blurred.name ? errors.name : null}
/>
<Select
label="Type"
bind:value={form.type}
on:change={onFieldChange}
options={AUTH_TYPE_LABELS}
on:blur={() => (blurred.type = true)}
error={blurred.type ? errors.type : null}
/>
{#if form.type === AUTH_TYPES.BASIC}
<Input
label="Username"
bind:value={form.basic.username}
on:change={onFieldChange}
on:blur={() => (blurred.basic.username = true)}
error={blurred.basic.username ? errors.basic.username : null}
/>
<Input
label="Password"
bind:value={form.basic.password}
on:change={onFieldChange}
on:blur={() => (blurred.basic.password = true)}
error={blurred.basic.password ? errors.basic.password : null}
/>
{/if}
{#if form.type === AUTH_TYPES.BEARER}
<Input
label="Token"
bind:value={form.bearer.token}
on:change={onFieldChange}
on:blur={() => (blurred.bearer.token = true)}
error={blurred.bearer.token ? errors.bearer.token : null}
/>
{/if}
</Layout>
</ModalContent>
<style>
</style>

View File

@ -0,0 +1,15 @@
export const AUTH_TYPES = {
BASIC: "basic",
BEARER: "bearer",
}
export const AUTH_TYPE_LABELS = [
{
label: "Basic Auth",
value: AUTH_TYPES.BASIC,
},
{
label: "Bearer Token",
value: AUTH_TYPES.BEARER,
},
]

View File

@ -12,33 +12,29 @@
} from "@budibase/bbui"
import { datasources, integrations, queries, tables } from "stores/backend"
import IntegrationConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/IntegrationConfigForm.svelte"
import RestExtraConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/RestExtraConfigForm.svelte"
import RestExtraConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/rest/RestExtraConfigForm.svelte"
import PlusConfigForm from "components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte"
import ICONS from "components/backend/DatasourceNavigator/icons"
import VerbRenderer from "./_components/VerbRenderer.svelte"
import CapitaliseRenderer from "components/common/renderers/CapitaliseRenderer.svelte"
import { IntegrationTypes } from "constants/backend"
import { isEqual } from "lodash"
import { cloneDeep } from "lodash/fp"
import ImportRestQueriesModal from "components/backend/DatasourceNavigator/modals/ImportRestQueriesModal.svelte"
import { onMount } from "svelte"
let importQueriesModal
let baseDatasource, changed
let changed
let datasource
const querySchema = {
name: {},
queryVerb: { displayName: "Method" },
}
$: datasource = $datasources.list.find(ds => ds._id === $datasources.selected)
$: baseDatasource = $datasources.list.find(
ds => ds._id === $datasources.selected
)
$: integration = datasource && $integrations[datasource.source]
$: {
if (
datasource &&
(!baseDatasource || baseDatasource.source !== datasource.source)
) {
baseDatasource = cloneDeep(datasource)
}
}
$: queryList = $queries.list.filter(
query => query.datasourceId === datasource?._id
)
@ -69,6 +65,10 @@
queries.select(query)
$goto(`./${query._id}`)
}
onMount(() => {
datasource = cloneDeep(baseDatasource)
})
</script>
<Modal bind:this={importQueriesModal}>
@ -90,7 +90,7 @@
height="26"
width="26"
/>
<Heading size="M">{baseDatasource.name}</Heading>
<Heading size="M">{datasource.name}</Heading>
</header>
<Body size="M">{integration.description}</Body>
</Layout>
@ -135,7 +135,9 @@
allowEditColumns={false}
allowEditRows={false}
allowSelectRows={false}
customRenderers={[{ column: "queryVerb", component: VerbRenderer }]}
customRenderers={[
{ column: "queryVerb", component: CapitaliseRenderer },
]}
/>
</div>
{/if}

View File

@ -51,6 +51,7 @@
let saveId
let response, schema, enabledHeaders
let datasourceType, integrationInfo, queryConfig, responseSuccess
let authConfigId
$: datasourceType = datasource?.source
$: integrationInfo = $integrations[datasourceType]
@ -59,6 +60,7 @@
$: checkQueryName(url)
$: responseSuccess =
response?.info?.code >= 200 && response?.info?.code <= 206
$: authConfigs = buildAuthConfigs(datasource)
function getSelectedQuery() {
return cloneDeep(
@ -94,6 +96,16 @@
return qs.length > 0 ? `${newUrl}?${qs}` : newUrl
}
const buildAuthConfigs = datasource => {
if (datasource?.config?.authConfigs) {
return datasource.config.authConfigs.map(c => ({
label: c.name,
value: c._id,
}))
}
return []
}
function learnMoreBanner() {
window.open("https://docs.budibase.com/building-apps/data/transformers")
}
@ -103,6 +115,7 @@
const queryString = buildQueryString(breakQs)
newQuery.fields.path = url.split("?")[0]
newQuery.fields.queryString = queryString
newQuery.fields.authConfigId = authConfigId
newQuery.fields.disabledHeaders = flipHeaderState(enabledHeaders)
newQuery.schema = fieldsToSchema(schema)
newQuery.parameters = keyValueToQueryParameters(bindings)
@ -136,8 +149,26 @@
}
}
onMount(() => {
const getAuthConfigId = () => {
let id = query.fields.authConfigId
if (id) {
// find the matching config on the datasource
const matchedConfig = datasource?.config?.authConfigs?.filter(
c => c._id === id
)[0]
// clear the id if the config is not found (deleted)
// i.e. just show 'None' in the dropdown
if (!matchedConfig) {
id = undefined
}
}
return id
}
onMount(async () => {
query = getSelectedQuery()
// clear any unsaved changes to the datasource
await datasources.init()
datasource = $datasources.list.find(ds => ds._id === query?.datasourceId)
const datasourceUrl = datasource?.config.url
const qs = query?.fields.queryString
@ -149,6 +180,7 @@
url = buildUrl(query.fields.path, breakQs)
schema = schemaToFields(query.schema)
bindings = queryParametersToKeyValue(query.parameters)
authConfigId = getAuthConfigId()
if (!query.fields.disabledHeaders) {
query.fields.disabledHeaders = {}
}
@ -258,6 +290,19 @@
/>
</Layout>
</Tab>
<div class="auth-container">
<div />
<!-- spacer -->
<div class="auth-select">
<Select
label="Auth"
labelPosition="left"
placeholder="None"
bind:value={authConfigId}
options={authConfigs}
/>
</div>
</div>
</Tabs>
</Layout>
</div>
@ -404,4 +449,12 @@
margin-top: var(--spacing-xl);
justify-content: center;
}
.auth-container {
width: 100%;
display: flex;
justify-content: space-between;
}
.auth-select {
width: 200px;
}
</style>

View File

@ -3,7 +3,7 @@ import { queryValidation } from "../validation"
import { generateQueryID } from "../../../../db/utils"
import { ImportInfo, ImportSource } from "./sources/base"
import { OpenAPI2 } from "./sources/openapi2"
import { Query } from './../../../../definitions/common';
import { Query } from "./../../../../definitions/common"
import { Curl } from "./sources/curl"
interface ImportResult {
errorQueries: Query[]

View File

@ -196,6 +196,27 @@ export interface Datasource extends Base {
}
}
export enum AuthType {
BASIC = "basic",
BEARER = "bearer",
}
interface AuthConfig {
_id: string
name: string
type: AuthType
config: BasicAuthConfig | BearerAuthConfig
}
export interface BasicAuthConfig {
username: string
password: string
}
export interface BearerAuthConfig {
token: string
}
export interface QueryParameter {
name: string
default: string
@ -210,6 +231,7 @@ export interface RestQueryFields {
bodyType: string
json: object
method: string
authConfigId: string
}
export interface RestConfig {
@ -217,6 +239,7 @@ export interface RestConfig {
defaultHeaders: {
[key: string]: any
}
authConfigs: AuthConfig[]
}
export interface Query {

View File

@ -4,6 +4,9 @@ import {
QueryTypes,
RestConfig,
RestQueryFields as RestQuery,
AuthType,
BasicAuthConfig,
BearerAuthConfig
} from "../definitions/datasource"
import { IntegrationBase } from "./base/IntegrationBase"
@ -147,11 +150,43 @@ module RestModule {
return complete
}
getAuthHeaders(authConfigId: string): { [key: string]: any }{
let headers: any = {}
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 AuthType.BASIC:
config = authConfig.config as BasicAuthConfig
headers.Authorization = `Basic ${Buffer.from(
`${config.username}:${config.password}`
).toString("base64")}`
break
case AuthType.BEARER:
config = authConfig.config as BearerAuthConfig
headers.Authorization = `Bearer ${config.token}`
break
}
}
}
return headers
}
async _req(query: RestQuery) {
const { path = "", queryString = "", headers = {}, method = "GET", disabledHeaders, bodyType, requestBody } = query
const { path = "", queryString = "", headers = {}, method = "GET", disabledHeaders, bodyType, requestBody, authConfigId } = query
const authHeaders = this.getAuthHeaders(authConfigId)
this.headers = {
...this.config.defaultHeaders,
...headers,
...authHeaders,
}
if (disabledHeaders) {
@ -177,7 +212,8 @@ module RestModule {
}
this.startTimeMs = performance.now()
const response = await fetch(this.getUrl(path, queryString), input)
const url = this.getUrl(path, queryString)
const response = await fetch(url, input)
return await this.parseResponse(response)
}
@ -205,5 +241,6 @@ module RestModule {
module.exports = {
schema: SCHEMA,
integration: RestIntegration,
AuthType,
}
}

View File

@ -12,6 +12,7 @@ jest.mock("node-fetch", () =>
)
const fetch = require("node-fetch")
const RestIntegration = require("../rest")
const { AuthType } = require("../rest")
class TestConfiguration {
constructor(config = {}) {
@ -112,4 +113,58 @@ describe("REST Integration", () => {
body: '{"name":"test"}',
})
})
describe("authentication", () => {
const basicAuth = {
_id: "c59c14bd1898a43baa08da68959b24686",
name: "basic-1",
type : AuthType.BASIC,
config : {
username: "user",
password: "password"
}
}
const bearerAuth = {
_id: "0d91d732f34e4befabeff50b392a8ff3",
name: "bearer-1",
type : AuthType.BEARER,
config : {
"token": "mytoken"
}
}
beforeEach(() => {
config = new TestConfiguration({
url: BASE_URL,
authConfigs : [basicAuth, bearerAuth]
})
})
it("adds basic auth", async () => {
const query = {
authConfigId: "c59c14bd1898a43baa08da68959b24686"
}
await config.integration.read(query)
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/?`, {
method: "GET",
headers: {
Authorization: "Basic dXNlcjpwYXNzd29yZA=="
},
})
})
it("adds bearer auth", async () => {
const query = {
authConfigId: "0d91d732f34e4befabeff50b392a8ff3"
}
await config.integration.read(query)
expect(fetch).toHaveBeenCalledWith(`${BASE_URL}/?`, {
method: "GET",
headers: {
Authorization: "Bearer mytoken"
},
})
})
})
})