Merge pull request #15771 from Budibase/BUDI-9127/support-header-and-body-method

Support header and body method
This commit is contained in:
Adria Navarro 2025-03-19 19:00:22 +01:00 committed by GitHub
commit 9b2b438e72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 242 additions and 96 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

@ -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"

View File

@ -32,6 +32,7 @@ export class OAuth2Store extends BudiStore<OAuth2StoreState> {
url: c.url,
clientId: c.clientId,
clientSecret: c.clientSecret,
method: c.method,
})),
loading: false,
}))

View File

@ -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) {

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 }
)
@ -41,6 +58,7 @@ router.delete(
router.post(
"/api/oauth2/validate",
authorized(PermissionType.BUILDER),
oAuth2ConfigValidationValidator(),
controller.validate
)

View File

@ -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,
},
])
)

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

@ -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) {
@ -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) {

View File

@ -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 {

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 {