Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for disabling /auth context path. Resolves #126 #143

Merged
merged 7 commits into from
Jun 13, 2023
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,15 @@ class Test {
mock.stop();
}

void quarkusKeycloakMocks() {
// to mock Keycloak without context path (v18.0.0+)
KeycloakMock mockNoContextPath = new KeycloakMock(aServerConfig().withNoContextPath().build());
// or to use custom one
KeycloakMock mockCustomContextPath = new KeycloakMock(aServerConfig().withContextPath("/context-path").build());
// if context path is not provided, '/auth' will be used as default due to backward compatibility reasons
KeycloakMock mockDefaultContextPath = new KeycloakMock(aServerConfig().build());
// ...
}
}
```

Expand Down
48 changes: 48 additions & 0 deletions mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@
public final class ServerConfig {

private static final String DEFAULT_HOSTNAME = "localhost";
private static final String DEFAULT_CONTEXT_PATH = "/auth";
private static final int DEFAULT_PORT = 8000;
private static final String DEFAULT_REALM = "master";
private static final String DEFAULT_SCOPE = "openid";

private final int port;
@Nonnull private final Protocol protocol;
@Nonnull private final String defaultHostname;
@Nonnull private final String contextPath;
@Nonnull private final String defaultRealm;
@Nonnull private final List<String> resourcesToMapRolesTo;
@Nonnull private final Set<String> defaultScopes;
Expand All @@ -28,6 +30,7 @@ private ServerConfig(@Nonnull final Builder builder) {
this.port = builder.port;
this.protocol = builder.protocol;
this.defaultHostname = builder.defaultHostname;
this.contextPath = builder.contextPath;
this.defaultRealm = builder.defaultRealm;
this.resourcesToMapRolesTo = builder.resourcesToMapRolesTo;
this.defaultScopes = builder.defaultScopes;
Expand Down Expand Up @@ -101,6 +104,18 @@ public String getDefaultHostname() {
return defaultHostname;
}

/**
* Keycloak context path.
*
* @return context path
* @see Builder#withContextPath(String)
* @see Builder#withNoContextPath()
*/
@Nonnull
public String getContextPath() {
return contextPath;
}

/**
* The default realm used in issuer claim.
*
Expand Down Expand Up @@ -143,6 +158,7 @@ public static final class Builder {
private int port = DEFAULT_PORT;
@Nonnull private Protocol protocol = Protocol.HTTP;
@Nonnull private String defaultHostname = DEFAULT_HOSTNAME;
@Nonnull private String contextPath = DEFAULT_CONTEXT_PATH;
@Nonnull private String defaultRealm = DEFAULT_REALM;
@Nonnull private final List<String> resourcesToMapRolesTo = new ArrayList<>();
@Nonnull private final Set<String> defaultScopes = new HashSet<>();
Expand Down Expand Up @@ -265,6 +281,38 @@ public Builder withResourcesToMapRolesTo(@Nonnull List<String> resources) {
return this;
}

/**
* Set context path.
*
* <p>Before quarkus based Keycloak distribution /auth prefix was obligatory. Now /auth prefix
* is removed and can be enabled/overridden in configuration to keep backward compatibility.
* Default value is '/auth' To disable context path use {@link #withNoContextPath()} method.
*
* @see <a href="https://www.keycloak.org/server/all-config#category-hostname">hostname-path</a>
* @see <a
* href="https://www.keycloak.org/migration/migrating-to-quarkus#_default_context_path_changed">Default
* context path changed</a>
* @param contextPath context path to use
* @return builder
*/
@Nonnull
public Builder withContextPath(@Nonnull String contextPath) {
this.contextPath = contextPath;
return this;
}

/**
* Disabling context path.
*
* @see #withContextPath(String)
* @return builder
*/
@Nonnull
public Builder withNoContextPath() {
this.contextPath = "";
return this;
}

/**
* Add a resource for which roles will be set.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
* }</pre>
*/
public class TokenConfig {
private static final Pattern ISSUER_PATH_PATTERN = Pattern.compile("^/auth/realms/([^/]+)$");
private static final Pattern ISSUER_PATH_PATTERN = Pattern.compile("^.*?/realms/([^/]+)$");

@Nonnull private final Set<String> audience;
@Nonnull private final String authorizedParty;
Expand Down Expand Up @@ -348,7 +348,7 @@ private String getRealm(@Nonnull final URI issuer) {
"The issuer '"
+ issuer
+ "' did not conform to the expected format"
+ " 'http[s]://$HOSTNAME[:port]/auth/realms/$REALM'.");
+ " 'http[s]://$HOSTNAME[:$PORT][/$CONTEXT_PATH]/realms/$REALM'.");
}
return matcher.group(1);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import javax.annotation.Nullable;

public class UrlConfiguration {
private static final String ISSUER_PATH = "/auth/realms/";
private static final String ISSUER_PATH = "/realms/";
private static final String AUTHENTICATION_CALLBACK_PATH = "authenticate/";
private static final String OUT_OF_BAND_PATH = "oob";
private static final String ISSUER_OPEN_ID_PATH = "protocol/openid-connect/";
Expand All @@ -19,6 +19,7 @@ public class UrlConfiguration {
@Nonnull private final Protocol protocol;
private final int port;
@Nonnull private final String hostname;
@Nonnull private final String contextPath;
@Nonnull private final String realm;

public UrlConfiguration(@Nonnull final ServerConfig serverConfig) {
Expand All @@ -30,6 +31,15 @@ public UrlConfiguration(@Nonnull final ServerConfig serverConfig) {
} else {
this.hostname = serverConfig.getDefaultHostname() + ":" + serverConfig.getPort();
}
if (Objects.requireNonNull(serverConfig.getContextPath()).isEmpty()
|| "/".equals(serverConfig.getContextPath())) {
this.contextPath = "";
} else {
this.contextPath =
serverConfig.getContextPath().startsWith("/")
? serverConfig.getContextPath()
: "/".concat(serverConfig.getContextPath());
}
this.realm = Objects.requireNonNull(serverConfig.getDefaultRealm());
}

Expand All @@ -40,6 +50,7 @@ private UrlConfiguration(
this.protocol = baseConfiguration.protocol;
this.port = baseConfiguration.port;
this.hostname = requestHost != null ? requestHost : baseConfiguration.hostname;
this.contextPath = baseConfiguration.contextPath;
this.realm = requestRealm != null ? requestRealm : baseConfiguration.realm;
}

Expand All @@ -60,12 +71,12 @@ URI getBaseUrl() {

@Nonnull
public URI getIssuer() {
return getBaseUrl().resolve(ISSUER_PATH + realm);
return getBaseUrl().resolve(contextPath + ISSUER_PATH + realm);
}

@Nonnull
public URI getIssuerPath() {
return getBaseUrl().resolve(ISSUER_PATH + realm + "/");
return getBaseUrl().resolve(contextPath + ISSUER_PATH + realm + "/");
}

@Nonnull
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ void unexpected_issuer_causes_exception() {
Builder builder = aTokenConfig();
assertThatThrownBy(() -> builder.withSourceToken(TOKEN_WITH_UNEXPECTED_ISSUER_URL))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("did not conform to the expected format");
.hasMessageContaining("did not conform to the expected format")
.hasMessageContaining("'http[s]://$HOSTNAME[:$PORT][/$CONTEXT_PATH]/realms/$REALM'");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ class UrlConfigurationTest {
private static final String DEFAULT_HOSTNAME = "defaultHost";
private static final String DEFAULT_REALM = "defaultRealm";
private static final String REQUEST_HOST = "requestHost";
private static final String REQUEST_HOST_NO_CONTEXT_PATH = "requestHostNoContextPath";
private static final String REQUEST_HOST_CUSTOM_CONTEXT_PATH = "requestHostCustomContextPath";
private static final String REQUEST_REALM = "requestRealm";

private UrlConfiguration urlConfiguration;
Expand Down Expand Up @@ -43,7 +45,21 @@ private static Stream<Arguments> server_config_and_expected_issuer_url() {
Arguments.of(aServerConfig().withPort(80).build(), "http://localhost/auth/realms/master"),
Arguments.of(
aServerConfig().withTls(true).withPort(443).build(),
"https://localhost/auth/realms/master"));
"https://localhost/auth/realms/master"),
Arguments.of(
aServerConfig().withNoContextPath().build(), "http://localhost:8000/realms/master"),
Arguments.of(
aServerConfig().withContextPath("auth").build(),
"http://localhost:8000/auth/realms/master"),
Arguments.of(
aServerConfig().withContextPath("/auth").build(),
"http://localhost:8000/auth/realms/master"),
Arguments.of(
aServerConfig().withContextPath("/context-path").build(),
"http://localhost:8000/context-path/realms/master"),
Arguments.of(
aServerConfig().withContextPath("complex/context/path").build(),
"http://localhost:8000/complex/context/path/realms/master"));
}

private static Stream<Arguments> request_host_and_realm_and_expected() {
Expand All @@ -54,6 +70,28 @@ private static Stream<Arguments> request_host_and_realm_and_expected() {
Arguments.of(REQUEST_HOST, REQUEST_REALM, "http://requestHost/auth/realms/requestRealm"));
}

private static Stream<Arguments> request_host_and_realm_and_expected_with_no_context_path() {
return Stream.of(
Arguments.of(
REQUEST_HOST_NO_CONTEXT_PATH, null, "http://requestHostNoContextPath/realms/master"),
Arguments.of(
REQUEST_HOST_NO_CONTEXT_PATH,
REQUEST_REALM,
"http://requestHostNoContextPath/realms/requestRealm"));
}

private static Stream<Arguments> request_host_and_realm_and_expected_with_custom_context_path() {
return Stream.of(
Arguments.of(
REQUEST_HOST_CUSTOM_CONTEXT_PATH,
null,
"http://requestHostCustomContextPath/custom/context/path/realms/master"),
Arguments.of(
REQUEST_HOST_CUSTOM_CONTEXT_PATH,
REQUEST_REALM,
"http://requestHostCustomContextPath/custom/context/path/realms/requestRealm"));
}

@ParameterizedTest
@MethodSource("server_config_and_expected_base_url")
void base_url_is_generated_correctly(ServerConfig serverConfig, String expected) {
Expand All @@ -80,6 +118,28 @@ void context_parameters_are_used_correctly(
assertThat(urlConfiguration.getIssuer()).hasToString(expected);
}

@ParameterizedTest
@MethodSource("request_host_and_realm_and_expected_with_no_context_path")
void context_parameters_are_used_correctly_for_server_config_with_no_context_path(
String requestHost, String requestRealm, String expected) {
urlConfiguration =
new UrlConfiguration(aServerConfig().withNoContextPath().build())
.forRequestContext(requestHost, requestRealm);

assertThat(urlConfiguration.getIssuer()).hasToString(expected);
}

@ParameterizedTest
@MethodSource("request_host_and_realm_and_expected_with_custom_context_path")
void context_parameters_are_used_correctly_for_server_config_with_custom_context_path(
String requestHost, String requestRealm, String expected) {
urlConfiguration =
new UrlConfiguration(aServerConfig().withContextPath("custom/context/path").build())
.forRequestContext(requestHost, requestRealm);

assertThat(urlConfiguration.getIssuer()).hasToString(expected);
}

@Test
void urls_are_correct() {
urlConfiguration = new UrlConfiguration(aServerConfig().build());
Expand All @@ -98,6 +158,48 @@ void urls_are_correct() {
.hasToString("http://localhost:8000/auth/realms/master/protocol/openid-connect/token");
}

@Test
void urls_are_correct_with_no_context_path() {
urlConfiguration = new UrlConfiguration(aServerConfig().withNoContextPath().build());

assertThat(urlConfiguration.getIssuerPath())
.hasToString("http://localhost:8000/realms/master/");
assertThat(urlConfiguration.getOpenIdPath("1234"))
.hasToString("http://localhost:8000/realms/master/protocol/openid-connect/1234");
assertThat(urlConfiguration.getAuthorizationEndpoint())
.hasToString("http://localhost:8000/realms/master/protocol/openid-connect/auth");
assertThat(urlConfiguration.getEndSessionEndpoint())
.hasToString("http://localhost:8000/realms/master/protocol/openid-connect/logout");
assertThat(urlConfiguration.getJwksUri())
.hasToString("http://localhost:8000/realms/master/protocol/openid-connect/certs");
assertThat(urlConfiguration.getTokenEndpoint())
.hasToString("http://localhost:8000/realms/master/protocol/openid-connect/token");
}

@Test
void urls_are_correct_with_custom_context_path() {
urlConfiguration =
new UrlConfiguration(aServerConfig().withContextPath("/custom/context/path").build());

assertThat(urlConfiguration.getIssuerPath())
.hasToString("http://localhost:8000/custom/context/path/realms/master/");
assertThat(urlConfiguration.getOpenIdPath("1234"))
.hasToString(
"http://localhost:8000/custom/context/path/realms/master/protocol/openid-connect/1234");
assertThat(urlConfiguration.getAuthorizationEndpoint())
.hasToString(
"http://localhost:8000/custom/context/path/realms/master/protocol/openid-connect/auth");
assertThat(urlConfiguration.getEndSessionEndpoint())
.hasToString(
"http://localhost:8000/custom/context/path/realms/master/protocol/openid-connect/logout");
assertThat(urlConfiguration.getJwksUri())
.hasToString(
"http://localhost:8000/custom/context/path/realms/master/protocol/openid-connect/certs");
assertThat(urlConfiguration.getTokenEndpoint())
.hasToString(
"http://localhost:8000/custom/context/path/realms/master/protocol/openid-connect/token");
}

@Test
void port_is_correct() {
urlConfiguration = new UrlConfiguration(aServerConfig().withPort(1234).build());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ public class Main implements Callable<Void> {
description = "Whether to use HTTPS instead of HTTP.")
private boolean tls;

@SuppressWarnings("FieldMayBeFinal")
@Option(
names = {"-cp", "--contextPath"},
description =
"Keycloak context path (default: ${DEFAULT-VALUE}). "
+ "If present, must be prefixed with '/', eg. --contextPath=/example-path")
private String contextPath = "/auth";

@SuppressWarnings("FieldMayBeFinal")
@Option(
names = {"-ncp", "--noContextPath"},
description = "If present context path will not be used. Good for mocking Keycloak 18.0.0+.")
private boolean noContextPath;

@Option(
names = {"-r", "--mapRolesToResources"},
description = "If set, roles will be assigned to these resources instead of the realm.",
Expand All @@ -51,14 +65,23 @@ public static void main(@Nonnull final String[] args) {

@Override
public Void call() {
String usedContextPath = noContextPath ? "" : contextPath;

new KeycloakMock(
aServerConfig()
.withPort(port)
.withTls(tls)
.withContextPath(usedContextPath)
.withResourcesToMapRolesTo(resourcesToMapRolesTo)
.build())
.start();
LOG.info("Server is running on {}://localhost:{}", (tls ? "https" : "http"), port);

LOG.info(
"Server is running on {}://localhost:{}{}",
(tls ? "https" : "http"),
port,
usedContextPath);

return null;
}
}