test(session): load tests for session api (#9212)

# Which Problems Are Solved

We currently are not able to benchmark the performance of the session
api

# How the Problems Are Solved

Load tests were added to
- use sessions in oidc tokens analog
https://zitadel.com/docs/guides/integrate/login-ui/oidc-standard

# Additional Context

- Closes https://github.com/zitadel/zitadel/issues/7847
This commit is contained in:
Silvan
2025-01-29 13:08:20 +01:00
committed by GitHub
parent 679ab58fa1
commit b10428fb56
18 changed files with 409 additions and 112 deletions

View File

@@ -21,10 +21,27 @@ export function loginByUsernamePassword(user: User) {
}
const initLoginTrend = new Trend('login_ui_init_login_duration', true);
function initLogin(): Response {
const response = http.get(url('/oauth/v2/authorize', { searchParams: Client() }));
export function initLogin(clientId?: string): Response {
let params = {};
let expectedStatus = 200;
if (clientId) {
params = {
headers: {
'x-zitadel-login-client': clientId,
},
redirects: 0,
};
expectedStatus = 302;
}
const response = http.get(
url('/oauth/v2/authorize', {
searchParams: Client(),
}),
params,
);
check(response, {
'authorize status ok': (r) => r.status == 200 || fail(`init login failed: ${r}`),
'authorize status ok': (r) => r.status == expectedStatus || fail(`init login failed: ${JSON.stringify(r)}`),
});
initLoginTrend.add(response.timings.duration);
return response;

View File

@@ -0,0 +1,25 @@
import http from 'k6/http';
import { Trend } from 'k6/metrics';
import url from './url';
import { check, fail } from 'k6';
const addIAMMemberTrend = new Trend('membership_iam_member', true);
export async function addIAMMember(userId: string, roles: string[], accessToken: string): Promise<void> {
const res = await http.post(
url('/admin/v1/members'),
JSON.stringify({
userId: userId,
roles: roles,
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
},
);
check(res, {
'member added successful': (r) => r.status == 200 || fail(`unable add member: ${JSON.stringify(res)}`),
});
addIAMMemberTrend.add(res.timings.duration);
}

View File

@@ -1,9 +1,9 @@
import { JSONObject, check, fail } from 'k6';
import encoding from 'k6/encoding';
import http, { RequestBody } from 'k6/http';
import http, { RequestBody, Response } from 'k6/http';
import { Trend } from 'k6/metrics';
import url from './url';
import { Config } from './config';
import { Client, Config } from './config';
// @ts-ignore Import module
import zitadel from 'k6/x/zitadel';
@@ -79,9 +79,11 @@ export function introspect(jwt: string, token: string) {
const clientCredentialsTrend = new Trend('oidc_client_credentials_duration', true);
export function clientCredentials(clientId: string, clientSecret: string): Promise<Tokens> {
return new Promise((resolve, reject) => {
const response = http.asyncRequest('POST', configuration().token_endpoint,
const response = http.asyncRequest(
'POST',
configuration().token_endpoint,
{
grant_type: "client_credentials",
grant_type: 'client_credentials',
scope: 'openid profile urn:zitadel:iam:org:project:id:zitadel:aud',
client_id: clientId,
client_secret: clientSecret,
@@ -91,26 +93,26 @@ export function clientCredentials(clientId: string, clientSecret: string): Promi
'Content-Type': 'application/x-www-form-urlencoded',
},
},
);
);
response.then((res) => {
check(res, {
'client credentials status ok': (r) => r.status === 200,
}) || reject(`client credentials request failed (client id: ${clientId}) status: ${res.status} body: ${res.body}`);
clientCredentialsTrend.add(res.timings.duration);
const tokens = new Tokens(res.json() as JSONObject)
const tokens = new Tokens(res.json() as JSONObject);
check(tokens, {
'client credentials token ok': (t) => t.accessToken !== undefined,
}) || reject(`client credentials access token missing (client id: ${clientId}`);
resolve(tokens)
resolve(tokens);
});
});
}
export interface TokenRequest {
payload(): RequestBody;
headers(): { [name: string]: string; };
headers(): { [name: string]: string };
}
const privateKey = open('../.keys/key.pem');
@@ -126,46 +128,83 @@ export class JWTProfileRequest implements TokenRequest {
this.keyPayload = {
userId: userId,
// 1 minute
expiration: 60*1_000_000_000,
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
});
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}`
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
scope: 'openid urn:zitadel:iam:org:project:id:zitadel:aud',
assertion: `${assertion}`,
};
};
public headers(): { [name: string]: string; } {
}
public headers(): { [name: string]: string } {
return {
'Content-Type': 'application/x-www-form-urlencoded'
'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(),
{
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')! != '',
})
.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);
});
return new Tokens(res.json() as JSONObject);
}
const authRequestBiIDTrend = new Trend('oidc_auth_request_by_id_duration', true);
export async function authRequestByID(id: string, tokens: any): Promise<Response> {
const response = http.get(url(`/v2/oidc/auth_requests/${id}`), {
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
},
});
};
check(response, {
'authorize status ok': (r) => r.status == 200 || fail(`auth request by failed: ${JSON.stringify(r)}`),
});
authRequestBiIDTrend.add(response.timings.duration);
return response;
}
const finalizeAuthRequestTrend = new Trend('oidc_auth_requst_by_id_duration', true);
export async function finalizeAuthRequest(id: string, session: any, tokens: any): Promise<Response> {
const res = await http.post(
url(`/v2/oidc/auth_requests/${id}`),
JSON.stringify({
session: {
sessionId: session.sessionId,
sessionToken: session.sessionToken,
},
}),
{
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
'Content-Type': 'application/json',
// 'Accept': 'application/json',
'x-zitadel-login-client': tokens.info.client_id,
},
},
);
check(res, {
'finalize auth request status ok': (r) => r.status == 200 || fail(`finalize auth request failed: ${JSON.stringify(r)}`),
});
finalizeAuthRequestTrend.add(res.timings.duration);
return res;
}

View File

@@ -3,33 +3,23 @@ import { Org } from './org';
import http from 'k6/http';
import url from './url';
import { check } from 'k6';
import { User } from './user';
export type Session = {
challenges: any;
id: string;
token: string;
};
const addSessionTrend = new Trend('session_add_session_duration', true);
export function createSession(user: User, org: Org, accessToken: string): Promise<Session> {
export function createSession(org: Org, accessToken: string, checks?: any): Promise<Session> {
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url('/v2beta/sessions'),
JSON.stringify({
checks: {
user: {
userId: user.userId,
}
}
}),
{
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
let response = http.asyncRequest('POST', url('/v2/sessions'), checks ? JSON.stringify({ checks: checks }) : null, {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
);
});
response.then((res) => {
check(res, {
'add Session status ok': (r) => r.status === 201,
@@ -40,3 +30,29 @@ export function createSession(user: User, org: Org, accessToken: string): Promis
});
});
}
const setSessionTrend = new Trend('session_set_session_duration', true);
export function setSession(id: string, session: any, accessToken: string, challenges?: any, checks?: any): Promise<Session> {
const body = {
sessionToken: session.sessionToken,
checks: checks ? checks : null,
challenges: challenges ? challenges : null,
};
return new Promise((resolve, reject) => {
let response = http.asyncRequest('PATCH', url(`/v2/sessions/${id}`), JSON.stringify(body), {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
// 'x-zitadel-orgid': org.organizationId,
},
});
response.then((res) => {
check(res, {
'set Session status ok': (r) => r.status === 200,
}) || reject(`unable to set Session status: ${res.status} body: ${res.body}`);
setSessionTrend.add(res.timings.duration);
resolve(res.json() as Session);
});
});
}

View File

@@ -1,7 +1,7 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import {createMachine, User, addMachineSecret} from '../user';
import {clientCredentials, userinfo} from '../oidc';
import { createMachine, User, addMachineSecret } from '../user';
import { clientCredentials, userinfo } from '../oidc';
import { Config, MaxVUs } from '../config';
export async function setup() {
@@ -37,10 +37,9 @@ export async function setup() {
}
export default function (data: any) {
clientCredentials(data.machines[__VU - 1].loginName, data.machines[__VU - 1].password)
.then((token) => {
userinfo(token.accessToken!)
})
clientCredentials(data.machines[__VU - 1].loginName, data.machines[__VU - 1].password).then((token) => {
userinfo(token.accessToken!);
});
}
export function teardown(data: any) {

View File

@@ -1,7 +1,7 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import {createMachine, User, addMachineKey} from '../user';
import {JWTProfileRequest, token, userinfo} from '../oidc';
import { createMachine, User, addMachineKey } from '../user';
import { JWTProfileRequest, token, userinfo } from '../oidc';
import { Config, MaxVUs } from '../config';
import encoding from 'k6/encoding';
@@ -10,10 +10,10 @@ 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) => {
@@ -24,16 +24,11 @@ export async function setup() {
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,
);
return addMachineKey(machine.userId, org, tokens.accessToken!, publicKey);
}),
)
).map((key, i) => {
@@ -45,7 +40,7 @@ export async function setup() {
}
export default function (data: any) {
token(new JWTProfileRequest(data.machines[__VU - 1].userId, data.machines[__VU - 1].keyId))
token(new JWTProfileRequest(data.machines[__VU - 1].userId, data.machines[__VU - 1].keyId));
}
export function teardown(data: any) {

View File

@@ -1,7 +1,7 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import {createMachine, User, addMachineKey} from '../user';
import {JWTProfileRequest, token, userinfo} from '../oidc';
import { createMachine, User, addMachineKey } from '../user';
import { JWTProfileRequest, token, userinfo } from '../oidc';
import { Config } from '../config';
import encoding from 'k6/encoding';
@@ -10,20 +10,20 @@ 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`);
const machine = await createMachine(`zitachine`, org, tokens.accessToken!);
console.info(`setup: machine ${machine.userId} created`);
const key = await addMachineKey(machine.userId, org, tokens.accessToken!, publicKey);
console.info(`setup: key ${key.keyId} added`);
return { tokens, machine: {userId: machine.userId, keyId: key.keyId}, org };
return { tokens, machine: { userId: machine.userId, keyId: key.keyId }, org };
}
export default function (data: any) {
token(new JWTProfileRequest(data.machine.userId, data.machine.keyId))
token(new JWTProfileRequest(data.machine.userId, data.machine.keyId));
}
export function teardown(data: any) {

View File

@@ -1,9 +1,9 @@
import { loginByUsernamePassword } from '../login_ui';
import { createOrg, removeOrg } from '../org';
import { User, createHuman } from '../user';
import { loginByUsernamePassword } from '../../login_ui';
import { createOrg, removeOrg } from '../../org';
import { User, createHuman } from '../../user';
import { Trend } from 'k6/metrics';
import { Config, MaxVUs } from '../config';
import { createSession } from '../session';
import { Config, MaxVUs } from '../../config';
import { createSession } from '../../session';
import { check } from 'k6';
export async function setup() {
@@ -27,12 +27,16 @@ export async function setup() {
const addSessionTrend = new Trend('add_session_duration', true);
export default async function (data: any) {
const start = new Date();
const session = await createSession(data.users[__VU - 1], data.org, data.tokens.accessToken);
const session = await createSession(data.org, data.tokens.accessToken, {
user: {
userId: data.users[__VU - 1].userId,
},
});
check(session, {
'add session is status ok': (s) => s.id !== "",
'add session is status ok': (s) => s.id !== '',
});
addSessionTrend.add(new Date().getTime() - start.getTime());
}

View File

@@ -0,0 +1,53 @@
import { loginByUsernamePassword, initLogin } from '../../login_ui';
import { createOrg, removeOrg } from '../../org';
import { User, addMachineKey, createMachine } from '../../user';
import { Trend } from 'k6/metrics';
import { Config } from '../../config';
import { check } from 'k6';
import { finalizeAuthRequest, JWTProfileRequest, token } from '../../oidc';
import { createSession } from '../../session';
import encoding from 'k6/encoding';
import { addIAMMember } from '../../membership';
const publicKey = encoding.b64encode(open('../.keys/key.pem.pub'));
export async function setup() {
const adminTokens = loginByUsernamePassword(Config.admin as User);
console.log('setup: admin signed in');
const org = await createOrg(adminTokens.accessToken!);
console.log(`setup: org (${org.organizationId}) created`);
const loginUser = await createMachine('load-test', org, adminTokens.accessToken!);
const loginUserKey = await addMachineKey(loginUser.userId, org, adminTokens.accessToken!, publicKey);
await addIAMMember(loginUser.userId, ['IAM_LOGIN_CLIENT'], adminTokens.accessToken!);
const tokens = await token(new JWTProfileRequest(loginUser.userId, loginUserKey.keyId));
return { tokens, user: loginUser, key: loginUserKey, org, adminTokens };
}
// implements the flow described in
// https://zitadel.com/docs/guides/integrate/login-ui/oidc-standard
const addSessionTrend = new Trend('oidc_session_duration', true);
export default async function (data: any) {
const start = new Date();
const authorizeResponse = initLogin(data.tokens.info.client_id);
const authRequestId = new URLSearchParams(authorizeResponse.headers['Location']).values().next().value;
check(authRequestId, {
'auth request id returned': (s) => s !== '',
});
const session = await createSession(data.org, data.tokens.accessToken, {
user: {
userId: data.user.userId,
},
});
await finalizeAuthRequest(authRequestId!, session, data.tokens!);
addSessionTrend.add(new Date().getTime() - start.getTime());
}
export function teardown(data: any) {
removeOrg(data.org, data.adminTokens.accessToken);
}

View File

@@ -0,0 +1,57 @@
import { loginByUsernamePassword } from '../../login_ui';
import { createOrg, removeOrg } from '../../org';
import { createHuman, setEmailOTPOnHuman, User } from '../../user';
import { Trend } from 'k6/metrics';
import { Config, MaxVUs } from '../../config';
import { createSession, setSession } from '../../session';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.log('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.log(`setup: org (${org.organizationId}) created`);
const humanPromises = Array.from({ length: MaxVUs() }, (_, i) => {
return createHuman(`zitizen-${i}`, org, tokens.accessToken!);
});
const humans = (await Promise.all(humanPromises)).map((user) => {
setEmailOTPOnHuman(user, org, tokens.accessToken!);
return { userId: user.userId, loginName: user.loginNames[0], password: 'Password1!' };
});
console.log(`setup: ${humans.length} users created`);
return { tokens, org, users: humans };
}
// implements the flow described in
// https://zitadel.com/docs/guides/integrate/login-ui/oidc-standard
const otpSessionTrend = new Trend('otp_session_duration', true);
export default async function (data: any) {
const start = new Date();
let session = await createSession(data.org, data.tokens.accessToken, {
user: {
loginName: data.users[__VU - 1].loginName,
},
});
const sessionId = (session as any).sessionId;
session = await setSession(sessionId, session, data.tokens.accessToken, {
otpEmail: {
return_code: {},
},
});
session = await setSession(sessionId, session, data.tokens.accessToken, null, {
otpEmail: {
code: session.challenges.otpEmail,
},
});
otpSessionTrend.add(new Date().getTime() - start.getTime());
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
}

View File

@@ -0,0 +1,49 @@
import { loginByUsernamePassword } from '../../login_ui';
import { createOrg, removeOrg } from '../../org';
import { createHuman, setEmailOTPOnHuman, User } from '../../user';
import { Trend } from 'k6/metrics';
import { Config, MaxVUs } from '../../config';
import { createSession, setSession } from '../../session';
export async function setup() {
const tokens = loginByUsernamePassword(Config.admin as User);
console.log('setup: admin signed in');
const org = await createOrg(tokens.accessToken!);
console.log(`setup: org (${org.organizationId}) created`);
const humanPromises = Array.from({ length: MaxVUs() }, (_, i) => {
return createHuman(`zitizen-${i}`, org, tokens.accessToken!);
});
const humans = (await Promise.all(humanPromises)).map((user) => {
setEmailOTPOnHuman(user, org, tokens.accessToken!);
return { userId: user.userId, loginName: user.loginNames[0], password: 'Password1!' };
});
console.log(`setup: ${humans.length} users created`);
return { tokens, org, users: humans };
}
const passwordSessionTrend = new Trend('password_session_duration', true);
export default async function (data: any) {
const start = new Date();
let session = await createSession(data.org, data.tokens.accessToken, {
user: {
loginName: data.users[__VU - 1].loginName,
},
});
const sessionId = (session as any).sessionId;
session = await setSession(sessionId, session, data.tokens.accessToken, null, {
password: {
password: data.users[__VU - 1].password,
},
});
passwordSessionTrend.add(new Date().getTime() - start.getTime());
}
export function teardown(data: any) {
removeOrg(data.org, data.tokens.accessToken);
}

View File

@@ -19,7 +19,7 @@ export function createHuman(username: string, org: Org, accessToken: string): Pr
return new Promise((resolve, reject) => {
let response = http.asyncRequest(
'POST',
url('/v2beta/users/human'),
url('/v2/users/human'),
JSON.stringify({
username: username,
organization: {
@@ -69,6 +69,23 @@ export function createHuman(username: string, org: Org, accessToken: string): Pr
});
}
const setEmailOTPOnHumanTrend = new Trend('set_human_email_otp_duration', true);
export async function setEmailOTPOnHuman(user: User, org: Org, accessToken: string): Promise<void> {
const response = await http.asyncRequest('POST', url(`/v2/users/${user.userId}/otp_email`), null, {
headers: {
authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'x-zitadel-orgid': org.organizationId,
},
});
check(response, {
'set email otp status ok': (r) => r.status === 200,
});
setEmailOTPOnHumanTrend.add(response.timings.duration);
return;
}
const updateHumanTrend = new Trend('update_human_duration', true);
export function updateHuman(
payload: any = {},
@@ -172,8 +189,8 @@ export function addMachinePat(userId: string, org: Org, accessToken: string): Pr
}
export type MachineSecret = {
clientId: string;
clientSecret: string;
clientId: string;
clientSecret: string;
};
const addMachineSecretTrend = new Trend('user_add_machine_secret_duration', true);
@@ -204,20 +221,23 @@ export type MachineKey = {
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,
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,