@budibase/worker 3.13.28 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@budibase/worker",
3
3
  "email": "hi@budibase.com",
4
- "version": "3.13.28",
4
+ "version": "3.14.0",
5
5
  "description": "Budibase background service",
6
6
  "main": "src/index.ts",
7
7
  "repository": {
@@ -93,6 +93,7 @@
93
93
  "rimraf": "3.0.2",
94
94
  "superagent": "^10.1.1",
95
95
  "supertest": "6.3.3",
96
+ "testcontainers": "10.16.0",
96
97
  "timekeeper": "2.2.0",
97
98
  "typescript": "5.7.2"
98
99
  },
@@ -114,5 +115,5 @@
114
115
  }
115
116
  }
116
117
  },
117
- "gitHead": "de8a462d540b26a2040f4c8059d0a2395234c628"
118
+ "gitHead": "7ff8f5fc9480931c373b86dd573882841dd290d4"
118
119
  }
@@ -236,6 +236,11 @@ async function processGoogleConfig(
236
236
  async function processOIDCConfig(config: OIDCConfigs, existing?: OIDCConfigs) {
237
237
  await verifySSOConfig(ConfigType.OIDC, config.configs[0])
238
238
 
239
+ const anyPkceSettings = config.configs.find(cfg => cfg.pkce)
240
+ if (anyPkceSettings && !(await pro.features.isPkceOidcEnabled())) {
241
+ throw new Error("License does not allow OIDC PKCE method support")
242
+ }
243
+
239
244
  if (existing) {
240
245
  for (const c of config.configs) {
241
246
  const existingConfig = existing.configs.find(e => e.uuid === c.uuid)
@@ -1,7 +1,7 @@
1
1
  import * as controller from "../../controllers/global/configs"
2
2
  import { auth } from "@budibase/backend-core"
3
3
  import Joi from "joi"
4
- import { ConfigType } from "@budibase/types"
4
+ import { ConfigType, PKCEMethod } from "@budibase/types"
5
5
  import { adminRoutes, loggedInRoutes } from "../endpointGroups"
6
6
 
7
7
  function smtpValidation() {
@@ -50,7 +50,8 @@ function oidcValidation() {
50
50
  name: Joi.string().allow("", null),
51
51
  uuid: Joi.string().required(),
52
52
  activated: Joi.boolean().required(),
53
- scopes: Joi.array().optional()
53
+ scopes: Joi.array().optional(),
54
+ pkce: Joi.string().valid(...Object.values(PKCEMethod)).optional()
54
55
  })
55
56
  ).required()
56
57
  }).unknown(true)
@@ -0,0 +1,291 @@
1
+ import { TestConfiguration } from "../../../../tests"
2
+ import {
3
+ getOIDCConfigs,
4
+ waitForDex,
5
+ generateCodeVerifier,
6
+ generateCodeChallenge,
7
+ buildAuthorizationUrl,
8
+ exchangeCodeForTokens,
9
+ } from "../../../../tests/utils/oidc"
10
+ import { ConfigType, OIDCInnerConfig, PKCEMethod } from "@budibase/types"
11
+ import { middleware } from "@budibase/backend-core"
12
+ import fetch from "node-fetch"
13
+ import { generator, mocks } from "@budibase/backend-core/tests"
14
+
15
+ mocks.licenses.usePkceOidc()
16
+
17
+ // Set longer timeout for container startup
18
+ jest.setTimeout(120000)
19
+
20
+ describe("OIDC Integration Tests", () => {
21
+ const config = new TestConfiguration()
22
+ let oidcConfigs: {
23
+ noPkce: OIDCInnerConfig
24
+ withPkce: OIDCInnerConfig
25
+ }
26
+ let dexPort: number
27
+
28
+ beforeAll(async () => {
29
+ await config.beforeAll()
30
+ dexPort = await waitForDex()
31
+ oidcConfigs = getOIDCConfigs(dexPort)
32
+ }, 120000)
33
+
34
+ afterAll(async () => {
35
+ await config.afterAll()
36
+ })
37
+
38
+ describe("OIDC Authentication Flow Tests", () => {
39
+ afterEach(async () => {
40
+ await config.deleteConfig(ConfigType.OIDC)
41
+ })
42
+
43
+ it("should generate authorization URL without PKCE", async () => {
44
+ const callbackUrl = "http://localhost:4001/api/global/auth/oidc/callback"
45
+ const strategyConfig = await middleware.oidc.fetchStrategyConfig(
46
+ oidcConfigs.noPkce,
47
+ callbackUrl
48
+ )
49
+
50
+ const state = generator.guid()
51
+ const nonce = generator.guid()
52
+
53
+ const authUrl = buildAuthorizationUrl({
54
+ authorizationUrl: strategyConfig.authorizationURL,
55
+ clientId: strategyConfig.clientID,
56
+ redirectUri: callbackUrl,
57
+ state,
58
+ nonce,
59
+ })
60
+
61
+ expect(authUrl).toContain(strategyConfig.authorizationURL)
62
+ expect(authUrl).toContain(`client_id=${strategyConfig.clientID}`)
63
+ expect(authUrl).toContain(
64
+ `redirect_uri=${encodeURIComponent(callbackUrl)}`
65
+ )
66
+ expect(authUrl).toContain(`state=${state}`)
67
+ expect(authUrl).toContain(`nonce=${nonce}`)
68
+ expect(authUrl).toContain("response_type=code")
69
+ expect(authUrl).toContain("scope=openid+profile+email")
70
+ expect(authUrl).not.toContain("code_challenge")
71
+ })
72
+
73
+ it("should generate authorization URL with PKCE", async () => {
74
+ const callbackUrl = "http://localhost:4001/api/global/auth/oidc/callback"
75
+ const strategyConfig = await middleware.oidc.fetchStrategyConfig(
76
+ oidcConfigs.withPkce,
77
+ callbackUrl
78
+ )
79
+
80
+ const state = generator.guid()
81
+ const nonce = generator.guid()
82
+ const codeVerifier = generateCodeVerifier()
83
+ const codeChallenge = generateCodeChallenge(codeVerifier, PKCEMethod.S256)
84
+
85
+ const authUrl = buildAuthorizationUrl({
86
+ authorizationUrl: strategyConfig.authorizationURL,
87
+ clientId: strategyConfig.clientID,
88
+ redirectUri: callbackUrl,
89
+ state,
90
+ nonce,
91
+ pkce: {
92
+ codeChallenge,
93
+ codeChallengeMethod: PKCEMethod.S256,
94
+ },
95
+ })
96
+
97
+ expect(authUrl).toContain(strategyConfig.authorizationURL)
98
+ expect(authUrl).toContain(`client_id=${strategyConfig.clientID}`)
99
+ expect(authUrl).toContain(
100
+ `redirect_uri=${encodeURIComponent(callbackUrl)}`
101
+ )
102
+ expect(authUrl).toContain(`state=${state}`)
103
+ expect(authUrl).toContain(`nonce=${nonce}`)
104
+ expect(authUrl).toContain("response_type=code")
105
+ expect(authUrl).toContain("scope=openid+profile+email")
106
+ expect(authUrl).toContain(
107
+ `code_challenge=${encodeURIComponent(codeChallenge)}`
108
+ )
109
+ expect(authUrl).toContain("code_challenge_method=S256")
110
+ })
111
+
112
+ it("should validate well-known OIDC configuration from Dex", async () => {
113
+ const wellKnownUrl = `http://localhost:${dexPort}/.well-known/openid-configuration`
114
+ const response = await fetch(wellKnownUrl)
115
+
116
+ expect(response.ok).toBe(true)
117
+
118
+ const config = await response.json()
119
+ expect(config.issuer).toBe(`http://localhost:${dexPort}`)
120
+ expect(config.authorization_endpoint).toBe(
121
+ `http://localhost:${dexPort}/auth`
122
+ )
123
+ expect(config.token_endpoint).toBe(`http://localhost:${dexPort}/token`)
124
+ expect(config.userinfo_endpoint).toBe(
125
+ `http://localhost:${dexPort}/userinfo`
126
+ )
127
+ expect(config.jwks_uri).toBe(`http://localhost:${dexPort}/keys`)
128
+ expect(config.scopes_supported).toContain("openid")
129
+ expect(config.response_types_supported).toContain("code")
130
+ expect(config.code_challenge_methods_supported).toContain("S256")
131
+ expect(config.code_challenge_methods_supported).toContain("plain")
132
+ })
133
+ })
134
+
135
+ describe("PKCE Authentication Tests", () => {
136
+ afterEach(async () => {
137
+ await config.deleteConfig(ConfigType.OIDC)
138
+ })
139
+
140
+ it("should generate valid PKCE code verifier", () => {
141
+ const verifier = generateCodeVerifier()
142
+
143
+ expect(verifier).toBeDefined()
144
+ expect(typeof verifier).toBe("string")
145
+ expect(verifier.length).toBeGreaterThan(40)
146
+ expect(verifier).toMatch(/^[A-Za-z0-9\-._~]+$/)
147
+
148
+ // Should generate different verifiers each time
149
+ const verifier2 = generateCodeVerifier()
150
+ expect(verifier).not.toBe(verifier2)
151
+ })
152
+
153
+ it("should generate valid PKCE code challenge for S256 method", () => {
154
+ const verifier = generateCodeVerifier()
155
+ const challenge = generateCodeChallenge(verifier, PKCEMethod.S256)
156
+
157
+ expect(challenge).toBeDefined()
158
+ expect(typeof challenge).toBe("string")
159
+ expect(challenge).not.toBe(verifier)
160
+ expect(challenge.length).toBe(43) // Base64url encoded SHA256 hash length
161
+ expect(challenge).toMatch(/^[A-Za-z0-9\-_]+$/)
162
+ })
163
+
164
+ it("should generate valid PKCE code challenge for PLAIN method", () => {
165
+ const verifier = generateCodeVerifier()
166
+ const challenge = generateCodeChallenge(verifier, PKCEMethod.PLAIN)
167
+
168
+ expect(challenge).toBeDefined()
169
+ expect(challenge).toBe(verifier) // PLAIN method returns verifier as-is
170
+ })
171
+
172
+ it("should reject invalid PKCE method", () => {
173
+ const verifier = generateCodeVerifier()
174
+
175
+ expect(() => {
176
+ generateCodeChallenge(verifier, "INVALID" as PKCEMethod)
177
+ }).toThrow("Unsupported PKCE method: INVALID")
178
+ })
179
+
180
+ it("should handle token exchange without PKCE", async () => {
181
+ const callbackUrl = "http://localhost:4001/api/global/auth/oidc/callback"
182
+ const strategyConfig = await middleware.oidc.fetchStrategyConfig(
183
+ oidcConfigs.noPkce,
184
+ callbackUrl
185
+ )
186
+
187
+ // Mock token exchange (since we can't easily get a real auth code in tests)
188
+ const mockCode = "mock-authorization-code"
189
+
190
+ try {
191
+ await exchangeCodeForTokens(
192
+ strategyConfig.tokenURL,
193
+ strategyConfig.clientID,
194
+ strategyConfig.clientSecret,
195
+ mockCode,
196
+ callbackUrl
197
+ )
198
+ } catch (error: any) {
199
+ // Expect this to fail with mock code, but validate the request structure
200
+ expect(error.message).toContain("Token exchange failed")
201
+ expect(error.message).toContain("400") // Bad Request for invalid code
202
+ }
203
+ })
204
+
205
+ it("should handle token exchange with PKCE", async () => {
206
+ const callbackUrl = "http://localhost:4001/api/global/auth/oidc/callback"
207
+ const strategyConfig = await middleware.oidc.fetchStrategyConfig(
208
+ oidcConfigs.withPkce,
209
+ callbackUrl
210
+ )
211
+
212
+ const codeVerifier = generateCodeVerifier()
213
+ const mockCode = "mock-authorization-code"
214
+
215
+ try {
216
+ await exchangeCodeForTokens(
217
+ strategyConfig.tokenURL,
218
+ strategyConfig.clientID,
219
+ strategyConfig.clientSecret,
220
+ mockCode,
221
+ callbackUrl,
222
+ codeVerifier
223
+ )
224
+ } catch (error: any) {
225
+ // Expect this to fail with mock code, but validate the request structure
226
+ expect(error.message).toContain("Token exchange failed")
227
+ expect(error.message).toContain("400") // Bad Request for invalid code
228
+ }
229
+ })
230
+ })
231
+
232
+ describe("Token Validation Tests", () => {
233
+ afterEach(async () => {
234
+ await config.deleteConfig(ConfigType.OIDC)
235
+ })
236
+
237
+ it("should validate OIDC strategy configuration for token endpoints", async () => {
238
+ const callbackUrl = "http://localhost:4001/api/global/auth/oidc/callback"
239
+
240
+ // Test without PKCE
241
+ const strategyConfigNoPkce = await middleware.oidc.fetchStrategyConfig(
242
+ oidcConfigs.noPkce,
243
+ callbackUrl
244
+ )
245
+
246
+ expect(strategyConfigNoPkce.tokenURL).toBeDefined()
247
+ expect(strategyConfigNoPkce.userInfoURL).toBeDefined()
248
+ expect(strategyConfigNoPkce.clientID).toBe("budibase-no-pkce")
249
+ expect(strategyConfigNoPkce.clientSecret).toBe("test-secret-no-pkce")
250
+ expect(strategyConfigNoPkce.pkce).toBeUndefined()
251
+
252
+ // Test with PKCE
253
+ const strategyConfigWithPkce = await middleware.oidc.fetchStrategyConfig(
254
+ oidcConfigs.withPkce,
255
+ callbackUrl
256
+ )
257
+
258
+ expect(strategyConfigWithPkce.tokenURL).toBeDefined()
259
+ expect(strategyConfigWithPkce.userInfoURL).toBeDefined()
260
+ expect(strategyConfigWithPkce.clientID).toBe("budibase-pkce")
261
+ expect(strategyConfigWithPkce.clientSecret).toBe("test-secret-pkce")
262
+ expect(strategyConfigWithPkce.pkce).toBe(PKCEMethod.S256)
263
+ })
264
+
265
+ it("should validate OIDC endpoints are accessible", async () => {
266
+ const callbackUrl = "http://localhost:4001/api/global/auth/oidc/callback"
267
+ const strategyConfig = await middleware.oidc.fetchStrategyConfig(
268
+ oidcConfigs.noPkce,
269
+ callbackUrl
270
+ )
271
+
272
+ // Test authorization endpoint
273
+ const authResponse = await fetch(strategyConfig.authorizationURL, {
274
+ method: "HEAD",
275
+ })
276
+ expect(authResponse.status).toBeLessThan(500) // Should not be server error
277
+
278
+ // Test token endpoint
279
+ const tokenResponse = await fetch(strategyConfig.tokenURL, {
280
+ method: "HEAD",
281
+ })
282
+ expect(tokenResponse.status).toBeLessThan(500) // Should not be server error
283
+
284
+ // Test userinfo endpoint
285
+ const userInfoResponse = await fetch(strategyConfig.userInfoURL, {
286
+ method: "HEAD",
287
+ })
288
+ expect(userInfoResponse.status).toBeLessThan(500) // Should not be server error
289
+ })
290
+ })
291
+ })
package/src/index.ts CHANGED
@@ -54,12 +54,18 @@ app.proxy = true
54
54
  app.use(handleScimBody)
55
55
  app.use(koaBody({ multipart: true }))
56
56
 
57
+ let store: any
58
+
57
59
  const sessionMiddleware: Middleware = async (ctx: any, next: any) => {
58
- const redisClient = await redis.clients.getSessionClient()
60
+ if (!store) {
61
+ const redisClient = await redis.clients.getSessionClient()
62
+ // @ts-expect-error - koa-redis types are weird
63
+ store = RedisStore({ client: redisClient.client })
64
+ }
65
+
59
66
  return koaSession(
60
67
  {
61
- // @ts-ignore
62
- store: new RedisStore({ client: redisClient.client }),
68
+ store,
63
69
  key: "koa:sess",
64
70
  maxAge: 86400000, // one day
65
71
  },
@@ -0,0 +1,207 @@
1
+ import { GenericContainer, Wait } from "testcontainers"
2
+ import { testContainerUtils } from "@budibase/backend-core/tests"
3
+ import { OIDCInnerConfig, PKCEMethod } from "@budibase/types"
4
+ import { generator } from "@budibase/backend-core/tests"
5
+ import fetch from "node-fetch"
6
+ import * as crypto from "crypto"
7
+
8
+ const DEX_IMAGE = "dexidp/dex:v2.37.0"
9
+ const DEX_PORT = 5556
10
+
11
+ let ports: testContainerUtils.Port[]
12
+
13
+ export async function getDexContainer(): Promise<testContainerUtils.Port[]> {
14
+ if (!ports) {
15
+ // Create the Dex configuration that will work with any port
16
+ const dexConfig = `
17
+ issuer: http://localhost:${DEX_PORT}
18
+
19
+ storage:
20
+ type: memory
21
+
22
+ web:
23
+ http: 0.0.0.0:${DEX_PORT}
24
+ allowedOrigins: ['*']
25
+
26
+ staticClients:
27
+ - id: budibase-no-pkce
28
+ secret: test-secret-no-pkce
29
+ name: 'Budibase Test (No PKCE)'
30
+ redirectURIs:
31
+ - 'http://localhost:4001/api/global/auth/oidc/callback'
32
+ - id: budibase-pkce
33
+ secret: test-secret-pkce
34
+ name: 'Budibase Test (PKCE)'
35
+ redirectURIs:
36
+ - 'http://localhost:4001/api/global/auth/oidc/callback'
37
+
38
+ enablePasswordDB: true
39
+
40
+ staticPasswords:
41
+ - email: test@budibase.com
42
+ hash: "$2y$12$6q53Pz1Xey3TZUhupeK1vO2zUk8uQsFnM1nJtrrwAHPIvMNnfwOB6" # "password"
43
+ username: testuser
44
+ userID: "1111"
45
+ `
46
+
47
+ const container = new GenericContainer(DEX_IMAGE)
48
+ // need to lock the port, its important for dex to present its port
49
+ .withExposedPorts({ container: 5556, host: 5556 })
50
+ .withCommand(["dex", "serve", "/etc/dex/cfg/config.yaml"])
51
+ .withCopyContentToContainer([
52
+ {
53
+ content: dexConfig,
54
+ target: "/etc/dex/cfg/config.yaml",
55
+ },
56
+ ])
57
+ .withWaitStrategy(
58
+ Wait.forLogMessage(
59
+ "listening (http) on 0.0.0.0:5556"
60
+ ).withStartupTimeout(60000)
61
+ )
62
+ ports = await testContainerUtils.startContainer(container)
63
+ }
64
+
65
+ return ports
66
+ }
67
+
68
+ export interface OIDCTestConfig {
69
+ noPkce: OIDCInnerConfig
70
+ withPkce: OIDCInnerConfig
71
+ }
72
+
73
+ export function getOIDCConfigs(port: number): OIDCTestConfig {
74
+ const configUrl = `http://localhost:${port}/.well-known/openid-configuration`
75
+ const noPkceConfig: OIDCInnerConfig = {
76
+ configUrl,
77
+ clientID: "budibase-no-pkce",
78
+ clientSecret: "test-secret-no-pkce",
79
+ logo: "",
80
+ name: "Test OIDC (No PKCE)",
81
+ uuid: generator.guid(),
82
+ activated: true,
83
+ scopes: ["openid", "profile", "email"],
84
+ }
85
+
86
+ const withPkceConfig: OIDCInnerConfig = {
87
+ configUrl,
88
+ clientID: "budibase-pkce",
89
+ clientSecret: "test-secret-pkce",
90
+ logo: "",
91
+ name: "Test OIDC (PKCE)",
92
+ uuid: generator.guid(),
93
+ activated: true,
94
+ scopes: ["openid", "profile", "email"],
95
+ pkce: PKCEMethod.S256,
96
+ }
97
+
98
+ return {
99
+ noPkce: noPkceConfig,
100
+ withPkce: withPkceConfig,
101
+ }
102
+ }
103
+
104
+ export async function waitForDex(): Promise<number> {
105
+ const containerPorts = await getDexContainer()
106
+ const dexPort = containerPorts.find(x => x.container === DEX_PORT)?.host
107
+ if (!dexPort) {
108
+ throw new Error("Dex port not found")
109
+ }
110
+ const wellKnownUrl = `http://localhost:${dexPort}/.well-known/openid-configuration`
111
+ const response = await fetch(wellKnownUrl)
112
+
113
+ if (!response.ok) {
114
+ throw new Error("Unable to fetch well known openid-configuration")
115
+ }
116
+ return dexPort
117
+ }
118
+
119
+ // PKCE Helper Functions
120
+ export function generateCodeVerifier(): string {
121
+ return crypto.randomBytes(32).toString("base64url")
122
+ }
123
+
124
+ export function generateCodeChallenge(
125
+ verifier: string,
126
+ method: PKCEMethod
127
+ ): string {
128
+ if (method === PKCEMethod.PLAIN) {
129
+ return verifier
130
+ } else if (method === PKCEMethod.S256) {
131
+ return crypto.createHash("sha256").update(verifier).digest("base64url")
132
+ }
133
+ throw new Error(`Unsupported PKCE method: ${method}`)
134
+ }
135
+
136
+ // OIDC Authentication Helper Functions
137
+ export interface AuthorizationUrlParams {
138
+ authorizationUrl: string
139
+ clientId: string
140
+ redirectUri: string
141
+ state: string
142
+ nonce: string
143
+ scopes?: string[]
144
+ pkce?: {
145
+ codeChallenge: string
146
+ codeChallengeMethod: PKCEMethod
147
+ }
148
+ }
149
+
150
+ export function buildAuthorizationUrl(params: AuthorizationUrlParams): string {
151
+ const url = new URL(params.authorizationUrl)
152
+ url.searchParams.set("response_type", "code")
153
+ url.searchParams.set("client_id", params.clientId)
154
+ url.searchParams.set("redirect_uri", params.redirectUri)
155
+ url.searchParams.set("state", params.state)
156
+ url.searchParams.set("nonce", params.nonce)
157
+ url.searchParams.set(
158
+ "scope",
159
+ (params.scopes || ["openid", "profile", "email"]).join(" ")
160
+ )
161
+
162
+ if (params.pkce) {
163
+ url.searchParams.set("code_challenge", params.pkce.codeChallenge)
164
+ url.searchParams.set(
165
+ "code_challenge_method",
166
+ params.pkce.codeChallengeMethod
167
+ )
168
+ }
169
+
170
+ return url.toString()
171
+ }
172
+
173
+ export async function exchangeCodeForTokens(
174
+ tokenUrl: string,
175
+ clientId: string,
176
+ clientSecret: string,
177
+ code: string,
178
+ redirectUri: string,
179
+ codeVerifier?: string
180
+ ): Promise<any> {
181
+ const body = new URLSearchParams({
182
+ grant_type: "authorization_code",
183
+ client_id: clientId,
184
+ client_secret: clientSecret,
185
+ code: code,
186
+ redirect_uri: redirectUri,
187
+ })
188
+
189
+ if (codeVerifier) {
190
+ body.set("code_verifier", codeVerifier)
191
+ }
192
+
193
+ const response = await fetch(tokenUrl, {
194
+ method: "POST",
195
+ headers: {
196
+ "Content-Type": "application/x-www-form-urlencoded",
197
+ },
198
+ body: body.toString(),
199
+ })
200
+
201
+ if (!response.ok) {
202
+ const errorText = await response.text()
203
+ throw new Error(`Token exchange failed: ${response.status} ${errorText}`)
204
+ }
205
+
206
+ return response.json()
207
+ }