Merge pull request #15771 from Budibase/BUDI-9127/support-header-and-body-method
Support header and body method
This commit is contained in:
commit
9b2b438e72
|
@ -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)
|
||||
|
|
|
@ -10,8 +10,12 @@
|
|||
Link,
|
||||
ModalContent,
|
||||
notifications,
|
||||
Select,
|
||||
} from "@budibase/bbui"
|
||||
import { PASSWORD_REPLACEMENT } from "@budibase/types"
|
||||
import {
|
||||
OAuth2CredentialsMethod,
|
||||
PASSWORD_REPLACEMENT,
|
||||
} from "@budibase/types"
|
||||
import type { ZodType } from "zod"
|
||||
import { z } from "zod"
|
||||
|
||||
|
@ -27,6 +31,17 @@
|
|||
? "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)
|
||||
|
||||
|
@ -45,6 +60,9 @@
|
|||
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)
|
||||
|
@ -77,6 +95,7 @@
|
|||
url: configData.url,
|
||||
clientId: configData.clientId,
|
||||
clientSecret: configData.clientSecret,
|
||||
method: configData.method,
|
||||
})
|
||||
if (!connectionValidation.valid) {
|
||||
let message = "Connection settings could not be validated"
|
||||
|
@ -119,6 +138,21 @@
|
|||
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"
|
||||
|
|
|
@ -32,6 +32,7 @@ export class OAuth2Store extends BudiStore<OAuth2StoreState> {
|
|||
url: c.url,
|
||||
clientId: c.clientId,
|
||||
clientSecret: c.clientSecret,
|
||||
method: c.method,
|
||||
})),
|
||||
loading: false,
|
||||
}))
|
||||
|
|
|
@ -21,6 +21,7 @@ function toFetchOAuth2ConfigsResponse(
|
|||
url: config.url,
|
||||
clientId: config.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: config.method,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -42,6 +43,7 @@ export async function create(
|
|||
url: body.url,
|
||||
clientId: body.clientId,
|
||||
clientSecret: body.clientSecret,
|
||||
method: body.method,
|
||||
}
|
||||
|
||||
const config = await sdk.oauth2.create(newConfig)
|
||||
|
@ -61,6 +63,7 @@ export async function edit(
|
|||
url: body.url,
|
||||
clientId: body.clientId,
|
||||
clientSecret: body.clientSecret,
|
||||
method: body.method,
|
||||
}
|
||||
|
||||
const config = await sdk.oauth2.update(toUpdate)
|
||||
|
@ -86,6 +89,7 @@ export async function validate(
|
|||
url: body.url,
|
||||
clientId: body.clientId,
|
||||
clientSecret: body.clientSecret,
|
||||
method: body.method,
|
||||
}
|
||||
|
||||
if (config.clientSecret === PASSWORD_REPLACEMENT && body.id) {
|
||||
|
|
|
@ -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 }
|
||||
)
|
||||
|
@ -41,6 +58,7 @@ router.delete(
|
|||
router.post(
|
||||
"/api/oauth2/validate",
|
||||
authorized(PermissionType.BUILDER),
|
||||
oAuth2ConfigValidationValidator(),
|
||||
controller.validate
|
||||
)
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import {
|
||||
OAuth2Config,
|
||||
OAuth2CredentialsMethod,
|
||||
PASSWORD_REPLACEMENT,
|
||||
UpsertOAuth2ConfigRequest,
|
||||
VirtualDocumentType,
|
||||
|
@ -17,6 +18,7 @@ describe("/oauth2", () => {
|
|||
url: generator.url(),
|
||||
clientId: generator.guid(),
|
||||
clientSecret: generator.hash(),
|
||||
method: generator.pickone(Object.values(OAuth2CredentialsMethod)),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -54,6 +56,7 @@ describe("/oauth2", () => {
|
|||
url: c.url,
|
||||
clientId: c.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: c.method,
|
||||
}))
|
||||
),
|
||||
})
|
||||
|
@ -74,6 +77,7 @@ describe("/oauth2", () => {
|
|||
url: oauth2Config.url,
|
||||
clientId: oauth2Config.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: oauth2Config.method,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
@ -93,6 +97,7 @@ describe("/oauth2", () => {
|
|||
url: oauth2Config.url,
|
||||
clientId: oauth2Config.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: oauth2Config.method,
|
||||
},
|
||||
{
|
||||
id: expectOAuth2ConfigId,
|
||||
|
@ -100,6 +105,7 @@ describe("/oauth2", () => {
|
|||
url: oauth2Config2.url,
|
||||
clientId: oauth2Config2.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: oauth2Config2.method,
|
||||
},
|
||||
])
|
||||
expect(response.configs[0].id).not.toEqual(response.configs[1].id)
|
||||
|
@ -125,6 +131,7 @@ describe("/oauth2", () => {
|
|||
url: oauth2Config.url,
|
||||
clientId: oauth2Config.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: oauth2Config.method,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
@ -161,6 +168,7 @@ describe("/oauth2", () => {
|
|||
url: configData.url,
|
||||
clientId: configData.clientId,
|
||||
clientSecret: PASSWORD_REPLACEMENT,
|
||||
method: configData.method,
|
||||
},
|
||||
])
|
||||
)
|
||||
|
|
|
@ -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", {
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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) {
|
||||
|
@ -36,20 +63,10 @@ export async function validateConfig(config: {
|
|||
url: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
method: OAuth2CredentialsMethod
|
||||
}): Promise<{ valid: boolean; message?: string }> {
|
||||
try {
|
||||
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) {
|
||||
|
|
|
@ -1,9 +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 {
|
||||
|
@ -15,6 +18,7 @@ export interface UpsertOAuth2ConfigRequest {
|
|||
url: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
method: OAuth2CredentialsMethod
|
||||
}
|
||||
|
||||
export interface UpsertOAuth2ConfigResponse {
|
||||
|
@ -26,6 +30,7 @@ export interface ValidateConfigRequest {
|
|||
url: string
|
||||
clientId: string
|
||||
clientSecret: string
|
||||
method: OAuth2CredentialsMethod
|
||||
}
|
||||
|
||||
export interface ValidateConfigResponse {
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue