@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.
|
|
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": "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|