diff --git a/packages/builder/src/pages/builder/app/[application]/settings/oauth2/OAuth2ConfigModalContent.svelte b/packages/builder/src/pages/builder/app/[application]/settings/oauth2/OAuth2ConfigModalContent.svelte index 21df65be12..f215b58869 100644 --- a/packages/builder/src/pages/builder/app/[application]/settings/oauth2/OAuth2ConfigModalContent.svelte +++ b/packages/builder/src/pages/builder/app/[application]/settings/oauth2/OAuth2ConfigModalContent.svelte @@ -15,6 +15,7 @@ import type { InsertOAuth2ConfigRequest } from "@budibase/types" import { OAuth2CredentialsMethod, + OAuth2GrantType, PASSWORD_REPLACEMENT, } from "@budibase/types" import type { ZodType } from "zod" @@ -27,6 +28,8 @@ $: data = (config as Partial) ?? {} + $: data.grantType ??= OAuth2GrantType.CLIENT_CREDENTIALS + $: isCreation = !config $: title = isCreation ? "Create new OAuth2 connection" @@ -64,6 +67,9 @@ method: z.nativeEnum(OAuth2CredentialsMethod, { message: "Authentication method is required.", }), + grantType: z.nativeEnum(OAuth2GrantType, { + message: "Grant type is required.", + }), }) satisfies ZodType const validationResult = validator.safeParse(config) @@ -97,6 +103,7 @@ clientId: configData.clientId, clientSecret: configData.clientSecret, method: configData.method, + grantType: configData.grantType, }) if (!connectionValidation.valid) { let message = "Connection settings could not be validated" @@ -158,6 +165,24 @@ access_token property. + + { clientId: c.clientId, clientSecret: c.clientSecret, method: c.method, + grantType: c.grantType, lastUsage: c.lastUsage, })), loading: false, diff --git a/packages/server/src/api/controllers/oauth2.ts b/packages/server/src/api/controllers/oauth2.ts index 7d44d7bed6..7c4a145655 100644 --- a/packages/server/src/api/controllers/oauth2.ts +++ b/packages/server/src/api/controllers/oauth2.ts @@ -24,6 +24,7 @@ function toFetchOAuth2ConfigsResponse( clientId: config.clientId, clientSecret: PASSWORD_REPLACEMENT, method: config.method, + grantType: config.grantType, } } @@ -54,6 +55,7 @@ export async function create( clientId: body.clientId, clientSecret: body.clientSecret, method: body.method, + grantType: body.grantType, } const config = await sdk.oauth2.create(newConfig) @@ -80,6 +82,7 @@ export async function edit( clientId: body.clientId, clientSecret: body.clientSecret, method: body.method, + grantType: body.grantType, } const config = await sdk.oauth2.update(toUpdate) @@ -104,6 +107,7 @@ export async function validate( clientId: body.clientId, clientSecret: body.clientSecret, method: body.method, + grantType: body.grantType, } if (config.clientSecret === PASSWORD_REPLACEMENT && body._id) { diff --git a/packages/server/src/api/routes/oauth2.ts b/packages/server/src/api/routes/oauth2.ts index 64b4f66248..fd40c1a5f9 100644 --- a/packages/server/src/api/routes/oauth2.ts +++ b/packages/server/src/api/routes/oauth2.ts @@ -1,5 +1,9 @@ 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 authorized from "../../middleware/authorized" @@ -13,6 +17,9 @@ const baseSchema = { method: Joi.string() .required() .valid(...Object.values(OAuth2CredentialsMethod)), + grantType: Joi.string() + .required() + .valid(...Object.values(OAuth2GrantType)), } const insertSchema = Joi.object({ diff --git a/packages/server/src/api/routes/tests/oauth2.spec.ts b/packages/server/src/api/routes/tests/oauth2.spec.ts index 7acd0cbdb2..ee01acec1d 100644 --- a/packages/server/src/api/routes/tests/oauth2.spec.ts +++ b/packages/server/src/api/routes/tests/oauth2.spec.ts @@ -3,6 +3,7 @@ import { InsertOAuth2ConfigRequest, OAuth2ConfigResponse, OAuth2CredentialsMethod, + OAuth2GrantType, PASSWORD_REPLACEMENT, } from "@budibase/types" import * as setup from "./utilities" @@ -19,6 +20,7 @@ describe("/oauth2", () => { clientId: generator.guid(), clientSecret: generator.hash(), method: generator.pickone(Object.values(OAuth2CredentialsMethod)), + grantType: generator.pickone(Object.values(OAuth2GrantType)), } } @@ -58,6 +60,7 @@ describe("/oauth2", () => { clientId: c.clientId, clientSecret: PASSWORD_REPLACEMENT, method: c.method, + grantType: c.grantType, })) ), }) @@ -80,6 +83,7 @@ describe("/oauth2", () => { clientId: oauth2Config.clientId, clientSecret: PASSWORD_REPLACEMENT, method: oauth2Config.method, + grantType: oauth2Config.grantType, }, ], }) @@ -102,6 +106,7 @@ describe("/oauth2", () => { clientId: oauth2Config.clientId, clientSecret: PASSWORD_REPLACEMENT, method: oauth2Config.method, + grantType: oauth2Config.grantType, }, { _id: expectOAuth2ConfigId, @@ -111,6 +116,7 @@ describe("/oauth2", () => { clientId: oauth2Config2.clientId, clientSecret: PASSWORD_REPLACEMENT, method: oauth2Config2.method, + grantType: oauth2Config2.grantType, }, ]) ) @@ -139,6 +145,7 @@ describe("/oauth2", () => { clientId: oauth2Config.clientId, clientSecret: PASSWORD_REPLACEMENT, method: oauth2Config.method, + grantType: oauth2Config.grantType, }, ]) }) @@ -177,6 +184,7 @@ describe("/oauth2", () => { clientId: configData.clientId, clientSecret: PASSWORD_REPLACEMENT, method: configData.method, + grantType: configData.grantType, }, ]) ) diff --git a/packages/server/src/integrations/tests/rest.spec.ts b/packages/server/src/integrations/tests/rest.spec.ts index 95f4179bc1..71ff711352 100644 --- a/packages/server/src/integrations/tests/rest.spec.ts +++ b/packages/server/src/integrations/tests/rest.spec.ts @@ -6,6 +6,7 @@ import { BearerRestAuthConfig, BodyType, OAuth2CredentialsMethod, + OAuth2GrantType, RestAuthType, } from "@budibase/types" import { Response } from "node-fetch" @@ -286,6 +287,7 @@ describe("REST Integration", () => { clientId: generator.guid(), clientSecret: secret, method: OAuth2CredentialsMethod.HEADER, + grantType: OAuth2GrantType.CLIENT_CREDENTIALS, }) const token = generator.guid() @@ -323,6 +325,7 @@ describe("REST Integration", () => { clientId: generator.guid(), clientSecret: secret, method: OAuth2CredentialsMethod.BODY, + grantType: OAuth2GrantType.CLIENT_CREDENTIALS, }) const token = generator.guid() diff --git a/packages/server/src/sdk/app/oauth2/tests/utils.spec.ts b/packages/server/src/sdk/app/oauth2/tests/utils.spec.ts index b968f3138e..13d8b7e980 100644 --- a/packages/server/src/sdk/app/oauth2/tests/utils.spec.ts +++ b/packages/server/src/sdk/app/oauth2/tests/utils.spec.ts @@ -6,7 +6,7 @@ import { getToken } 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" +import { OAuth2CredentialsMethod, OAuth2GrantType } from "@budibase/types" import { cache } from "@budibase/backend-core" import tk from "timekeeper" @@ -44,169 +44,175 @@ describe("oauth2 utils", () => { keycloakUrl = `http://127.0.0.1:${port}` }) - describe.each(Object.values(OAuth2CredentialsMethod))( - "getToken (in %s)", - method => { - it("successfully generates tokens", async () => { - const response = await config.doInContext(config.appId, async () => { + describe.each( + Object.values(OAuth2CredentialsMethod).flatMap(method => + Object.values(OAuth2GrantType).map< + [OAuth2CredentialsMethod, OAuth2GrantType] + >(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({ + 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(), 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({ - 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 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, - }) - - await getToken(oauthConfig._id) - }) - ).rejects.toThrow( - "Error fetching oauth2 token: Invalid client or Invalid client credentials" + await config.doInContext(config.appId, () => getToken(oauthConfig._id)) + await testUtils.queue.processMessages( + cache.docWritethrough.DocWritethroughProcessor.queue ) + + const usageLog = await config.doInContext(config.appId, () => + sdk.oauth2.getLastUsages([oauthConfig._id]) + ) + + expect(usageLog[oauthConfig._id]).toEqual(Date.now()) }) - describe("track usages", () => { - beforeAll(() => { - tk.freeze(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, + grantType, + }) + ) - it("tracks usages on generation", 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 expect( + config.doInContext(config.appId, () => getToken(oauthConfig._id)) + ).rejects.toThrow() + await testUtils.queue.processMessages( + cache.docWritethrough.DocWritethroughProcessor.queue + ) - await config.doInContext(config.appId, () => - getToken(oauthConfig._id) - ) - await testUtils.queue.processMessages( - cache.docWritethrough.DocWritethroughProcessor.queue - ) + const usageLog = await config.doInContext(config.appId, () => + sdk.oauth2.getLastUsages([oauthConfig._id]) + ) - 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]) ) - expect(usageLog[oauthConfig._id]).toEqual(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(), - }) - } - }) + expect(usageLog).toEqual({ + [oauthConfig._id]: Date.now(), + }) + } }) - } - ) + }) + }) }) diff --git a/packages/server/src/sdk/app/oauth2/utils.ts b/packages/server/src/sdk/app/oauth2/utils.ts index 349c62f9e7..993fbe83b1 100644 --- a/packages/server/src/sdk/app/oauth2/utils.ts +++ b/packages/server/src/sdk/app/oauth2/utils.ts @@ -1,7 +1,11 @@ import fetch, { RequestInit } from "node-fetch" import { HttpError } from "koa" 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" interface OAuth2LogDocument extends Document { @@ -15,6 +19,7 @@ async function fetchToken(config: { clientId: string clientSecret: string method: OAuth2CredentialsMethod + grantType: OAuth2GrantType }) { const fetchConfig: RequestInit = { method: "POST", @@ -37,7 +42,7 @@ async function fetchToken(config: { } } else { fetchConfig.body = new URLSearchParams({ - grant_type: "client_credentials", + grant_type: config.grantType, client_id: config.clientId, client_secret: config.clientSecret, }) @@ -82,6 +87,7 @@ export async function validateConfig(config: { clientId: string clientSecret: string method: OAuth2CredentialsMethod + grantType: OAuth2GrantType }): Promise<{ valid: boolean; message?: string }> { try { const resp = await fetchToken(config) diff --git a/packages/types/src/api/web/app/oauth2.ts b/packages/types/src/api/web/app/oauth2.ts index 13e8cf3adc..966f29035a 100644 --- a/packages/types/src/api/web/app/oauth2.ts +++ b/packages/types/src/api/web/app/oauth2.ts @@ -1,4 +1,4 @@ -import { OAuth2CredentialsMethod } from "@budibase/types" +import { OAuth2CredentialsMethod, OAuth2GrantType } from "@budibase/types" export interface OAuth2ConfigResponse { _id: string @@ -8,6 +8,7 @@ export interface OAuth2ConfigResponse { clientId: string clientSecret: string method: OAuth2CredentialsMethod + grantType: OAuth2GrantType } export interface FetchOAuth2ConfigsResponse { @@ -20,6 +21,7 @@ export interface InsertOAuth2ConfigRequest { clientId: string clientSecret: string method: OAuth2CredentialsMethod + grantType: OAuth2GrantType } export interface InsertOAuth2ConfigResponse { @@ -34,6 +36,7 @@ export interface UpdateOAuth2ConfigRequest { clientId: string clientSecret: string method: OAuth2CredentialsMethod + grantType: OAuth2GrantType } export interface UpdateOAuth2ConfigResponse { @@ -46,6 +49,7 @@ export interface ValidateConfigRequest { clientId: string clientSecret: string method: OAuth2CredentialsMethod + grantType: OAuth2GrantType } export interface ValidateConfigResponse { diff --git a/packages/types/src/documents/app/oauth2.ts b/packages/types/src/documents/app/oauth2.ts index d2ad895529..791fcf2ce2 100644 --- a/packages/types/src/documents/app/oauth2.ts +++ b/packages/types/src/documents/app/oauth2.ts @@ -5,10 +5,15 @@ export enum OAuth2CredentialsMethod { BODY = "BODY", } +export enum OAuth2GrantType { + CLIENT_CREDENTIALS = "client_credentials", +} + export interface OAuth2Config extends Document { name: string url: string clientId: string clientSecret: string method: OAuth2CredentialsMethod + grantType: OAuth2GrantType }