diff --git a/README.md b/README.md index 8b05bde..8ec36e0 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,7 @@ const zbc = new ZBClient("my-secure-broker.io:443", { oAuth: { url: "https://your-auth-endpoint/oauth/token", audience: "my-secure-broker.io", + scope: "myScope", clientId: "myClientId", clientSecret: "randomClientSecret", customRootCert: fs.readFileSync('./my_CA.pem'), @@ -599,6 +600,7 @@ Self-hosted or local broker with OAuth + TLS: ZEEBE_CLIENT_ID ZEEBE_CLIENT_SECRET ZEEBE_TOKEN_AUDIENCE +ZEEBE_TOKEN_SCOPE ZEEBE_AUTHORIZATION_SERVER_URL ZEEBE_ADDRESS ``` @@ -613,6 +615,7 @@ ZEEBE_CLIENT_ID='zeebe' ZEEBE_CLIENT_SECRET='zecret' ZEEBE_AUTHORIZATION_SERVER_URL='http://localhost:18080/auth/realms/camunda-platform/protocol/openid-connect/token' ZEEBE_TOKEN_AUDIENCE='zeebe.camunda.io' +ZEEBE_TOKEN_SCOPE='not needed' CAMUNDA_CREDENTIALS_SCOPES='Zeebe' CAMUNDA_OAUTH_URL='http://localhost:18080/auth/realms/camunda-platform/protocol/openid-connect/token' ``` diff --git a/src/__tests__/ConfigurationHydrator.spec.ts b/src/__tests__/ConfigurationHydrator.spec.ts index 0eee55c..edb3024 100644 --- a/src/__tests__/ConfigurationHydrator.spec.ts +++ b/src/__tests__/ConfigurationHydrator.spec.ts @@ -13,6 +13,7 @@ const ENV_VARS_TO_STORE = [ 'ZEEBE_GATEWAY_ADDRESS', 'ZEEBE_ADDRESS', 'ZEEBE_TOKEN_AUDIENCE', + 'ZEEBE_TOKEN_SCOPE', 'ZEEBE_AUTHORIZATION_SERVER_URL', 'ZEEBE_CLIENT_MAX_RETRIES', 'ZEEBE_CLIENT_RETRY', @@ -20,7 +21,7 @@ const ENV_VARS_TO_STORE = [ 'ZEEBE_CLIENT_SSL_ROOT_CERTS_PATH', 'ZEEBE_CLIENT_SSL_PRIVATE_KEY_PATH', 'ZEEBE_CLIENT_SSL_CERT_CHAIN_PATH', - 'ZEEBE_TENANT_ID' + 'ZEEBE_TENANT_ID', ] beforeAll(() => { @@ -96,6 +97,29 @@ test('Takes an explicit Gateway address over the environment ZEEBE_GATEWAY_ADDRE expect(conf.port).toBe('26600') }) +/** + * Self-managed + */ +test('Constructs the self-managed connection with oauth credentials', () => { + process.env.ZEEBE_CLIENT_SECRET = 'CLIENT_SECRET' + process.env.ZEEBE_CLIENT_ID = 'CLIENT_ID' + process.env.ZEEBE_GATEWAY_ADDRESS = 'zeebe://my-server:26600' + process.env.ZEEBE_TOKEN_AUDIENCE = 'TOKEN_AUDIENCE' + process.env.ZEEBE_TOKEN_SCOPE = 'TOKEN_SCOPE' + process.env.ZEEBE_AUTHORIZATION_SERVER_URL = 'https://auz' + + const conf = ConfigurationHydrator.configure(undefined, undefined) + + expect(conf.hostname).toBe('my-server') + expect(conf.port).toBe('26600') + expect(conf.oAuth!.audience).toBe('TOKEN_AUDIENCE') + expect(conf.oAuth!.scope).toBe('TOKEN_SCOPE') + expect(conf.oAuth!.clientId).toBe('CLIENT_ID') + expect(conf.oAuth!.audience).toBe('TOKEN_AUDIENCE') + expect(conf.oAuth!.clientSecret).toBe('CLIENT_SECRET') + expect(conf.oAuth!.url).toBe('https://auz') +}) + /** * Camunda Cloud */ @@ -105,7 +129,13 @@ test('Constructs the Camunda Cloud connection from the environment with clusterI process.env.ZEEBE_CLIENT_SECRET = 'WZahIGHjyj0-oQ7DZ_aH2wwNuZt5O8Sq0ZJTz0OaxfO7D6jaDBZxM_Q-BHRsiGO_' process.env.ZEEBE_CLIENT_ID = 'yStuGvJ6a1RQhy8DQpeXJ80yEpar3pXh' + delete process.env.ZEEBE_GATEWAY_ADDRESS + delete process.env.ZEEBE_TOKEN_AUDIENCE + delete process.env.ZEEBE_TOKEN_SCOPE + delete process.env.ZEEBE_AUTHORIZATION_SERVER_URL + delete process.env.ZEEBE_GATEWAY_ADDRESS + // process.env.ZEEBE_GATEWAY_ADDRESS = 'zeebe://localhost:26500' const conf = ConfigurationHydrator.configure(undefined, undefined) expect(conf.hostname).toBe( @@ -526,13 +556,17 @@ test('Tenant ID is picked up from environment', () => { }) test('Tenant ID is picked up from constructor options', () => { - const conf = ConfigurationHydrator.configure(undefined, {tenantId: 'thisOne'}) + const conf = ConfigurationHydrator.configure(undefined, { + tenantId: 'thisOne', + }) expect(conf.tenantId).toBe('thisOne') }) test('Tenant ID from constructor overrides environment', () => { process.env.ZEEBE_TENANT_ID = 'someId' - const conf = ConfigurationHydrator.configure(undefined, {tenantId: 'thisOne'}) + const conf = ConfigurationHydrator.configure(undefined, { + tenantId: 'thisOne', + }) expect(conf.tenantId).toBe('thisOne') }) diff --git a/src/__tests__/OAuthProvider.spec.ts b/src/__tests__/OAuthProvider.spec.ts index cccd1bd..0bb05a5 100644 --- a/src/__tests__/OAuthProvider.spec.ts +++ b/src/__tests__/OAuthProvider.spec.ts @@ -130,6 +130,41 @@ test('Throws in the constructor if the token cache is not writable', () => { expect(fs.existsSync(tokenCache)).toBe(false) }) +test('Send form encoded request', () => { + const o = new OAuthProvider({ + audience: 'token', + cacheOnDisk: false, + clientId: 'clientId', + clientSecret: 'clientSecret', + url: 'http://127.0.0.1:3001/foobar', + }) + const server = http + .createServer((req, res) => { + expect(req.url).toBe('/foobar') + expect(req.method).toBe('POST') + expect(req.headers['user-agent']).toContain('zeebe-client-nodejs/') + + let body = '' + req.on('data', chunk => { + body += chunk + }) + + req.on('end', () => { + expect(body).toEqual( + 'audience=token&client_id=clientId&client_secret=clientSecret&grant_type=client_credentials' + ) + + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('{"token": "something"}') + }) + }) + .listen(3001) + return o.getToken().finally(() => { + o.stopExpiryTimer() + return server.close() + }) +}) + test('Can set a custom user agent', () => { process.env.ZEEBE_CLIENT_CUSTOM_AGENT_STRING = 'modeler' const o = new OAuthProvider({ @@ -137,15 +172,37 @@ test('Can set a custom user agent', () => { cacheOnDisk: true, clientId: 'clientId', clientSecret: 'clientSecret', - url: 'url', + url: 'http://127.0.0.1:3002', + }) + const server = http + .createServer((req, res) => { + expect(req.method).toBe('POST') + expect(req.headers['user-agent']).toContain('modeler') + + req.on('data', () => { + // ignoring + }) + + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end('{"token": "something"}') + }) + }) + .listen(3002) + + return o.getToken().finally(() => { + o.stopExpiryTimer() + + delete process.env.ZEEBE_CLIENT_CUSTOM_AGENT_STRING + + return server.close() }) - expect(o.userAgentString.includes(' modeler')).toBe(true) - o.stopExpiryTimer() }) -test('Uses form encoding for request', done => { +test('Passes scope, if provided', () => { const o = new OAuthProvider({ audience: 'token', + scope: 'scope', cacheOnDisk: false, clientId: 'clientId', clientSecret: 'clientSecret', @@ -162,21 +219,23 @@ test('Uses form encoding for request', done => { req.on('end', () => { res.writeHead(200, { 'Content-Type': 'application/json' }) res.end('{"token": "something"}') - server.close() + expect(body).toEqual( - 'audience=token&client_id=clientId&client_secret=clientSecret&grant_type=client_credentials' + 'audience=token&client_id=clientId&client_secret=clientSecret&grant_type=client_credentials&scope=scope' ) - done() }) } }) .listen(3001) - o.getToken().then(() => o.stopExpiryTimer()) - expect(o.userAgentString.includes(' modeler')).toBe(true) + return o.getToken().finally(() => { + o.stopExpiryTimer() + + return server.close() + }) }) -test('In-memory cache is populated and evicted after timeout', done => { +test('In-memory cache is populated and evicted after timeout', () => { const delay = timeout => new Promise(res => setTimeout(() => res(null), timeout)) @@ -185,40 +244,40 @@ test('In-memory cache is populated and evicted after timeout', done => { cacheOnDisk: false, clientId: 'clientId', clientSecret: 'clientSecret', - url: 'http://127.0.0.1:3002', + url: 'http://127.0.0.1:3001', }) const server = http .createServer((req, res) => { - if (req.method === 'POST') { - let body = '' - req.on('data', chunk => { - body += chunk - }) + expect(req.method).toBe('POST') - req.on('end', () => { - res.writeHead(200, { 'Content-Type': 'application/json' }) - let expires_in = 2 // seconds - res.end( - '{"access_token": "something", "expires_in": ' + - expires_in + - '}' - ) - server.close() - expect(body).toEqual( - 'audience=token&client_id=clientId&client_secret=clientSecret&grant_type=client_credentials' - ) - }) - } + req.on('data', () => { + // ignoring + }) + + req.on('end', () => { + res.writeHead(200, { 'Content-Type': 'application/json' }) + let expires_in = 2 // seconds + res.end( + '{"access_token": "something", "expires_in": ' + + expires_in + + '}' + ) + }) }) - .listen(3002) + .listen(3001) - o.getToken().then(async _ => { - expect(o.tokenCache['clientId']).toBeDefined() - await delay(500) - expect(o.tokenCache['clientId']).toBeDefined() - await delay(1600) - expect(o.tokenCache['clientId']).not.toBeDefined() - o.stopExpiryTimer() - done() - }) + return o + .getToken() + .then(async () => { + expect(o.tokenCache['clientId']).toBeDefined() + await delay(500) + expect(o.tokenCache['clientId']).toBeDefined() + await delay(1600) + expect(o.tokenCache['clientId']).not.toBeDefined() + }) + .finally(() => { + o.stopExpiryTimer() + + return server.close() + }) }) diff --git a/src/lib/ConfigurationHydrator.ts b/src/lib/ConfigurationHydrator.ts index f1f9914..afe4547 100644 --- a/src/lib/ConfigurationHydrator.ts +++ b/src/lib/ConfigurationHydrator.ts @@ -21,6 +21,7 @@ export class ConfigurationHydrator { 'ZEEBE_CLIENT_SECRET', 'ZEEBE_SECURE_CONNECTION', 'ZEEBE_TOKEN_AUDIENCE', + 'ZEEBE_TOKEN_SCOPE', 'ZEEBE_AUTHORIZATION_SERVER_URL', 'ZEEBE_CAMUNDA_CLOUD_CLUSTER_ID', 'ZEEBE_BASIC_AUTH_PASSWORD', @@ -32,7 +33,7 @@ export class ConfigurationHydrator { 'ZEEBE_CLIENT_SSL_ROOT_CERTS_PATH', 'ZEEBE_CLIENT_SSL_PRIVATE_KEY_PATH', 'ZEEBE_CLIENT_SSL_CERT_CHAIN_PATH', - 'ZEEBE_TENANT_ID' + 'ZEEBE_TENANT_ID', ]) public static configure( @@ -52,7 +53,7 @@ export class ConfigurationHydrator { ...ConfigurationHydrator.readTLSFromEnvironment(options), ...ConfigurationHydrator.getEagerStatus(options), ...ConfigurationHydrator.getRetryConfiguration(options), - ...ConfigurationHydrator.getTenantId(options) + ...ConfigurationHydrator.getTenantId(options), } // inherit oAuth custom root certificates, unless @@ -128,6 +129,7 @@ export class ConfigurationHydrator { const clientId = ConfigurationHydrator.getClientIdFromEnv() const clientSecret = ConfigurationHydrator.getClientSecretFromEnv() const audience = ConfigurationHydrator.ENV().ZEEBE_TOKEN_AUDIENCE + const scope = ConfigurationHydrator.ENV().ZEEBE_TOKEN_SCOPE const authServerUrl = ConfigurationHydrator.ENV() .ZEEBE_AUTHORIZATION_SERVER_URL const clusterId = ConfigurationHydrator.ENV() @@ -144,6 +146,7 @@ export class ConfigurationHydrator { ? { oAuth: { audience, + scope, cacheOnDisk: true, clientId: clientId!, clientSecret, diff --git a/src/lib/OAuthProvider.ts b/src/lib/OAuthProvider.ts index 62a2909..66f7208 100644 --- a/src/lib/OAuthProvider.ts +++ b/src/lib/OAuthProvider.ts @@ -23,6 +23,8 @@ export interface OAuthProviderConfig { url: string /** OAuth Audience */ audience: string + /** OAuth Scope */ + scope?: string clientId: string clientSecret: string /** Custom TLS certificate for OAuth */ @@ -39,6 +41,7 @@ export class OAuthProvider { process.env.ZEEBE_TOKEN_CACHE_DIR || OAuthProvider.defaultTokenCache public cacheDir: string public audience: string + public scope?: string public url: string public clientId: string public clientSecret: string @@ -56,6 +59,8 @@ export class OAuthProvider { url, /** OAuth Audience */ audience, + /** OAuth Scope */ + scope, cacheDir, clientId, clientSecret, @@ -66,6 +71,7 @@ export class OAuthProvider { }: { url: string audience: string + scope?: string cacheDir?: string clientId: string clientSecret: string @@ -74,6 +80,7 @@ export class OAuthProvider { }) { this.url = url this.audience = audience + this.scope = scope this.clientId = clientId this.clientSecret = clientSecret this.customRootCert = customRootCert @@ -153,6 +160,9 @@ export class OAuthProvider { client_id: this.clientId, client_secret: this.clientSecret, grant_type: 'client_credentials', + ...( + this.scope && { scope: this.scope } || {} + ) } debug(`Requesting token from token endpoint...`)