Merge pull request #15793 from Budibase/BUDI-9127/display-grant-type

Display grant type field
This commit is contained in:
Adria Navarro 2025-03-24 15:00:22 +01:00 committed by GitHub
commit 234d23d4f4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 220 additions and 151 deletions

View File

@ -15,6 +15,7 @@
import type { InsertOAuth2ConfigRequest } from "@budibase/types" import type { InsertOAuth2ConfigRequest } from "@budibase/types"
import { import {
OAuth2CredentialsMethod, OAuth2CredentialsMethod,
OAuth2GrantType,
PASSWORD_REPLACEMENT, PASSWORD_REPLACEMENT,
} from "@budibase/types" } from "@budibase/types"
import type { ZodType } from "zod" import type { ZodType } from "zod"
@ -27,6 +28,8 @@
$: data = (config as Partial<OAuth2Config>) ?? {} $: data = (config as Partial<OAuth2Config>) ?? {}
$: data.grantType ??= OAuth2GrantType.CLIENT_CREDENTIALS
$: isCreation = !config $: isCreation = !config
$: title = isCreation $: title = isCreation
? "Create new OAuth2 connection" ? "Create new OAuth2 connection"
@ -64,6 +67,9 @@
method: z.nativeEnum(OAuth2CredentialsMethod, { method: z.nativeEnum(OAuth2CredentialsMethod, {
message: "Authentication method is required.", message: "Authentication method is required.",
}), }),
grantType: z.nativeEnum(OAuth2GrantType, {
message: "Grant type is required.",
}),
}) satisfies ZodType<InsertOAuth2ConfigRequest> }) satisfies ZodType<InsertOAuth2ConfigRequest>
const validationResult = validator.safeParse(config) const validationResult = validator.safeParse(config)
@ -97,6 +103,7 @@
clientId: configData.clientId, clientId: configData.clientId,
clientSecret: configData.clientSecret, clientSecret: configData.clientSecret,
method: configData.method, method: configData.method,
grantType: configData.grantType,
}) })
if (!connectionValidation.valid) { if (!connectionValidation.valid) {
let message = "Connection settings could not be validated" let message = "Connection settings could not be validated"
@ -158,6 +165,24 @@
access_token property. access_token property.
</Body> </Body>
</div> </div>
<Select
label="Grant type*"
options={[
{
label: "Client credentials",
value: OAuth2GrantType.CLIENT_CREDENTIALS,
},
]}
bind:value={data.grantType}
error={errors.grantType}
disabled
/>
<div class="field-info">
<Body size="XS" color="var(--spectrum-global-color-gray-700)">
Only client credentials mode is supported currently.
</Body>
</div>
<Input <Input
label="Service URL*" label="Service URL*"
placeholder="E.g. www.google.com" placeholder="E.g. www.google.com"

View File

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

View File

@ -24,6 +24,7 @@ function toFetchOAuth2ConfigsResponse(
clientId: config.clientId, clientId: config.clientId,
clientSecret: PASSWORD_REPLACEMENT, clientSecret: PASSWORD_REPLACEMENT,
method: config.method, method: config.method,
grantType: config.grantType,
} }
} }
@ -54,6 +55,7 @@ export async function create(
clientId: body.clientId, clientId: body.clientId,
clientSecret: body.clientSecret, clientSecret: body.clientSecret,
method: body.method, method: body.method,
grantType: body.grantType,
} }
const config = await sdk.oauth2.create(newConfig) const config = await sdk.oauth2.create(newConfig)
@ -80,6 +82,7 @@ export async function edit(
clientId: body.clientId, clientId: body.clientId,
clientSecret: body.clientSecret, clientSecret: body.clientSecret,
method: body.method, method: body.method,
grantType: body.grantType,
} }
const config = await sdk.oauth2.update(toUpdate) const config = await sdk.oauth2.update(toUpdate)
@ -104,6 +107,7 @@ export async function validate(
clientId: body.clientId, clientId: body.clientId,
clientSecret: body.clientSecret, clientSecret: body.clientSecret,
method: body.method, method: body.method,
grantType: body.grantType,
} }
if (config.clientSecret === PASSWORD_REPLACEMENT && body._id) { if (config.clientSecret === PASSWORD_REPLACEMENT && body._id) {

View File

@ -1,5 +1,9 @@
import Router from "@koa/router" import Router from "@koa/router"
import { OAuth2CredentialsMethod, PermissionType } from "@budibase/types" import {
OAuth2CredentialsMethod,
OAuth2GrantType,
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"
@ -13,6 +17,9 @@ const baseSchema = {
method: Joi.string() method: Joi.string()
.required() .required()
.valid(...Object.values(OAuth2CredentialsMethod)), .valid(...Object.values(OAuth2CredentialsMethod)),
grantType: Joi.string()
.required()
.valid(...Object.values(OAuth2GrantType)),
} }
const insertSchema = Joi.object({ const insertSchema = Joi.object({

View File

@ -3,6 +3,7 @@ import {
InsertOAuth2ConfigRequest, InsertOAuth2ConfigRequest,
OAuth2ConfigResponse, OAuth2ConfigResponse,
OAuth2CredentialsMethod, OAuth2CredentialsMethod,
OAuth2GrantType,
PASSWORD_REPLACEMENT, PASSWORD_REPLACEMENT,
} from "@budibase/types" } from "@budibase/types"
import * as setup from "./utilities" import * as setup from "./utilities"
@ -19,6 +20,7 @@ describe("/oauth2", () => {
clientId: generator.guid(), clientId: generator.guid(),
clientSecret: generator.hash(), clientSecret: generator.hash(),
method: generator.pickone(Object.values(OAuth2CredentialsMethod)), method: generator.pickone(Object.values(OAuth2CredentialsMethod)),
grantType: generator.pickone(Object.values(OAuth2GrantType)),
} }
} }
@ -58,6 +60,7 @@ describe("/oauth2", () => {
clientId: c.clientId, clientId: c.clientId,
clientSecret: PASSWORD_REPLACEMENT, clientSecret: PASSWORD_REPLACEMENT,
method: c.method, method: c.method,
grantType: c.grantType,
})) }))
), ),
}) })
@ -80,6 +83,7 @@ describe("/oauth2", () => {
clientId: oauth2Config.clientId, clientId: oauth2Config.clientId,
clientSecret: PASSWORD_REPLACEMENT, clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config.method, method: oauth2Config.method,
grantType: oauth2Config.grantType,
}, },
], ],
}) })
@ -102,6 +106,7 @@ describe("/oauth2", () => {
clientId: oauth2Config.clientId, clientId: oauth2Config.clientId,
clientSecret: PASSWORD_REPLACEMENT, clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config.method, method: oauth2Config.method,
grantType: oauth2Config.grantType,
}, },
{ {
_id: expectOAuth2ConfigId, _id: expectOAuth2ConfigId,
@ -111,6 +116,7 @@ describe("/oauth2", () => {
clientId: oauth2Config2.clientId, clientId: oauth2Config2.clientId,
clientSecret: PASSWORD_REPLACEMENT, clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config2.method, method: oauth2Config2.method,
grantType: oauth2Config2.grantType,
}, },
]) ])
) )
@ -139,6 +145,7 @@ describe("/oauth2", () => {
clientId: oauth2Config.clientId, clientId: oauth2Config.clientId,
clientSecret: PASSWORD_REPLACEMENT, clientSecret: PASSWORD_REPLACEMENT,
method: oauth2Config.method, method: oauth2Config.method,
grantType: oauth2Config.grantType,
}, },
]) ])
}) })
@ -177,6 +184,7 @@ describe("/oauth2", () => {
clientId: configData.clientId, clientId: configData.clientId,
clientSecret: PASSWORD_REPLACEMENT, clientSecret: PASSWORD_REPLACEMENT,
method: configData.method, method: configData.method,
grantType: configData.grantType,
}, },
]) ])
) )

