Skip to content
Merged
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
12 changes: 11 additions & 1 deletion tests/conformance/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<description>Keycloak Conformance Tests</description>

<properties>
<keycloak.conformance.imageTag>release-v5.1.44</keycloak.conformance.imageTag>
<keycloak.conformance.imageTag>release-v5.2.2</keycloak.conformance.imageTag>
<keycloak.conformance.mongoImageTag>6.0.13</keycloak.conformance.mongoImageTag>
</properties>

Expand Down Expand Up @@ -84,6 +84,16 @@
<artifactId>keycloak-test-framework-test-containers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-ui</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.keycloak.testframework</groupId>
<artifactId>keycloak-test-framework-oauth</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

package org.keycloak.tests.conformance;

import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import java.util.stream.Stream;
Expand Down Expand Up @@ -78,8 +79,23 @@ protected Stream<ConformanceModuleVariant> discoverModuleVariants(String plan, M
}

/**
* Drives the system under test once the module waits for it, e.g. delivers an OID4VP verifier
* request.
* Discovers all variant combinations of every named module from a single created plan. The modules share the
* same browser interaction and expected result, so a single test class can host a group of related modules.
*/
protected Stream<ConformanceModuleVariant> discoverModuleVariants(String plan, Map<String, String> planVariant,
List<String> names, ConformanceResult expectedResult, BrowserInteraction browserInteraction) {
ConformanceModuleVariant template = new ConformanceModuleVariant(plan, planVariant, names.get(0), Map.of(),
expectedResult, browserInteraction);
Map<String, List<Map<String, String>>> discovered = OpenIdConformanceSuite.instance().client()
.discoverModuleVariants(plan, planVariant, names, suiteConfig(template));
return names.stream().flatMap(name -> discovered.get(name).stream()
.map(moduleVariant -> new ConformanceModuleVariant(plan, planVariant, name, moduleVariant,
expectedResult, browserInteraction)));
}

