@budibase/worker 3.13.29 → 3.14.1
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 +3 -2
- package/src/api/controllers/global/configs.ts +5 -0
- package/src/api/controllers/global/users.ts +11 -1
- package/src/api/routes/global/configs.ts +3 -2
- package/src/api/routes/global/tests/oidc-integration.spec.ts +291 -0
- package/src/api/routes/global/tests/users.spec.ts +159 -1
- package/src/index.ts +9 -3
- package/src/tests/TestConfiguration.ts +12 -3
- package/src/tests/api/users.ts +13 -0
- package/src/tests/utils/oidc.ts +207 -0
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.1",
|
|
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": "95576e7a88dbb8563210818dd3d451b60770b901"
|
|
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)
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
LockType,
|
|
31
31
|
LookupAccountHolderResponse,
|
|
32
32
|
LookupTenantUserResponse,
|
|
33
|
+
OIDCUser,
|
|
33
34
|
SaveUserResponse,
|
|
34
35
|
SearchUsersRequest,
|
|
35
36
|
SearchUsersResponse,
|
|
@@ -114,11 +115,20 @@ export const changeTenantOwnerEmail = async (
|
|
|
114
115
|
try {
|
|
115
116
|
for (const tenantId of tenantIds) {
|
|
116
117
|
await tenancy.doInTenant(tenantId, async () => {
|
|
117
|
-
const tenantUser = await userSdk.db.getUserByEmail(
|
|
118
|
+
const tenantUser = (await userSdk.db.getUserByEmail(
|
|
119
|
+
originalEmail
|
|
120
|
+
)) as OIDCUser
|
|
118
121
|
if (!tenantUser) {
|
|
119
122
|
return
|
|
120
123
|
}
|
|
121
124
|
tenantUser.email = newAccountEmail
|
|
125
|
+
|
|
126
|
+
tenantUser.provider = undefined
|
|
127
|
+
tenantUser.providerType = undefined
|
|
128
|
+
tenantUser.thirdPartyProfile = undefined
|
|
129
|
+
tenantUser.profile = undefined
|
|
130
|
+
tenantUser.oauth2 = undefined
|
|
131
|
+
|
|
122
132
|
await userSdk.db.save(tenantUser, {
|
|
123
133
|
currentUserId: tenantUser._id,
|
|
124
134
|
isAccountHolder: true,
|
|
@@ -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
|
+
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { InviteUsersResponse, User } from "@budibase/types"
|
|
1
|
+
import { InviteUsersResponse, User, OIDCUser } from "@budibase/types"
|
|
2
2
|
|
|
3
3
|
import { TestConfiguration, mocks, structures } from "../../../../tests"
|
|
4
4
|
import { events, tenancy, accounts as _accounts } from "@budibase/backend-core"
|
|
@@ -800,4 +800,162 @@ describe("/api/global/users", () => {
|
|
|
800
800
|
expect(response.unsuccessful.length).toBe(1)
|
|
801
801
|
})
|
|
802
802
|
})
|
|
803
|
+
|
|
804
|
+
describe("PUT /api/global/users/tenant/owner", () => {
|
|
805
|
+
it("should successfully change tenant owner email for existing user", async () => {
|
|
806
|
+
const originalEmail = `original-${structures.uuid()}@example.com`
|
|
807
|
+
const newEmail = `new-${structures.uuid()}@example.com`
|
|
808
|
+
const tenantId = config.getTenantId()
|
|
809
|
+
|
|
810
|
+
const user = await config.doInTenant(async () => {
|
|
811
|
+
return await userSdk.db.save(
|
|
812
|
+
{
|
|
813
|
+
email: originalEmail,
|
|
814
|
+
tenantId,
|
|
815
|
+
} as any,
|
|
816
|
+
{ requirePassword: false, isAccountHolder: true }
|
|
817
|
+
)
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
await config.api.users.changeTenantOwnerEmail(newEmail, originalEmail, [
|
|
821
|
+
tenantId,
|
|
822
|
+
])
|
|
823
|
+
|
|
824
|
+
const updatedUser = await config.doInTenant(async () => {
|
|
825
|
+
return await userSdk.db.getUser(user._id!)
|
|
826
|
+
})
|
|
827
|
+
|
|
828
|
+
expect(updatedUser).toBeDefined()
|
|
829
|
+
expect(updatedUser!.email).toBe(newEmail)
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
it("should handle multiple tenants", async () => {
|
|
833
|
+
const originalEmail = `original-${structures.uuid()}@example.com`
|
|
834
|
+
const newEmail = `new-${structures.uuid()}@example.com`
|
|
835
|
+
const tenant1 = config.getTenantId()
|
|
836
|
+
const tenant2 = structures.tenant.id()
|
|
837
|
+
|
|
838
|
+
const user1 = await config.doInTenant(async () => {
|
|
839
|
+
return await userSdk.db.save(
|
|
840
|
+
{
|
|
841
|
+
email: originalEmail,
|
|
842
|
+
tenantId: tenant1,
|
|
843
|
+
} as any,
|
|
844
|
+
{ requirePassword: false, isAccountHolder: true }
|
|
845
|
+
)
|
|
846
|
+
})
|
|
847
|
+
|
|
848
|
+
const user2 = await config.doInSpecificTenant(tenant2, async () => {
|
|
849
|
+
return await userSdk.db.save(
|
|
850
|
+
{
|
|
851
|
+
email: originalEmail,
|
|
852
|
+
tenantId: tenant2,
|
|
853
|
+
} as any,
|
|
854
|
+
{ requirePassword: false, isAccountHolder: true }
|
|
855
|
+
)
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
await config.api.users.changeTenantOwnerEmail(newEmail, originalEmail, [
|
|
859
|
+
tenant1,
|
|
860
|
+
tenant2,
|
|
861
|
+
])
|
|
862
|
+
|
|
863
|
+
const updatedUser1 = await config.doInTenant(async () => {
|
|
864
|
+
return await userSdk.db.getUser(user1._id!)
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
const updatedUser2 = await config.doInSpecificTenant(
|
|
868
|
+
tenant2,
|
|
869
|
+
async () => {
|
|
870
|
+
return await userSdk.db.getUser(user2._id!)
|
|
871
|
+
}
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
expect(updatedUser1).toBeDefined()
|
|
875
|
+
expect(updatedUser1!.email).toBe(newEmail)
|
|
876
|
+
expect(updatedUser2).toBeDefined()
|
|
877
|
+
expect(updatedUser2!.email).toBe(newEmail)
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
it("should not fail if user doesn't exist in tenant", async () => {
|
|
881
|
+
const originalEmail = `nonexistent-${structures.uuid()}@example.com`
|
|
882
|
+
const newEmail = `new-${structures.uuid()}@example.com`
|
|
883
|
+
const tenantId = config.getTenantId()
|
|
884
|
+
|
|
885
|
+
await config.api.users.changeTenantOwnerEmail(newEmail, originalEmail, [
|
|
886
|
+
tenantId,
|
|
887
|
+
])
|
|
888
|
+
|
|
889
|
+
const user = await config.doInTenant(async () => {
|
|
890
|
+
return await userSdk.db.getUserByEmail(newEmail)
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
expect(user).toBeUndefined()
|
|
894
|
+
})
|
|
895
|
+
|
|
896
|
+
it("should handle empty tenant list", async () => {
|
|
897
|
+
const originalEmail = `original-${structures.uuid()}@example.com`
|
|
898
|
+
const newEmail = `new-${structures.uuid()}@example.com`
|
|
899
|
+
|
|
900
|
+
await config.api.users.changeTenantOwnerEmail(newEmail, originalEmail, [])
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
it("should clear all OIDC-related fields", async () => {
|
|
904
|
+
const originalEmail = `original-${structures.uuid()}@example.com`
|
|
905
|
+
const newEmail = `new-${structures.uuid()}@example.com`
|
|
906
|
+
const tenantId = config.getTenantId()
|
|
907
|
+
const profile = {}
|
|
908
|
+
const provider = "oidc"
|
|
909
|
+
const providerType = "oidc"
|
|
910
|
+
const thirdPartyProfile = {}
|
|
911
|
+
const oauth2 = {}
|
|
912
|
+
|
|
913
|
+
await config.doInTenant(async () => {
|
|
914
|
+
await userSdk.db.save(
|
|
915
|
+
{
|
|
916
|
+
email: originalEmail,
|
|
917
|
+
tenantId,
|
|
918
|
+
profile,
|
|
919
|
+
provider,
|
|
920
|
+
providerType,
|
|
921
|
+
thirdPartyProfile,
|
|
922
|
+
oauth2,
|
|
923
|
+
} as any,
|
|
924
|
+
{ requirePassword: false, isAccountHolder: true }
|
|
925
|
+
)
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
await config.api.users.changeTenantOwnerEmail(newEmail, originalEmail, [
|
|
929
|
+
tenantId,
|
|
930
|
+
])
|
|
931
|
+
|
|
932
|
+
const updatedUser = (await config.doInTenant(async () => {
|
|
933
|
+
return await userSdk.db.getUserByEmail(newEmail)
|
|
934
|
+
})) as OIDCUser
|
|
935
|
+
|
|
936
|
+
expect(updatedUser).toBeDefined()
|
|
937
|
+
expect(updatedUser!.email).toBe(newEmail)
|
|
938
|
+
expect(updatedUser.profile).toBe(undefined)
|
|
939
|
+
expect(updatedUser.provider).toBe(undefined)
|
|
940
|
+
expect(updatedUser.providerType).toBe(undefined)
|
|
941
|
+
expect(updatedUser.thirdPartyProfile).toBe(undefined)
|
|
942
|
+
expect(updatedUser.oauth2).toBe(undefined)
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
it("should require internal API headers", async () => {
|
|
946
|
+
const originalEmail = `original-${structures.uuid()}@example.com`
|
|
947
|
+
const newEmail = `new-${structures.uuid()}@example.com`
|
|
948
|
+
const tenantId = config.getTenantId()
|
|
949
|
+
|
|
950
|
+
await config.request
|
|
951
|
+
.put(`/api/global/users/tenant/owner`)
|
|
952
|
+
.send({
|
|
953
|
+
newAccountEmail: newEmail,
|
|
954
|
+
originalEmail,
|
|
955
|
+
tenantIds: [tenantId],
|
|
956
|
+
})
|
|
957
|
+
.set(config.defaultHeaders())
|
|
958
|
+
.expect(403)
|
|
959
|
+
})
|
|
960
|
+
})
|
|
803
961
|
})
|
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
|
},
|
|
@@ -138,9 +138,9 @@ class TestConfiguration {
|
|
|
138
138
|
|
|
139
139
|
// TENANCY
|
|
140
140
|
|
|
141
|
-
doInTenant(task:
|
|
142
|
-
return context.doInTenant(this.tenantId, () => {
|
|
143
|
-
return task()
|
|
141
|
+
async doInTenant<T>(task: () => Promise<T>): Promise<T> {
|
|
142
|
+
return await context.doInTenant(this.tenantId, async () => {
|
|
143
|
+
return await task()
|
|
144
144
|
})
|
|
145
145
|
}
|
|
146
146
|
|
|
@@ -171,6 +171,15 @@ class TestConfiguration {
|
|
|
171
171
|
}
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
async doInSpecificTenant<T>(
|
|
175
|
+
tenantId: string,
|
|
176
|
+
task: () => Promise<T>
|
|
177
|
+
): Promise<T> {
|
|
178
|
+
return await context.doInTenant(tenantId, async () => {
|
|
179
|
+
return await task()
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
174
183
|
// AUTH
|
|
175
184
|
|
|
176
185
|
async _createSession({
|
package/src/tests/api/users.ts
CHANGED
|
@@ -214,4 +214,17 @@ export class UserAPI extends TestAPI {
|
|
|
214
214
|
|
|
215
215
|
return resp.body as InviteUsersResponse
|
|
216
216
|
}
|
|
217
|
+
|
|
218
|
+
changeTenantOwnerEmail = (
|
|
219
|
+
newAccountEmail: string,
|
|
220
|
+
originalEmail: string,
|
|
221
|
+
tenantIds: string[],
|
|
222
|
+
status = 200
|
|
223
|
+
) => {
|
|
224
|
+
return this.request
|
|
225
|
+
.put(`/api/global/users/tenant/owner`)
|
|
226
|
+
.send({ newAccountEmail, originalEmail, tenantIds })
|
|
227
|
+
.set(this.config.internalAPIHeaders())
|
|
228
|
+
.expect(status)
|
|
229
|
+
}
|
|
217
230
|
}
|
|
@@ -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
|
+
}
|