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
This commit is contained in:
Silvan 2024-08-27 15:06:03 +02:00 committed by GitHub
parent cbbd44c303
commit 1ce9a4322e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 231 additions and 62 deletions

1
.gitignore vendored
View File

@ -31,6 +31,7 @@ sandbox.go
google-credentials google-credentials
key.json key.json
.keys/* .keys/*
load-test/.keys
# dumps # dumps
.backups .backups

View File

@ -6,33 +6,40 @@ ADMIN_PASSWORD ?=
.PHONY: human_password_login .PHONY: human_password_login
human_password_login: bundle 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 .PHONY: machine_pat_login
machine_pat_login: bundle 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 .PHONY: machine_client_credentials_login
machine_client_credentials_login: bundle 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 .PHONY: user_info
user_info: bundle 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 .PHONY: manipulate_user
manipulate_user: bundle 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 .PHONY: introspect
introspect: ensure_modules bundle introspect: ensure_modules bundle
go install go.k6.io/xk6/cmd/xk6@latest go install go.k6.io/xk6/cmd/xk6@latest
cd ../../xk6-modules && xk6 build --with xk6-zitadel=. 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 .PHONY: add_session
add_session: bundle 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 .PHONY: lint
lint: lint:
@ -50,4 +57,16 @@ endif
.PHONY: bundle .PHONY: bundle
bundle: bundle:
npm i npm i
npm run bundle 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

View File

@ -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 test: calls introspection endpoint using the given JWTs
* `make add_session` * `make add_session`
setup: creates human users setup: creates human users
test: creates new sessions with user id check 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

View File

@ -19,7 +19,7 @@
"babel-loader": "9.1.3", "babel-loader": "9.1.3",
"clean-webpack-plugin": "4.0.0", "clean-webpack-plugin": "4.0.0",
"copy-webpack-plugin": "^12.0.2", "copy-webpack-plugin": "^12.0.2",
"prettier": "^3.1.1", "prettier": "^3.3.3",
"prettier-plugin-organize-imports": "^3.2.4", "prettier-plugin-organize-imports": "^3.2.4",
"typescript": "5.4.5", "typescript": "5.4.5",
"webpack": "5.89.0", "webpack": "5.89.0",
@ -27,7 +27,7 @@
"webpack-glob-entries": "^1.0.1" "webpack-glob-entries": "^1.0.1"
}, },
"engines": { "engines": {
"node": "16 || 18 || 20" "node": "18 || 20"
} }
}, },
"node_modules/@ampproject/remapping": { "node_modules/@ampproject/remapping": {
@ -2389,12 +2389,12 @@
} }
}, },
"node_modules/braces": { "node_modules/braces": {
"version": "3.0.2", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"fill-range": "^7.0.1" "fill-range": "^7.1.1"
}, },
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@ -2841,9 +2841,9 @@
} }
}, },
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.0.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@ -2965,9 +2965,9 @@
} }
}, },
"node_modules/globby": { "node_modules/globby": {
"version": "14.0.1", "version": "14.0.2",
"resolved": "https://registry.npmjs.org/globby/-/globby-14.0.1.tgz", "resolved": "https://registry.npmjs.org/globby/-/globby-14.0.2.tgz",
"integrity": "sha512-jOMLD2Z7MAhyG8aJpNOpmziMOP4rPLcc95oQPKXBazW82z+CEgPFBQvEpRUa1KeIMUJo4Wsm+q6uzO/Q/4BksQ==", "integrity": "sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@sindresorhus/merge-streams": "^2.1.0", "@sindresorhus/merge-streams": "^2.1.0",
@ -3012,9 +3012,9 @@
} }
}, },
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.1", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
"integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">= 4" "node": ">= 4"
@ -3383,12 +3383,12 @@
} }
}, },
"node_modules/micromatch": { "node_modules/micromatch": {
"version": "4.0.5", "version": "4.0.7",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
"integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"braces": "^3.0.2", "braces": "^3.0.3",
"picomatch": "^2.3.1" "picomatch": "^2.3.1"
}, },
"engines": { "engines": {
@ -3636,9 +3636,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.2.5", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"dev": true, "dev": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"

View File

@ -4,30 +4,30 @@
"repository": "ssh://git@github.com/zitadel/zitadel.git", "repository": "ssh://git@github.com/zitadel/zitadel.git",
"author": "ZITADEL Authors <hi@zitadel.com>", "author": "ZITADEL Authors <hi@zitadel.com>",
"engines": { "engines": {
"node": "16 || 18 || 20" "node": "18 || 20"
}, },
"license": "Apache-2.0", "license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@babel/core": "7.23.7", "@babel/core": "7.23.7",
"@babel/plugin-proposal-class-properties": "7.13.0", "@babel/plugin-proposal-class-properties": "7.13.0",
"@babel/plugin-proposal-object-rest-spread": "7.13.8", "@babel/plugin-proposal-object-rest-spread": "7.13.8",
"@babel/preset-env": "7.23.8", "@babel/preset-env": "7.23.8",
"@babel/preset-typescript": "7.23.3", "@babel/preset-typescript": "7.23.3",
"@types/k6": ">=0.50.0", "@types/k6": ">=0.50.0",
"@types/webpack": "5.28.5", "@types/webpack": "5.28.5",
"babel-loader": "9.1.3", "babel-loader": "9.1.3",
"clean-webpack-plugin": "4.0.0", "clean-webpack-plugin": "4.0.0",
"copy-webpack-plugin": "^12.0.2", "copy-webpack-plugin": "^12.0.2",
"typescript": "5.4.5", "prettier": "^3.3.3",
"webpack": "5.89.0", "prettier-plugin-organize-imports": "^3.2.4",
"webpack-cli": "5.1.4", "typescript": "5.4.5",
"webpack-glob-entries": "^1.0.1", "webpack": "5.89.0",
"prettier": "^3.1.1", "webpack-cli": "5.1.4",
"prettier-plugin-organize-imports": "^3.2.4" "webpack-glob-entries": "^1.0.1"
}, },
"scripts": { "scripts": {
"bundle": "webpack", "bundle": "webpack",
"lint": "prettier --check src", "lint": "prettier --check src",
"lint:fix": "prettier --write src" "lint:fix": "prettier --write src"
} }
} }

View File

@ -1,8 +1,11 @@
import { JSONObject, check, fail } from 'k6'; import { JSONObject, check, fail } from 'k6';
import encoding from 'k6/encoding'; import encoding from 'k6/encoding';
import http from 'k6/http'; import http, { RequestBody } from 'k6/http';
import { Trend } from 'k6/metrics'; import { Trend } from 'k6/metrics';
import url from './url'; import url from './url';
import { Config } from './config';
// @ts-ignore Import module
import zitadel from 'k6/x/zitadel';
export class Tokens { export class Tokens {
idToken?: string; idToken?: string;
@ -103,4 +106,66 @@ export function clientCredentials(clientId: string, clientSecret: string): Promi
resolve(tokens) resolve(tokens)
}); });
}); });
} }
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<Tokens> {
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);
});
};

View File

@ -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');
}

View File

@ -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<MachineKey> {
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); const lockUserTrend = new Trend('lock_user_duration', true);
export function lockUser(userId: string, org: Org, accessToken: string): Promise<RefinedResponse<any>> { export function lockUser(userId: string, org: Org, accessToken: string): Promise<RefinedResponse<any>> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@ -32,14 +32,6 @@ module.exports = {
}, },
plugins: [ plugins: [
new CleanWebpackPlugin(), 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: { optimization: {
// Don't minimize, as it's not used in the browser // Don't minimize, as it's not used in the browser