From 1ce9a4322e00c5872d9f3c749c6eb5a0a174abc0 Mon Sep 17 00:00:00 2001 From: Silvan Date: Tue, 27 Aug 2024 15:06:03 +0200 Subject: [PATCH] test(load): machine jwt profile grant (#8482) # Which Problems Are Solved Currently there was no load test present for machine jwt profile grant. This test is now added # How the Problems Are Solved K6 test implemented. # Additional Context - part of https://github.com/zitadel/zitadel/issues/8352 --- .gitignore | 1 + load-test/Makefile | 35 +++++++--- load-test/README.md | 5 +- load-test/package-lock.json | 44 ++++++------ load-test/package.json | 42 +++++------ load-test/src/oidc.ts | 69 ++++++++++++++++++- .../use_cases/machine_jwt_profile_grant.ts | 57 +++++++++++++++ load-test/src/user.ts | 32 +++++++++ load-test/webpack.config.js | 8 --- 9 files changed, 231 insertions(+), 62 deletions(-) create mode 100644 load-test/src/use_cases/machine_jwt_profile_grant.ts diff --git a/.gitignore b/.gitignore index 4a8a762ef0..c08cbae77f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ sandbox.go google-credentials key.json .keys/* +load-test/.keys # dumps .backups diff --git a/load-test/Makefile b/load-test/Makefile index 106b272811..4d87760eca 100644 --- a/load-test/Makefile +++ b/load-test/Makefile @@ -6,33 +6,40 @@ ADMIN_PASSWORD ?= .PHONY: human_password_login human_password_login: bundle - k6 run dist/human_password_login.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/human_password_login.js --vus ${VUS} --duration ${DURATION} .PHONY: machine_pat_login machine_pat_login: bundle - k6 run dist/machine_pat_login.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_pat_login.js --vus ${VUS} --duration ${DURATION} .PHONY: machine_client_credentials_login machine_client_credentials_login: bundle - k6 run dist/machine_client_credentials_login.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_client_credentials_login.js --vus ${VUS} --duration ${DURATION} .PHONY: user_info user_info: bundle - k6 run dist/user_info.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/user_info.js --vus ${VUS} --duration ${DURATION} .PHONY: manipulate_user manipulate_user: bundle - k6 run dist/manipulate_user.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/manipulate_user.js --vus ${VUS} --duration ${DURATION} .PHONY: introspect introspect: ensure_modules bundle go install go.k6.io/xk6/cmd/xk6@latest cd ../../xk6-modules && xk6 build --with xk6-zitadel=. - ./../../xk6-modules/k6 run dist/introspection.js --vus ${VUS} --duration ${DURATION} + ./../../xk6-modules/k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/introspection.js --vus ${VUS} --duration ${DURATION} .PHONY: add_session add_session: bundle - k6 run dist/session.js --vus ${VUS} --duration ${DURATION} + k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/session.js --vus ${VUS} --duration ${DURATION} + +.PHONY: machine_jwt_profile_grant +machine_jwt_profile_grant: ensure_modules ensure_key_pair bundle + go install go.k6.io/xk6/cmd/xk6@latest + cd ../../xk6-modules && xk6 build --with xk6-zitadel=. + ./../../xk6-modules/k6 run --summary-trend-stats "min,avg,max,p(50),p(95),p(99)" dist/machine_jwt_profile_grant.js --iterations 1 + # --vus ${VUS} --duration ${DURATION} .PHONY: lint lint: @@ -50,4 +57,16 @@ endif .PHONY: bundle bundle: npm i - npm run bundle \ No newline at end of file + npm run bundle + +.PHONY: ensure_key_pair +ensure_key_pair: +ifeq (,$(wildcard $(PWD)/.keys)) + mkdir .keys +endif +ifeq (,$(wildcard $(PWD)/.keys/key.pem)) + openssl genrsa -out .keys/key.pem 2048 +endif +ifeq (,$(wildcard $(PWD)/.keys/key.pem.pub)) + openssl rsa -in .keys/key.pem -outform PEM -pubout -out .keys/key.pem.pub +endif \ No newline at end of file diff --git a/load-test/README.md b/load-test/README.md index 18705a897d..e046372ee0 100644 --- a/load-test/README.md +++ b/load-test/README.md @@ -49,4 +49,7 @@ Before you run the tests you need an initialized user. The tests don't implement test: calls introspection endpoint using the given JWTs * `make add_session` setup: creates human users - test: creates new sessions with user id check \ No newline at end of file + test: creates new sessions with user id check +* `make machine_jwt_profile_grant` + setup: generates private/public key, creates machine users, adds a key + test: creates a token and calls user info \ No newline at end of file diff --git a/load-test/package-lock.json b/load-test/package-lock.json index 3f519201bd..d6e10ba428 100644 --- a/load-test/package-lock.json +++ b/load-test/package-lock.json @@ -19,7 +19,7 @@ "babel-loader": "9.1.3", "clean-webpack-plugin": "4.0.0", "copy-webpack-plugin": "^12.0.2", - "prettier": "^3.1.1", + "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^3.2.4", "typescript": "5.4.5", "webpack": "5.89.0", @@ -27,7 +27,7 @@ "webpack-glob-entries": "^1.0.1" }, "engines": { - "node": "16 || 18 || 20" + "node": "18 || 20" } }, "node_modules/@ampproject/remapping": { @@ -2389,12 +2389,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -2841,9 +2841,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" @@ -2965,9 +2965,9 @@ } }, "node_modules/globby": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.1.tgz", - "integrity": "sha512-jOMLD2Z7MAhyG8aJpNOpmziMOP4rPLcc95oQPKXBazW82z+CEgPFBQvEpRUa1KeIMUJo4Wsm+q6uzO/Q/4BksQ==", + "version": "14.0.2", + "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz", + "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==", "dev": true, "dependencies": { "@sindresorhus/merge-streams": "^2.1.0", @@ -3012,9 +3012,9 @@ } }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" @@ -3383,12 +3383,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", + "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -3636,9 +3636,9 @@ } }, "node_modules/prettier": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", - "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" diff --git a/load-test/package.json b/load-test/package.json index c471a4a329..cf5297d246 100644 --- a/load-test/package.json +++ b/load-test/package.json @@ -4,30 +4,30 @@ "repository": "ssh://git@github.com/zitadel/zitadel.git", "author": "ZITADEL Authors ", "engines": { - "node": "16 || 18 || 20" + "node": "18 || 20" }, "license": "Apache-2.0", "devDependencies": { - "@babel/core": "7.23.7", - "@babel/plugin-proposal-class-properties": "7.13.0", - "@babel/plugin-proposal-object-rest-spread": "7.13.8", - "@babel/preset-env": "7.23.8", - "@babel/preset-typescript": "7.23.3", - "@types/k6": ">=0.50.0", - "@types/webpack": "5.28.5", - "babel-loader": "9.1.3", - "clean-webpack-plugin": "4.0.0", - "copy-webpack-plugin": "^12.0.2", - "typescript": "5.4.5", - "webpack": "5.89.0", - "webpack-cli": "5.1.4", - "webpack-glob-entries": "^1.0.1", - "prettier": "^3.1.1", - "prettier-plugin-organize-imports": "^3.2.4" + "@babel/core": "7.23.7", + "@babel/plugin-proposal-class-properties": "7.13.0", + "@babel/plugin-proposal-object-rest-spread": "7.13.8", + "@babel/preset-env": "7.23.8", + "@babel/preset-typescript": "7.23.3", + "@types/k6": ">=0.50.0", + "@types/webpack": "5.28.5", + "babel-loader": "9.1.3", + "clean-webpack-plugin": "4.0.0", + "copy-webpack-plugin": "^12.0.2", + "prettier": "^3.3.3", + "prettier-plugin-organize-imports": "^3.2.4", + "typescript": "5.4.5", + "webpack": "5.89.0", + "webpack-cli": "5.1.4", + "webpack-glob-entries": "^1.0.1" }, "scripts": { - "bundle": "webpack", - "lint": "prettier --check src", - "lint:fix": "prettier --write src" + "bundle": "webpack", + "lint": "prettier --check src", + "lint:fix": "prettier --write src" } - } \ No newline at end of file +} diff --git a/load-test/src/oidc.ts b/load-test/src/oidc.ts index a9173bfceb..a7ebce7dc3 100644 --- a/load-test/src/oidc.ts +++ b/load-test/src/oidc.ts @@ -1,8 +1,11 @@ import { JSONObject, check, fail } from 'k6'; import encoding from 'k6/encoding'; -import http from 'k6/http'; +import http, { RequestBody } from 'k6/http'; import { Trend } from 'k6/metrics'; import url from './url'; +import { Config } from './config'; +// @ts-ignore Import module +import zitadel from 'k6/x/zitadel'; export class Tokens { idToken?: string; @@ -103,4 +106,66 @@ export function clientCredentials(clientId: string, clientSecret: string): Promi resolve(tokens) }); }); -} \ No newline at end of file +} + +export interface TokenRequest { + payload(): RequestBody; + headers(): { [name: string]: string; }; +} + +const privateKey = open('../.keys/key.pem'); + +export class JWTProfileRequest implements TokenRequest { + keyPayload!: { + userId: string; + expiration: number; + keyId: string; + }; + + constructor(userId: string, keyId: string) { + this.keyPayload = { + userId: userId, + // 1 minute + expiration: 60*1_000_000_000, + keyId: keyId, + }; + } + + payload(): RequestBody{ + const assertion = zitadel.signJWTProfileAssertion( + this.keyPayload.userId, + this.keyPayload.keyId, + { + audience: [Config.host], + expiration: this.keyPayload.expiration, + key: privateKey + }); + return { + 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer', + scope: 'openid', + assertion: `${assertion}` + }; + }; + public headers(): { [name: string]: string; } { + return { + 'Content-Type': 'application/x-www-form-urlencoded' + }; + }; +} + +const tokenDurationTrend = new Trend('oidc_token_duration', true); +export async function token(request: TokenRequest): Promise { + return http.asyncRequest('POST', configuration().token_endpoint, + request.payload(), + { + headers: request.headers(), + }, + ).then((res) => { + tokenDurationTrend.add(res.timings.duration); + check(res, { + 'token status ok': (r) => r.status === 200, + 'access token returned': (r) => r.json('access_token')! != undefined && r.json('access_token')! != '', + }); + return new Tokens(res.json() as JSONObject); + }); +}; \ No newline at end of file diff --git a/load-test/src/use_cases/machine_jwt_profile_grant.ts b/load-test/src/use_cases/machine_jwt_profile_grant.ts new file mode 100644 index 0000000000..084ac4f684 --- /dev/null +++ b/load-test/src/use_cases/machine_jwt_profile_grant.ts @@ -0,0 +1,57 @@ +import { loginByUsernamePassword } from '../login_ui'; +import { createOrg, removeOrg } from '../org'; +import {createMachine, User, addMachineKey} from '../user'; +import {JWTProfileRequest, token, userinfo} from '../oidc'; +import { Config, MaxVUs } from '../config'; +import encoding from 'k6/encoding'; + +const publicKey = encoding.b64encode(open('../.keys/key.pem.pub')); + +export async function setup() { + const tokens = loginByUsernamePassword(Config.admin as User); + console.info('setup: admin signed in'); + + const org = await createOrg(tokens.accessToken!); + console.info(`setup: org (${org.organizationId}) created`); + + let machines = ( + await Promise.all( + Array.from({ length: MaxVUs() }, (_, i) => { + return createMachine(`zitachine-${i}`, org, tokens.accessToken!); + }), + ) + ).map((machine) => { + return { userId: machine.userId, loginName: machine.loginNames[0] }; + }); + console.info(`setup: ${machines.length} machines created`); + + let keys = ( + await Promise.all( + machines.map((machine) => { + return addMachineKey( + machine.userId, + org, + tokens.accessToken!, + publicKey, + ); + }), + ) + ).map((key, i) => { + return { userId: machines[i].userId, keyId: key.keyId }; + }); + console.info(`setup: ${keys.length} keys added`); + + return { tokens, machines: keys, org }; +} + +export default function (data: any) { + token(new JWTProfileRequest(data.machines[__VU - 1].userId, data.machines[__VU - 1].keyId)) + .then((token) => { + userinfo(token.accessToken!) + }) +} + +export function teardown(data: any) { + removeOrg(data.org, data.tokens.accessToken); + console.info('teardown: org removed'); +} diff --git a/load-test/src/user.ts b/load-test/src/user.ts index 21214b16ab..6402be2034 100644 --- a/load-test/src/user.ts +++ b/load-test/src/user.ts @@ -197,6 +197,38 @@ export function addMachineSecret(userId: string, org: Org, accessToken: string): }); } +export type MachineKey = { + keyId: string; +}; + +const addMachineKeyTrend = new Trend('user_add_machine_key_duration', true); +export function addMachineKey(userId: string, org: Org, accessToken: string, publicKey?: string): Promise { + return new Promise((resolve, reject) => { + let response = http.asyncRequest('POST', url(`/management/v1/users/${userId}/keys`), + JSON.stringify({ + type: 'KEY_TYPE_JSON', + userId: userId, + // base64 encoded public key + publicKey: publicKey + }), + { + headers: { + authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + 'x-zitadel-orgid': org.organizationId, + }, + }); + response.then((res) => { + check(res, { + 'generate machine key status ok': (r) => r.status === 200, + }) || reject(`unable to generate machine Key (user id: ${userId}) status: ${res.status} body: ${res.body}`); + + addMachineKeyTrend.add(res.timings.duration); + resolve(res.json()! as MachineKey); + }); + }); +} + const lockUserTrend = new Trend('lock_user_duration', true); export function lockUser(userId: string, org: Org, accessToken: string): Promise> { return new Promise((resolve, reject) => { diff --git a/load-test/webpack.config.js b/load-test/webpack.config.js index 4d5a4c6c4c..580e891425 100644 --- a/load-test/webpack.config.js +++ b/load-test/webpack.config.js @@ -32,14 +32,6 @@ module.exports = { }, plugins: [ new CleanWebpackPlugin(), - // Copy assets to the destination folder - // see `src/post-file-test.ts` for an test example using an asset - new CopyPlugin({ - patterns: [{ - from: path.resolve(__dirname, 'assets'), - noErrorOnMissing: true - }], - }), ], optimization: { // Don't minimize, as it's not used in the browser