/**
* Drives the system under test once the module waits for it, for example delivers an OID4VP verifier request or
* the credential offer the issuer initiated modules wait for.
*/
protected Consumer<ModuleRun> interaction(ConformanceModuleVariant moduleVariant) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public final class OpenIdConformanceSuite implements AutoCloseable {
public static final URI KEYCLOAK_BASE_URI = URI.create("https://host.testcontainers.internal:8443");

// Fallbacks for running outside Maven, where the defaults are set by the pom properties of the same name
private static final String DEFAULT_IMAGE_TAG = "release-v5.1.44";
private static final String DEFAULT_IMAGE_TAG = "release-v5.2.2";
private static final String DEFAULT_MONGO_IMAGE_TAG = "6.0.13";
private static final String NGINX_CERTIFICATE_PATH = "/etc/ssl/certs/nginx-selfsigned.crt";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,31 @@

import java.util.List;

public record BrowserFlow(String match, List<BrowserTask> tasks) {
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;

public record BrowserFlow(
String match,
// The suite uses this flow at most matchLimit times, then falls through to the next matching flow. This
// lets a module that visits the authorization endpoint several times run a different flow per visit.
@JsonProperty("match-limit") @JsonInclude(JsonInclude.Include.NON_NULL) Integer matchLimit,
List<BrowserTask> tasks) {

public BrowserFlow(String match, List<BrowserTask> tasks) {
this(match, null, tasks);
}

public record BrowserTask(String task, String match, boolean optional, List<List<Object>> commands) {

public record BrowserTask(String task, String match, List<List<Object>> commands) {
public static final String TEXT = "text";
public static final String CLICK = "click";
public static final String WAIT = "wait";
// Predefined action of the wait command that snapshots the page into the screenshot placeholder
public static final String UPDATE_IMAGE_PLACEHOLDER = "update-image-placeholder";

// A required task: the suite fails the run if its URL is never visited
public BrowserTask(String task, String match, List<List<Object>> commands) {
this(task, match, false, commands);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,26 @@ public interface BrowserInteraction {

BrowserInteraction NONE = new None();
BrowserInteraction LOGIN = new Login();

BrowserInteraction DENY_CONSENT = new DenyConsent();
BrowserInteraction LOGIN_ON_SECOND_VISIT = new LoginOnSecondVisit();
BrowserInteraction ERROR_CALLBACK = new ErrorCallback();

/**
* Asserts Keycloak renders its error page with a message matching {@code messageRegexp} at the authorization
* endpoint (no login).
*/
static BrowserInteraction errorPage(String messageRegexp) {
return new ErrorPage(messageRegexp);
}

/**
* Logs in and completes the first authorization, then asserts the second authorization is rejected with
* Keycloak's error page matching {@code messageRegexp}.
*/
static BrowserInteraction loginThenErrorPageOnSecondVisit(String messageRegexp) {
return new LoginThenErrorPageOnSecondVisit(messageRegexp);
}

List<BrowserFlow> browserFlows(BrowserContext context);

record BrowserContext(String realm, String username, String password, String callbackUrl) {
Expand All @@ -43,6 +58,36 @@ String loginPage() {
}
}

private static BrowserTask loginTask(BrowserContext context, boolean optional) {
return new BrowserTask(
"Keycloak Login",
context.loginPage(),
optional,
List.of(
// command, element selector type, element selector, value to enter
List.of(BrowserTask.TEXT, "id", "username", context.username()),
List.of(BrowserTask.TEXT, "id", "password", context.password()),
// command, element selector type, element selector
List.of(BrowserTask.CLICK, "id", "kc-login")));
}

private static BrowserTask verifyCompleteTask(BrowserContext context) {
return new BrowserTask(
"Verify Complete",
context.callbackUrl() + "*",
// command, element selector type, element selector, timeout in seconds
List.of(List.of(BrowserTask.WAIT, "id", "submission_complete", 10)));
}

private static BrowserTask errorPageTask(BrowserContext context, String messageRegexp) {
return new BrowserTask(
"Keycloak Error Page",
context.authorizationEndpoint(),
// command, element selector type, element selector, timeout in seconds, element text regexp, action
List.of(List.of(BrowserTask.WAIT, "id", "kc-error-message", 10, messageRegexp,
BrowserTask.UPDATE_IMAGE_PLACEHOLDER)));
}

record None() implements BrowserInteraction {

@Override
Expand All @@ -51,25 +96,83 @@ public List<BrowserFlow> browserFlows(BrowserContext context) {
}
}

/**
* Requires a login on the first authorization visit. Later visits reuse the SSO session, where Keycloak may
* skip the login page, so the login task is optional there.
*/
record Login() implements BrowserInteraction {

@Override
public List<BrowserFlow> browserFlows(BrowserContext context) {
BrowserFlow firstVisit = new BrowserFlow(context.authorizationEndpoint(), 1, List.of(
loginTask(context, false),
verifyCompleteTask(context)));
BrowserFlow laterVisits = new BrowserFlow(context.authorizationEndpoint(), List.of(
loginTask(context, true),
verifyCompleteTask(context)));
return List.of(firstVisit, laterVisits);
}
}

record DenyConsent() implements BrowserInteraction {

@Override
public List<BrowserFlow> browserFlows(BrowserContext context) {
return List.of(new BrowserFlow(context.authorizationEndpoint(), List.of(
// optional so a second authorization that reuses the SSO session skips the login
loginTask(context, true),
new BrowserFlow.BrowserTask(
"Keycloak Login",
context.loginPage(),
"Deny Consent",
// the consent (oauth grant) screen is served under login-actions during the auth flow,
// optional so a second authorization that goes straight to the callback is tolerated
"https://*/realms/" + context.realm() + "/login-actions/*",
true,
List.of(
// command, element selector type, element selector, value to enter
List.of(BrowserTask.TEXT, "id", "username", context.username()),
List.of(BrowserTask.TEXT, "id", "password", context.password()),
// command, element selector type, element selector
List.of(BrowserTask.CLICK, "id", "kc-login"))),
// wait for the deny button, then click it to reject the grant
List.of(BrowserTask.WAIT, "id", "kc-cancel", 10),
List.of(BrowserTask.CLICK, "id", "kc-cancel"))),
// the suite renders submission_complete on the callback for the access_denied response too
verifyCompleteTask(context))));
}
}

/**
* Completes a normal login on the first authorization visit, then requires Keycloak's error page (matching
* {@code messageRegexp}) on the second visit. The first flow uses match-limit 1 so it applies only once; the
* second flow handles every later visit.
*/
record LoginThenErrorPageOnSecondVisit(String messageRegexp) implements BrowserInteraction {

@Override
public List<BrowserFlow> browserFlows(BrowserContext context) {
BrowserFlow firstVisit = new BrowserFlow(context.authorizationEndpoint(), 1, List.of(
loginTask(context, false),
verifyCompleteTask(context)));
BrowserFlow secondVisit = new BrowserFlow(context.authorizationEndpoint(), List.of(
errorPageTask(context, messageRegexp)));
return List.of(firstVisit, secondVisit);
}
}

/**
* Loads the login page on the first authorization visit without authenticating, then logs in on the second
* visit. The first flow uses match-limit 1 so it applies only once, the second flow handles every later
* visit. This keeps the first authorization incomplete so a request_uri stays valid for the second visit.
*/
record LoginOnSecondVisit() implements BrowserInteraction {

@Override
public List<BrowserFlow> browserFlows(BrowserContext context) {
BrowserFlow firstVisit = new BrowserFlow(context.authorizationEndpoint(), 1, List.of(
new BrowserFlow.BrowserTask(
"Verify Complete",
context.callbackUrl() + "*",
// command, element selector type, element selector, timeout in seconds
List.of(List.of(BrowserTask.WAIT, "id", "submission_complete", 10))))));
"Load login page without authenticating",
context.loginPage(),
// wait for the form to confirm the request_uri was accepted, but do not submit it
List.of(List.of(BrowserTask.WAIT, "id", "username", 10)))));
BrowserFlow secondVisit = new BrowserFlow(context.authorizationEndpoint(), List.of(
loginTask(context, false),
verifyCompleteTask(context)));
return List.of(firstVisit, secondVisit);
}
}

Expand All @@ -78,12 +181,20 @@ record ErrorPage(String messageRegexp) implements BrowserInteraction {
@Override
public List<BrowserFlow> browserFlows(BrowserContext context) {
return List.of(new BrowserFlow(context.authorizationEndpoint(), List.of(
new BrowserFlow.BrowserTask(
"Keycloak Error Page",
context.authorizationEndpoint(),
// command, element selector type, element selector, timeout in seconds, element text regexp, action
List.of(List.of(BrowserTask.WAIT, "id", "kc-error-message", 10, messageRegexp,
BrowserTask.UPDATE_IMAGE_PLACEHOLDER))))));
errorPageTask(context, messageRegexp))));
}
}

/**
* Asserts Keycloak rejects the authorization request without any login by redirecting the error response
* straight to the callback, where the module validates it.
*/
record ErrorCallback() implements BrowserInteraction {

@Override
public List<BrowserFlow> browserFlows(BrowserContext context) {
return List.of(new BrowserFlow(context.authorizationEndpoint(), List.of(
verifyCompleteTask(context))));
}
}
}
Loading
Loading