View File

@ -6,6 +6,7 @@ import {
BearerRestAuthConfig, BearerRestAuthConfig,
BodyType, BodyType,
OAuth2CredentialsMethod, OAuth2CredentialsMethod,
OAuth2GrantType,
RestAuthType, RestAuthType,
} from "@budibase/types" } from "@budibase/types"
import { Response } from "node-fetch" import { Response } from "node-fetch"
@ -286,6 +287,7 @@ describe("REST Integration", () => {
clientId: generator.guid(), clientId: generator.guid(),
clientSecret: secret, clientSecret: secret,
method: OAuth2CredentialsMethod.HEADER, method: OAuth2CredentialsMethod.HEADER,
grantType: OAuth2GrantType.CLIENT_CREDENTIALS,
}) })
const token = generator.guid() const token = generator.guid()
@ -323,6 +325,7 @@ describe("REST Integration", () => {
clientId: generator.guid(), clientId: generator.guid(),
clientSecret: secret, clientSecret: secret,
method: OAuth2CredentialsMethod.BODY, method: OAuth2CredentialsMethod.BODY,
grantType: OAuth2GrantType.CLIENT_CREDENTIALS,
}) })
const token = generator.guid() const token = generator.guid()

View File

@ -6,7 +6,7 @@ import { getToken } 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" import { OAuth2CredentialsMethod, OAuth2GrantType } from "@budibase/types"
import { cache } from "@budibase/backend-core" import { cache } from "@budibase/backend-core"
import tk from "timekeeper" import tk from "timekeeper"
@ -44,169 +44,175 @@ describe("oauth2 utils", () => {
keycloakUrl = `http://127.0.0.1:${port}` keycloakUrl = `http://127.0.0.1:${port}`
}) })
describe.each(Object.values(OAuth2CredentialsMethod))( describe.each(
"getToken (in %s)", Object.values(OAuth2CredentialsMethod).flatMap(method =>
method => { Object.values(OAuth2GrantType).map<
it("successfully generates tokens", async () => { [OAuth2CredentialsMethod, OAuth2GrantType]
const response = await config.doInContext(config.appId, async () => { >(grantType => [method, grantType])
)
)("generateToken (in %s, grant type %s)", (method, grantType) => {
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,
grantType,
})
const response = await getToken(oauthConfig._id)
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({ const oauthConfig = await sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/wrong/protocol/openid-connect/token`,
clientId: "my-client",
clientSecret: "my-secret",
method,
grantType,
})
await getToken(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,
grantType,
})
await getToken(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,
grantType,
})
await getToken(oauthConfig._id)
})
).rejects.toThrow(
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
)
})
describe("track usages", () => {
beforeAll(() => {
tk.freeze(Date.now())
})
it("tracks usages on generation", async () => {
const oauthConfig = await config.doInContext(config.appId, () =>
sdk.oauth2.create({
name: generator.guid(), name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`, url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "my-client", clientId: "my-client",
clientSecret: "my-secret", clientSecret: "my-secret",
method, method,
grantType,
}) })
const response = await getToken(oauthConfig._id)
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,
})
await getToken(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,
})
await getToken(oauthConfig._id)
})
).rejects.toThrow(
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
) )
})
it("handles wrong secrets", async () => { await config.doInContext(config.appId, () => getToken(oauthConfig._id))
await expect( await testUtils.queue.processMessages(
config.doInContext(config.appId, async () => { cache.docWritethrough.DocWritethroughProcessor.queue
const oauthConfig = await sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "my-client",
clientSecret: "wrong-secret",
method,
})
await getToken(oauthConfig._id)
})
).rejects.toThrow(
"Error fetching oauth2 token: Invalid client or Invalid client credentials"
) )
const usageLog = await config.doInContext(config.appId, () =>
sdk.oauth2.getLastUsages([oauthConfig._id])
)
expect(usageLog[oauthConfig._id]).toEqual(Date.now())
}) })
describe("track usages", () => { it("does not track on failed usages", async () => {
beforeAll(() => { const oauthConfig = await config.doInContext(config.appId, () =>
tk.freeze(Date.now()) sdk.oauth2.create({
}) name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "wrong-client",
clientSecret: "my-secret",
method,
grantType,
})
)
it("tracks usages on generation", async () => { await expect(
const oauthConfig = await config.doInContext(config.appId, () => config.doInContext(config.appId, () => getToken(oauthConfig._id))
sdk.oauth2.create({ ).rejects.toThrow()
name: generator.guid(), await testUtils.queue.processMessages(
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`, cache.docWritethrough.DocWritethroughProcessor.queue
clientId: "my-client", )
clientSecret: "my-secret",
method,
})
)
await config.doInContext(config.appId, () => const usageLog = await config.doInContext(config.appId, () =>
getToken(oauthConfig._id) sdk.oauth2.getLastUsages([oauthConfig._id])
) )
await testUtils.queue.processMessages(
cache.docWritethrough.DocWritethroughProcessor.queue
)
const usageLog = await config.doInContext(config.appId, () => expect(usageLog[oauthConfig._id]).toBeUndefined()
})
it("tracks usages between prod and dev, keeping always the latest", async () => {
const oauthConfig = await config.doInContext(config.appId, () =>
sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "my-client",
clientSecret: "my-secret",
method,
grantType,
})
)
await config.doInContext(config.appId, () => getToken(oauthConfig._id))
await config.publish()
tk.travel(Date.now() + 100)
await config.doInContext(config.prodAppId, () =>
getToken(oauthConfig._id)
)
await testUtils.queue.processMessages(
cache.docWritethrough.DocWritethroughProcessor.queue
)
for (const appId of [config.appId, config.prodAppId]) {
const usageLog = await config.doInContext(appId, () =>
sdk.oauth2.getLastUsages([oauthConfig._id]) sdk.oauth2.getLastUsages([oauthConfig._id])
) )
expect(usageLog[oauthConfig._id]).toEqual(Date.now()) expect(usageLog).toEqual({
}) [oauthConfig._id]: Date.now(),
})
it("does not track on failed usages", async () => { }
const oauthConfig = await config.doInContext(config.appId, () =>
sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "wrong-client",
clientSecret: "my-secret",
method,
})
)
await expect(
config.doInContext(config.appId, () => getToken(oauthConfig._id))
).rejects.toThrow()
await testUtils.queue.processMessages(
cache.docWritethrough.DocWritethroughProcessor.queue
)
const usageLog = await config.doInContext(config.appId, () =>
sdk.oauth2.getLastUsages([oauthConfig._id])
)
expect(usageLog[oauthConfig._id]).toBeUndefined()
})
it("tracks usages between prod and dev, keeping always the latest", async () => {
const oauthConfig = await config.doInContext(config.appId, () =>
sdk.oauth2.create({
name: generator.guid(),
url: `${keycloakUrl}/realms/myrealm/protocol/openid-connect/token`,
clientId: "my-client",
clientSecret: "my-secret",
method,
})
)
await config.doInContext(config.appId, () =>
getToken(oauthConfig._id)
)
await config.publish()
tk.travel(Date.now() + 100)
await config.doInContext(config.prodAppId, () =>
getToken(oauthConfig._id)
)
await testUtils.queue.processMessages(
cache.docWritethrough.DocWritethroughProcessor.queue
)
for (const appId of [config.appId, config.prodAppId]) {
const usageLog = await config.doInContext(appId, () =>
sdk.oauth2.getLastUsages([oauthConfig._id])
)
expect(usageLog).toEqual({
[oauthConfig._id]: Date.now(),
})
}
})
}) })
} })
) })
}) })

View File

@ -1,7 +1,11 @@
import fetch, { RequestInit } 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 { Document, OAuth2CredentialsMethod } from "@budibase/types" import {
Document,
OAuth2CredentialsMethod,
OAuth2GrantType,
} from "@budibase/types"
import { cache, context, docIds } from "@budibase/backend-core" import { cache, context, docIds } from "@budibase/backend-core"
interface OAuth2LogDocument extends Document { interface OAuth2LogDocument extends Document {
@ -15,6 +19,7 @@ async function fetchToken(config: {
clientId: string clientId: string
clientSecret: string clientSecret: string
method: OAuth2CredentialsMethod method: OAuth2CredentialsMethod
grantType: OAuth2GrantType
}) { }) {
const fetchConfig: RequestInit = { const fetchConfig: RequestInit = {
method: "POST", method: "POST",
@ -37,7 +42,7 @@ async function fetchToken(config: {
} }
} else { } else {
fetchConfig.body = new URLSearchParams({ fetchConfig.body = new URLSearchParams({
grant_type: "client_credentials", grant_type: config.grantType,
client_id: config.clientId, client_id: config.clientId,
client_secret: config.clientSecret, client_secret: config.clientSecret,
}) })
@ -82,6 +87,7 @@ export async function validateConfig(config: {
clientId: string clientId: string
clientSecret: string clientSecret: string
method: OAuth2CredentialsMethod method: OAuth2CredentialsMethod
grantType: OAuth2GrantType
}): Promise<{ valid: boolean; message?: string }> { }): Promise<{ valid: boolean; message?: string }> {
try { try {
const resp = await fetchToken(config) const resp = await fetchToken(config)

View File

@ -1,4 +1,4 @@
import { OAuth2CredentialsMethod } from "@budibase/types" import { OAuth2CredentialsMethod, OAuth2GrantType } from "@budibase/types"
export interface OAuth2ConfigResponse { export interface OAuth2ConfigResponse {
_id: string _id: string
@ -8,6 +8,7 @@ export interface OAuth2ConfigResponse {
clientId: string clientId: string
clientSecret: string clientSecret: string
method: OAuth2CredentialsMethod method: OAuth2CredentialsMethod
grantType: OAuth2GrantType
} }
export interface FetchOAuth2ConfigsResponse { export interface FetchOAuth2ConfigsResponse {
@ -20,6 +21,7 @@ export interface InsertOAuth2ConfigRequest {
clientId: string clientId: string
clientSecret: string clientSecret: string
method: OAuth2CredentialsMethod method: OAuth2CredentialsMethod
grantType: OAuth2GrantType
} }
export interface InsertOAuth2ConfigResponse { export interface InsertOAuth2ConfigResponse {
@ -34,6 +36,7 @@ export interface UpdateOAuth2ConfigRequest {
clientId: string clientId: string
clientSecret: string clientSecret: string
method: OAuth2CredentialsMethod method: OAuth2CredentialsMethod
grantType: OAuth2GrantType
} }
export interface UpdateOAuth2ConfigResponse { export interface UpdateOAuth2ConfigResponse {
@ -46,6 +49,7 @@ export interface ValidateConfigRequest {
clientId: string clientId: string
clientSecret: string clientSecret: string
method: OAuth2CredentialsMethod method: OAuth2CredentialsMethod
grantType: OAuth2GrantType
} }
export interface ValidateConfigResponse { export interface ValidateConfigResponse {

View File

@ -5,10 +5,15 @@ export enum OAuth2CredentialsMethod {
BODY = "BODY", BODY = "BODY",
} }
export enum OAuth2GrantType {
CLIENT_CREDENTIALS = "client_credentials",
}
export interface OAuth2Config extends Document { export interface OAuth2Config extends Document {
name: string name: string
url: string url: string
clientId: string clientId: string
clientSecret: string clientSecret: string
method: OAuth2CredentialsMethod method: OAuth2CredentialsMethod
grantType: OAuth2GrantType
} }