|
| 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