Skip to content

Commit 4962217

Browse files
authored
Merge pull request #730 from camunda/oauth-test-hardening
test(oauth): add regression tests for OAuthProvider cache key and hardening
2 parents df1c3cc + 70bc908 commit 4962217

1 file changed

Lines changed: 306 additions & 0 deletions

File tree

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import fs from 'fs'
2+
import http from 'http'
3+
import { AddressInfo } from 'net'
4+
import os from 'os'
5+
import path from 'path'
6+
7+
import jwt from 'jsonwebtoken'
8+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
9+
10+
import { EnvironmentSetup } from '../../lib/EnvironmentSetup'
11+
12+
/**
13+
* Regression tests for the hardening changes in
14+
* https://github.com/camunda/camunda-8-js-sdk/pull/722
15+
*
16+
* These tests target *classes of defect* rather than specific instances:
17+
*
18+
* 1. Token cache keys must incorporate the client id actually used for the
19+
* request. On SaaS, `ZEEBE`/`OPERATE`/`TASKLIST`/`OPTIMIZE` use the
20+
* application client id, while `CONSOLE`/`MODELER` use the Console
21+
* client id. A cache key that only discriminates by audience (and
22+
* silently uses `this.clientId`) lets a Console token collide with a
23+
* Zeebe token's cache slot, and vice versa. The tests below verify
24+
* both audiences trigger their own token request with the correct
25+
* client id, and both returned tokens are served back to the caller.
26+
*
27+
* 2. `flushFileCache()` must actually delete `oauth-token-*.json` files
28+
* inside `cacheDir`, leave unrelated files alone, and tolerate a
29+
* missing directory. A latent bug in the previous implementation made
30+
* it a no-op in every realistic deployment (it called `unlinkSync`
31+
* with bare filenames relative to `process.cwd()`).
32+
*
33+
* 3. `redactClientSecret()` must mask the `client_secret` value in any
34+
* url-encoded body, regardless of what other fields surround it. Trace
35+
* logs are emitted at `debug('camunda:oauth')` level and should never
36+
* contain a plaintext secret.
37+
*/
38+
39+
let tmpDirs: string[] = []
40+
41+
beforeEach(() => {
42+
vi.resetModules()
43+
EnvironmentSetup.wipeEnv()
44+
})
45+
46+
afterEach(() => {
47+
for (const dir of tmpDirs) {
48+
try {
49+
fs.rmSync(dir, { recursive: true, force: true })
50+
} catch {
51+
/* ignore */
52+
}
53+
}
54+
tmpDirs = []
55+
})
56+
57+
function makeTmpDir(prefix: string): string {
58+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix))
59+
tmpDirs.push(dir)
60+
return dir
61+
}
62+
63+
type TokenRequest = { body: string; audience: string }
64+
65+
function startTokenServer(onRequest?: (req: TokenRequest) => void): {
66+
url: string
67+
requests: TokenRequest[]
68+
close: () => Promise<void>
69+
} {
70+
const requests: TokenRequest[] = []
71+
const secret = 'test-secret'
72+
const server = http.createServer((req, res) => {
73+
if (req.method !== 'POST') {
74+
res.statusCode = 405
75+
return res.end()
76+
}
77+
let body = ''
78+
req.on('data', (chunk) => {
79+
body += chunk.toString()
80+
})
81+
req.on('end', () => {
82+
const audienceMatch = /audience=([^&]+)/.exec(body)
83+
const audience = audienceMatch ? audienceMatch[1] : ''
84+
const entry = { body, audience }
85+
requests.push(entry)
86+
onRequest?.(entry)
87+
const token = jwt.sign(
88+
{ id: 1, aud: audience, sub: body }, // sub embeds body so test can distinguish tokens
89+
secret,
90+
{ expiresIn: 60 }
91+
)
92+
res.writeHead(200, { 'Content-Type': 'application/json' })
93+
res.end(JSON.stringify({ access_token: token, expires_in: 60 }))
94+
})
95+
})
96+
server.listen()
97+
const port = (server.address() as AddressInfo).port
98+
return {
99+
url: `http://127.0.0.1:${port}`,
100+
requests,
101+
close: () => new Promise<void>((resolve) => server.close(() => resolve())),
102+
}
103+
}
104+
105+
describe('OAuthProvider memory cache key includes clientIdToUse', () => {
106+
it('CONSOLE and ZEEBE audiences on the same provider issue distinct token requests with their own client ids and do not collide in cache', async () => {
107+
const { OAuthProvider } = (await vi.importActual('../../oauth')) as {
108+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
109+
OAuthProvider: any
110+
}
111+
const tokenCacheDir = makeTmpDir('oauth-cachekey-')
112+
const server = startTokenServer()
113+
114+
try {
115+
const o = new OAuthProvider({
116+
config: {
117+
CAMUNDA_OAUTH_URL: server.url,
118+
// Force SaaS behaviour so CONSOLE/MODELER route to console credentials
119+
// without needing a real SaaS URL.
120+
ZEEBE_CLIENT_ID: 'zeebe-client',
121+
ZEEBE_CLIENT_SECRET: 'zeebe-secret',
122+
CAMUNDA_CONSOLE_CLIENT_ID: 'console-client',
123+
CAMUNDA_CONSOLE_CLIENT_SECRET: 'console-secret',
124+
CAMUNDA_CONSOLE_OAUTH_AUDIENCE: 'api.cloud.camunda.io',
125+
CAMUNDA_ZEEBE_OAUTH_AUDIENCE: 'zeebe.camunda.io',
126+
CAMUNDA_TOKEN_CACHE_DIR: tokenCacheDir,
127+
CAMUNDA_TOKEN_DISK_CACHE_DISABLE: true,
128+
CAMUNDA_OAUTH_FAIL_ON_ERROR: true,
129+
},
130+
})
131+
// Pretend this is SaaS so that CONSOLE/MODELER use console credentials
132+
;(o as unknown as { isCamundaSaaS: boolean }).isCamundaSaaS = true
133+
134+
const zeebeHeaders = await o.getHeaders('ZEEBE')
135+
const consoleHeaders = await o.getHeaders('CONSOLE')
136+
137+
// Two distinct network requests — cache slots must not collide.
138+
expect(server.requests).toHaveLength(2)
139+
140+
const zeebeReq = server.requests.find((r) =>
141+
r.body.includes('client_id=zeebe-client')
142+
)
143+
const consoleReq = server.requests.find((r) =>
144+
r.body.includes('client_id=console-client')
145+
)
146+
expect(zeebeReq, 'ZEEBE token request must use zeebe-client').toBeTruthy()
147+
expect(
148+
consoleReq,
149+
'CONSOLE token request must use console-client'
150+
).toBeTruthy()
151+
expect(zeebeReq!.body).toContain('client_secret=zeebe-secret')
152+
expect(consoleReq!.body).toContain('client_secret=console-secret')
153+
154+
// Tokens returned to the caller must correspond to their respective requests.
155+
expect(zeebeHeaders.authorization).not.toEqual(
156+
consoleHeaders.authorization
157+
)
158+
159+
// Second call to the same audience must hit the memory cache (no extra request).
160+
const zeebeHeaders2 = await o.getHeaders('ZEEBE')
161+
expect(server.requests).toHaveLength(2)
162+
expect(zeebeHeaders2.authorization).toEqual(zeebeHeaders.authorization)
163+
164+
// Cache entries must be keyed by clientId+audience, not just audience.
165+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
166+
const keys = Object.keys((o as any).tokenCache)
167+
expect(keys.sort()).toEqual(
168+
['console-client-CONSOLE', 'zeebe-client-ZEEBE'].sort()
169+
)
170+
} finally {
171+
await server.close()
172+
}
173+
})
174+
175+
it('getCacheKey derives from the provided clientId, not from this.clientId', async () => {
176+
const { OAuthProvider } = (await vi.importActual('../../oauth')) as {
177+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
178+
OAuthProvider: any
179+
}
180+
const tokenCacheDir = makeTmpDir('oauth-cachekey-internal-')
181+
const o = new OAuthProvider({
182+
config: {
183+
CAMUNDA_OAUTH_URL: 'http://127.0.0.1:1',
184+
ZEEBE_CLIENT_ID: 'zeebe-client',
185+
ZEEBE_CLIENT_SECRET: 'zeebe-secret',
186+
CAMUNDA_CONSOLE_CLIENT_ID: 'console-client',
187+
CAMUNDA_CONSOLE_CLIENT_SECRET: 'console-secret',
188+
CAMUNDA_TOKEN_CACHE_DIR: tokenCacheDir,
189+
CAMUNDA_TOKEN_DISK_CACHE_DISABLE: true,
190+
},
191+
})
192+
193+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
194+
const getCacheKey = (o as any).getCacheKey as (
195+
clientId: string,
196+
audience: string
197+
) => string
198+
199+
// The class-of-defect check: different clientIds must yield different
200+
// keys even for the same audience. A regression where the key drops
201+
// clientId would collapse both of these to the same string.
202+
expect(getCacheKey('zeebe-client', 'ZEEBE')).toBe('zeebe-client-ZEEBE')
203+
expect(getCacheKey('console-client', 'ZEEBE')).toBe('console-client-ZEEBE')
204+
expect(getCacheKey('zeebe-client', 'CONSOLE')).toBe('zeebe-client-CONSOLE')
205+
expect(getCacheKey('zeebe-client', 'ZEEBE')).not.toBe(
206+
getCacheKey('console-client', 'ZEEBE')
207+
)
208+
})
209+
})
210+
211+
describe('OAuthProvider flushFileCache', () => {
212+
it('removes oauth-token-*.json files, leaves unrelated files, and does not blow up on a missing directory', async () => {
213+
const { OAuthProvider } = (await vi.importActual('../../oauth')) as {
214+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
215+
OAuthProvider: any
216+
}
217+
const tokenCacheDir = makeTmpDir('oauth-flush-')
218+
219+
const tokenFileA = path.join(
220+
tokenCacheDir,
221+
'oauth-token-zeebe-client-ZEEBE.json'
222+
)
223+
const tokenFileB = path.join(
224+
tokenCacheDir,
225+
'oauth-token-console-client-CONSOLE.json'
226+
)
227+
const tarpitFile = path.join(
228+
tokenCacheDir,
229+
'oauth-401-tarpit-zeebe-client-ZEEBE-abc.json'
230+
)
231+
const unrelatedFile = path.join(tokenCacheDir, 'keep-me.txt')
232+
fs.writeFileSync(tokenFileA, '{}')
233+
fs.writeFileSync(tokenFileB, '{}')
234+
fs.writeFileSync(tarpitFile, '{}')
235+
fs.writeFileSync(unrelatedFile, 'keep')
236+
237+
const o = new OAuthProvider({
238+
config: {
239+
CAMUNDA_OAUTH_URL: 'http://127.0.0.1:1',
240+
ZEEBE_CLIENT_ID: 'zeebe-client',
241+
ZEEBE_CLIENT_SECRET: 'zeebe-secret',
242+
CAMUNDA_TOKEN_CACHE_DIR: tokenCacheDir,
243+
},
244+
})
245+
246+
o.flushFileCache()
247+
248+
expect(fs.existsSync(tokenFileA)).toBe(false)
249+
expect(fs.existsSync(tokenFileB)).toBe(false)
250+
// Non-oauth-token files must be preserved (tarpit + unrelated).
251+
expect(fs.existsSync(tarpitFile)).toBe(true)
252+
expect(fs.existsSync(unrelatedFile)).toBe(true)
253+
254+
// Missing directory must not throw.
255+
fs.rmSync(tokenCacheDir, { recursive: true, force: true })
256+
expect(() => o.flushFileCache()).not.toThrow()
257+
})
258+
})
259+
260+
describe('OAuthProvider redactClientSecret', () => {
261+
it('masks client_secret in url-encoded bodies regardless of position', async () => {
262+
const { OAuthProvider } = (await vi.importActual('../../oauth')) as {
263+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
264+
OAuthProvider: any
265+
}
266+
const o = new OAuthProvider({
267+
config: {
268+
CAMUNDA_OAUTH_URL: 'http://127.0.0.1:1',
269+
ZEEBE_CLIENT_ID: 'zeebe-client',
270+
ZEEBE_CLIENT_SECRET: 'zeebe-secret',
271+
CAMUNDA_TOKEN_DISK_CACHE_DISABLE: true,
272+
},
273+
})
274+
275+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
276+
const redact = (o as any).redactClientSecret.bind(o) as (
277+
body: string
278+
) => string
279+
280+
const inputs = [
281+
'client_id=zeebe-client&client_secret=supersecret&grant_type=client_credentials',
282+
'grant_type=client_credentials&client_secret=another-secret&audience=aud',
283+
'client_secret=only-field-here',
284+
'client_id=zeebe-client&client_secret=value-with-special%2Bchars&scope=foo',
285+
]
286+
287+
for (const body of inputs) {
288+
const redacted = redact(body)
289+
// Class-of-defect: secret substring must never leak, regardless of position.
290+
expect(
291+
redacted,
292+
`redacted body must contain [REDACTED]: ${redacted}`
293+
).toContain('client_secret=[REDACTED]')
294+
const originalSecret = /client_secret=([^&]+)/.exec(body)![1]
295+
expect(
296+
redacted,
297+
`redacted body must not contain raw secret ${originalSecret}`
298+
).not.toContain(originalSecret)
299+
// Non-secret fields must be preserved verbatim.
300+
for (const part of body.split('&')) {
301+
if (part.startsWith('client_secret=')) continue
302+
expect(redacted).toContain(part)
303+
}
304+
}
305+
})
306+
})

0 commit comments

Comments
 (0)