diff --git a/README.md b/README.md index 039212d..ae42ac2 100644 --- a/README.md +++ b/README.md @@ -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()); + // ... + } } ``` diff --git a/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java b/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java index 22fbd73..8b6561b 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/api/ServerConfig.java @@ -13,6 +13,7 @@ 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"; @@ -20,6 +21,7 @@ public final class ServerConfig { 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 resourcesToMapRolesTo; @Nonnull private final Set defaultScopes; @@ -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; @@ -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. * @@ -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 resourcesToMapRolesTo = new ArrayList<>(); @Nonnull private final Set defaultScopes = new HashSet<>(); @@ -265,6 +281,38 @@ public Builder withResourcesToMapRolesTo(@Nonnull List resources) { return this; } + /** + * Set context path. + * + *

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 hostname-path + * @see Default + * context path changed + * @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. * diff --git a/mock/src/main/java/com/tngtech/keycloakmock/api/TokenConfig.java b/mock/src/main/java/com/tngtech/keycloakmock/api/TokenConfig.java index 4afa759..1eebbd7 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/api/TokenConfig.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/api/TokenConfig.java @@ -31,7 +31,7 @@ * } */ 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 audience; @Nonnull private final String authorizedParty; @@ -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); } diff --git a/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java b/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java index 5a1dd01..aecc9fc 100644 --- a/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java +++ b/mock/src/main/java/com/tngtech/keycloakmock/impl/UrlConfiguration.java @@ -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/"; @@ -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) { @@ -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()); } @@ -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; } @@ -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 diff --git a/mock/src/test/java/com/tngtech/keycloakmock/api/TokenConfigTest.java b/mock/src/test/java/com/tngtech/keycloakmock/api/TokenConfigTest.java index 301d31b..7fb18ee 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/api/TokenConfigTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/api/TokenConfigTest.java @@ -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'"); } } diff --git a/mock/src/test/java/com/tngtech/keycloakmock/impl/UrlConfigurationTest.java b/mock/src/test/java/com/tngtech/keycloakmock/impl/UrlConfigurationTest.java index ef3576f..cc78641 100644 --- a/mock/src/test/java/com/tngtech/keycloakmock/impl/UrlConfigurationTest.java +++ b/mock/src/test/java/com/tngtech/keycloakmock/impl/UrlConfigurationTest.java @@ -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; @@ -43,7 +45,21 @@ private static Stream 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 request_host_and_realm_and_expected() { @@ -54,6 +70,28 @@ private static Stream request_host_and_realm_and_expected() { Arguments.of(REQUEST_HOST, REQUEST_REALM, "http://requestHost/auth/realms/requestRealm")); } + private static Stream 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 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) { @@ -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()); @@ -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()); diff --git a/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java b/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java index 1457f37..316eec1 100644 --- a/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java +++ b/standalone/src/main/java/com/tngtech/keycloakmock/standalone/Main.java @@ -32,6 +32,20 @@ public class Main implements Callable { 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.", @@ -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; } }