Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions benchmark/src/main/java/org/keycloak/benchmark/Config.java
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ public class Config {
*/
public static final int refreshTokenCount = Integer.getInteger("refresh-token-count", 0);

public static final boolean usePkce = Boolean.getBoolean("use-pkce");

public static final boolean refreshCloseHttpConnection = Boolean.getBoolean(System.getProperty("refresh-close-http-connection", "true"));

/**
Expand Down
18 changes: 17 additions & 1 deletion benchmark/src/main/scala/keycloak/Utils.scala
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package keycloak

import java.net.URLEncoder
import java.util.UUID
import java.security.MessageDigest
import java.util.{Base64, UUID}
import java.util.concurrent.ThreadLocalRandom
import scala.util.Random

Expand Down Expand Up @@ -31,4 +32,19 @@ object Utils {

def randomUUID(rand: Random = ThreadLocalRandom.current()): String =
new UUID(rand.nextLong(), rand.nextLong()).toString

private val PKCE_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"

def generateCodeVerifier(length: Int = 43, rand: Random = ThreadLocalRandom.current()): String = {
val sb = new StringBuilder
for (_ <- 0 until length) {
sb.append(PKCE_CHARS.charAt(rand.nextInt(PKCE_CHARS.length)))
}
sb.toString()
}

def computeCodeChallenge(verifier: String): String = {
val digest = MessageDigest.getInstance("SHA-256").digest(verifier.getBytes("US-ASCII"))
Base64.getUrlEncoder.withoutPadding.encodeToString(digest)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ class KeycloakScenarioBuilder {
scope = scope.trim().replace(',', ' ')
}

s.setAll("keycloakServer" -> serverUrl,
var session = s.setAll("keycloakServer" -> serverUrl,
"state" -> randomUUID(),
"wrongPasswordCount" -> new AtomicInteger(Config.badLoginCount),
"realm" -> realmName,
Expand All @@ -123,6 +123,16 @@ class KeycloakScenarioBuilder {
"adminPassword" -> Config.adminPassword,
"scope" -> scope
)

if (Config.usePkce) {
val verifier = Utils.generateCodeVerifier()
session = session.setAll(
"codeVerifier" -> verifier,
"codeChallenge" -> Utils.computeCodeChallenge(verifier)
)
}

session
})
.exitHereIfFailed

Expand All @@ -137,21 +147,26 @@ class KeycloakScenarioBuilder {
}

def openLoginPage(pauseAfter: Boolean): KeycloakScenarioBuilder = {
var request = http("Browser to Log In Endpoint")
.get(LOGIN_ENDPOINT)
.headers(UI_HEADERS)
.queryParam("login", "true")
.queryParam("response_type", "code")
.queryParam("client_id", "#{clientId}")
.queryParam("state", "#{state}")
.queryParam("redirect_uri", "#{redirectUri}")
.queryParam("scope", "#{scope}")

if (Config.usePkce) {
request = request
.queryParam("code_challenge", "#{codeChallenge}")
.queryParam("code_challenge_method", "S256")
}

chainBuilder = chainBuilder
.exec(http("Browser to Log In Endpoint")
.get(LOGIN_ENDPOINT)
.headers(UI_HEADERS)
.queryParam("login", "true")
.queryParam("response_type", "code")
.queryParam("client_id", "#{clientId}")
.queryParam("state", "#{state}")
.queryParam("redirect_uri", "#{redirectUri}")
.queryParam("scope", "#{scope}")
.exec(request
.check(status.is(200),
regex("action=\"([^\"]*)\"").find.transform(_.replaceAll("&amp;", "&")).saveAs("login-form-uri")))
// if already logged in the check will fail with:
// status.find.is(200), but actually found 302
// The reason is that instead of returning the login page we are immediately redirected to the app that requested authentication
.exitHereIfFailed
if (pauseAfter) {
userThinkPause()
Expand Down Expand Up @@ -230,15 +245,22 @@ class KeycloakScenarioBuilder {
}

def exchangeCode(): KeycloakScenarioBuilder = {
var request = http("Exchange Code")
.post(TOKEN_ENDPOINT)
.headers(UI_HEADERS)
.formParam("grant_type", "authorization_code")
.formParam("client_id", "#{clientId}")
.formParam("redirect_uri", "#{redirectUri}")
.formParam("code", "#{code}")

if (Config.usePkce) {
request = request.formParam("code_verifier", "#{codeVerifier}")
} else {
request = request.formParam("client_secret", "#{clientSecret}")
}

chainBuilder = chainBuilder
.exec(http("Exchange Code")
.post(TOKEN_ENDPOINT)
.headers(UI_HEADERS)
.formParam("grant_type", "authorization_code")
.formParam("client_id", "#{clientId}")
.formParam("client_secret", "#{clientSecret}")
.formParam("redirect_uri", "#{redirectUri}")
.formParam("code", "#{code}")
.exec(request
.check(
status.is(200),
jsonPath("$..id_token").find.saveAs("idToken"),
Expand Down
7 changes: 7 additions & 0 deletions doc/benchmark/modules/ROOT/pages/configuration.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,13 @@ Used in xref:scenario/authorization-code.adoc[].
| Close the HTTP connection after a token refresh.
Used in xref:scenario/authorization-code.adoc[].

| [[use-pkce]][.nowrap]`--use-pkce`
| `false`
| Enable PKCE (RFC 7636) for the authorization code flow.
When enabled, a `code_verifier` and S256 `code_challenge` are generated per user and sent on the authorization and token endpoints.
The `client_secret` is omitted from the token exchange, enabling public client flows.
Used in xref:scenario/authorization-code.adoc[].

| [[basic-url]][.nowrap]`--basic-url`
| (not set)
| URL to be called in the xref:scenario/basic-get.adoc[].
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ Instead, use IP addresses of other interfaces, or `+http://0.0.0.0+`, or run Key

* Keycloak is running.
* Realm, user and client exist with the values listed on the CLI.
* The client needs to have client authentication enabled, which results in an OIDC type of confidential access type, as the confidential client secret is used in the authorization code exchange.
* When running without PKCE, the client needs to have client authentication enabled, which results in an OIDC type of confidential access type, as the confidential client secret is used in the authorization code exchange.
* When running with PKCE (`--use-pkce=true`), the client can be a public client (e.g., `security-admin-console`). No client secret is needed.
* This scenario doesn't need any service account roles set for the client.

See xref:preparing-keycloak.adoc[] for details how to automate this for the realm and the client.
Expand All @@ -51,6 +52,31 @@ bin/kcb.sh \
--log-http-on-failure
----

== PKCE support

To benchmark the authorization code flow with PKCE (Proof Key for Code Exchange, https://datatracker.ietf.org/doc/html/rfc7636[RFC 7636]), pass `--use-pkce=true`.
This enables benchmarking against public clients that enforce PKCE, such as the built-in `security-admin-console` client.

When PKCE is enabled:

* A per-user `code_verifier` is generated and its S256 `code_challenge` is sent on the authorization request.
* The `code_verifier` is sent on the token exchange request.
* The `client_secret` parameter is omitted from the token exchange (public client flow).

.Example with PKCE using the admin console client
[source,bash]
----
bin/kcb.sh \
--scenario=keycloak.scenario.authentication.AuthorizationCode \
--server-url=http://0.0.0.0:8080/ \
--realm-name=master \
--username=admin \
--user-password=admin \
--client-id=security-admin-console \
--client-redirect-uri=http://0.0.0.0:8080/admin/master/console/ \
--use-pkce=true
----

== Variants

To create offline sessions, set the parameter xref:configuration.adoc#scope[`--scope`] to a value including `offline_access`, for example, `openid profile offline_access`.
Expand Down