From c3d9859c6e668dd4bf9674115b36afe6347c324b Mon Sep 17 00:00:00 2001 From: Marko Strukelj Date: Mon, 31 Oct 2016 22:00:43 +0100 Subject: [PATCH 1/2] KEYCLOAK-912 Admin CLI --- .../org/keycloak/admin/client/Config.java | 2 +- .../org/keycloak/admin/client/Keycloak.java | 24 +- .../admin/client/KeycloakBuilder.java | 16 +- .../client/resource/BearerAuthFilter.java | 6 +- integration/client-cli/admin-cli/pom.xml | 177 ++++++ .../admin-cli/src/main/bin/kcadm.bat | 8 + .../admin-cli/src/main/bin/kcadm.sh | 23 + .../keycloak/client/admin/cli/KcAdmMain.java | 94 +++ .../cli/aesh/AeshConsoleCallbackImpl.java | 117 ++++ .../client/admin/cli/aesh/AeshEnhancer.java | 41 ++ .../client/admin/cli/aesh/Globals.java | 31 + .../admin/cli/aesh/ValveInputStream.java | 89 +++ .../cli/commands/AbstractAuthOptionsCmd.java | 267 +++++++++ .../commands/AbstractGlobalOptionsCmd.java | 113 ++++ .../cli/commands/AbstractRequestCmd.java | 435 ++++++++++++++ .../admin/cli/commands/AddRolesCmd.java | 334 +++++++++++ .../client/admin/cli/commands/ConfigCmd.java | 95 +++ .../cli/commands/ConfigCredentialsCmd.java | 275 +++++++++ .../cli/commands/ConfigTruststoreCmd.java | 200 +++++++ .../client/admin/cli/commands/CreateCmd.java | 167 ++++++ .../client/admin/cli/commands/DeleteCmd.java | 104 ++++ .../client/admin/cli/commands/GetCmd.java | 168 ++++++ .../admin/cli/commands/GetRolesCmd.java | 325 ++++++++++ .../client/admin/cli/commands/HelpCmd.java | 107 ++++ .../client/admin/cli/commands/KcAdmCmd.java | 101 ++++ .../admin/cli/commands/NewObjectCmd.java | 204 +++++++ .../admin/cli/commands/RemoveRolesCmd.java | 334 +++++++++++ .../admin/cli/commands/SetPasswordCmd.java | 177 ++++++ .../client/admin/cli/commands/UpdateCmd.java | 165 ++++++ .../client/admin/cli/common/AttributeKey.java | 170 ++++++ .../admin/cli/common/AttributeOperation.java | 58 ++ .../admin/cli/common/CmdStdinContext.java | 44 ++ .../client/admin/cli/config/ConfigData.java | 176 ++++++ .../admin/cli/config/ConfigHandler.java | 28 + .../cli/config/ConfigUpdateOperation.java | 26 + .../admin/cli/config/FileConfigHandler.java | 135 +++++ .../cli/config/InMemoryConfigHandler.java | 39 ++ .../admin/cli/config/RealmConfigData.java | 172 ++++++ .../admin/cli/httpcomponents/HttpDelete.java | 39 ++ .../cli/operations/ClientOperations.java | 29 + .../admin/cli/operations/GroupOperations.java | 58 ++ .../admin/cli/operations/LocalSearch.java | 60 ++ .../admin/cli/operations/RoleOperations.java | 129 ++++ .../admin/cli/operations/UserOperations.java | 96 +++ .../util/AccessibleBufferOutputStream.java | 66 +++ .../admin/cli/util/AttributeException.java | 39 ++ .../client/admin/cli/util/AuthUtil.java | 202 +++++++ .../client/admin/cli/util/ConfigUtil.java | 116 ++++ .../client/admin/cli/util/FilterUtil.java | 59 ++ .../client/admin/cli/util/Header.java | 39 ++ .../client/admin/cli/util/Headers.java | 55 ++ .../client/admin/cli/util/HeadersBody.java | 72 +++ .../admin/cli/util/HeadersBodyStatus.java | 68 +++ .../admin/cli/util/HttpResponseException.java | 34 ++ .../client/admin/cli/util/HttpUtil.java | 450 ++++++++++++++ .../client/admin/cli/util/IoUtil.java | 255 ++++++++ .../client/admin/cli/util/OsArch.java | 71 +++ .../client/admin/cli/util/OsUtil.java | 64 ++ .../client/admin/cli/util/OutputFormat.java | 25 + .../client/admin/cli/util/OutputUtil.java | 107 ++++ .../client/admin/cli/util/ParseUtil.java | 111 ++++ .../client/admin/cli/util/ReflectionUtil.java | 228 +++++++ .../client/admin/cli/util/ReturnFields.java | 333 +++++++++++ .../services/javax.ws.rs.ext.Providers | 23 + .../admin/cli/util/MergeAttributesTest.java | 101 ++++ .../admin/cli/util/ReturnFieldsTest.java | 142 +++++ .../client-cli/client-cli-dist/assembly.xml | 12 + .../client-cli/client-cli-dist/pom.xml | 4 + .../client-registration-cli/pom.xml | 1 - .../cli/commands/ConfigTruststoreCmd.java | 20 +- integration/client-cli/pom.xml | 11 + pom.xml | 5 + .../org/keycloak/testsuite/cli/KcAdmExec.java | 58 ++ .../org/keycloak/testsuite/cli/KcRegExec.java | 445 +------------- .../testsuite/cli/exec/AbstractExec.java | 245 ++++++++ .../cli/exec/AbstractExecBuilder.java | 44 ++ .../cli/{ => exec}/ExecutionException.java | 2 +- .../cli/exec/InteractiveInputStream.java | 120 ++++ .../cli/exec/LoggingOutputStream.java | 53 ++ .../testsuite/cli/exec/NullInputStream.java | 12 + .../cli/exec/StreamReaderThread.java | 33 ++ .../testsuite/cli/AbstractCliTest.java | 54 ++ .../cli/admin/AbstractAdmCliTest.java | 387 ++++++++++++ .../testsuite/cli/admin/KcAdmCreateTest.java | 131 ++++ .../testsuite/cli/admin/KcAdmTest.java | 561 ++++++++++++++++++ .../cli/admin/KcAdmTruststoreTest.java | 115 ++++ .../testsuite/cli/admin/KcAdmUpdateTest.java | 130 ++++ ...ctCliTest.java => AbstractRegCliTest.java} | 43 +- .../cli/registration/KcRegConfigTest.java | 2 +- .../cli/registration/KcRegCreateTest.java | 2 +- .../testsuite/cli/registration/KcRegTest.java | 6 +- .../cli/registration/KcRegTruststoreTest.java | 2 +- .../cli/registration/KcRegUpdateTest.java | 2 +- .../registration/KcRegUpdateTokenTest.java | 2 +- .../cli/kcadm/admin-cli-keystore.jks | Bin 0 -> 2029 bytes 95 files changed, 10123 insertions(+), 497 deletions(-) create mode 100755 integration/client-cli/admin-cli/pom.xml create mode 100644 integration/client-cli/admin-cli/src/main/bin/kcadm.bat create mode 100755 integration/client-cli/admin-cli/src/main/bin/kcadm.sh create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/KcAdmMain.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/AeshConsoleCallbackImpl.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/AeshEnhancer.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/Globals.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/ValveInputStream.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractAuthOptionsCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractGlobalOptionsCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractRequestCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AddRolesCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCredentialsCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigTruststoreCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/CreateCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/DeleteCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/GetCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/GetRolesCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/HelpCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/KcAdmCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/NewObjectCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/RemoveRolesCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/SetPasswordCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/UpdateCmd.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/AttributeKey.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/AttributeOperation.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/CmdStdinContext.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigData.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigHandler.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigUpdateOperation.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/FileConfigHandler.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/InMemoryConfigHandler.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/RealmConfigData.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/httpcomponents/HttpDelete.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/ClientOperations.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/GroupOperations.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/LocalSearch.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/RoleOperations.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/UserOperations.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AccessibleBufferOutputStream.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AttributeException.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AuthUtil.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ConfigUtil.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/FilterUtil.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/Header.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/Headers.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HeadersBody.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HeadersBodyStatus.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpResponseException.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpUtil.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/IoUtil.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OsArch.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OsUtil.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OutputFormat.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OutputUtil.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ParseUtil.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ReflectionUtil.java create mode 100644 integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ReturnFields.java create mode 100644 integration/client-cli/admin-cli/src/main/resources/META-INF/services/javax.ws.rs.ext.Providers create mode 100644 integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/util/MergeAttributesTest.java create mode 100644 integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/util/ReturnFieldsTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcAdmExec.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExec.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExecBuilder.java rename testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/{ => exec}/ExecutionException.java (93%) create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/InteractiveInputStream.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/LoggingOutputStream.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/NullInputStream.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/StreamReaderThread.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/AbstractCliTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/AbstractAdmCliTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmCreateTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmTruststoreTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmUpdateTest.java rename testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/{AbstractCliTest.java => AbstractRegCliTest.java} (93%) create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/cli/kcadm/admin-cli-keystore.jks diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/Config.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/Config.java index 4e628e5a4ce..183f4864ca9 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/Config.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/Config.java @@ -110,7 +110,7 @@ public class Config { } public static void checkGrantType(String grantType) { - if (!PASSWORD.equals(grantType) && !CLIENT_CREDENTIALS.equals(grantType)) { + if (grantType != null && !PASSWORD.equals(grantType) && !CLIENT_CREDENTIALS.equals(grantType)) { throw new IllegalArgumentException("Unsupported grantType: " + grantType + " (only " + PASSWORD + " and " + CLIENT_CREDENTIALS + " are supported)"); } diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/Keycloak.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/Keycloak.java index d267d174315..8c4235cbf59 100755 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/Keycloak.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/Keycloak.java @@ -43,18 +43,22 @@ import static org.keycloak.OAuth2Constants.PASSWORD; public class Keycloak { private final Config config; private final TokenManager tokenManager; + private String authToken; private final ResteasyWebTarget target; private final ResteasyClient client; - Keycloak(String serverUrl, String realm, String username, String password, String clientId, String clientSecret, String grantType, ResteasyClient resteasyClient) { + Keycloak(String serverUrl, String realm, String username, String password, String clientId, String clientSecret, String grantType, ResteasyClient resteasyClient, String authtoken) { config = new Config(serverUrl, realm, username, password, clientId, clientSecret, grantType); client = resteasyClient != null ? resteasyClient : new ResteasyClientBuilder().connectionPoolSize(10).build(); - - tokenManager = new TokenManager(config, client); + authToken = authtoken; + tokenManager = authtoken == null ? new TokenManager(config, client) : null; target = client.target(config.getServerUrl()); + target.register(newAuthFilter()); + } - target.register(new BearerAuthFilter(tokenManager)); + private BearerAuthFilter newAuthFilter() { + return authToken != null ? new BearerAuthFilter(authToken) : new BearerAuthFilter(tokenManager); } public static Keycloak getInstance(String serverUrl, String realm, String username, String password, String clientId, String clientSecret, SSLContext sslContext) { @@ -63,15 +67,19 @@ public class Keycloak { .hostnameVerification(ResteasyClientBuilder.HostnameVerificationPolicy.WILDCARD) .connectionPoolSize(10).build(); - return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, PASSWORD, client); + return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, PASSWORD, client, null); } public static Keycloak getInstance(String serverUrl, String realm, String username, String password, String clientId, String clientSecret) { - return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, PASSWORD, null); + return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, PASSWORD, null, null); } public static Keycloak getInstance(String serverUrl, String realm, String username, String password, String clientId) { - return new Keycloak(serverUrl, realm, username, password, clientId, null, PASSWORD, null); + return new Keycloak(serverUrl, realm, username, password, clientId, null, PASSWORD, null, null); + } + + public static Keycloak getInstance(String serverUrl, String realm, String clientId, String authtoken) { + return new Keycloak(serverUrl, realm, null, null, clientId, null, PASSWORD, null, null); } public RealmsResource realms() { @@ -100,7 +108,7 @@ public class Keycloak { * @return */ public T proxy(Class proxyClass, URI absoluteURI) { - return client.target(absoluteURI).register(new BearerAuthFilter(tokenManager)).proxy(proxyClass); + return client.target(absoluteURI).register(newAuthFilter()).proxy(proxyClass); } /** diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/KeycloakBuilder.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/KeycloakBuilder.java index e192d9abce6..0c003d3e84e 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/KeycloakBuilder.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/KeycloakBuilder.java @@ -60,8 +60,9 @@ public class KeycloakBuilder { private String password; private String clientId; private String clientSecret; - private String grantType = PASSWORD; + private String grantType; private ResteasyClient resteasyClient; + private String authorization; public KeycloakBuilder serverUrl(String serverUrl) { this.serverUrl = serverUrl; @@ -104,6 +105,11 @@ public class KeycloakBuilder { return this; } + public KeycloakBuilder authorization(String auth) { + this.authorization = auth; + return this; + } + /** * Builds a new Keycloak client from this builder. */ @@ -116,6 +122,10 @@ public class KeycloakBuilder { throw new IllegalStateException("realm required"); } + if (authorization == null && grantType == null) { + grantType = PASSWORD; + } + if (PASSWORD.equals(grantType)) { if (username == null) { throw new IllegalStateException("username required"); @@ -130,11 +140,11 @@ public class KeycloakBuilder { } } - if (clientId == null) { + if (authorization == null && clientId == null) { throw new IllegalStateException("clientId required"); } - return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, grantType, resteasyClient); + return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, grantType, resteasyClient, authorization); } private KeycloakBuilder() { diff --git a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/BearerAuthFilter.java b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/BearerAuthFilter.java index 514aeea0f9c..0d4992fcfc1 100644 --- a/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/BearerAuthFilter.java +++ b/integration/admin-client/src/main/java/org/keycloak/admin/client/resource/BearerAuthFilter.java @@ -49,8 +49,10 @@ public class BearerAuthFilter implements ClientRequestFilter, ClientResponseFilt @Override public void filter(ClientRequestContext requestContext) throws IOException { - String authHeader = AUTH_HEADER_PREFIX + (tokenManager != null ? tokenManager.getAccessTokenString() : tokenString); - + String authHeader = (tokenManager != null ? tokenManager.getAccessTokenString() : tokenString); + if (!authHeader.startsWith(AUTH_HEADER_PREFIX)) { + authHeader = AUTH_HEADER_PREFIX + authHeader; + } requestContext.getHeaders().add(HttpHeaders.AUTHORIZATION, authHeader); } diff --git a/integration/client-cli/admin-cli/pom.xml b/integration/client-cli/admin-cli/pom.xml new file mode 100755 index 00000000000..90676bf94dc --- /dev/null +++ b/integration/client-cli/admin-cli/pom.xml @@ -0,0 +1,177 @@ + + + + + + keycloak-client-cli-parent + org.keycloak + 2.5.0.Final-SNAPSHOT + + 4.0.0 + + keycloak-admin-cli + Keycloak Admin CLI + + + + + org.jboss.aesh + aesh + + + org.keycloak + keycloak-core + + + org.apache.httpcomponents + httpclient + + + + junit + junit + + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + + package + + shade + + + + + org.keycloak:keycloak-core + + org/keycloak/util/** + org/keycloak/json/** + org/keycloak/jose/jws/** + org/keycloak/jose/jwk/** + org/keycloak/representations/adapters/config/** + org/keycloak/representations/adapters/action/** + org/keycloak/representations/AccessTokenResponse.class + + org/keycloak/representations/idm/** + org/keycloak/representations/JsonWebToken.class + + + + org.keycloak:keycloak-common + + org/keycloak/common/util/** + + + + org.bouncycastle:bcprov-jdk15on + + **/** + + + + org.bouncycastle:bcpkix-jdk15on + + **/** + + + + com.fasterxml.jackson.core:jackson-core + + **/** + + + + com.fasterxml.jackson.core:jackson-databind + + **/** + + + + com.fasterxml.jackson.core:jackson-annotations + + com/fasterxml/jackson/annotation/** + + + + org.jboss.resteasy:resteasy-client + + **/** + + + + org.jboss.resteasy:resteasy-jaxrs + + **/** + + + + org.jboss.resteasy:resteasy-jackson2-provider + + **/** + + + + org.jboss.spec.javax.ws.rs:jboss-jaxrs-api_2.0_spec + + **/** + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 1.8 + 1.8 + + + + + + diff --git a/integration/client-cli/admin-cli/src/main/bin/kcadm.bat b/integration/client-cli/admin-cli/src/main/bin/kcadm.bat new file mode 100644 index 00000000000..46dc256fed4 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/bin/kcadm.bat @@ -0,0 +1,8 @@ +@echo off + +if "%OS%" == "Windows_NT" ( + set "DIRNAME=%~dp0%" +) else ( + set DIRNAME=.\ +) +java %KC_OPTS% -cp %DIRNAME%\client\keycloak-admin-cli-${project.version}.jar org.keycloak.client.admin.cli.KcAdmMain %* diff --git a/integration/client-cli/admin-cli/src/main/bin/kcadm.sh b/integration/client-cli/admin-cli/src/main/bin/kcadm.sh new file mode 100755 index 00000000000..26df7e16045 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/bin/kcadm.sh @@ -0,0 +1,23 @@ +#!/bin/sh +case "`uname`" in + CYGWIN*) + CFILE = `cygpath "$0"` + RESOLVED_NAME=`readlink -f "$CFILE"` + ;; + Darwin*) + RESOLVED_NAME=`readlink "$0"` + ;; + FreeBSD) + RESOLVED_NAME=`readlink -f "$0"` + ;; + Linux) + RESOLVED_NAME=`readlink -f "$0"` + ;; +esac + +if [ "x$RESOLVED_NAME" = "x" ]; then + RESOLVED_NAME="$0" +fi + +DIRNAME=`dirname "$RESOLVED_NAME"` +java $KC_OPTS -cp $DIRNAME/client/keycloak-admin-cli-${project.version}.jar org.keycloak.client.admin.cli.KcAdmMain "$@" \ No newline at end of file diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/KcAdmMain.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/KcAdmMain.java new file mode 100644 index 00000000000..948007701fb --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/KcAdmMain.java @@ -0,0 +1,94 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli; + +import org.jboss.aesh.console.AeshConsoleBuilder; +import org.jboss.aesh.console.AeshConsoleImpl; +import org.jboss.aesh.console.Prompt; +import org.jboss.aesh.console.command.registry.AeshCommandRegistryBuilder; +import org.jboss.aesh.console.command.registry.CommandRegistry; +import org.jboss.aesh.console.settings.Settings; +import org.jboss.aesh.console.settings.SettingsBuilder; +import org.keycloak.client.admin.cli.aesh.AeshEnhancer; +import org.keycloak.client.admin.cli.aesh.Globals; +import org.keycloak.client.admin.cli.aesh.ValveInputStream; +import org.keycloak.client.admin.cli.commands.KcAdmCmd; + +import java.util.ArrayList; +import java.util.Arrays; + +/** + * @author Marko Strukelj + */ +public class KcAdmMain { + + public static void main(String [] args) { + + Globals.stdin = new ValveInputStream(); + + Settings settings = new SettingsBuilder() + .logging(false) + .readInputrc(false) + .disableCompletion(true) + .disableHistory(true) + .enableAlias(false) + .enableExport(false) + .inputStream(Globals.stdin) + .create(); + + CommandRegistry registry = new AeshCommandRegistryBuilder() + .command(KcAdmCmd.class) + .create(); + + AeshConsoleImpl console = (AeshConsoleImpl) new AeshConsoleBuilder() + .settings(settings) + .commandRegistry(registry) + .prompt(new Prompt("")) +// .commandInvocationProvider(new CommandInvocationServices() { +// +// }) + .create(); + + AeshEnhancer.enhance(console); + + // work around parser issues with quotes and brackets + ArrayList arguments = new ArrayList<>(); + arguments.add("kcadm"); + arguments.addAll(Arrays.asList(args)); + Globals.args = arguments; + + StringBuilder b = new StringBuilder(); + for (String s : args) { + // quote if necessary + boolean needQuote = false; + needQuote = s.indexOf(' ') != -1 || s.indexOf('\"') != -1 || s.indexOf('\'') != -1; + b.append(' '); + if (needQuote) { + b.append('\''); + } + b.append(s); + if (needQuote) { + b.append('\''); + } + } + console.setEcho(false); + + console.execute("kcadm" + b.toString()); + + console.start(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/AeshConsoleCallbackImpl.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/AeshConsoleCallbackImpl.java new file mode 100644 index 00000000000..fedaaa0c241 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/AeshConsoleCallbackImpl.java @@ -0,0 +1,117 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.aesh; + +import org.jboss.aesh.cl.parser.OptionParserException; +import org.jboss.aesh.cl.result.ResultHandler; +import org.jboss.aesh.console.AeshConsoleCallback; +import org.jboss.aesh.console.AeshConsoleImpl; +import org.jboss.aesh.console.ConsoleOperation; +import org.jboss.aesh.console.command.CommandNotFoundException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.container.CommandContainer; +import org.jboss.aesh.console.command.container.CommandContainerResult; +import org.jboss.aesh.console.command.invocation.AeshCommandInvocation; +import org.jboss.aesh.console.command.invocation.AeshCommandInvocationProvider; +import org.jboss.aesh.parser.AeshLine; +import org.jboss.aesh.parser.ParserStatus; + +import java.lang.reflect.Method; + +class AeshConsoleCallbackImpl extends AeshConsoleCallback { + + private final AeshConsoleImpl console; + private CommandResult result; + + AeshConsoleCallbackImpl(AeshConsoleImpl aeshConsole) { + this.console = aeshConsole; + } + + @Override + @SuppressWarnings("unchecked") + public int execute(ConsoleOperation output) throws InterruptedException { + if (output != null && output.getBuffer().trim().length() > 0) { + ResultHandler resultHandler = null; + //AeshLine aeshLine = Parser.findAllWords(output.getBuffer()); + AeshLine aeshLine = new AeshLine(output.getBuffer(), Globals.args, ParserStatus.OK, ""); + try (CommandContainer commandContainer = getCommand(output, aeshLine)) { + resultHandler = commandContainer.getParser().getProcessedCommand().getResultHandler(); + CommandContainerResult ccResult = + commandContainer.executeCommand(aeshLine, console.getInvocationProviders(), console.getAeshContext(), + new AeshCommandInvocationProvider().enhanceCommandInvocation( + new AeshCommandInvocation(console, + output.getControlOperator(), output.getPid(), this))); + + result = ccResult.getCommandResult(); + + if(result == CommandResult.SUCCESS && resultHandler != null) + resultHandler.onSuccess(); + else if(resultHandler != null) + resultHandler.onFailure(result); + + if (result == CommandResult.FAILURE) { + // we assume the command has already output any error messages + System.exit(1); + } + } catch (Exception e) { + console.stop(); + + if (e instanceof OptionParserException) { + System.err.println("Unknown command: " + aeshLine.getWords().get(0)); + } else { + System.err.println(e.getMessage()); + } + if (Globals.dumpTrace) { + e.printStackTrace(); + } + + System.exit(1); + } + } + // empty line + else if (output != null) { + result = CommandResult.FAILURE; + } + else { + //stop(); + result = CommandResult.FAILURE; + } + + if (result == CommandResult.SUCCESS) { + return 0; + } else { + return 1; + } + } + + private CommandContainer getCommand(ConsoleOperation output, AeshLine aeshLine) throws CommandNotFoundException { + Method m; + try { + m = console.getClass().getDeclaredMethod("getCommand", AeshLine.class, String.class); + } catch (NoSuchMethodException e) { + throw new RuntimeException("Unexpected error: ", e); + } + + m.setAccessible(true); + + try { + return (CommandContainer) m.invoke(console, aeshLine, output.getBuffer()); + } catch (Exception e) { + throw new RuntimeException("Unexpected error: ", e); + } + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/AeshEnhancer.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/AeshEnhancer.java new file mode 100644 index 00000000000..9e21b18dc0a --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/AeshEnhancer.java @@ -0,0 +1,41 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.aesh; + +import org.jboss.aesh.console.AeshConsoleImpl; +import org.jboss.aesh.console.Console; + +import java.lang.reflect.Field; + +/** + * @author Marko Strukelj + */ +public class AeshEnhancer { + + public static void enhance(AeshConsoleImpl console) { + try { + Globals.stdin.setConsole(console); + + Field field = AeshConsoleImpl.class.getDeclaredField("console"); + field.setAccessible(true); + Console internalConsole = (Console) field.get(console); + internalConsole.setConsoleCallback(new AeshConsoleCallbackImpl(console)); + } catch (Exception e) { + throw new RuntimeException("Failed to install Aesh enhancement", e); + } + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/Globals.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/Globals.java new file mode 100644 index 00000000000..e16a43c863c --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/Globals.java @@ -0,0 +1,31 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.aesh; + +import java.util.List; + +/** + * @author Marko Strukelj + */ +public class Globals { + + public static boolean dumpTrace = false; + + public static ValveInputStream stdin; + + public static List args; +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/ValveInputStream.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/ValveInputStream.java new file mode 100644 index 00000000000..ec2cf4ff76c --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/aesh/ValveInputStream.java @@ -0,0 +1,89 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.aesh; + +import org.jboss.aesh.console.AeshConsoleImpl; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +/** + * This stream blocks and waits, until there is a stream in the queue. + * It reads the stream to the end, then stops Aesh console. + * + * @author Marko Strukelj + */ +public class ValveInputStream extends InputStream { + + private BlockingQueue queue = new LinkedBlockingQueue<>(10); + + private InputStream current; + + private AeshConsoleImpl console; + + @Override + public int read() throws IOException { + if (current == null) { + try { + current = queue.take(); + } catch (InterruptedException e) { + throw new InterruptedIOException("Signalled to exit"); + } + } + int c = current.read(); + if (c == -1) { + //current = null; + if (console != null) { + console.stop(); + } + } + + return c; + } + + /** + * For some reason AeshInputStream wants to do blocking read of whole buffers, which for stdin + * results in blocked input. + */ + @Override + public int read(byte b[], int off, int len) throws IOException { + int c = read(); + if (c == -1) { + return c; + } + b[off] = (byte) c; + return 1; + } + + public void setInputStream(InputStream is) { + if (queue.contains(is)) { + return; + } + queue.add(is); + } + + public void setConsole(AeshConsoleImpl console) { + this.console = console; + } + + public boolean isStdinAvailable() { + return console.isRunning(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractAuthOptionsCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractAuthOptionsCmd.java new file mode 100644 index 00000000000..1473d7ff539 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractAuthOptionsCmd.java @@ -0,0 +1,267 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.Option; +import org.jboss.aesh.console.command.invocation.CommandInvocation; +import org.keycloak.client.admin.cli.config.ConfigData; +import org.keycloak.client.admin.cli.config.ConfigHandler; +import org.keycloak.client.admin.cli.config.FileConfigHandler; +import org.keycloak.client.admin.cli.config.InMemoryConfigHandler; +import org.keycloak.client.admin.cli.config.RealmConfigData; +import org.keycloak.client.admin.cli.util.ConfigUtil; +import org.keycloak.client.admin.cli.util.HttpUtil; +import org.keycloak.client.admin.cli.util.IoUtil; + +import java.io.File; + +import static org.keycloak.client.admin.cli.config.FileConfigHandler.setConfigFile; +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CLIENT; +import static org.keycloak.client.admin.cli.util.ConfigUtil.checkAuthInfo; +import static org.keycloak.client.admin.cli.util.ConfigUtil.checkServerInfo; +import static org.keycloak.client.admin.cli.util.ConfigUtil.loadConfig; + + +/** + * @author Marko Strukelj + */ +public abstract class AbstractAuthOptionsCmd extends AbstractGlobalOptionsCmd { + + @Option(shortName = 'a', name = "admin-root", description = "URL of Admin REST endpoint root if not default - e.g. http://localhost:8080/auth/admin") + String adminRestRoot; + + @Option(name = "config", description = "Path to the config file (~/.keycloak/kcadm.config by default)") + String config; + + @Option(name = "no-config", description = "No configuration file should be used, no authentication info should be saved", hasValue = false) + boolean noconfig; + + @Option(name = "server", description = "Server endpoint url (e.g. 'http://localhost:8080/auth')") + String server; + + @Option(shortName = 'r', name = "target-realm", description = "Realm to target - when it's different than the realm we authenticate against") + String targetRealm; + + @Option(name = "realm", description = "Realm name to authenticate against") + String realm; + + @Option(name = "client", description = "Realm name to authenticate against") + String clientId; + + @Option(name = "user", description = "Username to login with") + String user; + + @Option(name = "password", description = "Password to login with (prompted for if not specified and --user is used)") + String password; + + @Option(name = "secret", description = "Secret to authenticate the client (prompted for if no --user or --keystore is specified)") + String secret; + + @Option(name = "keystore", description = "Path to a keystore containing private key") + String keystore; + + @Option(name = "storepass", description = "Keystore password (prompted for if not specified and --keystore is used)") + String storePass; + + @Option(name = "keypass", description = "Key password (prompted for if not specified and --keystore is used without --storepass, \n otherwise defaults to keystore password)") + String keyPass; + + @Option(name = "alias", description = "Alias of the key inside a keystore (defaults to the value of ClientId)") + String alias; + + @Option(name = "truststore", description = "Path to a truststore") + String trustStore; + + @Option(name = "trustpass", description = "Truststore password (prompted for if not specified and --truststore is used)") + String trustPass; + + + protected void initFromParent(AbstractAuthOptionsCmd parent) { + + super.initFromParent(parent); + + noconfig = parent.noconfig; + config = parent.config; + server = parent.server; + realm = parent.realm; + clientId = parent.clientId; + user = parent.user; + password = parent.password; + secret = parent.secret; + keystore = parent.keystore; + storePass = parent.storePass; + keyPass = parent.keyPass; + alias = parent.alias; + trustStore = parent.trustStore; + trustPass = parent.trustPass; + } + + protected void applyDefaultOptionValues() { + if (clientId == null) { + clientId = DEFAULT_CLIENT; + } + } + + protected boolean noOptions() { + return server == null && realm == null && clientId == null && secret == null && + user == null && password == null && + keystore == null && storePass == null && keyPass == null && alias == null && + trustStore == null && trustPass == null && config == null && (args == null || args.size() == 0); + } + + + protected String getTargetRealm(ConfigData config) { + return targetRealm != null ? targetRealm : config.getRealm(); + } + + protected void processGlobalOptions() { + + super.processGlobalOptions(); + + if (config != null && noconfig) { + throw new RuntimeException("Options --config and --no-config are mutually exclusive"); + } + + if (!noconfig) { + setConfigFile(config != null ? config : ConfigUtil.DEFAULT_CONFIG_FILE_PATH); + ConfigUtil.setHandler(new FileConfigHandler()); + } else { + InMemoryConfigHandler handler = new InMemoryConfigHandler(); + ConfigData data = new ConfigData(); + initConfigData(data); + handler.setConfigData(data); + ConfigUtil.setHandler(handler); + } + } + + protected void setupTruststore(ConfigData configData, CommandInvocation invocation ) { + + if (!configData.getServerUrl().startsWith("https:")) { + return; + } + + String truststore = trustStore; + if (truststore == null) { + truststore = configData.getTruststore(); + } + + if (truststore != null) { + String pass = trustPass; + if (pass == null) { + pass = configData.getTrustpass(); + } + if (pass == null) { + pass = IoUtil.readSecret("Enter truststore password: ", invocation); + } + + try { + HttpUtil.setTruststore(new File(truststore), pass); + } catch (Exception e) { + throw new RuntimeException("Failed to load truststore: " + truststore, e); + } + } + } + + protected ConfigData ensureAuthInfo(ConfigData config, CommandInvocation commandInvocation) { + + if (requiresLogin()) { + // make sure current handler is in-memory handler + // restore it at the end + ConfigHandler old = ConfigUtil.getHandler(); + try { + // make sure all defaults are initialized after this point + applyDefaultOptionValues(); + + initConfigData(config); + ConfigUtil.setupInMemoryHandler(config); + + ConfigCredentialsCmd login = new ConfigCredentialsCmd(); + login.initFromParent(this); + login.init(config); + login.process(commandInvocation); + + // this must be executed before finally block which restores config handler + return loadConfig(); + + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + ConfigUtil.setHandler(old); + } + + } else { + checkAuthInfo(config); + + // make sure all defaults are initialized after this point + applyDefaultOptionValues(); + return loadConfig(); + } + } + + protected boolean requiresLogin() { + return user != null || password != null || secret != null || keystore != null + || keyPass != null || storePass != null || alias != null; + } + + protected ConfigData copyWithServerInfo(ConfigData config) { + + ConfigData result = config.deepcopy(); + + if (server != null) { + result.setServerUrl(server); + } + if (realm != null) { + result.setRealm(realm); + } + + checkServerInfo(result); + return result; + } + + private void initConfigData(ConfigData data) { + if (server != null) + data.setServerUrl(server); + if (realm != null) + data.setRealm(realm); + if (trustStore != null) + data.setTruststore(trustStore); + + RealmConfigData rdata = data.sessionRealmConfigData(); + if (clientId != null) + rdata.setClientId(clientId); + if (secret != null) + rdata.setSecret(secret); + } + + protected void checkUnsupportedOptions(String ... options) { + if (options.length % 2 != 0) { + throw new IllegalArgumentException("Even number of argument required"); + } + + for (int i = 0; i < options.length; i++) { + String name = options[i]; + String value = options[++i]; + + if (value != null) { + throw new RuntimeException("Unsupported option: " + name); + } + } + } + +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractGlobalOptionsCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractGlobalOptionsCmd.java new file mode 100644 index 00000000000..c49c83c8d89 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractGlobalOptionsCmd.java @@ -0,0 +1,113 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jboss.aesh.cl.Arguments; +import org.jboss.aesh.cl.Option; +import org.jboss.aesh.console.command.Command; +import org.keycloak.client.admin.cli.aesh.Globals; +import org.keycloak.client.admin.cli.util.FilterUtil; +import org.keycloak.client.admin.cli.util.ReturnFields; + +import java.io.IOException; +import java.util.Iterator; +import java.util.List; + +import static org.keycloak.client.admin.cli.util.HttpUtil.normalize; +import static org.keycloak.client.admin.cli.util.IoUtil.printOut; + +/** + * @author Marko Strukelj + */ +public abstract class AbstractGlobalOptionsCmd implements Command { + + @Option(shortName = 'x', description = "Print full stack trace when exiting with error", hasValue = false) + boolean dumpTrace; + + @Option(name = "help", description = "Print command specific help", hasValue = false) + boolean help; + + + // we don't want Aesh to handle illegal options + @Arguments + List args; + + + protected void initFromParent(AbstractGlobalOptionsCmd parent) { + dumpTrace = parent.dumpTrace; + help = parent.help; + args = parent.args; + } + + protected void processGlobalOptions() { + Globals.dumpTrace = dumpTrace; + } + + protected boolean printHelp() { + if (help || nothingToDo()) { + printOut(help()); + return true; + } + + return false; + } + + protected boolean nothingToDo() { + return false; + } + + protected String help() { + return KcAdmCmd.usage(); + } + + protected String composeAdminRoot(String server) { + return normalize(server) + "admin"; + } + + + protected void requireValue(Iterator it, String option) { + if (!it.hasNext()) { + throw new IllegalArgumentException("Option " + option + " requires a value"); + } + } + + protected String extractTypeNameFromUri(String resourceUrl) { + String type = extractLastComponentOfUri(resourceUrl); + if (type.endsWith("s")) { + type = type.substring(0, type.length()-1); + } + return type; + } + + protected String extractLastComponentOfUri(String resourceUrl) { + int endPos = resourceUrl.endsWith("/") ? resourceUrl.length()-2 : resourceUrl.length()-1; + int pos = resourceUrl.lastIndexOf("/", endPos); + pos = pos == -1 ? 0 : pos; + return resourceUrl.substring(pos+1, endPos+1); + } + + protected JsonNode applyFieldFilter(ObjectMapper mapper, JsonNode rootNode, ReturnFields returnFields) { + // construct new JsonNode that satisfies filtering specified by returnFields + try { + return FilterUtil.copyFilteredObject(rootNode, returnFields); + } catch (IOException e) { + throw new RuntimeException("Failed to apply fields filter", e); + } + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractRequestCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractRequestCmd.java new file mode 100644 index 00000000000..84d957963fe --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AbstractRequestCmd.java @@ -0,0 +1,435 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; +import org.keycloak.client.admin.cli.common.AttributeOperation; +import org.keycloak.client.admin.cli.common.CmdStdinContext; +import org.keycloak.client.admin.cli.config.ConfigData; +import org.keycloak.client.admin.cli.util.AccessibleBufferOutputStream; +import org.keycloak.client.admin.cli.util.Header; +import org.keycloak.client.admin.cli.util.Headers; +import org.keycloak.client.admin.cli.util.HeadersBody; +import org.keycloak.client.admin.cli.util.HeadersBodyStatus; +import org.keycloak.client.admin.cli.util.HttpUtil; +import org.keycloak.client.admin.cli.util.OutputFormat; +import org.keycloak.client.admin.cli.util.ReflectionUtil; +import org.keycloak.client.admin.cli.util.ReturnFields; + +import java.io.BufferedInputStream; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +import static org.keycloak.client.admin.cli.common.AttributeOperation.Type.DELETE; +import static org.keycloak.client.admin.cli.common.AttributeOperation.Type.SET; +import static org.keycloak.client.admin.cli.util.AuthUtil.ensureToken; +import static org.keycloak.client.admin.cli.util.ConfigUtil.credentialsAvailable; +import static org.keycloak.client.admin.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.admin.cli.util.HttpUtil.checkSuccess; +import static org.keycloak.client.admin.cli.util.HttpUtil.composeResourceUrl; +import static org.keycloak.client.admin.cli.util.HttpUtil.doGet; +import static org.keycloak.client.admin.cli.util.IoUtil.copyStream; +import static org.keycloak.client.admin.cli.util.IoUtil.printErr; +import static org.keycloak.client.admin.cli.util.IoUtil.printOut; +import static org.keycloak.client.admin.cli.util.OutputUtil.MAPPER; +import static org.keycloak.client.admin.cli.util.OutputUtil.printAsCsv; +import static org.keycloak.client.admin.cli.util.ParseUtil.mergeAttributes; +import static org.keycloak.client.admin.cli.util.ParseUtil.parseFileOrStdin; +import static org.keycloak.client.admin.cli.util.ParseUtil.parseKeyVal; + +/** + * @author Marko Strukelj + */ +public abstract class AbstractRequestCmd extends AbstractAuthOptionsCmd { + + String file; + + String fields; + + boolean printHeaders; + + boolean returnId; + + boolean outputResult; + + boolean compressed; + + boolean unquoted; + + boolean mergeMode; + + boolean noMerge; + + Integer offset; + + Integer limit; + + String format = "json"; + + OutputFormat outputFormat; + + String httpVerb; + + Headers headers = new Headers(); + + List attrs = new LinkedList<>(); + + Map filter = new HashMap<>(); + + String url = null; + + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + initOptions(); + + if (printHelp()) { + return help ? CommandResult.SUCCESS : CommandResult.FAILURE; + } + + processGlobalOptions(); + + processOptions(commandInvocation); + + return process(commandInvocation); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(e.getMessage() + suggestHelp(), e); + } finally { + commandInvocation.stop(); + } + } + + abstract void initOptions(); + + abstract String suggestHelp(); + + + void processOptions(CommandInvocation commandInvocation) { + + if (args == null || args.isEmpty()) { + throw new IllegalArgumentException("URI not specified"); + } + + Iterator it = args.iterator(); + + while (it.hasNext()) { + String option = it.next(); + switch (option) { + case "-s": + case "--set": { + if (!it.hasNext()) { + throw new IllegalArgumentException("Option " + option + " requires a value"); + } + String[] keyVal = parseKeyVal(it.next()); + attrs.add(new AttributeOperation(SET, keyVal[0], keyVal[1])); + break; + } + case "-d": + case "--delete": { + attrs.add(new AttributeOperation(DELETE, it.next())); + break; + } + case "-h": + case "--header": { + requireValue(it, option); + String[] keyVal = parseKeyVal(it.next()); + headers.add(keyVal[0], keyVal[1]); + break; + } + case "-q": + case "--query": { + if (!it.hasNext()) { + throw new IllegalArgumentException("Option " + option + " requires a value"); + } + String arg = it.next(); + String[] keyVal; + if (arg.indexOf("=") == -1) { + keyVal = new String[] {"", arg}; + } else { + keyVal = parseKeyVal(arg); + } + filter.put(keyVal[0], keyVal[1]); + break; + } + default: { + if (url == null) { + url = option; + } else { + throw new IllegalArgumentException("Invalid option: " + option); + } + } + } + } + + + if (url == null) { + throw new IllegalArgumentException("Resource URI not specified"); + } + + if (outputResult && returnId) { + throw new IllegalArgumentException("Options -o and -i are mutually exclusive"); + } + + try { + outputFormat = OutputFormat.valueOf(format.toUpperCase()); + } catch (Exception e) { + throw new RuntimeException("Unsupported output format: " + format); + } + + if (mergeMode && noMerge) { + throw new IllegalArgumentException("Options --merge and --no-merge are mutually exclusive"); + } + + if (file == null && attrs.size() > 0 && !noMerge) { + mergeMode = true; + } + } + + + + public CommandResult process(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + // see if Content-Type header is explicitly set to non-json value + Header ctype = headers.get("content-type"); + + InputStream body = null; + + CmdStdinContext ctx = new CmdStdinContext<>(); + + if (file != null) { + if (ctype != null && !"application/json".equals(ctype.getValue())) { + if ("-".equals(file)) { + body = System.in; + } else { + try { + body = new BufferedInputStream(new FileInputStream(file)); + } catch (FileNotFoundException e) { + throw new RuntimeException("File not found: " + file); + } + } + } else { + ctx = parseFileOrStdin(file); + } + } + + ConfigData config = loadConfig(); + config = copyWithServerInfo(config); + + setupTruststore(config, commandInvocation); + + String auth = null; + + config = ensureAuthInfo(config, commandInvocation); + config = copyWithServerInfo(config); + if (credentialsAvailable(config)) { + auth = ensureToken(config); + } + + auth = auth != null ? "Bearer " + auth : null; + + if (auth != null) { + headers.addIfMissing("Authorization", auth); + } + + + final String server = config.getServerUrl(); + final String realm = getTargetRealm(config); + final String adminRoot = adminRestRoot != null ? adminRestRoot : composeAdminRoot(server); + + + String resourceUrl = composeResourceUrl(adminRoot, realm, url); + String typeName = extractTypeNameFromUri(resourceUrl); + + + if (filter.size() > 0) { + resourceUrl = HttpUtil.addQueryParamsToUri(resourceUrl, filter); + } + + headers.addIfMissing("Accept", "application/json"); + + if (isUpdate() && mergeMode) { + ObjectNode result; + HeadersBodyStatus response; + try { + response = HttpUtil.doGet(resourceUrl, new HeadersBody(headers)); + checkSuccess(resourceUrl, response); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + copyStream(response.getBody(), buffer); + + result = MAPPER.readValue(buffer.toByteArray(), ObjectNode.class); + + } catch (IOException e) { + throw new RuntimeException("HTTP request error: " + e.getMessage(), e); + } + + CmdStdinContext ctxremote = new CmdStdinContext<>(); + ctxremote.setResult(result); + + // merge local representation over remote one + if (ctx.getResult() != null) { + ReflectionUtil.merge(ctx.getResult(), (ObjectNode) ctxremote.getResult()); + } + ctx = ctxremote; + } + + if (attrs.size() > 0) { + if (body != null) { + throw new RuntimeException("Can't set attributes on content of type other than application/json"); + } + + ctx = mergeAttributes(ctx, MAPPER.createObjectNode(), attrs); + } + + if (body == null && ctx.getContent() != null) { + body = new ByteArrayInputStream(ctx.getContent().getBytes(Charset.forName("utf-8"))); + } + + ReturnFields returnFields = null; + + if (fields != null) { + returnFields = new ReturnFields(fields); + } + + // make sure content type is set + if (body != null) { + headers.addIfMissing("Content-Type", "application/json"); + } + + LinkedHashMap queryParams = new LinkedHashMap<>(); + if (offset != null) { + queryParams.put("first", String.valueOf(offset)); + } + if (limit != null) { + queryParams.put("max", String.valueOf(limit)); + } + if (queryParams.size() > 0) { + resourceUrl = HttpUtil.addQueryParamsToUri(resourceUrl, queryParams); + } + + HeadersBodyStatus response; + try { + response = HttpUtil.doRequest(httpVerb, resourceUrl, new HeadersBody(headers, body)); + } catch (IOException e) { + throw new RuntimeException("HTTP request error: " + e.getMessage(), e); + } + + // output response + if (printHeaders) { + printOut(response.getStatus()); + for (Header header : response.getHeaders()) { + printOut(header.getName() + ": " + header.getValue()); + } + } + + checkSuccess(resourceUrl, response); + + AccessibleBufferOutputStream abos = new AccessibleBufferOutputStream(System.out); + if (response.getBody() == null) { + throw new RuntimeException("Internal error - response body should never be null"); + } + + if (printHeaders) { + printOut(""); + } + + + Header location = response.getHeaders().get("Location"); + String id = location != null ? extractLastComponentOfUri(location.getValue()) : null; + if (id != null) { + if (returnId) { + printOut(id); + } else if (!outputResult) { + printErr("Created new " + typeName + " with id '" + id + "'"); + } + } + + if (outputResult) { + + if (isCreateOrUpdate() && (response.getStatusCode() == 204 || id != null)) { + // get object for id + headers = new Headers(); + if (auth != null) { + headers.add("Authorization", auth); + } + try { + String fetchUrl = id != null ? (resourceUrl + "/" + id) : resourceUrl; + response = doGet(fetchUrl, new HeadersBody(headers)); + } catch (IOException e) { + throw new RuntimeException("HTTP request error: " + e.getMessage(), e); + } + } + + Header contentType = response.getHeaders().get("content-type"); + boolean canPrettyPrint = contentType != null && contentType.getValue().equals("application/json"); + boolean pretty = !compressed; + + if (canPrettyPrint && (pretty || returnFields != null)) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + copyStream(response.getBody(), buffer); + + try { + JsonNode rootNode = MAPPER.readValue(buffer.toByteArray(), JsonNode.class); + if (returnFields != null) { + rootNode = applyFieldFilter(MAPPER, rootNode, returnFields); + } + if (outputFormat == OutputFormat.JSON) { + // now pretty print it to output + MAPPER.writeValue(abos, rootNode); + } else { + printAsCsv(rootNode, returnFields, unquoted); + } + } catch (Exception ignored) { + copyStream(new ByteArrayInputStream(buffer.toByteArray()), abos); + } + } else { + copyStream(response.getBody(), abos); + } + } + + int lastByte = abos.getLastByte(); + if (lastByte != -1 && lastByte != 13 && lastByte != 10) { + printErr(""); + } + + return CommandResult.SUCCESS; + } + + private boolean isUpdate() { + return "put".equals(httpVerb); + } + + private boolean isCreateOrUpdate() { + return "post".equals(httpVerb) || "put".equals(httpVerb); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AddRolesCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AddRolesCmd.java new file mode 100644 index 00000000000..fecd042cc2a --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/AddRolesCmd.java @@ -0,0 +1,334 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.cl.Option; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; +import org.keycloak.client.admin.cli.config.ConfigData; +import org.keycloak.client.admin.cli.operations.ClientOperations; +import org.keycloak.client.admin.cli.operations.GroupOperations; +import org.keycloak.client.admin.cli.operations.RoleOperations; +import org.keycloak.client.admin.cli.operations.LocalSearch; +import org.keycloak.client.admin.cli.operations.UserOperations; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +import static org.keycloak.client.admin.cli.util.AuthUtil.ensureToken; +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.admin.cli.util.ConfigUtil.credentialsAvailable; +import static org.keycloak.client.admin.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "add-roles", description = "[ARGUMENTS]") +public class AddRolesCmd extends AbstractAuthOptionsCmd { + + @Option(name = "uusername", description = "Target user's 'username'") + String uusername; + + @Option(name = "uid", description = "Target user's 'id'") + String uid; + + @Option(name = "gname", description = "Target group's 'name'") + String gname; + + @Option(name = "gpath", description = "Target group's 'path'") + String gpath; + + @Option(name = "gid", description = "Target group's 'id'") + String gid; + + @Option(name = "cclientid", description = "Target client's 'clientId'") + String cclientid; + + @Option(name = "cid", description = "Target client's 'id'") + String cid; + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + List roleNames = new LinkedList<>(); + List roleIds = new LinkedList<>(); + + try { + if (printHelp()) { + return help ? CommandResult.SUCCESS : CommandResult.FAILURE; + } + + processGlobalOptions(); + + Iterator it = args.iterator(); + + while (it.hasNext()) { + String option = it.next(); + switch (option) { + case "--rolename": { + optionRequiresValueCheck(it, option); + roleNames.add(it.next()); + break; + } + case "--roleid": { + optionRequiresValueCheck(it, option); + roleIds.add(it.next()); + break; + } + default: { + throw new IllegalArgumentException("Invalid option: " + option); + } + } + } + + if (uid != null && uusername != null) { + throw new IllegalArgumentException("Incompatible options: --uid and --uusername are mutually exclusive"); + } + + if ((gid != null && gname != null) || (gid != null && gpath != null) || (gname != null && gpath != null)) { + throw new IllegalArgumentException("Incompatible options: --gid, --gname and --gpath are mutually exclusive"); + } + + if (roleNames.isEmpty() && roleIds.isEmpty()) { + throw new IllegalArgumentException("No role specified. Use --rolename or --roleid to specify roles"); + } + + if (cid != null && cclientid != null) { + throw new IllegalArgumentException("Incompatible options: --cid and --cclientid are mutually exclusive"); + } + + if (isUserSpecified() && isGroupSpecified()) { + throw new IllegalArgumentException("Incompatible options: --uusername / --uid can't be used at the same time as --gname / --gid / --gpath"); + } + + if (!isUserSpecified() && !isGroupSpecified()) { + throw new IllegalArgumentException("No user nor group specified. Use --uusername / --uid to specify user or --gname / --gid / --gpath to specify group"); + } + + + ConfigData config = loadConfig(); + config = copyWithServerInfo(config); + + setupTruststore(config, commandInvocation); + + String auth = null; + + config = ensureAuthInfo(config, commandInvocation); + config = copyWithServerInfo(config); + if (credentialsAvailable(config)) { + auth = ensureToken(config); + } + + auth = auth != null ? "Bearer " + auth : null; + + final String server = config.getServerUrl(); + final String realm = getTargetRealm(config); + final String adminRoot = adminRestRoot != null ? adminRestRoot : composeAdminRoot(server); + + + if (isUserSpecified()) { + if (uid == null) { + uid = UserOperations.getIdFromUsername(adminRoot, realm, auth, uusername); + } + if (isClientSpecified()) { + // list client roles for a user + if (cid == null) { + cid = ClientOperations.getIdFromClientId(adminRoot, realm, auth, cclientid); + } + + List roles = RoleOperations.getClientRoles(adminRoot, realm, cid, auth); + Set rolesToAdd = getRoleRepresentations(roleNames, roleIds, new LocalSearch(roles)); + + // now add all the roles + UserOperations.addClientRoles(adminRoot, realm, auth, uid, cid, new ArrayList<>(rolesToAdd)); + + } else { + + Set rolesToAdd = getRoleRepresentations(roleNames, roleIds, + new LocalSearch(RoleOperations.getRealmRolesAsNodes(adminRoot, realm, auth))); + + // now add all the roles + UserOperations.addRealmRoles(adminRoot, realm, auth, uid, new ArrayList<>(rolesToAdd)); + } + + } else if (isGroupSpecified()) { + if (gname != null) { + gid = GroupOperations.getIdFromName(adminRoot, realm, auth, gname); + } else if (gpath != null) { + gid = GroupOperations.getIdFromPath(adminRoot, realm, auth, gpath); + } + if (isClientSpecified()) { + // list client roles for a group + if (cid == null) { + cid = ClientOperations.getIdFromClientId(adminRoot, realm, auth, cclientid); + } + + List roles = RoleOperations.getClientRoles(adminRoot, realm, cid, auth); + Set rolesToAdd = getRoleRepresentations(roleNames, roleIds, new LocalSearch(roles)); + + // now add all the roles + GroupOperations.addClientRoles(adminRoot, realm, auth, gid, cid, new ArrayList<>(rolesToAdd)); + + } else { + + Set rolesToAdd = getRoleRepresentations(roleNames, roleIds, + new LocalSearch(RoleOperations.getRealmRolesAsNodes(adminRoot, realm, auth))); + + // now add all the roles + GroupOperations.addRealmRoles(adminRoot, realm, auth, gid, new ArrayList<>(rolesToAdd)); + } + + } else { + + throw new IllegalArgumentException("No user nor group specified. Use --uusername / --uid to specify user or --gname / --gid / --gpath to specify group"); + } + + return CommandResult.SUCCESS; + + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(e.getMessage() + suggestHelp(), e); + } finally { + commandInvocation.stop(); + } + } + + private Set getRoleRepresentations(List roleNames, List roleIds, LocalSearch roleSearch) { + Set rolesToAdd = new HashSet<>(); + + // now we process roles + for (String name : roleNames) { + ObjectNode r = roleSearch.exactMatchOne(name, "name"); + if (r == null) { + throw new RuntimeException("Role not found for name: " + name); + } + rolesToAdd.add(r); + } + for (String id : roleIds) { + ObjectNode r = roleSearch.exactMatchOne(id, "id"); + if (r == null) { + throw new RuntimeException("Role not found for id: " + id); + } + rolesToAdd.add(r); + } + return rolesToAdd; + } + + private void optionRequiresValueCheck(Iterator it, String option) { + if (!it.hasNext()) { + throw new IllegalArgumentException("Option " + option + " requires a value"); + } + } + + private boolean isClientSpecified() { + return cid != null || cclientid != null; + } + + private boolean isGroupSpecified() { + return gid != null || gname != null || gpath != null; + } + + private boolean isUserSpecified() { + return uid != null || uusername != null; + } + + + @Override + protected boolean nothingToDo() { + return noOptions() && uusername == null && uid == null && cclientid == null && (args == null || args.size() == 0); + } + + protected String suggestHelp() { + return EOL + "Try '" + CMD + " help add-roles' for more information"; + } + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " add-roles (--uusername USERNAME | --uid ID) [--cclientid CLIENT_ID | --cid ID] (--rolename NAME | --roleid ID)+ [ARGUMENTS]"); + out.println("Usage: " + CMD + " add-roles (--gname NAME | --gpath PATH | --gid ID) [--cclientid CLIENT_ID | --cid ID] (--rolename NAME | --roleid ID)+ [ARGUMENTS]"); + out.println(); + out.println("Command to add realm or client roles to a user or group."); + out.println(); + out.println("Use `" + CMD + " config credentials` to establish an authenticated session, or use CREDENTIALS OPTIONS"); + out.println("to perform one time authentication."); + out.println(); + out.println("If client is specified using --cclientid or --cid then roles to add are client roles, otherwise they are realm roles."); + out.println("Either a user, or a group needs to be specified. If user is specified using --uusername or --uid then roles are added"); + out.println("to a specific user. If group is specified using --gname, --gpath or --gid then roles are added to a specific group."); + out.println("One or more roles have to be specified using --rolename or --roleid so that they are added to a group or a user."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" --config Path to the config file (" + DEFAULT_CONFIG_FILE_STRING + " by default)"); + out.println(" --truststore PATH Path to a truststore containing trusted certificates"); + out.println(" --trustpass PASSWORD Truststore password (prompted for if not specified and --truststore is used)"); + out.println(" CREDENTIALS OPTIONS Same set of options as accepted by '" + CMD + " config credentials' in order to establish"); + out.println(" an authenticated sessions. This allows on-the-fly transient authentication that does"); + out.println(" not touch a config file."); + out.println(); + out.println(" Command specific options:"); + out.println(" --uusername User's 'username'. If more than one user exists with the same username"); + out.println(" you'll have to use --uid to specify the target user"); + out.println(" --uid User's 'id' attribute"); + out.println(" --gname Group's 'name'. If more than one group exists with the same name you'll have"); + out.println(" to use --gid, or --gpath to specify the target group"); + out.println(" --gpath Group's 'path' attribute"); + out.println(" --gid Group's 'id' attribute"); + out.println(" --cclientid Client's 'clientId' attribute"); + out.println(" --cid Client's 'id' attribute"); + out.println(" --rolename Role's 'name' attribute"); + out.println(" --roleid Role's 'id' attribute"); + out.println(" -a, --admin-root URL URL of Admin REST endpoint root if not default - e.g. http://localhost:8080/auth/admin"); + out.println(" -r, --target-realm REALM Target realm to issue requests against if not the one authenticated against"); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Add 'offline_access' realm role to a user:"); + out.println(" " + PROMPT + " " + CMD + " add-roles -r demorealm --uusername testuser --rolename offline_access"); + out.println(); + out.println("Add 'realm-management' client roles 'view-users', 'view-clients' and 'view-realm' to a user:"); + out.println(" " + PROMPT + " " + CMD + " add-roles -r demorealm --uusername testuser --cclientid realm-management --rolename view-users --rolename view-clients --rolename view-realm"); + out.println(); + out.println("Add 'uma_authorization' realm role to a group:"); + out.println(" " + PROMPT + " " + CMD + " add-roles -r demorealm --gname PowerUsers --rolename uma_authorization"); + out.println(); + out.println("Add 'realm-management' client roles 'realm-admin' to a group:"); + out.println(" " + PROMPT + " " + CMD + " add-roles -r demorealm --gname PowerUsers --cclientid realm-management --rolename realm-admin"); + out.println(); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCmd.java new file mode 100644 index 00000000000..f2975616f0c --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCmd.java @@ -0,0 +1,95 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.GroupCommandDefinition; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; + + +/** + * @author Marko Strukelj + */ + +@GroupCommandDefinition(name = "config", description = "COMMAND [ARGUMENTS]", groupCommands = {ConfigCredentialsCmd.class} ) +public class ConfigCmd extends AbstractAuthOptionsCmd { + + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + if (args != null && args.size() > 0) { + String cmd = args.get(0); + switch (cmd) { + case "credentials": { + args.remove(0); + ConfigCredentialsCmd command = new ConfigCredentialsCmd(); + command.initFromParent(this); + return command.execute(commandInvocation); + } + case "truststore": { + args.remove(0); + ConfigTruststoreCmd command = new ConfigTruststoreCmd(); + command.initFromParent(this); + return command.execute(commandInvocation); + } + default: { + if (printHelp()) { + return help ? CommandResult.SUCCESS : CommandResult.FAILURE; + } + throw new IllegalArgumentException("Unknown sub-command: " + cmd + suggestHelp()); + } + } + } + + if (printHelp()) { + return help ? CommandResult.SUCCESS : CommandResult.FAILURE; + } + + throw new IllegalArgumentException("Sub-command required by '" + CMD + " config' - one of: 'credentials', 'truststore'"); + + } finally { + commandInvocation.stop(); + } + } + + protected String suggestHelp() { + return EOL + "Try '" + CMD + " help config' for more information"; + } + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " config SUB_COMMAND [ARGUMENTS]"); + out.println(); + out.println("Where SUB_COMMAND is one of: 'credentials', 'truststore'"); + out.println(); + out.println(); + out.println("Use '" + CMD + " help config SUB_COMMAND' for more info."); + out.println("Use '" + CMD + " help' for general information and a list of commands."); + return sb.toString(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCredentialsCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCredentialsCmd.java new file mode 100644 index 00000000000..c84c52e417b --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigCredentialsCmd.java @@ -0,0 +1,275 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; +import org.keycloak.client.admin.cli.config.ConfigData; +import org.keycloak.client.admin.cli.config.RealmConfigData; +import org.keycloak.client.admin.cli.util.AuthUtil; +import org.keycloak.representations.AccessTokenResponse; + +import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.net.URL; + +import static org.keycloak.client.admin.cli.util.AuthUtil.getAuthTokens; +import static org.keycloak.client.admin.cli.util.AuthUtil.getAuthTokensByJWT; +import static org.keycloak.client.admin.cli.util.AuthUtil.getAuthTokensBySecret; +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.admin.cli.util.ConfigUtil.getHandler; +import static org.keycloak.client.admin.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.admin.cli.util.ConfigUtil.saveTokens; +import static org.keycloak.client.admin.cli.util.IoUtil.printErr; +import static org.keycloak.client.admin.cli.util.IoUtil.readSecret; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.client.admin.cli.util.OsUtil.OS_ARCH; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; + + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "credentials", description = "--server SERVER_URL --realm REALM [ARGUMENTS]") +public class ConfigCredentialsCmd extends AbstractAuthOptionsCmd { + + private int sigLifetime = 600; + + + public void init(ConfigData configData) { + if (server == null) { + server = configData.getServerUrl(); + } + if (realm == null) { + realm = configData.getRealm(); + } + if (trustStore == null) { + trustStore = configData.getTruststore(); + } + + RealmConfigData rdata = configData.getRealmConfigData(server, realm); + if (rdata == null) { + return; + } + + if (clientId == null) { + clientId = rdata.getClientId(); + } + } + + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + if (printHelp()) { + return help ? CommandResult.SUCCESS : CommandResult.FAILURE; + } + + processGlobalOptions(); + + return process(commandInvocation); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(e.getMessage() + suggestHelp(), e); + } finally { + commandInvocation.stop(); + } + } + + @Override + protected boolean nothingToDo() { + return noOptions(); + } + + public CommandResult process(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + // check server + if (server == null) { + throw new IllegalArgumentException("Required option not specified: --server"); + } + + try { + new URL(server); + } catch (Exception e) { + throw new RuntimeException("Invalid server endpoint url: " + server, e); + } + + if (realm == null) + throw new IllegalArgumentException("Required option not specified: --realm"); + + String signedRequestToken = null; + boolean clientSet = clientId != null; + + applyDefaultOptionValues(); + + if (user != null) { + printErr("Logging into " + server + " as user " + user + " of realm " + realm); + + // if user was set there needs to be a password so we can authenticate + if (password == null) { + password = readSecret("Enter password: ", commandInvocation); + } + // if secret was set to be read from stdin, then ask for it + if ("-".equals(secret) && keystore == null) { + secret = readSecret("Enter client secret: ", commandInvocation); + } + } else if (keystore != null || secret != null || clientSet) { + printErr("Logging into " + server + " as " + "service-account-" + clientId + " of realm " + realm); + if (keystore == null) { + if (secret == null) { + secret = readSecret("Enter client secret: ", commandInvocation); + } + } + } + + if (keystore != null) { + if (secret != null) { + throw new IllegalArgumentException("Can't use both --keystore and --secret"); + } + + if (!new File(keystore).isFile()) { + throw new RuntimeException("No such keystore file: " + keystore); + } + + if (storePass == null) { + storePass = readSecret("Enter keystore password: ", commandInvocation); + keyPass = readSecret("Enter key password: ", commandInvocation); + } + + if (keyPass == null) { + keyPass = storePass; + } + + if (alias == null) { + alias = clientId; + } + + String realmInfoUrl = server + "/realms/" + realm; + + signedRequestToken = AuthUtil.getSignedRequestToken(keystore, storePass, keyPass, + alias, sigLifetime, clientId, realmInfoUrl); + } + + // if only server and realm are set, just save config and be done + if (user == null && secret == null && keystore == null) { + getHandler().saveMergeConfig(config -> { + config.setServerUrl(server); + config.setRealm(realm); + }); + return CommandResult.SUCCESS; + } + + setupTruststore(copyWithServerInfo(loadConfig()), commandInvocation); + + // now use the token endpoint to retrieve access token, and refresh token + AccessTokenResponse tokens = signedRequestToken != null ? + getAuthTokensByJWT(server, realm, user, password, clientId, signedRequestToken) : + secret != null ? + getAuthTokensBySecret(server, realm, user, password, clientId, secret) : + getAuthTokens(server, realm, user, password, clientId); + + Long sigExpiresAt = signedRequestToken == null ? null : System.currentTimeMillis() + sigLifetime * 1000; + + // save tokens to config file + saveTokens(tokens, server, realm, clientId, signedRequestToken, sigExpiresAt, secret); + + return CommandResult.SUCCESS; + } + + protected String suggestHelp() { + return EOL + "Try '" + CMD + " help config credentials' for more information"; + } + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " config credentials --server SERVER_URL --realm REALM --user USER [--password PASSWORD] [ARGUMENTS]"); + out.println(" " + CMD + " config credentials --server SERVER_URL --realm REALM --client CLIENT_ID [--secret SECRET] [ARGUMENTS]"); + out.println(" " + CMD + " config credentials --server SERVER_URL --realm REALM --client CLIENT_ID [--keystore KEYSTORE] [ARGUMENTS]"); + out.println(); + out.println("Command to establish an authenticated client session with the server. There are many authentication"); + out.println("options available, and it depends on server side client authentication configuration how client can or should authenticate."); + out.println("The information always required includes --server, and --realm. Then, --user and / or --client need to be used to authenticate."); + out.println("If --client is not provided it defaults to 'admin-cli'. The authentication options / requirements depend on how this client is configured."); + out.println(); + out.println("If confidential client authentication is also configured, you may have to specify a client id, and client credentials in addition to"); + out.println("user credentials. Client credentials are either a client secret, or a keystore information to use Signed JWT mechanism."); + out.println("If only client credentials are provided, and no user credentials, then the service account is used for login."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" --config Path to a config file (" + DEFAULT_CONFIG_FILE_STRING + " by default)"); + out.println(" --truststore PATH Path to a truststore containing trusted certificates"); + out.println(" --trustpass PASSWORD Truststore password (prompted for if not specified and --truststore is used)"); + out.println(); + out.println(" Command specific options:"); + out.println(" --server SERVER_URL Server endpoint url (e.g. 'http://localhost:8080/auth')"); + out.println(" --realm REALM Realm name to use"); + out.println(" --user USER Username to login with"); + out.println(" --password PASSWORD Password to login with (prompted for if not specified and --user is used)"); + out.println(" --client CLIENT_ID ClientId used by this client tool ('admin-cli' by default)"); + out.println(" --secret SECRET Secret to authenticate the client (prompted for if --client is specified, and no --keystore is specified)"); + out.println(" --keystore PATH Path to a keystore containing private key"); + out.println(" --storepass PASSWORD Keystore password (prompted for if not specified and --keystore is used)"); + out.println(" --keypass PASSWORD Key password (prompted for if not specified and --keystore is used without --storepass,"); + out.println(" otherwise defaults to keystore password)"); + out.println(" --alias ALIAS Alias of the key inside a keystore (defaults to the value of ClientId)"); + out.println(); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Login as 'admin' user of 'master' realm to a local Keycloak server running on default port."); + out.println("You will be prompted for a password:"); + out.println(" " + PROMPT + " " + CMD + " config credentials --server http://localhost:8080/auth --realm master --user admin"); + out.println(); + out.println("Login to Keycloak server at non-default endpoint passing the password via standard input:"); + if (OS_ARCH.isWindows()) { + out.println(" " + PROMPT + " echo mypassword | " + CMD + " config credentials --server http://localhost:9080/auth --realm master --user admin"); + } else { + out.println(" " + PROMPT + " " + CMD + " config credentials --server http://localhost:9080/auth --realm master --user admin << EOF"); + out.println(" mypassword"); + out.println(" EOF"); + } + out.println(); + out.println("Login specifying a password through command line:"); + out.println(" " + PROMPT + " " + CMD + " config credentials --server http://localhost:9080/auth --realm master --user admin --password " + OS_ARCH.envVar("PASSWORD")); + out.println(); + out.println("Login using a client service account of a custom client. You will be prompted for a client secret:"); + out.println(" " + PROMPT + " " + CMD + " config credentials --server http://localhost:9080/auth --realm master --client reg-cli"); + out.println(); + out.println("Login using a client service account of a custom client, authenticating with signed JWT."); + out.println("You will be prompted for a keystore password, and a key password:"); + out.println(" " + PROMPT + " " + CMD + " config credentials --server http://localhost:9080/auth --realm master --client reg-cli --keystore " + OS_ARCH.path("~/.keycloak/keystore.jks")); + out.println(); + out.println("Login as 'user' while also authenticating a custom client with signed JWT."); + out.println("You will be prompted for a user password, a keystore password, and a key password:"); + out.println(" " + PROMPT + " " + CMD + " config credentials --server http://localhost:9080/auth --realm master --user user --client reg-cli --keystore " + OS_ARCH.path("~/.keycloak/keystore.jks")); + out.println(); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigTruststoreCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigTruststoreCmd.java new file mode 100644 index 00000000000..38f7b9fbece --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/ConfigTruststoreCmd.java @@ -0,0 +1,200 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; + +import java.io.File; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.admin.cli.util.ConfigUtil.saveMergeConfig; +import static org.keycloak.client.admin.cli.util.IoUtil.readSecret; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.client.admin.cli.util.OsUtil.OS_ARCH; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "truststore", description = "PATH [ARGUMENTS]") +public class ConfigTruststoreCmd extends AbstractAuthOptionsCmd { + + private ConfigCmd parent; + + private boolean delete; + + + protected void initFromParent(ConfigCmd parent) { + this.parent = parent; + super.initFromParent(parent); + } + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + if (printHelp()) { + return help ? CommandResult.SUCCESS : CommandResult.FAILURE; + } + + return process(commandInvocation); + + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(e.getMessage() + suggestHelp(), e); + } finally { + commandInvocation.stop(); + } + } + + @Override + protected boolean nothingToDo() { + return noOptions(); + } + + public CommandResult process(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + List args = new ArrayList<>(); + + Iterator it = parent.args.iterator(); + + while (it.hasNext()) { + String arg = it.next(); + switch (arg) { + case "-d": + case "--delete": { + delete = true; + break; + } + default: { + args.add(arg); + } + } + } + + if (args.size() > 1) { + throw new IllegalArgumentException("Invalid option: " + args.get(1)); + } + + String truststore = null; + if (args.size() > 0) { + truststore = args.get(0); + } + + checkUnsupportedOptions("--server", server, + "--realm", realm, + "--client", clientId, + "--user", user, + "--password", password, + "--secret", secret, + "--truststore", trustStore, + "--keystore", keystore, + "--keypass", keyPass, + "--alias", alias); + + // now update the config + processGlobalOptions(); + + String store; + String pass; + + if (!delete) { + + if (truststore == null) { + throw new IllegalArgumentException("No truststore specified"); + } + + if (!new File(truststore).isFile()) { + throw new RuntimeException("Truststore file not found: " + truststore); + } + + if ("-".equals(trustPass)) { + trustPass = readSecret("Enter truststore password: ", commandInvocation); + } + + store = truststore; + pass = trustPass; + + } else { + if (truststore != null) { + throw new IllegalArgumentException("Option --delete is mutually exclusive with specifying a TRUSTSTORE"); + } + if (trustPass != null) { + throw new IllegalArgumentException("Options --trustpass and --delete are mutually exclusive"); + } + store = null; + pass = null; + } + + saveMergeConfig(config -> { + config.setTruststore(store); + config.setTrustpass(pass); + }); + + return CommandResult.SUCCESS; + } + + protected String suggestHelp() { + return EOL + "Try '" + CMD + " help config truststore' for more information"; + } + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " config truststore [TRUSTSTORE | --delete] [--trustpass PASSWORD] [ARGUMENTS]"); + out.println(); + out.println("Command to configure a global truststore to use when using https to connect to Keycloak server."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" --config Path to the config file (" + DEFAULT_CONFIG_FILE_STRING + " by default)"); + out.println(); + out.println(" Command specific options:"); + out.println(" TRUSTSTORE Path to truststore file"); + out.println(" --trustpass PASSWORD Truststore password to unlock truststore (prompted for if set to '-')"); + out.println(" -d, --delete Remove truststore configuration"); + out.println(); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Specify a truststore - you will be prompted for truststore password every time it is used:"); + out.println(" " + PROMPT + " " + CMD + " config truststore " + OS_ARCH.path("~/.keycloak/truststore.jks")); + out.println(); + out.println("Specify a truststore, and password - truststore will automatically be used without prompting for password:"); + out.println(" " + PROMPT + " " + CMD + " config truststore --storepass " + OS_ARCH.envVar("PASSWORD") + " " + OS_ARCH.path("~/.keycloak/truststore.jks")); + out.println(); + out.println("Remove truststore configuration:"); + out.println(" " + PROMPT + " " + CMD + " config truststore --delete"); + out.println(); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/CreateCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/CreateCmd.java new file mode 100644 index 00000000000..63591f129a8 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/CreateCmd.java @@ -0,0 +1,167 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.cl.Option; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.client.admin.cli.util.OsUtil.OS_ARCH; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "create", description = "Command to create new resources") +public class CreateCmd extends AbstractRequestCmd { + + @Option(shortName = 'f', name = "file", description = "Read object from file or standard input if FILENAME is set to '-'", hasValue = true) + String file; + + @Option(shortName = 'F', name = "fields", description = "A pattern specifying which attributes of JSON response body to actually display as result - causes mismatch with Content-Length header", hasValue = true) + String fields; + + @Option(shortName = 'H', name = "print-headers", description = "Print response headers", hasValue = false) + boolean printHeaders; + + @Option(shortName = 'i', name = "id", description = "After creation only print id of created resource to standard output", hasValue = false) + boolean returnId = false; + + @Option(shortName = 'o', name = "output", description = "After creation output the new resource to standard output", hasValue = false) + boolean outputResult = false; + + @Option(shortName = 'c', name = "compressed", description = "Don't pretty print the output", hasValue = false) + boolean compressed = false; + + //@OptionGroup(shortName = 's', name = "set", description = "Set attribute to the specified value") + //Map attributes = new LinkedHashMap<>(); + + @Override + void initOptions() { + // set options on parent + super.file = file; + super.fields = fields; + super.printHeaders = printHeaders; + super.returnId = returnId; + super.outputResult = outputResult; + super.compressed = compressed; + super.httpVerb = "post"; + } + + @Override + protected boolean nothingToDo() { + return noOptions() && file == null && (args == null || args.size() == 0); + } + + protected String suggestHelp() { + return EOL + "Try '" + CMD + " help create' for more information"; + } + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " create ENDPOINT_URI [ARGUMENTS]"); + out.println(); + out.println("Command to create new resources on the server."); + out.println(); + out.println("Use `" + CMD + " config credentials` to establish an authenticated sessions, or use CREDENTIALS OPTIONS"); + out.println("to perform one time authentication."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" --config Path to the config file (" + DEFAULT_CONFIG_FILE_STRING + " by default)"); + out.println(" --truststore PATH Path to a truststore containing trusted certificates"); + out.println(" --trustpass PASSWORD Truststore password (prompted for if not specified and --truststore is used)"); + out.println(" CREDENTIALS OPTIONS Same set of options as accepted by '" + CMD + " config credentials' in order to establish"); + out.println(" an authenticated sessions. This allows on-the-fly transient authentication that does"); + out.println(" not touch a config file."); + out.println(); + out.println(" Command specific options:"); + out.println(" ENDPOINT_URI URI used to compose a target resource url. Commonly used values are:"); + out.println(" realms, users, roles, groups, clients, keys, serverinfo, components ..."); + out.println(" If it starts with 'http://' then it will be used as target resource url"); + out.println(" -r, --target-realm REALM Target realm to issue requests against if not the one authenticated against"); + out.println(" -s, --set NAME=VALUE Set a specific attribute NAME to a specified value VALUE"); + out.println(" -d, --delete NAME Remove a specific attribute NAME from JSON request body"); + out.println(" -f, --file FILENAME Read object from file or standard input if FILENAME is set to '-'"); + out.println(" -q, --query NAME=VALUE Add to request URI a NAME query parameter with value VALUE"); + out.println(" -h, --header NAME=VALUE Set request header NAME to VALUE"); + out.println(); + out.println(" -H, --print-headers Print response headers"); + out.println(" -o, --output After creation output the new resource to standard output"); + out.println(" -i, --id After creation only print id of the new resource to standard output"); + out.println(" -F, --fields FILTER A filter pattern to specify which fields of a JSON response to output"); + out.println(" -c, --compressed Don't pretty print the output"); + out.println(" -a, --admin-root URL URL of Admin REST endpoint root if not default - e.g. http://localhost:8080/auth/admin"); + out.println(); + out.println(); + out.println("Nested attributes are supported by using '.' to separate components of a KEY. Optionaly, the KEY components "); + out.println("can be quoted with double quotes - e.g. my_client.attributes.\"external.user.id\". If VALUE starts with [ and "); + out.println("ends with ] the attribute will be set as a JSON array. If VALUE starts with { and ends with } the attribute "); + out.println("will be set as a JSON object. If KEY ends with an array index - e.g. clients[3]=VALUE - then the specified item"); + out.println("of the array is updated. If KEY+=VALUE syntax is used, then KEY is assumed to be an array, and another item is"); + out.println("added to it."); + out.println(); + out.println("Attributes can also be deleted. If KEY ends with an array index, then the targeted item of an array is removed"); + out.println("and the following items are shifted."); + out.println(); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Create a new realm:"); + out.println(" " + PROMPT + " " + CMD + " create realms -s realm=demorealm -s enabled=true"); + out.println(); + out.println("Create a new realm role in realm 'demorealm' returning newly created role:"); + out.println(" " + PROMPT + " " + CMD + " create roles -r demorealm -s name=manage-all -o"); + out.println(); + out.println("Create a new user in realm 'demorealm' returning only 'id', and 'username' attributes:"); + out.println(" " + PROMPT + " " + CMD + " create users -r demorealm -s username=testuser -s enabled=true -o --fields id,username"); + out.println(); + out.println("Create a new client using configuration read from standard input:"); + if (OS_ARCH.isWindows()) { + out.println(" " + PROMPT + " echo { \"clientId\": \"my_client\" } | " + CMD + " create clients -r demorealm -f -"); + } else { + out.println(" " + PROMPT + " " + CMD + " create clients -r demorealm -f - << EOF"); + out.println(" {"); + out.println(" \"clientId\": \"my_client\""); + out.println(" }"); + out.println(" EOF"); + } + out.println(); + out.println("Create a client using file as a template, and override some attributes - return an 'id' of new client:"); + out.println(" " + PROMPT + " " + CMD + " create clients -r demorealm -f my_client.json -s clientId=my_client2 -s 'redirectUris=[\"http://localhost:8980/myapp/*\"]' -i"); + out.println(); + out.println("Create a new client role for client my_client in realm 'demorealm' (replace ID with output of previous example command):"); + out.println(" " + PROMPT + " " + CMD + " create clients/ID/roles -r demorealm -s name=client_role"); + out.println(); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } + +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/DeleteCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/DeleteCmd.java new file mode 100644 index 00000000000..7ef31e1c89a --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/DeleteCmd.java @@ -0,0 +1,104 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.CommandDefinition; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; + + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "delete", description = "CLIENT [GLOBAL_OPTIONS]") +public class DeleteCmd extends CreateCmd { + + void initOptions() { + super.initOptions(); + httpVerb = "delete"; + } + + @Override + protected boolean nothingToDo() { + return noOptions() && (args == null || args.size() == 0); + } + + protected String suggestHelp() { + return EOL + "Try '" + CMD + " help delete' for more information"; + } + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " delete ENDPOINT_URI [ARGUMENTS]"); + out.println(); + out.println("Command to delete resources on the server."); + out.println(); + out.println("Use `" + CMD + " config credentials` to establish an authenticated sessions, or use CREDENTIALS OPTIONS"); + out.println("to perform one time authentication."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" --config Path to the config file (" + DEFAULT_CONFIG_FILE_STRING + " by default)"); + out.println(" --truststore PATH Path to a truststore containing trusted certificates"); + out.println(" --trustpass PASSWORD Truststore password (prompted for if not specified and --truststore is used)"); + out.println(" CREDENTIALS OPTIONS Same set of options as accepted by '" + CMD + " config credentials' in order to establish"); + out.println(" an authenticated sessions. This allows on-the-fly transient authentication that does"); + out.println(" not touch a config file."); + out.println(); + out.println(" Command specific options:"); + out.println(" ENDPOINT_URI URI used to compose a target resource url. Commonly used values start with:"); + out.println(" realms/, users/, roles/, groups/, clients/, keys/, components/ ..."); + out.println(" If it starts with 'http://' then it will be used as target resource url"); + out.println(" -r, --target-realm REALM Target realm to issue requests against if not the one authenticated against"); + out.println(" -s, --set NAME=VALUE Send a body with request - set a specific attribute NAME to a specified value VALUE"); + out.println(" -d, --delete NAME Remove a specific attribute NAME from JSON request body"); + out.println(" -f, --file FILENAME Send a body with request - read object from file or standard input if FILENAME is set to '-'"); + out.println(" -q, --query NAME=VALUE Add to request URI a NAME query parameter with value VALUE"); + out.println(" -h, --header NAME=VALUE Set request header NAME to VALUE"); + out.println(); + out.println(" -H, --print-headers Print response headers"); + out.println(" -o, --output After delete output any response to standard output"); + out.println(" -F, --fields FILTER A filter pattern to specify which fields of a JSON response to output"); + out.println(" -c, --compressed Don't pretty print the output"); + out.println(" -a, --admin-root URL URL of Admin REST endpoint root if not default - e.g. http://localhost:8080/auth/admin"); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Delete a realm role:"); + out.println(" " + PROMPT + " " + CMD + " delete roles/manage-all -r demorealm"); + out.println(); + out.println("Delete a user (replace USER_ID with the value of user's 'id' attribute):"); + out.println(" " + PROMPT + " " + CMD + " delete users/USER_ID -r demorealm"); + out.println(); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/GetCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/GetCmd.java new file mode 100644 index 00000000000..27005e4d16a --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/GetCmd.java @@ -0,0 +1,168 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.cl.Option; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "get", description = "[ARGUMENTS]") +public class GetCmd extends AbstractRequestCmd { + + @Option(name = "noquotes", description = "", hasValue = false) + boolean unquoted; + + @Option(shortName = 'F', name = "fields", description = "A pattern specifying which attributes of JSON response body to actually display as result - causes mismatch with Content-Length header") + String fields; + + @Option(shortName = 'H', name = "print-headers", description = "Print response headers", hasValue = false) + boolean printHeaders; + + @Option(shortName = 'c', name = "compressed", description = "Don't pretty print the output", hasValue = false) + boolean compressed; + + @Option(shortName = 'o', name = "offset", description = "Number of results from beginning of resultset to skip") + Integer offset; + + @Option(shortName = 'l', name = "limit", description = "Maksimum number of results to return") + Integer limit; + + @Option(name = "format", description = "Output format - one of: json, csv", defaultValue = "json") + String format; + + + @Override + void initOptions() { + // set options on parent + super.fields = fields; + super.printHeaders = printHeaders; + super.returnId = false; + super.outputResult = true; + super.compressed = compressed; + super.offset = offset; + super.limit = limit; + super.format = format; + super.unquoted = unquoted; + super.httpVerb = "get"; + } + + @Override + protected boolean nothingToDo() { + return noOptions() && (args == null || args.size() == 0); + } + + protected String suggestHelp() { + return EOL + "Try '" + CMD + " help get' for more information"; + } + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " get ENDPOINT_URI [ARGUMENTS]"); + out.println(); + out.println("Command to retrieve existing resources from the server."); + out.println(); + out.println("Use `" + CMD + " config credentials` to establish an authenticated session, or use CREDENTIALS OPTIONS"); + out.println("to perform one time authentication."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" --config Path to the config file (" + DEFAULT_CONFIG_FILE_STRING + " by default)"); + out.println(" --truststore PATH Path to a truststore containing trusted certificates"); + out.println(" --trustpass PASSWORD Truststore password (prompted for if not specified and --truststore is used)"); + out.println(" CREDENTIALS OPTIONS Same set of options as accepted by '" + CMD + " config credentials' in order to establish"); + out.println(" an authenticated sessions. This allows on-the-fly transient authentication that does"); + out.println(" not touch a config file."); + out.println(); + out.println(" Command specific options:"); + out.println(" ENDPOINT_URI URI used to compose a target resource url. Commonly used values are:"); + out.println(" realms, users, roles, groups, clients, keys, serverinfo, components ..."); + out.println(" If it starts with 'http://' then it will be used as target resource url"); + out.println(" -r, --target-realm REALM Target realm to issue requests against if not the one authenticated against"); + out.println(" -q, --query NAME=VALUE Add to request URI a NAME query parameter with value VALUE"); + out.println(" -h, --header NAME=VALUE Set request header NAME to VALUE"); + out.println(" -o, --offset OFFSET Set paging offset - adds a query parameter 'first' which some endpoints recognize"); + out.println(" -l, --limit LIMIT Set limit to number of items in result - adds a query parameter 'max' "); + out.println(" which some endpoints recognize"); + out.println(); + out.println(" -H, --print-headers Print response headers"); + out.println(" -o, --output After delete output any response to standard output"); + out.println(" -F, --fields FILTER A filter pattern to specify which fields of a JSON response to output"); + out.println(" -c, --compressed Don't pretty print the output"); + out.println(" --format FORMAT Set output format to comma-separated-values by using 'csv'. Default format is 'json'"); + out.println(" --noquotes Don't quote strings when output format is 'csv'"); + out.println(" -a, --admin-root URL URL of Admin REST endpoint root if not default - e.g. http://localhost:8080/auth/admin"); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Get all realms, displaying only some of the attributes:"); + out.println(" " + PROMPT + " " + CMD + " get realms --fields id,realm,enabled"); + out.println(); + out.println("Get 'demorealm':"); + out.println(" " + PROMPT + " " + CMD + " get realms/demorealm"); + out.println(); + out.println("Get all configured identity providers in demorealm, displaying only some of the attributes:"); + out.println(" " + PROMPT + " " + CMD + " get identity-provider/instances -r demorealm --fields alias,providerId,enabled"); + out.println(); + out.println("Get all clients in demorealm, displaying only some of the attributes:"); + out.println(" " + PROMPT + " " + CMD + " get clients -r demorealm --fields 'id,clientId,protocolMappers(id,name,protocol,protocolMapper)'"); + out.println(); + out.println("Get specific client in demorealm, and remove 'id', and 'protocolMappers' attributes in order to use"); + out.println("it as a template (replace ID with client's 'id'):"); + out.println(" " + PROMPT + " " + CMD + " get clients/ID -r demorealm --fields '*(*),-id,-protocolMappers' > realm-template.json"); + out.println(); + out.println("Display first level attributes available on 'serverinfo' resource:"); + out.println(" " + PROMPT + " " + CMD + " get serverinfo -r demorealm --fields '*'"); + out.println(); + out.println("Display system info and memory info:"); + out.println(" " + PROMPT + " " + CMD + " get serverinfo -r demorealm --fields 'systemInfo(*),memoryInfo(*)'"); + out.println(); + out.println("Get adapter configuration for the client (replace ID with client's 'id'):"); + out.println(" " + PROMPT + " " + CMD + " get clients/ID/installation/providers/keycloak-oidc-keycloak-json -r demorealm"); + out.println(); + out.println("Get first 100 users at the most:"); + out.println(" " + PROMPT + " " + CMD + " get users -r demorealm --offset 0 --limit 100"); + out.println(); + out.println("Note: 'users' endpoint knows how to handle --offset and --limit. Most other endpoints don't."); + out.println(); + out.println("Get all users whose 'username' matches '*test*' pattern, and 'email' matches '*@google.com*':"); + out.println(" " + PROMPT + " " + CMD + " get users -r demorealm -q username=test -q email=@google.com"); + out.println(); + out.println("Note: it is the 'users' endpoint that interprets query parameters 'username', and 'email' in such a way that"); + out.println("it results in the described semantics. Another endpoint may provide a different semantics."); + out.println(); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/GetRolesCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/GetRolesCmd.java new file mode 100644 index 00000000000..7b0fd5f2bc4 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/GetRolesCmd.java @@ -0,0 +1,325 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.cl.Option; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; +import org.keycloak.client.admin.cli.config.ConfigData; +import org.keycloak.client.admin.cli.operations.ClientOperations; +import org.keycloak.client.admin.cli.operations.GroupOperations; +import org.keycloak.client.admin.cli.operations.RoleOperations; +import org.keycloak.client.admin.cli.operations.UserOperations; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; + +import static org.keycloak.client.admin.cli.util.AuthUtil.ensureToken; +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.admin.cli.util.ConfigUtil.credentialsAvailable; +import static org.keycloak.client.admin.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.admin.cli.util.HttpUtil.composeResourceUrl; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "get-roles", description = "[ARGUMENTS]") +public class GetRolesCmd extends GetCmd { + + @Option(name = "uusername", description = "Target user's 'username'") + String uusername; + + @Option(name = "uid", description = "Target user's 'id'") + String uid; + + @Option(name = "cclientid", description = "Target client's 'clientId'") + String cclientid; + + @Option(name = "cid", description = "Target client's 'id'") + String cid; + + @Option(name = "rolename", description = "Target role's 'name'") + String rname; + + @Option(name = "roleid", description = "Target role's 'id'") + String rid; + + @Option(name = "gname", description = "Target group's 'name'") + String gname; + + @Option(name = "gpath", description = "Target group's 'path'") + String gpath; + + @Option(name = "gid", description = "Target group's 'id'") + String gid; + + @Option(name = "available", description = "List only available roles", hasValue = false) + boolean available; + + @Option(name = "effective", description = "List assigned roles including transitively included roles", hasValue = false) + boolean effective; + + @Option(name = "all", description = "List roles for all clients in addition to realm roles", hasValue = false) + boolean all; + + + void initOptions() { + + super.initOptions(); + + // hack args so that GetCmd option check doesn't fail + // set a placeholder + if (args == null) { + args = new ArrayList(); + } + if (args.size() == 0) { + args.add("uri"); + } else { + args.add(0, "uri"); + } + } + + void processOptions(CommandInvocation commandInvocation) { + + if (uid != null && uusername != null) { + throw new IllegalArgumentException("Incompatible options: --uid and --uusername are mutually exclusive"); + } + + if ((gid != null && gname != null) || (gid != null && gpath != null) || (gname != null && gpath != null)) { + throw new IllegalArgumentException("Incompatible options: --gid, --gname and --gpath are mutually exclusive"); + } + + if (rid != null && rname != null) { + throw new IllegalArgumentException("Incompatible options: --roleid and --rolename are mutually exclusive"); + } + + if (cid != null && cclientid != null) { + throw new IllegalArgumentException("Incompatible options: --cid and --cclientid are mutually exclusive"); + } + + if (isUserSpecified() && isGroupSpecified()) { + throw new IllegalArgumentException("Incompatible options: --uusername / --uid can't be used at the same time as --gname / --gid / --gpath"); + } + + super.processOptions(commandInvocation); + } + + public CommandResult process(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + ConfigData config = loadConfig(); + config = copyWithServerInfo(config); + + setupTruststore(config, commandInvocation); + + String auth = null; + + config = ensureAuthInfo(config, commandInvocation); + config = copyWithServerInfo(config); + if (credentialsAvailable(config)) { + auth = ensureToken(config); + } + + auth = auth != null ? "Bearer " + auth : null; + + final String server = config.getServerUrl(); + final String realm = getTargetRealm(config); + final String adminRoot = adminRestRoot != null ? adminRestRoot : composeAdminRoot(server); + + if (isUserSpecified()) { + if (uid == null) { + uid = UserOperations.getIdFromUsername(adminRoot, realm, auth, uusername); + } + if (isClientSpecified()) { + // list client roles for a user + if (cid == null) { + cid = ClientOperations.getIdFromClientId(adminRoot, realm, auth, cclientid); + } + if (available) { + super.url = composeResourceUrl(adminRoot, realm, "users/" + uid + "/role-mappings/clients/" + cid + "/available"); + } else if (effective) { + super.url = composeResourceUrl(adminRoot, realm, "users/" + uid + "/role-mappings/clients/" + cid + "/composite"); + } else { + super.url = composeResourceUrl(adminRoot, realm, "users/" + uid + "/role-mappings/clients/" + cid); + } + } else { + // list realm roles for a user + if (available) { + super.url = composeResourceUrl(adminRoot, realm, "users/" + uid + "/role-mappings/realm/available"); + } else if (effective) { + super.url = composeResourceUrl(adminRoot, realm, "users/" + uid + "/role-mappings/realm/composite"); + } else { + super.url = composeResourceUrl(adminRoot, realm, "users/" + uid + "/role-mappings/realm"); + } + } + } else if (isGroupSpecified()) { + if (gname != null) { + gid = GroupOperations.getIdFromName(adminRoot, realm, auth, gname); + } else if (gpath != null) { + gid = GroupOperations.getIdFromPath(adminRoot, realm, auth, gpath); + } + if (isClientSpecified()) { + // list client roles for a group + if (cid == null) { + cid = ClientOperations.getIdFromClientId(adminRoot, realm, auth, cclientid); + } + if (available) { + super.url = composeResourceUrl(adminRoot, realm, "groups/" + gid + "/role-mappings/clients/" + cid + "/available"); + } else if (effective) { + super.url = composeResourceUrl(adminRoot, realm, "groups/" + gid + "/role-mappings/clients/" + cid + "/composite"); + } else { + super.url = composeResourceUrl(adminRoot, realm, "groups/" + gid + "/role-mappings/clients/" + cid); + } + } else { + // list realm roles for a group + if (available) { + super.url = composeResourceUrl(adminRoot, realm, "groups/" + gid + "/role-mappings/realm/available"); + } else if (effective) { + super.url = composeResourceUrl(adminRoot, realm, "groups/" + gid + "/role-mappings/realm/composite"); + } else { + super.url = composeResourceUrl(adminRoot, realm, "groups/" + gid + "/role-mappings/realm"); + } + } + } else if (isClientSpecified()) { + if (cid == null) { + cid = ClientOperations.getIdFromClientId(adminRoot, realm, auth, cclientid); + } + + if (isRoleSpecified()) { + // get specific client role + if (rname == null) { + rname = RoleOperations.getClientRoleNameFromId(adminRoot, realm, auth, cid, rid); + } + super.url = composeResourceUrl(adminRoot, realm, "clients/" + cid + "/roles/" + rname); + } else { + // list defined client roles + super.url = composeResourceUrl(adminRoot, realm, "clients/" + cid + "/roles"); + } + } else { + if (isRoleSpecified()) { + // get specific realm role + if (rname == null) { + rname = RoleOperations.getClientRoleNameFromId(adminRoot, realm, auth, cid, rid); + } + super.url = composeResourceUrl(adminRoot, realm, "roles/" + rname); + } else { + // list defined realm roles + super.url = composeResourceUrl(adminRoot, realm, "roles"); + } + } + + return super.process(commandInvocation); + } + + private boolean isRoleSpecified() { + return rid != null || rname != null; + } + + private boolean isClientSpecified() { + return cid != null || cclientid != null; + } + + private boolean isGroupSpecified() { + return gid != null || gname != null || gpath != null; + } + + private boolean isUserSpecified() { + return uid != null || uusername != null; + } + + protected String suggestHelp() { + return ""; + } + + protected boolean nothingToDo() { + return false; + } + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " get-roles [--cclientid CLIENT_ID | --cid ID] [ARGUMENTS]"); + out.println("Usage: " + CMD + " get-roles (--uusername USERNAME | --uid ID) [--cclientid CLIENT_ID | --cid ID] [--available | --effective] (ARGUMENTS)"); + out.println("Usage: " + CMD + " get-roles (--gname NAME | --gpath PATH | --gid ID) [--cclientid CLIENT_ID | --cid ID] [--available | --effective] [ARGUMENTS]"); + out.println(); + out.println("Command to list realm or client roles on a realm, user or group."); + out.println(); + out.println("Use `" + CMD + " config credentials` to establish an authenticated session, or use CREDENTIALS OPTIONS"); + out.println("to perform one time authentication."); + out.println(); + out.println("If client is specified using --cclientid or --cid then client roles are listed, otherwise realm roles are listed."); + out.println("If user is specified using --uusername or --uid then roles are listed for a specific user."); + out.println("If group is specified using --gname, --gpath or --gid then roles are listed for a specific group."); + out.println("If neither user nor group is specified then defined roles are listed for a realm or specific client"); + out.println("If role is specified using --rolename or --roleid then only that specific role is returned."); + out.println("If --available is specified, then only roles not yet added to the target user or group are returned."); + out.println("If --effective is specified, then roles added to the target user or group are transitively resolved and a full"); + out.println("set of roles in effect for that user or group is returned."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" --config Path to the config file (" + DEFAULT_CONFIG_FILE_STRING + " by default)"); + out.println(" --truststore PATH Path to a truststore containing trusted certificates"); + out.println(" --trustpass PASSWORD Truststore password (prompted for if not specified and --truststore is used)"); + out.println(" CREDENTIALS OPTIONS Same set of options as accepted by '" + CMD + " config credentials' in order to establish"); + out.println(" an authenticated sessions. This allows on-the-fly transient authentication that does"); + out.println(" not touch a config file."); + out.println(); + out.println(" Command specific options:"); + out.println(" --uusername User's 'username'. If more than one user exists with the same username"); + out.println(" you'll have to use --uid to specify the target user"); + out.println(" --uid User's 'id' attribute"); + out.println(" --gname Group's 'name'. If more than one group exists with the same name you'll have"); + out.println(" to use --gid, or --gpath to specify the target group"); + out.println(" --gpath Group's 'path' attribute"); + out.println(" --gid Group's 'id' attribute"); + out.println(" --cclientid Client's 'clientId' attribute"); + out.println(" --cid Client's 'id' attribute"); + out.println(" --rolename Role's 'name' attribute"); + out.println(" --roleid Role's 'id' attribute"); + out.println(" -a, --admin-root URL URL of Admin REST endpoint root if not default - e.g. http://localhost:8080/auth/admin"); + out.println(" -r, --target-realm REALM Target realm to issue requests against if not the one authenticated against"); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Get all realm roles defined on a realm:"); + out.println(" " + PROMPT + " " + CMD + " get-roles -r demorealm"); + out.println(); + out.println("Get all client roles defined on a specific client, displaying only 'id' and 'name':"); + out.println(" " + PROMPT + " " + CMD + " get-roles -r demorealm --cclientid realm-management --fields id,name"); + out.println(); + out.println("List all realm roles for a specific user:"); + out.println(" " + PROMPT + " " + CMD + " get-roles -r demorealm --uusername testuser"); + out.println(); + out.println("List effective client roles for 'realm-management' client for a specific user:"); + out.println(" " + PROMPT + " " + CMD + " get-roles -r demorealm --uusername testuser --cclientid realm-management --effective"); + out.println(); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/HelpCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/HelpCmd.java new file mode 100644 index 00000000000..191f7d90462 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/HelpCmd.java @@ -0,0 +1,107 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.Arguments; +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.console.command.Command; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; + +import java.util.List; + +import static org.keycloak.client.admin.cli.util.IoUtil.printOut; + + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "help", description = "This help") +public class HelpCmd implements Command { + + @Arguments + List args; + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + if (args == null || args.size() == 0) { + printOut(KcAdmCmd.usage()); + } else { + outer: + switch (args.get(0)) { + case "config": { + if (args.size() > 1) { + switch (args.get(1)) { + case "credentials": { + printOut(ConfigCredentialsCmd.usage()); + break outer; + } + case "truststore": { + printOut(ConfigTruststoreCmd.usage()); + break outer; + } + } + } + printOut(ConfigCmd.usage()); + break; + } + case "create": { + printOut(CreateCmd.usage()); + break; + } + case "get": { + printOut(GetCmd.usage()); + break; + } + case "update": { + printOut(UpdateCmd.usage()); + break; + } + case "delete": { + printOut(DeleteCmd.usage()); + break; + } + case "get-roles": { + printOut(GetRolesCmd.usage()); + break; + } + case "add-roles": { + printOut(AddRolesCmd.usage()); + break; + } + case "remove-roles": { + printOut(RemoveRolesCmd.usage()); + break; + } + case "set-password": { + printOut(SetPasswordCmd.usage()); + break; + } + default: { + throw new RuntimeException("Unknown command: " + args.get(0)); + } + } + } + + return CommandResult.SUCCESS; + } finally { + commandInvocation.stop(); + } + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/KcAdmCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/KcAdmCmd.java new file mode 100644 index 00000000000..d62183444e7 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/KcAdmCmd.java @@ -0,0 +1,101 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.GroupCommandDefinition; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.admin.cli.util.IoUtil.printErr; +import static org.keycloak.client.admin.cli.util.IoUtil.printOut; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; + + +/** + * @author Marko Strukelj + */ + +@GroupCommandDefinition(name = "kcadm", description = "COMMAND [ARGUMENTS]", groupCommands = { + HelpCmd.class, ConfigCmd.class, NewObjectCmd.class, CreateCmd.class, GetCmd.class, UpdateCmd.class, DeleteCmd.class, + AddRolesCmd.class, RemoveRolesCmd.class, GetRolesCmd.class, SetPasswordCmd.class} ) +public class KcAdmCmd extends AbstractGlobalOptionsCmd { + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + // if --help was requested then status is SUCCESS + // if not we print help anyway, but status is FAILURE + if (printHelp()) { + return CommandResult.SUCCESS; + } else if (args != null && args.size() > 0) { + printErr("Unknown command: " + args.get(0)); + return CommandResult.FAILURE; + } else { + printOut(usage()); + return CommandResult.FAILURE; + } + } finally { + commandInvocation.stop(); + } + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Keycloak Admin CLI"); + out.println(); + out.println("Use '" + CMD + " config credentials' command with username and password to start a session against a specific"); + out.println("server and realm."); + out.println(); + out.println("For example:"); + out.println(); + out.println(" " + PROMPT + " " + CMD + " config credentials --server http://localhost:8080/auth --realm master --user admin"); + out.println(" Enter password: "); + out.println(" Logging into http://localhost:8080/auth as user admin of realm master"); + out.println(); + out.println("Any configured username can be used for login, but to perform admin operations the user"); + out.println("needs proper roles, otherwise operations will fail."); + out.println(); + out.println("Usage: " + CMD + " COMMAND [ARGUMENTS]"); + out.println(); + out.println("Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" --help Print help for specific command"); + out.println(" --config Path to the config file (" + DEFAULT_CONFIG_FILE_STRING + " by default)"); + out.println(); + out.println("Commands: "); + out.println(" config Set up credentials, and other configuration settings using the config file"); + out.println(" create Create new resource"); + out.println(" get Get a resource"); + out.println(" update Update a resource"); + out.println(" delete Delete a resource"); + out.println(" get-roles List roles for a user or a group"); + out.println(" add-roles Add role to a user or a group"); + out.println(" remove-roles Remove role from a user or a group"); + out.println(" set-password Re-set password for a user"); + out.println(" help This help"); + out.println(); + out.println("Use '" + CMD + " help ' for more information about a given command."); + return sb.toString(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/NewObjectCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/NewObjectCmd.java new file mode 100644 index 00000000000..33fcdc6ae7e --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/NewObjectCmd.java @@ -0,0 +1,204 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import com.fasterxml.jackson.databind.JsonNode; +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.cl.Option; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; +import org.keycloak.client.admin.cli.common.AttributeOperation; +import org.keycloak.client.admin.cli.common.CmdStdinContext; +import org.keycloak.client.admin.cli.util.AccessibleBufferOutputStream; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.nio.charset.Charset; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +import static org.keycloak.client.admin.cli.common.AttributeOperation.Type.SET; +import static org.keycloak.client.admin.cli.util.IoUtil.copyStream; +import static org.keycloak.client.admin.cli.util.IoUtil.printErr; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.client.admin.cli.util.OsUtil.OS_ARCH; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; +import static org.keycloak.client.admin.cli.util.OutputUtil.MAPPER; +import static org.keycloak.client.admin.cli.util.ParseUtil.mergeAttributes; +import static org.keycloak.client.admin.cli.util.ParseUtil.parseFileOrStdin; +import static org.keycloak.client.admin.cli.util.ParseUtil.parseKeyVal; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "new-object", description = "Command to create new JSON objects locally") +public class NewObjectCmd extends AbstractGlobalOptionsCmd { + + @Option(shortName = 'f', name = "file", description = "Read object from file or standard input if FILENAME is set to '-'", hasValue = true) + String file; + + @Option(shortName = 'c', name = "compressed", description = "Don't pretty print the output", hasValue = false) + boolean compressed; + + //@OptionGroup(shortName = 's', name = "set", description = "Set attribute to the specified value") + //Map attributes = new LinkedHashMap<>(); + + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + if (printHelp()) { + return help ? CommandResult.SUCCESS : CommandResult.FAILURE; + } + + processGlobalOptions(); + + return process(commandInvocation); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(e.getMessage() + suggestHelp(), e); + } finally { + commandInvocation.stop(); + } + } + + public CommandResult process(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + List attrs = new LinkedList<>(); + + Iterator it = args.iterator(); + + while (it.hasNext()) { + String option = it.next(); + switch (option) { + case "-s": + case "--set": { + if (!it.hasNext()) { + throw new IllegalArgumentException("Option " + option + " requires a value"); + } + String[] keyVal = parseKeyVal(it.next()); + attrs.add(new AttributeOperation(SET, keyVal[0], keyVal[1])); + break; + } + default: { + throw new IllegalArgumentException("Invalid option: " + option); + } + } + } + + InputStream body = null; + + CmdStdinContext ctx = new CmdStdinContext<>(); + + if (file != null) { + ctx = parseFileOrStdin(file); + } + + if (attrs.size() > 0) { + ctx = mergeAttributes(ctx, MAPPER.createObjectNode(), attrs); + } + + if (body == null && ctx.getContent() != null) { + body = new ByteArrayInputStream(ctx.getContent().getBytes(Charset.forName("utf-8"))); + } + + AccessibleBufferOutputStream abos = new AccessibleBufferOutputStream(System.out); + + if (!compressed) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + copyStream(body, buffer); + + try { + JsonNode rootNode = MAPPER.readValue(buffer.toByteArray(), JsonNode.class); + // now pretty print it to output + MAPPER.writeValue(abos, rootNode); + } catch (Exception ignored) { + copyStream(new ByteArrayInputStream(buffer.toByteArray()), abos); + } + } else { + copyStream(body, System.out); + } + + int lastByte = abos.getLastByte(); + if (lastByte != -1 && lastByte != 13 && lastByte != 10) { + printErr(""); + } + + return CommandResult.SUCCESS; + } + + + @Override + protected boolean nothingToDo() { + return file == null && (args == null || args.size() == 0); + } + + protected String suggestHelp() { + return EOL + "Try '" + CMD + " help create' for more information"; + } + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " new-object [ARGUMENTS]"); + out.println(); + out.println("Command to compose JSON objects from attributes, and merge changes into existing JSON documents."); + out.println(); + out.println("This is a local command that does not perform any server requests. It's functionality is fully "); + out.println("integrated into 'create', 'update' and 'delete' commands. It's supposed to be a helper tool only."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(); + out.println(" Command specific options:"); + out.println(" -s, --set NAME=VALUE Set a specific attribute NAME to a specified value VALUE"); + out.println(" -f, --file FILENAME Read object from file or standard input if FILENAME is set to '-'"); + out.println(" -c, --compressed Don't pretty print the output"); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Create a new JSON document with two top level attributes:"); + out.println(" " + PROMPT + " " + CMD + " new-object -s realm=demorealm -s enabled=true"); + out.println(); + out.println("Read a JSON document and apply changes on top of it:"); + if (OS_ARCH.isWindows()) { + out.println(" " + PROMPT + " echo { \"clientId\": \"my_client\" } | " + CMD + " new-object -s enabled=true -f -"); + } else { + out.println(" " + PROMPT + " " + CMD + " new-object -s enabled=true -f - << EOF"); + out.println(" {"); + out.println(" \"clientId\": \"my_client\""); + out.println(" }"); + out.println(" EOF"); + } + out.println(); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } + +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/RemoveRolesCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/RemoveRolesCmd.java new file mode 100644 index 00000000000..84698c3e654 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/RemoveRolesCmd.java @@ -0,0 +1,334 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.cl.Option; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; +import org.keycloak.client.admin.cli.config.ConfigData; +import org.keycloak.client.admin.cli.operations.ClientOperations; +import org.keycloak.client.admin.cli.operations.GroupOperations; +import org.keycloak.client.admin.cli.operations.RoleOperations; +import org.keycloak.client.admin.cli.operations.LocalSearch; +import org.keycloak.client.admin.cli.operations.UserOperations; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Set; + +import static org.keycloak.client.admin.cli.util.AuthUtil.ensureToken; +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.admin.cli.util.ConfigUtil.credentialsAvailable; +import static org.keycloak.client.admin.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "remove-roles", description = "[ARGUMENTS]") +public class RemoveRolesCmd extends AbstractAuthOptionsCmd { + + @Option(name = "uusername", description = "Target user's 'username'") + String uusername; + + @Option(name = "uid", description = "Target user's 'id'") + String uid; + + @Option(name = "gname", description = "Target group's 'name'") + String gname; + + @Option(name = "gpath", description = "Target group's 'path'") + String gpath; + + @Option(name = "gid", description = "Target group's 'id'") + String gid; + + @Option(name = "cclientid", description = "Target client's 'clientId'") + String cclientid; + + @Option(name = "cid", description = "Target client's 'id'") + String cid; + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + List roleNames = new LinkedList<>(); + List roleIds = new LinkedList<>(); + + try { + if (printHelp()) { + return help ? CommandResult.SUCCESS : CommandResult.FAILURE; + } + + processGlobalOptions(); + + Iterator it = args.iterator(); + + while (it.hasNext()) { + String option = it.next(); + switch (option) { + case "--rolename": { + optionRequiresValueCheck(it, option); + roleNames.add(it.next()); + break; + } + case "--roleid": { + optionRequiresValueCheck(it, option); + roleIds.add(it.next()); + break; + } + default: { + throw new IllegalArgumentException("Invalid option: " + option); + } + } + } + + if (uid != null && uusername != null) { + throw new IllegalArgumentException("Incompatible options: --uid and --uusername are mutually exclusive"); + } + + if ((gid != null && gname != null) || (gid != null && gpath != null) || (gname != null && gpath != null)) { + throw new IllegalArgumentException("Incompatible options: --gid, --gname and --gpath are mutually exclusive"); + } + + if (roleNames.isEmpty() && roleIds.isEmpty()) { + throw new IllegalArgumentException("No role specified. Use --rolename or --roleid to specify roles"); + } + + if (cid != null && cclientid != null) { + throw new IllegalArgumentException("Incompatible options: --cid and --cclientid are mutually exclusive"); + } + + if (isUserSpecified() && isGroupSpecified()) { + throw new IllegalArgumentException("Incompatible options: --uusername / --uid can't be used at the same time as --gname / --gid / --gpath"); + } + + if (!isUserSpecified() && !isGroupSpecified()) { + throw new IllegalArgumentException("No user nor group specified. Use --uusername / --uid to specify user or --gname / --gid / --gpath to specify group"); + } + + + ConfigData config = loadConfig(); + config = copyWithServerInfo(config); + + setupTruststore(config, commandInvocation); + + String auth = null; + + config = ensureAuthInfo(config, commandInvocation); + config = copyWithServerInfo(config); + if (credentialsAvailable(config)) { + auth = ensureToken(config); + } + + auth = auth != null ? "Bearer " + auth : null; + + final String server = config.getServerUrl(); + final String realm = getTargetRealm(config); + final String adminRoot = adminRestRoot != null ? adminRestRoot : composeAdminRoot(server); + + + if (isUserSpecified()) { + if (uid == null) { + uid = UserOperations.getIdFromUsername(adminRoot, realm, auth, uusername); + } + if (isClientSpecified()) { + // remove client roles from a user + if (cid == null) { + cid = ClientOperations.getIdFromClientId(adminRoot, realm, auth, cclientid); + } + + List roles = RoleOperations.getClientRoles(adminRoot, realm, cid, auth); + Set rolesToAdd = getRoleRepresentations(roleNames, roleIds, new LocalSearch(roles)); + + // now remove the roles + UserOperations.removeClientRoles(adminRoot, realm, auth, uid, cid, new ArrayList<>(rolesToAdd)); + + } else { + + Set rolesToAdd = getRoleRepresentations(roleNames, roleIds, + new LocalSearch(RoleOperations.getRealmRolesAsNodes(adminRoot, realm, auth))); + + // now remove the roles + UserOperations.removeRealmRoles(adminRoot, realm, auth, uid, new ArrayList<>(rolesToAdd)); + } + + } else if (isGroupSpecified()) { + if (gname != null) { + gid = GroupOperations.getIdFromName(adminRoot, realm, auth, gname); + } else if (gpath != null) { + gid = GroupOperations.getIdFromPath(adminRoot, realm, auth, gpath); + } + if (isClientSpecified()) { + // remove client roles from a group + if (cid == null) { + cid = ClientOperations.getIdFromClientId(adminRoot, realm, auth, cclientid); + } + + List roles = RoleOperations.getClientRoles(adminRoot, realm, cid, auth); + Set rolesToAdd = getRoleRepresentations(roleNames, roleIds, new LocalSearch(roles)); + + // now remove the roles + GroupOperations.removeClientRoles(adminRoot, realm, auth, gid, cid, new ArrayList<>(rolesToAdd)); + + } else { + + Set rolesToAdd = getRoleRepresentations(roleNames, roleIds, + new LocalSearch(RoleOperations.getRealmRolesAsNodes(adminRoot, realm, auth))); + + // now remove the roles + GroupOperations.removeRealmRoles(adminRoot, realm, auth, gid, new ArrayList<>(rolesToAdd)); + } + + } else { + + throw new IllegalArgumentException("No user nor group specified. Use --uusername / --uid to specify user or --gname / --gid / --gpath to specify group"); + } + + return CommandResult.SUCCESS; + + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(e.getMessage() + suggestHelp(), e); + } finally { + commandInvocation.stop(); + } + } + + private Set getRoleRepresentations(List roleNames, List roleIds, LocalSearch roleSearch) { + Set rolesToAdd = new HashSet<>(); + + // now we process roles + for (String name : roleNames) { + ObjectNode r = roleSearch.exactMatchOne(name, "name"); + if (r == null) { + throw new RuntimeException("Role not found for name: " + name); + } + rolesToAdd.add(r); + } + for (String id : roleIds) { + ObjectNode r = roleSearch.exactMatchOne(id, "id"); + if (r == null) { + throw new RuntimeException("Role not found for id: " + id); + } + rolesToAdd.add(r); + } + return rolesToAdd; + } + + private void optionRequiresValueCheck(Iterator it, String option) { + if (!it.hasNext()) { + throw new IllegalArgumentException("Option " + option + " requires a value"); + } + } + + private boolean isClientSpecified() { + return cid != null || cclientid != null; + } + + private boolean isGroupSpecified() { + return gid != null || gname != null || gpath != null; + } + + private boolean isUserSpecified() { + return uid != null || uusername != null; + } + + + @Override + protected boolean nothingToDo() { + return noOptions() && uusername == null && uid == null && cclientid == null && (args == null || args.size() == 0); + } + + protected String suggestHelp() { + return EOL + "Try '" + CMD + " help remove-roles' for more information"; + } + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " remove-roles (--uusername USERNAME | --uid ID) [--cclientid CLIENT_ID | --cid ID] (--rolename NAME | --roleid ID)+ [ARGUMENTS]"); + out.println("Usage: " + CMD + " remove-roles (--gname NAME | --gpath PATH | --gid ID) [--cclientid CLIENT_ID | --cid ID] (--rolename NAME | --roleid ID)+ [ARGUMENTS]"); + out.println(); + out.println("Command to remove realm or client roles from a user or group."); + out.println(); + out.println("Use `" + CMD + " config credentials` to establish an authenticated session, or use CREDENTIALS OPTIONS"); + out.println("to perform one time authentication."); + out.println(); + out.println("If client is specified using --cclientid or --cid then roles to remove are client roles, otherwise they are realm roles."); + out.println("Either a user, or a group needs to be specified. If user is specified using --uusername or --uid then roles are removed"); + out.println("from a specific user. If group is specified using --gname, --gpath or --gid then roles are removed from a specific group."); + out.println("One or more roles have to be specified using --rolename or --roleid to be removed from a group or a user."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" --config Path to the config file (" + DEFAULT_CONFIG_FILE_STRING + " by default)"); + out.println(" --truststore PATH Path to a truststore containing trusted certificates"); + out.println(" --trustpass PASSWORD Truststore password (prompted for if not specified and --truststore is used)"); + out.println(" CREDENTIALS OPTIONS Same set of options as accepted by '" + CMD + " config credentials' in order to establish"); + out.println(" an authenticated sessions. This allows on-the-fly transient authentication that does"); + out.println(" not touch a config file."); + out.println(); + out.println(" Command specific options:"); + out.println(" --uusername User's 'username'. If more than one user exists with the same username"); + out.println(" you'll have to use --uid to specify the target user"); + out.println(" --uid User's 'id' attribute"); + out.println(" --gname Group's 'name'. If more than one group exists with the same name you'll have"); + out.println(" to use --gid, or --gpath to specify the target group"); + out.println(" --gpath Group's 'path' attribute"); + out.println(" --gid Group's 'id' attribute"); + out.println(" --cclientid Client's 'clientId' attribute"); + out.println(" --cid Client's 'id' attribute"); + out.println(" --rolename Role's 'name' attribute"); + out.println(" --roleid Role's 'id' attribute"); + out.println(" -a, --admin-root URL URL of Admin REST endpoint root if not default - e.g. http://localhost:8080/auth/admin"); + out.println(" -r, --target-realm REALM Target realm to issue requests against if not the one authenticated against"); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Remove 'offline_access' realm role from a user:"); + out.println(" " + PROMPT + " " + CMD + " remove-roles -r demorealm --uusername testuser --rolename offline_access"); + out.println(); + out.println("Remove 'realm-management' client roles 'view-users', 'view-clients' and 'view-realm' from a user:"); + out.println(" " + PROMPT + " " + CMD + " remove-roles -r demorealm --uusername testuser --cclientid realm-management --rolename view-users --rolename view-clients --rolename view-realm"); + out.println(); + out.println("Remove 'uma_authorization' realm role to a group:"); + out.println(" " + PROMPT + " " + CMD + " remove-roles -r demorealm --gname PowerUsers --rolename uma_authorization"); + out.println(); + out.println("Remove 'realm-management' client roles 'realm-admin' from a group:"); + out.println(" " + PROMPT + " " + CMD + " remove-roles -r demorealm --gname PowerUsers --cclientid realm-management --rolename realm-admin"); + out.println(); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/SetPasswordCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/SetPasswordCmd.java new file mode 100644 index 00000000000..f3ce6ea4d1d --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/SetPasswordCmd.java @@ -0,0 +1,177 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.cl.Option; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; +import org.keycloak.client.admin.cli.config.ConfigData; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.keycloak.client.admin.cli.operations.UserOperations.getIdFromUsername; +import static org.keycloak.client.admin.cli.operations.UserOperations.resetUserPassword; +import static org.keycloak.client.admin.cli.util.AuthUtil.ensureToken; +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.admin.cli.util.ConfigUtil.credentialsAvailable; +import static org.keycloak.client.admin.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.admin.cli.util.IoUtil.readSecret; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "set-password", description = "[ARGUMENTS]") +public class SetPasswordCmd extends AbstractAuthOptionsCmd { + + @Option(name = "username", description = "Username") + String username; + + @Option(name = "userid", description = "User ID") + String userid; + + @Option(shortName = 'p', name = "new-password", description = "New password") + String pass; + + @Option(shortName = 't', name = "temporary", description = "is password temporary", hasValue = false) + boolean temporary; + + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + if (printHelp()) { + return help ? CommandResult.SUCCESS : CommandResult.FAILURE; + } + + processGlobalOptions(); + + return process(commandInvocation); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(e.getMessage() + suggestHelp(), e); + } finally { + commandInvocation.stop(); + } + } + + + public CommandResult process(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + if (args != null && args.size() > 0) { + throw new IllegalArgumentException("Invalid option: " + args.get(0)); + } + + if (userid == null && username == null) { + throw new IllegalArgumentException("No user specified. Use --username or --userid to specify user"); + } + + if (userid != null && username != null) { + throw new IllegalArgumentException("Options --userid and --username are mutually exclusive"); + } + + if (pass == null) { + pass = readSecret("Enter password: ", commandInvocation); + } + + ConfigData config = loadConfig(); + config = copyWithServerInfo(config); + + setupTruststore(config, commandInvocation); + + String auth = null; + + config = ensureAuthInfo(config, commandInvocation); + config = copyWithServerInfo(config); + if (credentialsAvailable(config)) { + auth = ensureToken(config); + } + + auth = auth != null ? "Bearer " + auth : null; + + final String server = config.getServerUrl(); + final String realm = getTargetRealm(config); + final String adminRoot = adminRestRoot != null ? adminRestRoot : composeAdminRoot(server); + + // if username is specified resolve id + if (username != null) { + userid = getIdFromUsername(adminRoot, realm, auth, username); + } + + resetUserPassword(adminRoot, realm, auth, userid, pass, temporary); + + return CommandResult.SUCCESS; + } + + @Override + protected boolean nothingToDo() { + return noOptions() && username == null && userid == null && pass == null; + } + + protected String suggestHelp() { + return EOL + "Try '" + CMD + " help set-password' for more information"; + } + + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " set-password (--username USERNAME | --userid ID) [--password PASSWORD] [ARGUMENTS]"); + out.println(); + out.println("Command to reset user's password."); + out.println(); + out.println("Use `" + CMD + " config credentials` to establish an authenticated session, or use CREDENTIALS OPTIONS"); + out.println("to perform one time authentication."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" --config Path to the config file (" + DEFAULT_CONFIG_FILE_STRING + " by default)"); + out.println(" --truststore PATH Path to a truststore containing trusted certificates"); + out.println(" --trustpass PASSWORD Truststore password (prompted for if not specified and --truststore is used)"); + out.println(" CREDENTIALS OPTIONS Same set of options as accepted by '" + CMD + " config credentials' in order to establish"); + out.println(" an authenticated sessions. This allows on-the-fly transient authentication that does"); + out.println(" not touch a config file."); + out.println(); + out.println(" Command specific options:"); + out.println(" --username USERNAME Identify target user by 'username'"); + out.println(" --userid ID Identify target user by 'id'"); + out.println(" -p, --new-password New password to set. If not specified you will be prompted for it."); + out.println(" -t, --temporary Make the new password temporary - user has to change it on next logon"); + out.println(" -a, --admin-root URL URL of Admin REST endpoint root if not default - e.g. http://localhost:8080/auth/admin"); + out.println(" -r, --target-realm REALM Target realm to issue requests against if not the one authenticated against"); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Set new temporary password for the user:"); + out.println(" " + PROMPT + " " + CMD + " set-password -r demorealm --username testuser --password NEWPASS -t"); + out.println(); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } + +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/UpdateCmd.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/UpdateCmd.java new file mode 100644 index 00000000000..3ce91b321a7 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/commands/UpdateCmd.java @@ -0,0 +1,165 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.keycloak.client.admin.cli.commands; + +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.cl.Option; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.client.admin.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "update", description = "CLIENT_ID [ARGUMENTS]") +public class UpdateCmd extends AbstractRequestCmd { + + @Option(shortName = 'f', name = "file", description = "Read object from file or standard input if FILENAME is set to '-'") + String file; + + @Option(shortName = 'F', name = "fields", description = "A pattern specifying which attributes of JSON response body to actually display as result - causes mismatch with Content-Length header") + String fields; + + @Option(shortName = 'H', name = "print-headers", description = "Print response headers", hasValue = false) + boolean printHeaders; + + @Option(shortName = 'm', name = "merge", description = "Merge new values with existing configuration on the server - for when the default is not to merge (i.e. if --file is used)", hasValue = false) + boolean mergeMode; + + @Option(shortName = 'n', name = "no-merge", description = "Don't merge new values with existing configuration on the server - for when the default is to merge (i.e. is --set is used while --file is not used)", hasValue = false) + boolean noMerge; + + @Option(shortName = 'o', name = "output", description = "After update output the new client configuration", hasValue = false) + boolean outputResult; + + @Option(shortName = 'c', name = "compressed", description = "Don't pretty print the output", hasValue = false) + boolean compressed; + + //@GroupOption(shortName = 's', name = "set", description = "Set specific attribute to a specified value", hasValue = true) + //private List attributes = new ArrayList<>(); + + + @Override + void initOptions() { + // set options on parent + super.file = file; + super.fields = fields; + super.printHeaders = printHeaders; + super.returnId = false; + super.outputResult = true; + super.compressed = compressed; + super.mergeMode = mergeMode; + super.noMerge = noMerge; + super.outputResult = outputResult; + super.httpVerb = "put"; + } + + @Override + protected boolean nothingToDo() { + return noOptions() && file == null && (args == null || args.size() == 0); + } + + protected String suggestHelp() { + return EOL + "Try '" + CMD + " help update' for more information"; + } + + protected String help() { + return usage(); + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " update ENDPOINT_URI [ARGUMENTS]"); + out.println(); + out.println("Command to update existing resources on the server."); + out.println(); + out.println("Use `" + CMD + " config credentials` to establish an authenticated sessions, or use CREDENTIALS OPTIONS"); + out.println("to perform one time authentication."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" --config Path to the config file (" + DEFAULT_CONFIG_FILE_STRING + " by default)"); + out.println(" --truststore PATH Path to a truststore containing trusted certificates"); + out.println(" --trustpass PASSWORD Truststore password (prompted for if not specified and --truststore is used)"); + out.println(" CREDENTIALS OPTIONS Same set of options as accepted by '" + CMD + " config credentials' in order to establish"); + out.println(" an authenticated sessions. This allows on-the-fly transient authentication that does"); + out.println(" not touch a config file."); + out.println(); + out.println(" Command specific options:"); + out.println(" ENDPOINT_URI URI used to compose a target resource url. Commonly used values start with:"); + out.println(" realms/, users/, roles/, groups/, clients/, keys/, components/ ..."); + out.println(" If it starts with 'http://' then it will be used as target resource url"); + out.println(" -r, --target-realm REALM Target realm to issue requests against if not the one authenticated against"); + out.println(" -s, --set NAME=VALUE Set a specific attribute NAME to a specified value VALUE"); + out.println(" NAME+=VALUE Add item VALUE to list attribute NAME"); + out.println(" -d, --delete NAME Remove a specific attribute NAME from JSON request body"); + out.println(" -f, --file FILENAME Read object from file or standard input if FILENAME is set to '-'"); + out.println(" -q, --query NAME=VALUE Add to request URI a NAME query parameter with value VALUE"); + out.println(" -h, --header NAME=VALUE Set request header NAME to VALUE"); + out.println(" -m, --merge Merge new values with existing configuration on the server"); + out.println(" Merge is automatically enabled unless --file is specified"); + out.println(" -n, --no-merge Suppress merge mode"); + out.println(); + out.println(" -H, --print-headers Print response headers"); + out.println(" -o, --output After update output the new resource to standard output"); + out.println(" -F, --fields FILTER A filter pattern to specify which fields of a JSON response to output"); + out.println(" -c, --compressed Don't pretty print the output"); + out.println(" -a, --admin-root URL URL of Admin REST endpoint root if not default - e.g. http://localhost:8080/auth/admin"); + out.println(); + out.println(); + out.println("Nested attributes are supported by using '.' to separate components of a KEY. Optionaly, the KEY components "); + out.println("can be quoted with double quotes - e.g. my_client.attributes.\"external.user.id\". If VALUE starts with [ and "); + out.println("ends with ] the attribute will be set as a JSON array. If VALUE starts with { and ends with } the attribute "); + out.println("will be set as a JSON object. If KEY ends with an array index - e.g. clients[3]=VALUE - then the specified item"); + out.println("of the array is updated. If KEY+=VALUE syntax is used, then KEY is assumed to be an array, and another item is"); + out.println("added to it."); + out.println(); + out.println("Attributes can also be deleted. If KEY ends with an array index, then the targeted item of an array is removed"); + out.println("and the following items are shifted."); + out.println(); + out.println("Merged mode fetches target resource item from the server, applies attribute changes to it, and sends it"); + out.println("back to the server."); + out.println(); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Update a target realm by fetching current configuration from the server, and applying specified changes"); + out.println(" " + PROMPT + " " + CMD + " update realms/demorealm -s registrationAllowed=true"); + out.println(); + out.println("Update a client by overwriting existing configuration using local file as a template (replace ID with client's 'id'):"); + out.println(" " + PROMPT + " " + CMD + " update clients/ID -f new_my_client.json -s 'redirectUris=[\"http://localhost:8080/myapp/*\"]'"); + out.println(); + out.println("Update client by fetching current configuration from server and merging with specified changes (replace ID with client's 'id'):"); + out.println(" " + PROMPT + " " + CMD + " update clients/ID -f new_my_client.json -s enabled=true --merge"); + out.println(); + out.println("Reset user's password (replace ID with user's 'id'):"); + out.println(" " + PROMPT + " " + CMD + " update users/ID/reset-password -r demorealm -s type=password -s value=NEWPASSWORD -s temporary=true -n"); + out.println(); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/AttributeKey.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/AttributeKey.java new file mode 100644 index 00000000000..7cb0dc885fb --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/AttributeKey.java @@ -0,0 +1,170 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.common; + +import java.util.Collections; +import java.util.LinkedList; +import java.util.List; + +/** + * @author Marko Strukelj + */ +public class AttributeKey { + + private static final int START = 0; + private static final int QUOTED = 1; + private static final int UNQUOTED = 2; + private static final int END = 3; + + private List components; + private boolean append; + + public AttributeKey() { + components = Collections.emptyList(); + } + + public AttributeKey(String key) { + if (key.endsWith("+")) { + append = true; + key = key.substring(0, key.length() - 1); + } + components = parse(key); + } + + static List parse(String key) { + + if (key == null || "".equals(key)) { + return Collections.emptyList(); + } + + List cs = new LinkedList<>(); + StringBuilder sb = new StringBuilder(); + int state = START; + + char[] buf = key.toCharArray(); + + for (int pos = 0; pos < buf.length; pos++) { + char c = buf[pos]; + + if (state == START) { + if ('\"' == c) { + state = QUOTED; + } else if ('.' == c) { + throw new RuntimeException("Invalid attribute key: " + key + " (at position " + (pos + 1) + ")"); + } else { + state = UNQUOTED; + sb.append(c); + } + } else if (state == QUOTED) { + if ('\"' == c) { + state = END; + } else { + sb.append(c); + } + } else if (state == UNQUOTED || state == END) { + if ('.' == c) { + state = START; + cs.add(new Component(sb.toString())); + sb.setLength(0); + } else if (state == END || '\"' == c) { + throw new RuntimeException("Invalid attribute key: " + key + " (at position " + (pos + 1) + ")"); + } else { + sb.append(c); + } + } + } + + boolean ok = false; + if (sb.length() > 0) { + if (state == UNQUOTED || state == END) { + cs.add(new Component(sb.toString())); + ok = true; + } + } else if (state == END) { + ok = true; + } + + if (!ok) { + throw new RuntimeException("Invalid attribute key: " + key + " (at position " + (buf.length) + ")"); + } + + return Collections.unmodifiableList(cs); + } + + public List getComponents() { + return components; + } + + public boolean isAppend() { + return append; + } + + @Override + public String toString() { + StringBuilder sb = new StringBuilder(); + for (Component c: components) { + if (sb.length() > 0) { + sb.append("."); + } + sb.append(c.toString()); + } + return sb.toString(); + } + + + + public static class Component { + + private int index = -1; + private String name; + + Component(String name) { + if (name.endsWith("]")) { + int pos = name.lastIndexOf("[", name.length() - 1); + if (pos == -1) { + throw new RuntimeException("Invalid attribute key: " + name + " (']' not allowed here)"); + } + String idx = name.substring(pos + 1, name.length() - 1); + try { + index = Integer.parseInt(idx); + } catch (Exception e) { + throw new RuntimeException("Invalid attribute key: " + name + " (Invalid array index: '[" + idx + "]')"); + } + this.name = name.substring(0, pos); + } else { + this.name = name; + } + } + + public boolean isArray() { + return index >= 0; + } + + public int getIndex() { + return index; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return name + (index != -1 ? "[" + index + "]" : ""); + } + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/AttributeOperation.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/AttributeOperation.java new file mode 100644 index 00000000000..4db483e81fa --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/AttributeOperation.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.common; + +/** + * @author Marko Strukelj + */ +public class AttributeOperation { + + private Type type; + private AttributeKey key; + private String value; + + public AttributeOperation(Type type, String key) { + this(type, key, null); + } + + public AttributeOperation(Type type, String key, String value) { + if (type == Type.DELETE && value != null) { + throw new IllegalArgumentException("When type is DELETE, value has to be null"); + } + this.type = type; + this.key = new AttributeKey(key); + this.value = value; + } + + public Type getType() { + return type; + } + + public AttributeKey getKey() { + return key; + } + + public String getValue() { + return value; + } + + + public enum Type { + SET, + DELETE + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/CmdStdinContext.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/CmdStdinContext.java new file mode 100644 index 00000000000..1c6752666a4 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/common/CmdStdinContext.java @@ -0,0 +1,44 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.common; + +/** + * @author Marko Strukelj + */ +public class CmdStdinContext { + + private T result; + private String content; + + public CmdStdinContext() {} + + public T getResult() { + return result; + } + + public void setResult(T result) { + this.result = result; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigData.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigData.java new file mode 100644 index 00000000000..e327335903d --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigData.java @@ -0,0 +1,176 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.config; + +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * @author Marko Strukelj + */ +public class ConfigData { + + private String serverUrl; + + private String realm; + + private String truststore; + + private String trustpass; + + private Map> endpoints = new HashMap<>(); + + + public String getServerUrl() { + return serverUrl; + } + + public void setServerUrl(String serverUrl) { + this.serverUrl = serverUrl; + } + + public String getRealm() { + return realm; + } + + public void setRealm(String realm) { + this.realm = realm; + } + + public String getTruststore() { + return truststore; + } + + public void setTruststore(String truststore) { + this.truststore = truststore; + } + + public String getTrustpass() { + return trustpass; + } + + public void setTrustpass(String trustpass) { + this.trustpass = trustpass; + } + + public Map> getEndpoints() { + return endpoints; + } + + public void setEndpoints(Map> endpoints) { + for (Map.Entry> entry: endpoints.entrySet()) { + String endpoint = entry.getKey(); + for (Map.Entry sub: entry.getValue().entrySet()) { + RealmConfigData rdata = sub.getValue(); + rdata.serverUrl(endpoint); + rdata.realm(sub.getKey()); + } + } + this.endpoints = endpoints; + } + + public RealmConfigData sessionRealmConfigData() { + if (serverUrl == null) + throw new RuntimeException("Illegal state - no current endpoint in config data"); + if (realm == null) + throw new RuntimeException("Illegal state - no current realm in config data"); + return ensureRealmConfigData(serverUrl, realm); + } + + public RealmConfigData getRealmConfigData(String endpoint, String realm) { + Map realmData = endpoints.get(endpoint); + if (realmData == null) { + return null; + } + return realmData.get(realm); + } + + public RealmConfigData ensureRealmConfigData(String endpoint, String realm) { + RealmConfigData result = getRealmConfigData(endpoint, realm); + if (result == null) { + result = new RealmConfigData(); + result.serverUrl(endpoint); + result.realm(realm); + setRealmConfigData(result); + } + return result; + } + + + public void setRealmConfigData(RealmConfigData data) { + Map realm = endpoints.get(data.serverUrl()); + if (realm == null) { + realm = new HashMap<>(); + endpoints.put(data.serverUrl(), realm); + } + realm.put(data.realm(), data); + } + + public void merge(ConfigData source) { + serverUrl = source.serverUrl; + realm = source.realm; + truststore = source.truststore; + trustpass = source.trustpass; + + RealmConfigData current = getRealmConfigData(serverUrl, realm); + RealmConfigData sourceRealm = source.getRealmConfigData(serverUrl, realm); + + if (current == null) { + setRealmConfigData(sourceRealm); + } else { + current.merge(sourceRealm); + } + } + + public ConfigData deepcopy() { + ConfigData data = new ConfigData(); + data.serverUrl = serverUrl; + data.realm = realm; + data.truststore = truststore; + data.trustpass = trustpass; + data.endpoints = new HashMap<>(); + + for (Map.Entry> item: endpoints.entrySet()) { + + Map nuitems = new HashMap<>(); + Map curitems = item.getValue(); + + if (curitems != null) { + for (Map.Entry ditem : curitems.entrySet()) { + RealmConfigData nudata = ditem.getValue(); + if (nudata != null) { + nuitems.put(ditem.getKey(), nudata.deepcopy()); + } + } + data.endpoints.put(item.getKey(), nuitems); + } + } + return data; + } + + @Override + public String toString() { + try { + return JsonSerialization.writeValueAsPrettyString(this); + } catch (IOException e) { + return super.toString() + " - Error: " + e.toString(); + } + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigHandler.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigHandler.java new file mode 100644 index 00000000000..fd318c569b8 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigHandler.java @@ -0,0 +1,28 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.config; + +/** + * @author Marko Strukelj + */ +public interface ConfigHandler { + + void saveMergeConfig(ConfigUpdateOperation op); + + ConfigData loadConfig(); + +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigUpdateOperation.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigUpdateOperation.java new file mode 100644 index 00000000000..15276a051fc --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/ConfigUpdateOperation.java @@ -0,0 +1,26 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.config; + +/** + * @author Marko Strukelj + */ +public interface ConfigUpdateOperation { + + void update(ConfigData data); + +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/FileConfigHandler.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/FileConfigHandler.java new file mode 100644 index 00000000000..cb76dfeec95 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/FileConfigHandler.java @@ -0,0 +1,135 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.config; + +import org.keycloak.client.admin.cli.util.IoUtil; +import org.keycloak.util.JsonSerialization; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.RandomAccessFile; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.channels.OverlappingFileLockException; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.keycloak.client.admin.cli.util.IoUtil.printErr; + +/** + * @author Marko Strukelj + */ +public class FileConfigHandler implements ConfigHandler { + + private static final long MAX_SIZE = 10 * 1024 * 1024; + private static String configFile; + + public static void setConfigFile(String filename) { + configFile = filename; + } + + public static String getConfigFile() { + return configFile; + } + + public ConfigData loadConfig() { + // for now just dumb impl ignoring file locks for read + File file = new File(configFile); + if (!file.isFile() || file.length() == 0) { + return new ConfigData(); + } + + try { + try (FileInputStream is = new FileInputStream(configFile)) { + return JsonSerialization.readValue(is, ConfigData.class); + } + } catch (IOException e) { + throw new RuntimeException("Failed to load " + configFile, e); + } + } + + public static void ensureFile() { + Path path = null; + try { + path = Paths.get(new File(configFile).getAbsolutePath()); + IoUtil.ensureFile(path); + } catch (Exception e) { + throw new RuntimeException("Failed to create config file: " + path, e); + } + } + + public void saveMergeConfig(ConfigUpdateOperation op) { + try { + ensureFile(); + + try (RandomAccessFile file = new RandomAccessFile(new File(configFile), "rw")) { + FileChannel fileChannel = file.getChannel(); + + FileLock fileLock = null; + + // lock file for write + int tryCount = 0; + do try { + fileLock = fileChannel.tryLock(); + break; + } catch (OverlappingFileLockException e) { + // sleep a little, and try again + try { + Thread.sleep(100); + continue; + } catch (InterruptedException e1) { + throw new RuntimeException("Interrupted"); + } + } while (tryCount++ < 10); + + if (fileLock != null) { + try { + // load config from file + ConfigData config = new ConfigData(); + long size = file.length(); + if (size > MAX_SIZE) { + printErr("Config file " + configFile + " is too big. It will be overwritten."); + file.setLength(0); + } else if (size > 0){ + byte[] buf = new byte[(int) size]; + file.readFully(buf); + config = JsonSerialization.readValue(new ByteArrayInputStream(buf), ConfigData.class); + } + + // update loaded config + op.update(config); + + // save config to file + byte [] content = JsonSerialization.writeValueAsPrettyString(config).getBytes("utf-8"); + file.seek(0); + file.write(content); + file.setLength(content.length); + + } finally { + fileLock.release(); + } + } else { + throw new RuntimeException("Failed to get lock on " + configFile); + } + } + } catch (IOException e) { + throw new RuntimeException("Failed to save " + configFile, e); + } + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/InMemoryConfigHandler.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/InMemoryConfigHandler.java new file mode 100644 index 00000000000..7ac43775e70 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/InMemoryConfigHandler.java @@ -0,0 +1,39 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.config; + +/** + * @author Marko Strukelj + */ +public class InMemoryConfigHandler implements ConfigHandler { + + private ConfigData cached; + + @Override + public void saveMergeConfig(ConfigUpdateOperation config) { + config.update(cached); + } + + @Override + public ConfigData loadConfig() { + return cached; + } + + public void setConfigData(ConfigData data) { + this.cached = data; + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/RealmConfigData.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/RealmConfigData.java new file mode 100644 index 00000000000..2a8b163647c --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/config/RealmConfigData.java @@ -0,0 +1,172 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.config; + +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; + +/** + * @author Marko Strukelj + */ +public class RealmConfigData { + + private String serverUrl; + + private String realm; + + private String clientId; + + private String token; + + private String refreshToken; + + private String signingToken; + + private String secret; + + private Long expiresAt; + + private Long refreshExpiresAt; + + private Long sigExpiresAt; + + + public String serverUrl() { + return serverUrl; + } + + public void serverUrl(String serverUrl) { + this.serverUrl = serverUrl; + } + + public String realm() { + return realm; + } + + public void realm(String realm) { + this.realm = realm; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public String getRefreshToken() { + return refreshToken; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + + public String getSigningToken() { + return signingToken; + } + + public void setSigningToken(String signingToken) { + this.signingToken = signingToken; + } + + public String getSecret() { + return secret; + } + + public void setSecret(String secret) { + this.secret = secret; + } + + public Long getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(Long expiresAt) { + this.expiresAt = expiresAt; + } + + public Long getRefreshExpiresAt() { + return refreshExpiresAt; + } + + public void setRefreshExpiresAt(Long refreshExpiresAt) { + this.refreshExpiresAt = refreshExpiresAt; + } + + public Long getSigExpiresAt() { + return sigExpiresAt; + } + + public void setSigExpiresAt(Long sigExpiresAt) { + this.sigExpiresAt = sigExpiresAt; + } + + public void merge(RealmConfigData source) { + serverUrl = source.serverUrl; + realm = source.realm; + clientId = source.clientId; + token = source.token; + refreshToken = source.refreshToken; + signingToken = source.signingToken; + secret = source.secret; + expiresAt = source.expiresAt; + refreshExpiresAt = source.refreshExpiresAt; + sigExpiresAt = source.sigExpiresAt; + } + + public void mergeRefreshTokens(RealmConfigData source) { + token = source.token; + refreshToken = source.refreshToken; + expiresAt = source.expiresAt; + refreshExpiresAt = source.refreshExpiresAt; + } + + @Override + public String toString() { + try { + return JsonSerialization.writeValueAsPrettyString(this); + } catch (IOException e) { + return super.toString() + " - Error: " + e.toString(); + } + } + + public RealmConfigData deepcopy() { + RealmConfigData data = new RealmConfigData(); + data.serverUrl = serverUrl; + data.realm = realm; + data.clientId = clientId; + data.token = token; + data.refreshToken = refreshToken; + data.signingToken = signingToken; + data.secret = secret; + data.expiresAt = expiresAt; + data.refreshExpiresAt = refreshExpiresAt; + data.sigExpiresAt = sigExpiresAt; + return data; + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/httpcomponents/HttpDelete.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/httpcomponents/HttpDelete.java new file mode 100644 index 00000000000..7d553ea87c1 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/httpcomponents/HttpDelete.java @@ -0,0 +1,39 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.httpcomponents; + +import org.apache.http.annotation.NotThreadSafe; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; + +import java.net.URI; + +/** + * @author Marko Strukelj + */ +@NotThreadSafe +public class HttpDelete extends HttpEntityEnclosingRequestBase { + + public HttpDelete(final String uri) { + super(); + setURI(URI.create(uri)); + } + + @Override + public String getMethod() { + return "DELETE"; + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/ClientOperations.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/ClientOperations.java new file mode 100644 index 00000000000..7de4e29baaf --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/ClientOperations.java @@ -0,0 +1,29 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.operations; + +import static org.keycloak.client.admin.cli.util.HttpUtil.getIdForType; + +/** + * @author Marko Strukelj + */ +public class ClientOperations { + + public static String getIdFromClientId(String rootUrl, String realm, String auth, String clientId) { + return getIdForType(rootUrl, realm, auth, "clients", "clientId", clientId); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/GroupOperations.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/GroupOperations.java new file mode 100644 index 00000000000..a9cec660403 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/GroupOperations.java @@ -0,0 +1,58 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.operations; + +import java.util.List; + +import static org.keycloak.client.admin.cli.util.HttpUtil.composeResourceUrl; +import static org.keycloak.client.admin.cli.util.HttpUtil.doDeleteJSON; +import static org.keycloak.client.admin.cli.util.HttpUtil.doPostJSON; +import static org.keycloak.client.admin.cli.util.HttpUtil.getIdForType; + +/** + * @author Marko Strukelj + */ +public class GroupOperations { + + public static String getIdFromName(String rootUrl, String realm, String auth, String groupname) { + return getIdForType(rootUrl, realm, auth, "groups", "name", groupname); + } + + public static String getIdFromPath(String rootUrl, String realm, String auth, String path) { + return getIdForType(rootUrl, realm, auth, "groups", "path", path); + } + + public static void addRealmRoles(String rootUrl, String realm, String auth, String groupid, List roles) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "groups/" + groupid + "/role-mappings/realm"); + doPostJSON(resourceUrl, auth, roles); + } + + public static void addClientRoles(String rootUrl, String realm, String auth, String groupid, String idOfClient, List roles) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "groups/" + groupid + "/role-mappings/clients/" + idOfClient); + doPostJSON(resourceUrl, auth, roles); + } + + public static void removeRealmRoles(String rootUrl, String realm, String auth, String groupid, List roles) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "groups/" + groupid + "/role-mappings/realm"); + doDeleteJSON(resourceUrl, auth, roles); + } + + public static void removeClientRoles(String rootUrl, String realm, String auth, String groupid, String idOfClient, List roles) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "groups/" + groupid + "/role-mappings/clients/" + idOfClient); + doDeleteJSON(resourceUrl, auth, roles); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/LocalSearch.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/LocalSearch.java new file mode 100644 index 00000000000..c91bb955981 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/LocalSearch.java @@ -0,0 +1,60 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.operations; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.util.LinkedList; +import java.util.List; + +/** + * @author Marko Strukelj + */ +public class LocalSearch { + + private List items; + + public LocalSearch(List items) { + this.items = items; + } + + public ObjectNode exactMatchOne(String value, String ... attrs) { + + List matched = new LinkedList<>(); + + for (ObjectNode item: items) { + for (String attr: attrs) { + JsonNode node = item.get(attr); + if (node != null && node.asText().equals(value)) { + matched.add(item); + break; + } + } + } + + if (matched.size() == 0) { + return null; + } + + if (matched.size() > 1) { + throw new RuntimeException("More than one match"); + } + + return matched.get(0); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/RoleOperations.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/RoleOperations.java new file mode 100644 index 00000000000..d3274fdad20 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/RoleOperations.java @@ -0,0 +1,129 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.operations; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.keycloak.representations.idm.RoleRepresentation; + +import java.util.ArrayList; +import java.util.List; + +import static org.keycloak.client.admin.cli.util.HttpUtil.composeResourceUrl; +import static org.keycloak.client.admin.cli.util.HttpUtil.doGetJSON; +import static org.keycloak.client.admin.cli.util.HttpUtil.getAttrForType; + +/** + * @author Marko Strukelj + */ +public class RoleOperations { + + public static class LIST_OF_ROLES extends ArrayList{}; + public static class LIST_OF_NODES extends ArrayList{}; + + public static String getRoleNameFromId(String adminRoot, String realm, String auth, String rid) { + return getAttrForType(adminRoot, realm, auth, "roles", "id", rid, "name"); + } + + public static String getClientRoleNameFromId(String adminRoot, String realm, String auth, String cid, String rid) { + return getAttrForType(adminRoot, realm, auth, "clients/" + cid + "/roles", "id", rid, "name"); + } + + public static List getRealmRoles(String rootUrl, String realm, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "roles"); + return doGetJSON(LIST_OF_ROLES.class, resourceUrl, auth); + } + + public static ObjectNode getRealmRole(String rootUrl, String realm, String rolename, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "roles/" + rolename); + return doGetJSON(ObjectNode.class, resourceUrl, auth); + } + + public static List getClientRoles(String rootUrl, String realm, String idOfClient, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "clients/" + idOfClient + "/roles"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static ObjectNode getClientRole(String rootUrl, String realm, String idOfClient, String rolename, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "clients/" + idOfClient + "/roles/" + rolename); + return doGetJSON(ObjectNode.class, resourceUrl, auth); + } + + public static List getRealmRolesAsNodes(String rootUrl, String realm, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "roles"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getRealmRolesForUserAsNodes(String rootUrl, String realm, String userid, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "users/" + userid + "/role-mappings/realm"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getCompositeRealmRolesForUserAsNodes(String rootUrl, String realm, String userid, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "users/" + userid + "/role-mappings/realm/composite"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getAvailableRealmRolesForUserAsNodes(String rootUrl, String realm, String userid, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "users/" + userid + "/role-mappings/realm/available"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getClientRolesForUserAsNodes(String rootUrl, String realm, String userid, String idOfClient, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "users/" + userid + "/role-mappings/clients/" + idOfClient); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getCompositeClientRolesForUserAsNodes(String rootUrl, String realm, String userid, String idOfClient, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "users/" + userid + "/role-mappings/clients/" + idOfClient + "/composite"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getAvailableClientRolesForUserAsNodes(String rootUrl, String realm, String userid, String idOfClient, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "users/" + userid + "/role-mappings/clients/" + idOfClient + "/available"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getRealmRolesForGroupAsNodes(String rootUrl, String realm, String groupid, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "groups/" + groupid + "/role-mappings/realm"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getCompositeRealmRolesForGroupAsNodes(String rootUrl, String realm, String groupid, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "groups/" + groupid + "/role-mappings/realm/composite"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getAvailableRealmRolesForGroupAsNodes(String rootUrl, String realm, String groupid, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "groups/" + groupid + "/role-mappings/realm/available"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getClientRolesForGroupAsNodes(String rootUrl, String realm, String groupid, String idOfClient, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "groups/" + groupid + "/role-mappings/clients/" + idOfClient); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getCompositeClientRolesForGroupAsNodes(String rootUrl, String realm, String groupid, String idOfClient, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "groups/" + groupid + "/role-mappings/clients/" + idOfClient + "/composite"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } + + public static List getAvailableClientRolesForGroupAsNodes(String rootUrl, String realm, String groupid, String idOfClient, String auth) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "groups/" + groupid + "/role-mappings/clients/" + idOfClient + "/available"); + return doGetJSON(LIST_OF_NODES.class, resourceUrl, auth); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/UserOperations.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/UserOperations.java new file mode 100644 index 00000000000..593c97e3139 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/operations/UserOperations.java @@ -0,0 +1,96 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.operations; + +import org.keycloak.client.admin.cli.util.Headers; +import org.keycloak.client.admin.cli.util.HeadersBody; +import org.keycloak.client.admin.cli.util.HeadersBodyStatus; +import org.keycloak.client.admin.cli.util.HttpUtil; +import org.keycloak.representations.idm.CredentialRepresentation; +import org.keycloak.util.JsonSerialization; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; + +import static org.keycloak.client.admin.cli.util.HttpUtil.composeResourceUrl; +import static org.keycloak.client.admin.cli.util.HttpUtil.doDeleteJSON; +import static org.keycloak.client.admin.cli.util.HttpUtil.doPostJSON; +import static org.keycloak.client.admin.cli.util.HttpUtil.getIdForType; + +/** + * @author Marko Strukelj + */ +public class UserOperations { + + public static void addRealmRoles(String rootUrl, String realm, String auth, String userid, List roles) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "users/" + userid + "/role-mappings/realm"); + doPostJSON(resourceUrl, auth, roles); + } + + public static void addClientRoles(String rootUrl, String realm, String auth, String userid, String idOfClient, List roles) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "users/" + userid + "/role-mappings/clients/" + idOfClient); + doPostJSON(resourceUrl, auth, roles); + } + + public static void removeRealmRoles(String rootUrl, String realm, String auth, String userid, List roles) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "users/" + userid + "/role-mappings/realm"); + doDeleteJSON(resourceUrl, auth, roles); + } + + public static void removeClientRoles(String rootUrl, String realm, String auth, String userid, String idOfClient, List roles) { + String resourceUrl = composeResourceUrl(rootUrl, realm, "users/" + userid + "/role-mappings/clients/" + idOfClient); + doDeleteJSON(resourceUrl, auth, roles); + } + + public static void resetUserPassword(String rootUrl, String realm, String auth, String userid, String password, boolean temporary) { + + String resourceUrl = composeResourceUrl(rootUrl, realm, "users/" + userid + "/reset-password"); + + Headers headers = new Headers(); + if (auth != null) { + headers.add("Authorization", auth); + } + headers.add("Content-Type", "application/json"); + + CredentialRepresentation credentials = new CredentialRepresentation(); + credentials.setType("password"); + credentials.setTemporary(temporary); + credentials.setValue(password); + + HeadersBodyStatus response; + + byte[] body; + try { + body = JsonSerialization.writeValueAsBytes(credentials); + } catch (IOException e) { + throw new RuntimeException("Failed to serialize JSON", e); + } + + try { + response = HttpUtil.doRequest("put", resourceUrl, new HeadersBody(headers, new ByteArrayInputStream(body))); + } catch (IOException e) { + throw new RuntimeException("HTTP request failed: PUT " + resourceUrl + "\n" + new String(body), e); + } + + response.checkSuccess(); + } + + public static String getIdFromUsername(String rootUrl, String realm, String auth, String username) { + return getIdForType(rootUrl, realm, auth, "users", "username", username); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AccessibleBufferOutputStream.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AccessibleBufferOutputStream.java new file mode 100644 index 00000000000..984784eb34f --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AccessibleBufferOutputStream.java @@ -0,0 +1,66 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +/** + * @author Marko Strukelj + */ +public class AccessibleBufferOutputStream extends FilterOutputStream{ + + private byte[] buf; + + /** + * Creates an output stream filter built on top of the specified + * underlying output stream. + * + * @param out the underlying output stream to be assigned to + * the field this.out for later use, or + * null if this instance is to be + * created without an underlying stream. + */ + public AccessibleBufferOutputStream(OutputStream out) { + super(out); + } + + @Override + public void write(int b) throws IOException { + super.write(b); + buf = new byte[] {(byte) b}; + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + super.write(b, off, len); + buf = new byte[len]; + System.arraycopy(b, off, buf, 0, len); + } + + public byte[] getBuffer() { + return buf; + } + + public int getLastByte() { + if (buf != null && buf.length > 0) { + return 0xFF & buf[buf.length-1]; + } + return -1; + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AttributeException.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AttributeException.java new file mode 100644 index 00000000000..721baf260e8 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AttributeException.java @@ -0,0 +1,39 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +/** + * @author Marko Strukelj + */ +public class AttributeException extends RuntimeException { + + private final String attrName; + + public AttributeException(String attrName, String message) { + super(message); + this.attrName = attrName; + } + + public AttributeException(String attrName, String message, Throwable th) { + super(message, th); + this.attrName = attrName; + } + + public String getAttributeName() { + return attrName; + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AuthUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AuthUtil.java new file mode 100644 index 00000000000..ddfca0c2416 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/AuthUtil.java @@ -0,0 +1,202 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import org.keycloak.client.admin.cli.config.ConfigData; +import org.keycloak.client.admin.cli.config.RealmConfigData; +import org.keycloak.common.util.KeystoreUtil; +import org.keycloak.common.util.Time; +import org.keycloak.jose.jws.JWSBuilder; +import org.keycloak.representations.AccessTokenResponse; +import org.keycloak.representations.JsonWebToken; +import org.keycloak.util.BasicAuthHelper; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.security.KeyPair; +import java.util.UUID; + +import static java.lang.System.currentTimeMillis; +import static org.keycloak.client.admin.cli.util.ConfigUtil.checkAuthInfo; +import static org.keycloak.client.admin.cli.util.ConfigUtil.saveMergeConfig; +import static org.keycloak.client.admin.cli.util.HttpUtil.APPLICATION_FORM_URL_ENCODED; +import static org.keycloak.client.admin.cli.util.HttpUtil.APPLICATION_JSON; +import static org.keycloak.client.admin.cli.util.HttpUtil.doPost; +import static org.keycloak.client.admin.cli.util.HttpUtil.urlencode; + +/** + * @author Marko Strukelj + */ +public class AuthUtil { + + public static String ensureToken(ConfigData config) { + + checkAuthInfo(config); + + RealmConfigData realmConfig = config.sessionRealmConfigData(); + + long now = currentTimeMillis(); + + // check expires of access_token against time + // if it's less than 5s to expiry, renew it + if (realmConfig.getExpiresAt() - now < 5000) { + + // check refresh_token against expiry time + // if it's less than 5s to expiry, fail with credentials expired + if (realmConfig.getRefreshExpiresAt() - now < 5000) { + throw new RuntimeException("Session has expired. Login again with '" + OsUtil.CMD + " config credentials'"); + } + + if (realmConfig.getSigExpiresAt() != null && realmConfig.getSigExpiresAt() - now < 5000) { + throw new RuntimeException("Session has expired. Login again with '" + OsUtil.CMD + " config credentials'"); + } + + try { + String authorization = null; + + StringBuilder body = new StringBuilder("grant_type=refresh_token") + .append("&refresh_token=").append(realmConfig.getRefreshToken()) + .append("&client_id=").append(urlencode(realmConfig.getClientId())); + + if (realmConfig.getSigningToken() != null) { + body.append("&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + .append("&client_assertion=").append(realmConfig.getSigningToken()); + } else if (realmConfig.getSecret() != null) { + authorization = BasicAuthHelper.createHeader(realmConfig.getClientId(), realmConfig.getSecret()); + } + + InputStream result = doPost(realmConfig.serverUrl() + "/realms/" + realmConfig.realm() + "/protocol/openid-connect/token", + APPLICATION_FORM_URL_ENCODED, APPLICATION_JSON, body.toString(), authorization); + + AccessTokenResponse token = JsonSerialization.readValue(result, AccessTokenResponse.class); + + saveMergeConfig(cfg -> { + RealmConfigData realmData = cfg.sessionRealmConfigData(); + realmData.setToken(token.getToken()); + realmData.setRefreshToken(token.getRefreshToken()); + realmData.setExpiresAt(currentTimeMillis() + token.getExpiresIn() * 1000); + realmData.setRefreshExpiresAt(currentTimeMillis() + token.getRefreshExpiresIn() * 1000); + }); + return token.getToken(); + + } catch (Exception e) { + throw new RuntimeException("Failed to refresh access token - " + e.getMessage(), e); + } + } + + return realmConfig.getToken(); + } + + public static AccessTokenResponse getAuthTokens(String server, String realm, String user, String password, String clientId) { + StringBuilder body = new StringBuilder(); + try { + body.append("grant_type=password") + .append("&username=").append(urlencode(user)) + .append("&password=").append(urlencode(password)) + .append("&client_id=").append(urlencode(clientId)); + + InputStream result = doPost(server + "/realms/" + realm + "/protocol/openid-connect/token", + APPLICATION_FORM_URL_ENCODED, APPLICATION_JSON, body.toString(), null); + return JsonSerialization.readValue(result, AccessTokenResponse.class); + + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("Unexpected error: ", e); + } catch (IOException e) { + throw new RuntimeException("Error receiving response: ", e); + } + } + + public static AccessTokenResponse getAuthTokensByJWT(String server, String realm, String user, String password, String clientId, String signedRequestToken) { + StringBuilder body = new StringBuilder(); + try { + body.append("client_id=").append(urlencode(clientId)) + .append("&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer") + .append("&client_assertion=").append(signedRequestToken); + + if (user != null) { + if (password == null) { + throw new RuntimeException("No password specified"); + } + body.append("&grant_type=password") + .append("&username=").append(urlencode(user)) + .append("&password=").append(urlencode(password)); + } else { + body.append("&grant_type=client_credentials"); + } + + InputStream result = doPost(server + "/realms/" + realm + "/protocol/openid-connect/token", + APPLICATION_FORM_URL_ENCODED, APPLICATION_JSON, body.toString(), null); + return JsonSerialization.readValue(result, AccessTokenResponse.class); + + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("Unexpected error: ", e); + } catch (IOException e) { + throw new RuntimeException("Error receiving response: ", e); + } + } + + public static AccessTokenResponse getAuthTokensBySecret(String server, String realm, String user, String password, String clientId, String secret) { + + StringBuilder body = new StringBuilder(); + try { + if (user != null) { + if (password == null) { + throw new RuntimeException("No password specified"); + } + + body.append("client_id=").append(urlencode(clientId)) + .append("&grant_type=password") + .append("&username=").append(urlencode(user)) + .append("&password=").append(urlencode(password)); + } else { + body.append("grant_type=client_credentials"); + } + + InputStream result = doPost(server + "/realms/" + realm + "/protocol/openid-connect/token", + APPLICATION_FORM_URL_ENCODED, APPLICATION_JSON, body.toString(), BasicAuthHelper.createHeader(clientId, secret)); + return JsonSerialization.readValue(result, AccessTokenResponse.class); + + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("Unexpected error: ", e); + } catch (IOException e) { + throw new RuntimeException("Error receiving response: ", e); + } + } + + public static String getSignedRequestToken(String keystore, String storePass, String keyPass, String alias, int sigLifetime, String clientId, String realmInfoUrl) { + + KeyPair keypair = KeystoreUtil.loadKeyPairFromKeystore(keystore, storePass, keyPass, alias, KeystoreUtil.KeystoreFormat.JKS); + + JsonWebToken reqToken = new JsonWebToken(); + reqToken.id(UUID.randomUUID().toString()); + reqToken.issuer(clientId); + reqToken.subject(clientId); + reqToken.audience(realmInfoUrl); + + int now = Time.currentTime(); + reqToken.issuedAt(now); + reqToken.expiration(now + sigLifetime); + reqToken.notBefore(now); + + String signedRequestToken = new JWSBuilder() + .jsonContent(reqToken) + .rsa256(keypair.getPrivate()); + return signedRequestToken; + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ConfigUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ConfigUtil.java new file mode 100644 index 00000000000..3699048848e --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ConfigUtil.java @@ -0,0 +1,116 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import org.keycloak.client.admin.cli.config.ConfigData; +import org.keycloak.client.admin.cli.config.ConfigHandler; +import org.keycloak.client.admin.cli.config.ConfigUpdateOperation; +import org.keycloak.client.admin.cli.config.InMemoryConfigHandler; +import org.keycloak.client.admin.cli.config.RealmConfigData; +import org.keycloak.representations.AccessTokenResponse; + +/** + * @author Marko Strukelj + */ +public class ConfigUtil { + + public static final String DEFAULT_CLIENT = "admin-cli"; + + public static final String DEFAULT_CONFIG_FILE_STRING = OsUtil.OS_ARCH.isWindows() ? "%HOMEDRIVE%%HOMEPATH%\\.keycloak\\kcadm.config" : "~/.keycloak/kcadm.config"; + + public static final String DEFAULT_CONFIG_FILE_PATH = System.getProperty("user.home") + "/.keycloak/kcadm.config"; + + private static ConfigHandler handler; + + public static ConfigHandler getHandler() { + return handler; + } + + public static void setHandler(ConfigHandler handler) { + ConfigUtil.handler = handler; + } + + public static void saveTokens(AccessTokenResponse tokens, String endpoint, String realm, String clientId, String signKey, Long sigExpiresAt, String secret) { + handler.saveMergeConfig(config -> { + config.setServerUrl(endpoint); + config.setRealm(realm); + + RealmConfigData realmConfig = config.ensureRealmConfigData(endpoint, realm); + realmConfig.setToken(tokens.getToken()); + realmConfig.setRefreshToken(tokens.getRefreshToken()); + realmConfig.setSigningToken(signKey); + realmConfig.setSecret(secret); + realmConfig.setExpiresAt(System.currentTimeMillis() + tokens.getExpiresIn() * 1000); + realmConfig.setRefreshExpiresAt(tokens.getRefreshExpiresIn() == 0 ? + Long.MAX_VALUE : System.currentTimeMillis() + tokens.getRefreshExpiresIn() * 1000); + realmConfig.setSigExpiresAt(sigExpiresAt); + realmConfig.setClientId(clientId); + }); + } + + public static void checkServerInfo(ConfigData config) { + if (config.getServerUrl() == null || config.getRealm() == null) { + throw new RuntimeException("No server or realm specified. Use --server, --realm, or '" + OsUtil.CMD + " config credentials'."); + } + } + + public static void checkAuthInfo(ConfigData config) { + checkServerInfo(config); + } + + public static boolean credentialsAvailable(ConfigData config) { + return config.getServerUrl() != null && config.getRealm() != null + && config.sessionRealmConfigData() != null && config.sessionRealmConfigData().getRefreshToken() != null; + } + + public static ConfigData loadConfig() { + if (handler == null) { + throw new RuntimeException("No ConfigHandler set"); + } + + return handler.loadConfig(); + } + + public static void saveMergeConfig(ConfigUpdateOperation op) { + if (handler == null) { + throw new RuntimeException("No ConfigHandler set"); + } + + handler.saveMergeConfig(op); + } + + public static void setupInMemoryHandler(ConfigData config) { + InMemoryConfigHandler memhandler = null; + if (handler instanceof InMemoryConfigHandler) { + memhandler = (InMemoryConfigHandler) handler; + } else { + memhandler = new InMemoryConfigHandler(); + handler = memhandler; + } + memhandler.setConfigData(config); + } + + public static String getEffectiveClientId(ConfigData config) { + String clientId = DEFAULT_CLIENT; + + RealmConfigData realmData = config.sessionRealmConfigData(); + if (realmData != null && realmData.getClientId() != null) { + clientId = realmData.getClientId(); + } + return clientId; + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/FilterUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/FilterUtil.java new file mode 100644 index 00000000000..6a68f85d29a --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/FilterUtil.java @@ -0,0 +1,59 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; + +import java.io.IOException; +import java.util.Iterator; + +import static org.keycloak.client.admin.cli.util.OutputUtil.MAPPER; +import static org.keycloak.client.admin.cli.util.OutputUtil.convertToJsonNode; + +/** + * @author Marko Strukelj + */ +public class FilterUtil { + + public static JsonNode copyFilteredObject(Object object, ReturnFields returnFields) throws IOException { + + JsonNode node = convertToJsonNode(object); + + JsonNode r = node; + if (node.isArray()) { + ArrayNode ar = MAPPER.createArrayNode(); + for (JsonNode item: node) { + ar.add(copyFilteredObject(item, returnFields)); + } + r = ar; + + } else if (node.isObject()){ + r = MAPPER.createObjectNode(); + Iterator fieldNames = node.fieldNames(); + while (fieldNames.hasNext()) { + String name = fieldNames.next(); + if (returnFields.included(name)) { + JsonNode value = copyFilteredObject(node.get(name), returnFields.child(name)); + ((ObjectNode) r).set(name, value); + } + } + } + return r; + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/Header.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/Header.java new file mode 100644 index 00000000000..6ba4a600cc9 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/Header.java @@ -0,0 +1,39 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +/** + * @author Marko Strukelj + */ +public class Header { + + private String name; + private String value; + + public Header(String key, String value) { + this.name = key; + this.value = value; + } + + public String getName() { + return name; + } + + public String getValue() { + return value; + } +} \ No newline at end of file diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/Headers.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/Headers.java new file mode 100644 index 00000000000..338971fce23 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/Headers.java @@ -0,0 +1,55 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import java.util.Iterator; +import java.util.LinkedHashMap; + +/** + * @author Marko Strukelj + */ +public class Headers implements Iterable
{ + + private LinkedHashMap headers = new LinkedHashMap<>(); + + public void add(String header, String value) { + headers.put(header.toLowerCase(), new Header(header, value)); + } + + public boolean addIfMissing(String header, String value) { + String key = header.toLowerCase(); + if (!headers.containsKey(key)) { + headers.put(key, new Header(header, value)); + return true; + } + return false; + } + + public boolean contains(String header) { + String key = header.toLowerCase(); + return headers.containsKey(key); + } + + public Header get(String header) { + return headers.get(header.toLowerCase()); + } + + @Override + public Iterator
iterator() { + return headers.values().iterator(); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HeadersBody.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HeadersBody.java new file mode 100644 index 00000000000..c217fd2196f --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HeadersBody.java @@ -0,0 +1,72 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.nio.charset.Charset; + +import static org.keycloak.client.admin.cli.util.IoUtil.copyStream; + +/** + * @author Marko Strukelj + */ +public class HeadersBody { + + private Headers headers; + private InputStream body; + + + public HeadersBody(Headers headers) { + this.headers = headers; + } + + public HeadersBody(Headers headers, InputStream body) { + this.headers = headers; + this.body = body; + } + + public Headers getHeaders() { + return headers; + } + + public InputStream getBody() { + return body; + } + + public String readBodyString() { + byte [] buffer = readBodyBytes(); + return new String(buffer, Charset.forName(getContentCharset())); + } + + public byte[] readBodyBytes() { + ByteArrayOutputStream os = new ByteArrayOutputStream(); + copyStream(getBody(), os); + return os.toByteArray(); + } + + public String getContentCharset() { + Header contentType = headers.get("Content-Type"); + if (contentType != null) { + int pos = contentType.getValue().lastIndexOf("charset="); + if (pos != -1) { + return contentType.getValue().substring(pos + 8); + } + } + return "iso-8859-1"; + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HeadersBodyStatus.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HeadersBodyStatus.java new file mode 100644 index 00000000000..9ce89651f52 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HeadersBodyStatus.java @@ -0,0 +1,68 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import org.keycloak.util.JsonSerialization; + +import java.io.InputStream; +import java.util.Map; + +/** + * @author Marko Strukelj + */ +public class HeadersBodyStatus extends HeadersBody { + + private final String status; + + public HeadersBodyStatus(String status, Headers headers, InputStream body) { + super(headers, body); + this.status = status; + } + + public String getStatus() { + return status; + } + + private String getStatusCodeAndReason() { + return getStatus().substring(9); + } + + public void checkSuccess() { + int code = getStatusCode(); + if (code < 200 || code >= 300) { + String content = readBodyString(); + Map error = null; + try { + error = JsonSerialization.readValue(content, Map.class); + } catch (Exception ignored) { + } + + String message = null; + if (error != null) { + String description = error.get("error_description"); + String err = error.get("error"); + String msg = error.get("errorMessage"); + message = msg != null ? msg : err != null ? (description + " ["+ error.get("error") + "]") : null; + } + throw new HttpResponseException(getStatusCodeAndReason(), message, new RuntimeException(content)); + } + } + + public int getStatusCode() { + return Integer.valueOf(status.split(" ")[1]); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpResponseException.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpResponseException.java new file mode 100644 index 00000000000..692b38467a1 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpResponseException.java @@ -0,0 +1,34 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +/** + * @author Marko Strukelj + */ +public class HttpResponseException extends RuntimeException { + + private String status; + + HttpResponseException(String status, String message, Throwable cause) { + super(message != null ? message : "HTTP error - " + status, cause); + this.status = status; + } + + public int getStatusCode() { + return Integer.valueOf(status.split(" ")[0]); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpUtil.java new file mode 100644 index 00000000000..6de14ce0d1c --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpUtil.java @@ -0,0 +1,450 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.apache.http.HeaderIterator; +import org.apache.http.HttpHeaders; +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpHead; +import org.apache.http.client.methods.HttpOptions; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.client.methods.HttpPut; +import org.apache.http.client.methods.HttpRequestBase; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.entity.InputStreamEntity; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.HttpClientBuilder; +import org.apache.http.ssl.SSLContexts; +import org.keycloak.client.admin.cli.httpcomponents.HttpDelete; +import org.keycloak.client.admin.cli.operations.LocalSearch; +import org.keycloak.client.admin.cli.operations.RoleOperations; +import org.keycloak.util.JsonSerialization; + +import javax.net.ssl.SSLContext; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.keycloak.common.util.ObjectUtil.capitalize; + +/** + * @author Marko Strukelj + */ +public class HttpUtil { + + public static final String APPLICATION_XML = "application/xml"; + public static final String APPLICATION_JSON = "application/json"; + public static final String APPLICATION_FORM_URL_ENCODED = "application/x-www-form-urlencoded"; + public static final String UTF_8 = "utf-8"; + + private static HttpClient httpClient; + private static SSLConnectionSocketFactory sslsf; + + public static InputStream doGet(String url, String acceptType, String authorization) { + try { + HttpGet request = new HttpGet(url); + request.setHeader(HttpHeaders.ACCEPT, acceptType); + return doRequest(authorization, request); + } catch (IOException e) { + throw new RuntimeException("Failed to send request - " + e.getMessage(), e); + } + } + + public static InputStream doPost(String url, String contentType, String acceptType, String content, String authorization) { + try { + return doPostOrPut(contentType, acceptType, content, authorization, new HttpPost(url)); + } catch (IOException e) { + throw new RuntimeException("Failed to send request - " + e.getMessage(), e); + } + } + + public static InputStream doPut(String url, String contentType, String acceptType, String content, String authorization) { + try { + return doPostOrPut(contentType, acceptType, content, authorization, new HttpPut(url)); + } catch (IOException e) { + throw new RuntimeException("Failed to send request - " + e.getMessage(), e); + } + } + + public static void doDelete(String url, String authorization) { + try { + HttpDelete request = new HttpDelete(url); + doRequest(authorization, request); + } catch (IOException e) { + throw new RuntimeException("Failed to send request - " + e.getMessage(), e); + } + } + + + public static HeadersBodyStatus doGet(String url, HeadersBody request) throws IOException { + return doRequest("get", url, request); + } + + public static HeadersBodyStatus doPost(String url, HeadersBody request) throws IOException { + return doRequest("post", url, request); + } + + public static HeadersBodyStatus doPut(String url, HeadersBody request) throws IOException { + return doRequest("put", url, request); + } + + public static HeadersBodyStatus doDelete(String url, HeadersBody request) throws IOException { + return doRequest("delete", url, request); + } + + public static HeadersBodyStatus doRequest(String type, String url, HeadersBody request) throws IOException { + HttpRequestBase req; + switch (type) { + case "get": + req = new HttpGet(url); + break; + case "post": + req = new HttpPost(url); + break; + case "put": + req = new HttpPut(url); + break; + case "delete": + req = new HttpDelete(url); + break; + case "options": + req = new HttpOptions(url); + break; + case "head": + req = new HttpHead(url); + break; + default: + throw new RuntimeException("Method not supported: " + type); + } + addHeaders(req, request.getHeaders()); + + if (request.getBody() != null) { + if (req instanceof HttpEntityEnclosingRequestBase == false) { + throw new RuntimeException("Request type does not support body: " + type); + } + ((HttpEntityEnclosingRequestBase) req).setEntity(new InputStreamEntity(request.getBody())); + } + + HttpResponse res = getHttpClient().execute(req); + InputStream responseStream = null; + if (res.getEntity() != null) { + responseStream = res.getEntity().getContent(); + } else { + responseStream = new InputStream() { + @Override + public int read () throws IOException { + return -1; + } + }; + } + + Headers headers = new Headers(); + HeaderIterator it = res.headerIterator(); + while (it.hasNext()) { + org.apache.http.Header header = it.nextHeader(); + headers.add(header.getName(), header.getValue()); + } + + return new HeadersBodyStatus(res.getStatusLine().toString(), headers, responseStream); + } + + private static void addHeaders(HttpRequestBase request, Headers headers) { + for (Header header: headers) { + request.setHeader(header.getName(), header.getValue()); + } + } + + private static InputStream doPostOrPut(String contentType, String acceptType, String content, String authorization, HttpEntityEnclosingRequestBase request) throws IOException { + request.setHeader(HttpHeaders.CONTENT_TYPE, contentType); + request.setHeader(HttpHeaders.ACCEPT, acceptType); + if (content != null) { + request.setEntity(new StringEntity(content)); + } + + return doRequest(authorization, request); + } + + private static InputStream doRequest(String authorization, HttpRequestBase request) throws IOException { + addAuth(request, authorization); + + HttpResponse response = getHttpClient().execute(request); + InputStream responseStream = null; + if (response.getEntity() != null) { + responseStream = response.getEntity().getContent(); + } + + int code = response.getStatusLine().getStatusCode(); + if (code >= 200 && code < 300) { + return responseStream; + } else { + Map error = null; + try { + org.apache.http.Header header = response.getEntity().getContentType(); + if (header != null && APPLICATION_JSON.equals(header.getValue())) { + error = JsonSerialization.readValue(responseStream, Map.class); + } + } catch (Exception e) { + throw new RuntimeException("Failed to read error response - " + e.getMessage(), e); + } finally { + responseStream.close(); + } + + String message = null; + if (error != null) { + message = error.get("error_description") + " [" + error.get("error") + "]"; + } + throw new RuntimeException(message != null ? message : response.getStatusLine().getStatusCode() + " " + response.getStatusLine().getReasonPhrase()); + } + } + + private static void addAuth(HttpRequestBase request, String authorization) { + if (authorization != null) { + request.setHeader(HttpHeaders.AUTHORIZATION, authorization); + } + } + + public static HttpClient getHttpClient() { + if (httpClient == null) { + if (sslsf != null) { + httpClient = HttpClientBuilder.create().useSystemProperties().setSSLSocketFactory(sslsf).build(); + } else { + httpClient = HttpClientBuilder.create().useSystemProperties().build(); + } + } + return httpClient; + } + + public static String urlencode(String value) { + try { + return URLEncoder.encode(value, UTF_8); + } catch (UnsupportedEncodingException e) { + throw new RuntimeException("Failed to urlencode", e); + } + } + + public static void setTruststore(File file, String password) throws CertificateException, NoSuchAlgorithmException, KeyStoreException, IOException, KeyManagementException { + if (!file.isFile()) { + throw new RuntimeException("Truststore file not found: " + file.getAbsolutePath()); + } + SSLContext theContext = SSLContexts.custom() + .useProtocol("TLS") + .loadTrustMaterial(file, password == null ? null : password.toCharArray()) + .build(); + sslsf = new SSLConnectionSocketFactory(theContext); + } + + public static String extractIdFromLocation(String location) { + int last = location.lastIndexOf("/"); + if (last != -1) { + return location.substring(last + 1); + } + return null; + } + + public static String addQueryParamsToUri(String uri, String ... queryParams) { + if (queryParams == null) { + return uri; + } + + if (queryParams.length % 2 != 0) { + throw new RuntimeException("Value missing for query parameter: " + queryParams[queryParams.length-1]); + } + + Map params = new LinkedHashMap<>(); + for (int i = 0; i < queryParams.length; i += 2) { + params.put(queryParams[i], queryParams[i+1]); + } + return addQueryParamsToUri(uri, params); + } + + public static String addQueryParamsToUri(String uri, Map queryParams) { + + if (queryParams.size() == 0) { + return uri; + } + + StringBuilder query = new StringBuilder(); + for (Map.Entry params: queryParams.entrySet()) { + try { + if (query.length() > 0) { + query.append("&"); + } + query.append(params.getKey()).append("=").append(URLEncoder.encode(params.getValue(), "utf-8")); + } catch (Exception e) { + throw new RuntimeException("Failed to encode query params: " + params.getKey() + "=" + params.getValue()); + } + } + + return uri + (uri.indexOf("?") == -1 ? "?" : "&") + query; + } + + public static String composeResourceUrl(String adminRoot, String realm, String uri) { + if (!uri.startsWith("http:")) { + if ("realms".equals(uri) || uri.startsWith("realms/")) { + uri = normalize(adminRoot) + uri; + } else if ("serverinfo".equals(uri)) { + uri = normalize(adminRoot) + uri; + } else { + uri = normalize(adminRoot) + "realms/" + realm + "/" + uri; + } + } + return uri; + } + + public static String normalize(String value) { + return value.endsWith("/") ? value : value + "/"; + } + + public static void checkSuccess(String url, HeadersBodyStatus response) { + try { + response.checkSuccess(); + } catch (HttpResponseException e) { + if (e.getStatusCode() == 404) { + throw new RuntimeException("Resource not found for url: " + url, e); + } + throw e; + } + } + + public static T doGetJSON(Class type, String resourceUrl, String auth) { + + Headers headers = new Headers(); + if (auth != null) { + headers.add("Authorization", auth); + } + headers.add("Accept", "application/json"); + + HeadersBodyStatus response; + try { + response = HttpUtil.doRequest("get", resourceUrl, new HeadersBody(headers)); + } catch (IOException e) { + throw new RuntimeException("HTTP request failed: GET " + resourceUrl, e); + } + + checkSuccess(resourceUrl, response); + + T result; + try { + result = JsonSerialization.readValue(response.getBody(), type); + } catch (IOException e) { + throw new RuntimeException("Failed to read JSON response", e); + } + + return result; + } + + public static void doPostJSON(String resourceUrl, String auth, Object content) { + Headers headers = new Headers(); + if (auth != null) { + headers.add("Authorization", auth); + } + headers.add("Content-Type", "application/json"); + + HeadersBodyStatus response; + + byte[] body; + try { + body = JsonSerialization.writeValueAsBytes(content); + } catch (IOException e) { + throw new RuntimeException("Failed to serialize JSON", e); + } + + try { + response = HttpUtil.doRequest("post", resourceUrl, new HeadersBody(headers, new ByteArrayInputStream(body))); + } catch (IOException e) { + throw new RuntimeException("HTTP request failed: POST " + resourceUrl + "\n" + new String(body), e); + } + + checkSuccess(resourceUrl, response); + } + + public static void doDeleteJSON(String resourceUrl, String auth, Object content) { + Headers headers = new Headers(); + if (auth != null) { + headers.add("Authorization", auth); + } + headers.add("Content-Type", "application/json"); + + HeadersBodyStatus response; + + byte[] body; + try { + body = JsonSerialization.writeValueAsBytes(content); + } catch (IOException e) { + throw new RuntimeException("Failed to serialize JSON", e); + } + + try { + response = HttpUtil.doRequest("delete", resourceUrl, new HeadersBody(headers, new ByteArrayInputStream(body))); + } catch (IOException e) { + throw new RuntimeException("HTTP request failed: DELETE " + resourceUrl + "\n" + new String(body), e); + } + + checkSuccess(resourceUrl, response); + } + + public static String getIdForType(String rootUrl, String realm, String auth, String resourceEndpoint, String attrName, String attrValue) { + + return getAttrForType(rootUrl, realm, auth, resourceEndpoint, attrName, attrValue, "id"); + } + + public static String getAttrForType(String rootUrl, String realm, String auth, String resourceEndpoint, String attrName, String attrValue, String returnAttrName) { + + String resourceUrl = composeResourceUrl(rootUrl, realm, resourceEndpoint); + resourceUrl = HttpUtil.addQueryParamsToUri(resourceUrl, attrName, attrValue, "first", "0", "max", "2"); + + List users = doGetJSON(RoleOperations.LIST_OF_NODES.class, resourceUrl, auth); + + ObjectNode user; + try { + user = new LocalSearch(users).exactMatchOne(attrValue, attrName); + } catch (Exception e) { + throw new RuntimeException("Multiple " + resourceEndpoint + " found for " + attrName + ": " + attrValue, e); + } + + String typeName = singularize(resourceEndpoint); + if (user == null) { + throw new RuntimeException(capitalize(typeName) + " not found for " + attrName + ": " + attrValue); + } + + JsonNode attr = user.get(returnAttrName); + if (attr == null) { + throw new RuntimeException("Returned " + typeName + " info has no '" + returnAttrName + "' attribute"); + } + return attr.asText(); + } + + + public static String singularize(String value) { + return value.substring(0, value.length()-1); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/IoUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/IoUtil.java new file mode 100644 index 00000000000..f0c50d34373 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/IoUtil.java @@ -0,0 +1,255 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import org.jboss.aesh.console.AeshConsoleBufferBuilder; +import org.jboss.aesh.console.AeshInputProcessorBuilder; +import org.jboss.aesh.console.ConsoleBuffer; +import org.jboss.aesh.console.InputProcessor; +import org.jboss.aesh.console.Prompt; +import org.jboss.aesh.console.command.invocation.CommandInvocation; +import org.keycloak.client.admin.cli.aesh.Globals; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.AclEntry; +import java.nio.file.attribute.AclEntryPermission; +import java.nio.file.attribute.AclEntryType; +import java.nio.file.attribute.AclFileAttributeView; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.UserPrincipal; +import java.util.Formatter; +import java.util.HashSet; +import java.util.List; +import java.util.ListIterator; +import java.util.Set; + +import static java.nio.file.Files.createDirectories; +import static java.nio.file.Files.createFile; +import static java.nio.file.Files.isDirectory; +import static java.nio.file.Files.isRegularFile; +import static org.keycloak.client.admin.cli.util.OsUtil.OS_ARCH; + +/** + * @author Marko Strukelj + */ +public class IoUtil { + + public static String readFileOrStdin(String file) { + String content; + if ("-".equals(file)) { + content = readFully(System.in); + } else { + try (InputStream is = new FileInputStream(file)) { + content = readFully(is); + } catch (FileNotFoundException e) { + throw new RuntimeException("File not found: " + file); + } catch (IOException e) { + throw new RuntimeException("Failed to read file: " + file, e); + } + } + return content; + } + + public static void waitFor(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + throw new RuntimeException("Interrupted"); + } + } + + public static String readSecret(String prompt, CommandInvocation invocation) { + + // TODO Windows hack - masking not working on Windows + char maskChar = OS_ARCH.isWindows() ? 0 : '*'; + ConsoleBuffer consoleBuffer = new AeshConsoleBufferBuilder() + .shell(invocation.getShell()) + .prompt(new Prompt(prompt, maskChar)) + .create(); + InputProcessor inputProcessor = new AeshInputProcessorBuilder() + .consoleBuffer(consoleBuffer) + .create(); + + consoleBuffer.displayPrompt(); + + // activate stdin + Globals.stdin.setInputStream(System.in); + + String result; + try { + do { + result = inputProcessor.parseOperation(invocation.getInput()); + } while (result == null); + } catch (Exception e) { + throw new RuntimeException("^C", e); + } + /* + if (!Globals.stdin.isStdinAvailable()) { + try { + return readLine(new InputStreamReader(System.in)); + } catch (IOException e) { + throw new RuntimeException("Standard input not available"); + } + } + */ + // Windows hack - get rid of any \n + result = result.replaceAll("\\n", ""); + return result; + } + + public static String readFully(InputStream is) { + Charset charset = Charset.forName("utf-8"); + StringBuilder out = new StringBuilder(); + byte [] buf = new byte[8192]; + + int rc; + try { + while ((rc = is.read(buf)) != -1) { + out.append(new String(buf, 0, rc, charset)); + } + } catch (Exception e) { + throw new RuntimeException("Failed to read stream", e); + } + return out.toString(); + } + + public static void copyStream(InputStream is, OutputStream os) { + + byte [] buf = new byte[8192]; + + int rc; + try (InputStream input = is) { + while ((rc = input.read(buf)) != -1) { + os.write(buf, 0, rc); + } + } catch (Exception e) { + throw new RuntimeException("Failed to read/write a stream: ", e); + } finally { + try { + os.flush(); + } catch (IOException e) { + throw new RuntimeException("Failed to write a stream: ", e); + } + } + } + + public static void ensureFile(Path path) throws IOException { + + FileSystem fs = FileSystems.getDefault(); + Set supportedViews = fs.supportedFileAttributeViews(); + Path parent = path.getParent(); + + if (!isDirectory(parent)) { + createDirectories(parent); + // make sure only owner can read/write it + if (supportedViews.contains("posix")) { + setUnixPermissions(parent); + } else if (supportedViews.contains("acl")) { + setWindowsPermissions(parent); + } else { + warnErr("Failed to restrict access permissions on .keycloak directory: " + parent); + } + } + if (!isRegularFile(path)) { + createFile(path); + // make sure only owner can read/write it + if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) { + setUnixPermissions(path); + } else if (supportedViews.contains("acl")) { + setWindowsPermissions(path); + } else { + warnErr("Failed to restrict access permissions on config file: " + path); + } + } + } + + private static void setUnixPermissions(Path path) throws IOException { + Set perms = new HashSet<>(); + perms.add(PosixFilePermission.OWNER_READ); + perms.add(PosixFilePermission.OWNER_WRITE); + if (isDirectory(path)) { + perms.add(PosixFilePermission.OWNER_EXECUTE); + } + Files.setPosixFilePermissions(path, perms); + } + + private static void setWindowsPermissions(Path path) throws IOException { + AclFileAttributeView view = Files.getFileAttributeView(path, AclFileAttributeView.class); + UserPrincipal owner = view.getOwner(); + List acl = view.getAcl(); + ListIterator it = acl.listIterator(); + while (it.hasNext()) { + AclEntry entry = it.next(); + if ("BUILTIN\\Administrators".equals(entry.principal().getName()) || "NT AUTHORITY\\SYSTEM".equals(entry.principal().getName())) { + continue; + } + it.remove(); + } + AclEntry entry = AclEntry.newBuilder() + .setType(AclEntryType.ALLOW) + .setPrincipal(owner) + .setPermissions(AclEntryPermission.READ_DATA, AclEntryPermission.WRITE_DATA, + AclEntryPermission.APPEND_DATA, AclEntryPermission.READ_NAMED_ATTRS, + AclEntryPermission.WRITE_NAMED_ATTRS, AclEntryPermission.EXECUTE, + AclEntryPermission.READ_ATTRIBUTES, AclEntryPermission.WRITE_ATTRIBUTES, + AclEntryPermission.DELETE, AclEntryPermission.READ_ACL, AclEntryPermission.SYNCHRONIZE) + .build(); + acl.add(entry); + view.setAcl(acl); + } + + public static void printOut(String msg) { + System.out.println(msg); + } + + public static void printErr(String msg) { + System.err.println(msg); + } + + public static void printfOut(String format, String ... params) { + System.out.println(new Formatter().format("WARN: " + format, params)); + } + + public static void warnOut(String msg) { + System.out.println("WARN: " + msg); + } + + public static void warnErr(String msg) { + System.err.println("WARN: " + msg); + } + + public static void warnfOut(String format, String ... params) { + System.out.println(new Formatter().format("WARN: " + format, params)); + } + + public static void warnfErr(String format, String ... params) { + System.err.println(new Formatter().format("WARN: " + format, params)); + } + + public static void logOut(String msg) { + System.out.println("LOG: " + msg); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OsArch.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OsArch.java new file mode 100644 index 00000000000..13cf424d875 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OsArch.java @@ -0,0 +1,71 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +/** + * @author Marko Strukelj + */ +public class OsArch { + + private String os; + private String arch; + private boolean legacy; + + public OsArch(String os, String arch) { + this(os, arch, false); + } + + public OsArch(String os, String arch, boolean legacy) { + this.os = os; + this.arch = arch; + this.legacy = legacy; + } + + public String os() { + return os; + } + + public String arch() { + return arch; + } + + public boolean isLegacy() { + return legacy; + } + + public boolean isWindows() { + return "win32".equals(os); + } + + public String envVar(String var) { + if (isWindows()) { + return "%" + var + "%"; + } else { + return "$" + var; + } + } + + public String path(String path) { + if (isWindows()) { + path = path.replaceAll("/", "\\\\"); + if (path.startsWith("~")) { + path = "%HOMEPATH%" + path.substring(1); + } + } + return path; + } +} \ No newline at end of file diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OsUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OsUtil.java new file mode 100644 index 00000000000..d7a3840a9f4 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OsUtil.java @@ -0,0 +1,64 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +/** + * @author Marko Strukelj + */ +public class OsUtil { + + public static final OsArch OS_ARCH = determineOSAndArch(); + // TODO: move CMD out of this class + public static final String CMD = OS_ARCH.isWindows() ? "kcadm.bat" : "kcadm.sh"; + + public static final String PROMPT = OS_ARCH.isWindows() ? "c:\\>" : "$"; + + public static final String EOL = OS_ARCH.isWindows() ? "\r\n" : "\n"; + + + public static OsArch determineOSAndArch() { + String os = System.getProperty("os.name").toLowerCase(); + String arch = System.getProperty("os.arch"); + + if (arch.equals("amd64")) { + arch = "x86_64"; + } + + if (os.startsWith("linux")) { + if (arch.equals("x86") || arch.equals("i386") || arch.equals("i586")) { + arch = "i686"; + } + return new OsArch("linux", arch); + } else if (os.startsWith("windows")) { + if (arch.equals("x86")) { + arch = "i386"; + } + if (os.indexOf("2008") != -1 || os.indexOf("2003") != -1 || os.indexOf("vista") != -1) { + return new OsArch("win32", arch, true); + } else { + return new OsArch("win32", arch); + } + } else if (os.startsWith("sunos")) { + return new OsArch("sunos5", "x86_64"); + } else if (os.startsWith("mac os x")) { + return new OsArch("osx", "x86_64"); + } + + // unsupported platform + throw new RuntimeException("Could not determine OS and architecture for this operating system: " + os); + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OutputFormat.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OutputFormat.java new file mode 100644 index 00000000000..31181cc296d --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OutputFormat.java @@ -0,0 +1,25 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +/** + * @author Marko Strukelj + */ +public enum OutputFormat { + JSON, + CSV +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OutputUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OutputUtil.java new file mode 100644 index 00000000000..a9931d7c219 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/OutputUtil.java @@ -0,0 +1,107 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.TextNode; +import org.keycloak.util.JsonSerialization; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Iterator; +import java.util.Map; + +import static org.keycloak.client.admin.cli.util.IoUtil.printOut; + +/** + * @author Marko Strukelj + */ +public class OutputUtil { + + public static ObjectMapper MAPPER = new ObjectMapper(); + + static { + MAPPER.enable(SerializationFeature.INDENT_OUTPUT); + MAPPER.setSerializationInclusion(JsonInclude.Include.NON_NULL); + } + + public static JsonNode convertToJsonNode(Object object) throws IOException { + if (object instanceof JsonNode) { + return (JsonNode) object; + } + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + buffer.write(JsonSerialization.writeValueAsBytes(object)); + return MAPPER.readValue(buffer.toByteArray(), JsonNode.class); + } + + + public static void printAsCsv(Object object, ReturnFields fields, boolean unquoted) throws IOException { + + JsonNode node = convertToJsonNode(object); + if (!node.isArray()) { + ArrayNode listNode = MAPPER.createArrayNode(); + listNode.add(node); + node = listNode; + } + + for (JsonNode item: node) { + StringBuilder buffer = new StringBuilder(); + printObjectAsCsv(buffer, item, fields, unquoted); + + printOut(buffer.length() > 0 ? buffer.substring(1) : ""); + } + } + + static void printObjectAsCsv(StringBuilder out, JsonNode node, boolean unquoted) { + printObjectAsCsv(out, node, null, unquoted); + } + + static void printObjectAsCsv(StringBuilder out, JsonNode node, ReturnFields fields, boolean unquoted) { + + if (node.isObject()) { + if (fields == null) { + Iterator> it = node.fields(); + while (it.hasNext()) { + printObjectAsCsv(out, it.next().getValue(), unquoted); + } + } else { + Iterator it = fields.iterator(); + while (it.hasNext()) { + String field = it.next(); + JsonNode attr = node.get(field); + printObjectAsCsv(out, attr, fields.child(field), unquoted); + } + } + } else if (node.isArray()) { + for (JsonNode item: node) { + printObjectAsCsv(out, item, fields, unquoted); + } + } else if (node != null) { + out.append(","); + if (unquoted && node instanceof TextNode) { + out.append(node.asText()); + } else { + out.append(node.toString()); + } + } + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ParseUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ParseUtil.java new file mode 100644 index 00000000000..e1b2b9a5db7 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ParseUtil.java @@ -0,0 +1,111 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.keycloak.client.admin.cli.common.AttributeOperation; +import org.keycloak.client.admin.cli.common.CmdStdinContext; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.List; + +import static org.keycloak.client.admin.cli.util.IoUtil.readFileOrStdin; +import static org.keycloak.client.admin.cli.util.ReflectionUtil.setAttributes; + +/** + * @author Marko Strukelj + */ +public class ParseUtil { + + public static String[] parseKeyVal(String keyval) { + // we expect = as a separator + int pos = keyval.indexOf("="); + if (pos <= 0) { + throw new RuntimeException("Invalid key=value parameter: [" + keyval + "]"); + } + + String [] parsed = new String[2]; + parsed[0] = keyval.substring(0, pos); + parsed[1] = keyval.substring(pos+1); + + return parsed; + } + + public static CmdStdinContext parseFileOrStdin(String file) { + + String content = readFileOrStdin(file).trim(); + JsonNode result = null; + + if (content.length() == 0) { + throw new RuntimeException("Document provided by --file option is empty"); + } + + try { + result = JsonSerialization.readValue(content, JsonNode.class); + } catch (JsonParseException e) { + throw new RuntimeException("Not a valid JSON document - " + e.getMessage(), e); + } catch (IOException e) { + throw new RuntimeException("Failed to read the input document as JSON: " + e.getMessage(), e); + } catch (Exception e) { + throw new RuntimeException("Not a valid JSON document", e); + } + + CmdStdinContext ctx = new CmdStdinContext<>(); + ctx.setContent(content); + ctx.setResult(result); + return ctx; + } + + public static CmdStdinContext mergeAttributes(CmdStdinContext ctx, ObjectNode newObject, List attrs) { + String content = ctx.getContent(); + JsonNode node = ctx.getResult(); + if (node != null && !node.isObject()) { + throw new RuntimeException("Not a JSON object: " + node); + } + ObjectNode result = (ObjectNode) node; + try { + + if (result == null) { + try { + result = newObject; + } catch (Throwable e) { + throw new RuntimeException("Failed to instantiate object: " + e.getMessage(), e); + } + } + + if (result != null) { + try { + setAttributes(result, attrs); + } catch (AttributeException e) { + throw new RuntimeException("Failed to set attribute '" + e.getAttributeName() + "' on document type '" + result.getClass().getName() + "'", e); + } + content = JsonSerialization.writeValueAsString(result); + } else { + throw new RuntimeException("Setting attributes is not supported for type: " + result.getClass().getName()); + } + } catch (IOException e) { + throw new RuntimeException("Failed to merge set attributes with configuration from file", e); + } + + ctx.setContent(content); + ctx.setResult(result); + return ctx; + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ReflectionUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ReflectionUtil.java new file mode 100644 index 00000000000..81c2d29b122 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ReflectionUtil.java @@ -0,0 +1,228 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.BooleanNode; +import com.fasterxml.jackson.databind.node.DoubleNode; +import com.fasterxml.jackson.databind.node.LongNode; +import com.fasterxml.jackson.databind.node.NullNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.databind.node.TextNode; +import org.keycloak.client.admin.cli.common.AttributeKey; +import org.keycloak.client.admin.cli.common.AttributeOperation; + +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import static org.keycloak.client.admin.cli.common.AttributeOperation.Type.SET; +import static org.keycloak.client.admin.cli.util.OutputUtil.MAPPER; + +/** + * @author Marko Strukelj + */ +public class ReflectionUtil { + + public static void setAttributes(JsonNode client, List attrs) { + for (AttributeOperation item: attrs) { + AttributeKey attr = item.getKey(); + JsonNode nested = client; + + List cs = attr.getComponents(); + for (int i = 0; i < cs.size(); i++) { + AttributeKey.Component c = cs.get(i); + + // if this is the last component of the name, + // then if SET we need to set value on nested: + // if value already set on nested, then overwrite, maybe remove node + add new node + // if DELETE we need to remove or nullify value (if isArray) + // else get child and + // if exist set nested to child + // else if SET create new empty object or array - depending on c.isArray() + // + + // if this is the last component of the name + if (i == cs.size() - 1) { + String val = item.getValue(); + ObjectNode obj = (ObjectNode) nested; + + if (SET == item.getType()) { + JsonNode valNode = valueToJsonNode(val); + if (c.isArray() || attr.isAppend()) { + JsonNode list = obj.get(c.getName()); + // child expected to be an array + if ( ! (list instanceof ArrayNode)) { + // replace with new array + list = MAPPER.createArrayNode(); + obj.set(c.getName(), list); + } + setArrayItem((ArrayNode) list, c.getIndex(), valNode); + } else { + ((ObjectNode) nested).set(c.getName(), valNode); + } + } else { + // type == DELETE + if (c.isArray()) { + JsonNode list = obj.get(c.getName()); + // child expected to be an array + if (list instanceof ArrayNode) { + removeArrayItem((ArrayNode) list, c.getIndex()); + } + } else { + obj.remove(c.getName()); + } + } + } else { + // get child and + // if exist set nested to child + // else create new empty object or array - depending on c.isArray() + JsonNode node = nested.get(c.getName()); + if (node == null) { + if (c.isArray()) { + node = MAPPER.createArrayNode(); + } else { + node = MAPPER.createObjectNode(); + } + ((ObjectNode) nested).set(c.getName(), node); + } + nested = node; + } + } + } + } + + private static void setArrayItem(ArrayNode list, int index, JsonNode valNode) { + if (index == -1) { + // append to end of array + list.add(valNode); + return; + } + // make sure items up to index exist + for (int i = list.size(); i < index+1; i++) { + list.add(NullNode.instance); + } + list.set(index, valNode); + } + + private static void removeArrayItem(ArrayNode list, int index) { + if (index == -1) { + throw new IllegalArgumentException("Internal error - should never be called with index == -1"); + } + list.remove(index); + } + + private static JsonNode valueToJsonNode(String val) { + // try get value as JSON object + try { + return MAPPER.readValue(val, ObjectNode.class); + } catch (Exception ignored) { + } + + // try get value as JSON array + try { + return MAPPER.readValue(val, ArrayNode.class); + } catch (Exception ignored) { + } + + if (isBoolean(val)) { + return BooleanNode.valueOf(Boolean.valueOf(val)); + } else if (isInteger(val)) { + return LongNode.valueOf(Long.valueOf(val)); + } else if (isNumber(val)) { + return DoubleNode.valueOf(Double.valueOf(val)); + } else if (isQuoted(val)) { + return TextNode.valueOf(unquote(val)); + } + + return TextNode.valueOf(val); + } + + private static boolean isInteger(String val) { + try { + Long.valueOf(val); + return true; + } catch (Exception ignored) { + return false; + } + } + + private static boolean isNumber(String val) { + try { + Double.valueOf(val); + return true; + } catch (Exception ignored) { + return false; + } + } + + private static boolean isBoolean(String val) { + return "false".equals(val) || "true".equals(val); + } + + private static boolean isQuoted(String val) { + return val.startsWith("'") || val.startsWith("\""); + } + + private static String unquote(String val) { + if (!(val.startsWith("'") || val.startsWith("\"")) || !(val.endsWith("'") || val.endsWith("\""))) { + throw new RuntimeException("Invalid string value: " + val); + } + return val.substring(1, val.length()-1); + } + + public static void merge(JsonNode source, ObjectNode dest) { + // Iterate over source + // For each child check if exists on the destination + // if it does go deep + // otherwise copy over + // if it's last component, set it on destination + + if (!source.isObject()) { + throw new RuntimeException("Not a JSON object: " + source); + } + + Iterator> it = ((ObjectNode) source).fields(); + while (it.hasNext()) { + Map.Entry item = it.next(); + String name = item.getKey(); + JsonNode node = item.getValue(); + + JsonNode destNode = dest.get(name); + if (destNode != null) { + if (destNode.isObject()) { + if (node.isObject()) { + merge(node, (ObjectNode) destNode); + } else { + throw new RuntimeException("Attribute is of incompatible type - " + name + ": " + node); + } + } else if (destNode.isArray()) { + if (node.isArray()) { + dest.set(name, node); + } else { + throw new RuntimeException("Attribute is of incompatible type - " + name + ": " + node); + } + } else { + dest.set(name, node); + } + } else { + dest.set(name, node); + } + } + } +} diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ReturnFields.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ReturnFields.java new file mode 100644 index 00000000000..a1854714571 --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/ReturnFields.java @@ -0,0 +1,333 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; + +/** + * @author Marko Strukelj + */ +public class ReturnFields implements Iterable { + + public static ReturnFields ALL = new ReturnFields() { + @Override + public ReturnFields child(String field) { + return NONE; + } + + @Override + public boolean included(String... pathSegments) { + return true; + } + + @Override + public boolean excluded(String field) { + return false; + } + + @Override + public Iterator iterator() { + return Collections.singletonList("*").iterator(); + } + + @Override + public boolean isEmpty() { + return false; + } + + public boolean isAll() { + return true; + } + + @Override + public String toString() { + return "[ReturnFields ALL]"; + } + }; + + public static ReturnFields NONE = new ReturnFields() { + @Override + public ReturnFields child(String field) { + return this; + } + + @Override + public boolean included(String... pathSegments) { + return false; + } + + @Override + public boolean excluded(String field) { + return false; + } + + @Override + public Iterator iterator() { + List emptyList = Collections.emptyList(); + return emptyList.iterator(); + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public boolean isAll() { + return false; + } + + @Override + public String toString() { + return "[ReturnFields NONE]"; + } + }; + + public static ReturnFields ALL_RECURSIVELY = new ReturnFields() { + @Override + public ReturnFields child(String field) { + return this; + } + + @Override + public boolean included(String... pathSegments) { + return true; + } + + @Override + public boolean excluded(String field) { + return false; + } + + @Override + public Iterator iterator() { + List emptyList = Collections.emptyList(); + return emptyList.iterator(); + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public boolean isAll() { + return true; + } + }; + + private enum TargetState { + IdentCommaOpen, + Ident, + Comma, + Anything + } + + private enum FieldState { + start, + name, + end + } + + + private HashMap fields = new LinkedHashMap<>(); + + + + public ReturnFields() {} + + public ReturnFields(String spec) { + + if (spec == null || spec.trim().length() == 0) { + throw new IllegalArgumentException("Fields spec is null or empty!"); + } + // parse the spec, building up the tree for nested children + char[] buf = spec.toCharArray(); + StringBuilder token = new StringBuilder(buf.length); + + // stack for handling depth + LinkedList> specs = new LinkedList<>(); + specs.add(fields); + + // parser state + FieldState fldState = FieldState.start; + TargetState state = TargetState.Ident; + + int i; + for (i = 0; i < buf.length; i++) { + char c = buf[i]; + + if (c == ',') { + if (state == TargetState.Ident) { + error(spec, i); + } + if (fldState == FieldState.name) { + specs.getLast().put(token.toString(), null); + token.setLength(0); + } + state = TargetState.Ident; + fldState = FieldState.start; + } else if (c == '(') { + if (state != TargetState.IdentCommaOpen && state != TargetState.Anything) { + error(spec, i); + } + ReturnFields sub = new ReturnFields(); + specs.getLast().put(token.toString(), sub); + specs.add(sub.fields); + token.setLength(0); + + state = TargetState.Ident; + fldState = FieldState.start; + } else if (c == ')') { + if (state != TargetState.Anything) { + error(spec, i); + } + if (fldState == FieldState.name) { + specs.getLast().put(token.toString(), null); + token.setLength(0); + + } + specs.removeLast(); + + fldState = FieldState.end; + state = specs.size() > 1 ? TargetState.Anything : TargetState.Comma; + } else { + token.append(c); + if (fldState == FieldState.start) { + fldState = FieldState.name; + state = specs.size() > 1 ? TargetState.Anything : TargetState.IdentCommaOpen; + } + } + } + + if (specs.size() > 1) { + error(spec, i); + } + + if (token.length() > 0) { + specs.getLast().put(token.toString(), null); + } else if (!(state == TargetState.Anything || state == TargetState.Comma)) { + error(spec, i); + } + } + + private void error(String spec, int i) { + throw new RuntimeException("Invalid fields specification at position " + i + ": " + spec); + } + + + + /** + * Get ReturnFields for a child field of JSONObject type. + * + *

For basic-typed fields this always returns null. Use included() for those.

+ * + * @param field The child field name for nested returns. + * @return ReturnFields for a child field + */ + public ReturnFields child(String field) { + ReturnFields returnFields = fields.get(field); + if (returnFields == null) { + returnFields = fields.get("*"); + if (returnFields == null) { + returnFields = ReturnFields.NONE; + } + } + return returnFields; + } + + /** + * Check to see if the field should be included in JSON response. + * + *

The check can be performed for any level of depth relative to current nesting level, by specifying multiple path segments.

+ * + * @param pathSegments Segments to test in the tree of return fields. + * @return true if the specified path should be part of JSON response or not + */ + public boolean included(String... pathSegments) { + + if (pathSegments == null || pathSegments.length == 0) { + throw new IllegalArgumentException("No path specified!"); + } + ReturnFields current = this; + + for (String path : pathSegments) { + if (current == null) { + return false; + } + + if (current.fields.containsKey("-" + path)) { + return false; + } + if (current.fields.containsKey("*")) { + return true; + } + if (!current.fields.containsKey(path)) { + return false; + } + current = current.fields.get(path); + } + return true; + } + + /** + * Check to see if the field specified is set to be explicitly excluded. + * @param field The field name to check + * @return If the field was explicitly set to be excluded + */ + public boolean excluded(String field) { + if (fields.containsKey("-" + field)) { + return true; + } else { + return false; + } + } + + /** + * Iterate over child fields to be included in response. + * + *

To get nested field specifier use child(name) passing the field name this iterator returns.

+ * + * @return iterator over child fields to be included in response. + */ + public Iterator iterator() { + return fields.keySet().iterator(); + } + + /** + * Determine if zero fields should be returned. + * + * @return true if the list is empty, else, false + */ + public boolean isEmpty() { + return this.fields.isEmpty(); + } + + public boolean isAll() { + return this.fields.keySet().contains("*"); + } + + @Override + public String toString() { + return "[ReturnFieldsImpl: fields=" + this.fields + "]"; + } +} diff --git a/integration/client-cli/admin-cli/src/main/resources/META-INF/services/javax.ws.rs.ext.Providers b/integration/client-cli/admin-cli/src/main/resources/META-INF/services/javax.ws.rs.ext.Providers new file mode 100644 index 00000000000..bc2a259057f --- /dev/null +++ b/integration/client-cli/admin-cli/src/main/resources/META-INF/services/javax.ws.rs.ext.Providers @@ -0,0 +1,23 @@ +org.jboss.resteasy.plugins.providers.jackson.ResteasyJackson2Provider +org.jboss.resteasy.plugins.providers.DataSourceProvider +org.jboss.resteasy.plugins.providers.DocumentProvider +org.jboss.resteasy.plugins.providers.DefaultTextPlain +org.jboss.resteasy.plugins.providers.StringTextStar +org.jboss.resteasy.plugins.providers.SourceProvider +org.jboss.resteasy.plugins.providers.InputStreamProvider +org.jboss.resteasy.plugins.providers.ReaderProvider +org.jboss.resteasy.plugins.providers.ByteArrayProvider +org.jboss.resteasy.plugins.providers.FormUrlEncodedProvider +org.jboss.resteasy.plugins.providers.JaxrsFormProvider +org.jboss.resteasy.plugins.providers.FileProvider +org.jboss.resteasy.plugins.providers.FileRangeWriter +org.jboss.resteasy.plugins.providers.StreamingOutputProvider +org.jboss.resteasy.plugins.providers.IIOImageProvider +org.jboss.resteasy.plugins.providers.SerializableProvider +org.jboss.resteasy.plugins.interceptors.CacheControlFeature +org.jboss.resteasy.plugins.interceptors.encoding.AcceptEncodingGZIPInterceptor +org.jboss.resteasy.plugins.interceptors.encoding.AcceptEncodingGZIPFilter +org.jboss.resteasy.plugins.interceptors.encoding.ClientContentEncodingAnnotationFeature +org.jboss.resteasy.plugins.interceptors.encoding.GZIPDecodingInterceptor +org.jboss.resteasy.plugins.interceptors.encoding.GZIPEncodingInterceptor +org.jboss.resteasy.plugins.interceptors.encoding.ServerContentEncodingAnnotationFeature diff --git a/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/util/MergeAttributesTest.java b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/util/MergeAttributesTest.java new file mode 100644 index 00000000000..ef6d897adcc --- /dev/null +++ b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/util/MergeAttributesTest.java @@ -0,0 +1,101 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Test; +import org.keycloak.client.admin.cli.common.AttributeOperation; +import org.keycloak.client.admin.cli.common.CmdStdinContext; + +import java.nio.charset.Charset; +import java.util.LinkedList; +import java.util.List; + +import static org.keycloak.client.admin.cli.common.AttributeOperation.Type.DELETE; +import static org.keycloak.client.admin.cli.common.AttributeOperation.Type.SET; +import static org.keycloak.client.admin.cli.util.OutputUtil.MAPPER; +import static org.keycloak.client.admin.cli.util.ParseUtil.mergeAttributes; + +/** + * @author Marko Strukelj + */ +public class MergeAttributesTest { + + @Test + public void testMergeAttrs() throws Exception { + + List attrs = new LinkedList<>(); + attrs.add(new AttributeOperation(SET, "realm", "nurealm")); + attrs.add(new AttributeOperation(SET, "enabled", "true")); + attrs.add(new AttributeOperation(SET, "revokeRefreshToken", "true")); + attrs.add(new AttributeOperation(SET, "accessTokenLifespan", "900")); + attrs.add(new AttributeOperation(SET, "smtpServer.host", "localhost")); + attrs.add(new AttributeOperation(SET, "extra.key1", "somevalue")); + attrs.add(new AttributeOperation(SET, "extra.key2", "[\"somevalue\"]")); + attrs.add(new AttributeOperation(SET, "extra.key3[1]", "second item")); + attrs.add(new AttributeOperation(SET, "extra.key4", "\"true\"")); + attrs.add(new AttributeOperation(SET, "extra.key5", "\"1000\"")); + attrs.add(new AttributeOperation(DELETE, "id")); + attrs.add(new AttributeOperation(DELETE, "attributes.\"_browser_header.xFrameOptions\"")); + + String localJSON = "{\n" + + " \"id\" : \"24e5d572-756a-435b-8b2b-edbd0a7aa93d\",\n" + + " \"realm\" : \"demorealm\",\n" + + " \"notBefore\" : 0,\n" + + " \"revokeRefreshToken\" : false,\n" + + " \"accessTokenLifespan\" : 300,\n" + + " \"defaultRoles\" : [ \"offline_access\", \"uma_authorization\" ],\n" + + " \"smtpServer\" : { },\n" + + " \"attributes\" : {\n" + + " \"_browser_header.xFrameOptions\" : \"SAMEORIGIN\",\n" + + " \"_browser_header.contentSecurityPolicy\" : \"frame-src 'self'\"\n" + + " }\n" + + "}"; + + ObjectNode localNode = MAPPER.readValue(localJSON.getBytes(Charset.forName("utf-8")), ObjectNode.class); + CmdStdinContext ctx = new CmdStdinContext<>(); + ctx.setResult(localNode); + + ctx = mergeAttributes(ctx, MAPPER.createObjectNode(), attrs); + System.out.println(ctx); + + String remoteJSON = "{\n" + + " \"id\" : \"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\",\n" + + " \"realm\" : \"demorealm\",\n" + + " \"notBefore\" : 0,\n" + + " \"revokeRefreshToken\" : false,\n" + + " \"accessTokenLifespan\" : 300,\n" + + " \"defaultRoles\" : [ \"uma_authorization\" ],\n" + + " \"remote\" : \"value\",\n" + + " \"attributes\" : {\n" + + " \"_browser_header.xFrameOptions\" : \"SAMEORIGIN\",\n" + + " \"_browser_header.x\" : \"ORIGIN\",\n" + + " \"_browser_header.contentSecurityPolicy\" : \"frame-src 'self'\"\n" + + " }\n" + + "}"; + + ObjectNode remoteNode = MAPPER.readValue(remoteJSON.getBytes(Charset.forName("utf-8")), ObjectNode.class); + CmdStdinContext ctxremote = new CmdStdinContext<>(); + ctxremote.setResult(remoteNode); + + ReflectionUtil.merge(ctx.getResult(), ctxremote.getResult()); + System.out.println(ctx); + + //ctx = mergeAttributes(ctx, MAPPER.createObjectNode(), attrs); + } +} diff --git a/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/util/ReturnFieldsTest.java b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/util/ReturnFieldsTest.java new file mode 100644 index 00000000000..bd2105be8f2 --- /dev/null +++ b/integration/client-cli/admin-cli/src/test/java/org/keycloak/client/admin/cli/util/ReturnFieldsTest.java @@ -0,0 +1,142 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.keycloak.client.admin.cli.util; + +import org.junit.Assert; +import org.junit.Test; + +/** + * @author Marko Strukelj + */ +public class ReturnFieldsTest { + + @Test + public void testBasic() { + String spec = "field1,field2,field3"; + ReturnFields fspec = new ReturnFields(spec); + + StringBuilder val = new StringBuilder(); + for (String field : fspec) { + if (val.length() > 0) + val.append(','); + val.append(field); + } + Assert.assertEquals(spec, val.toString()); + + // check catching errors + + String[] specs = { + "", + null, + ",", + "field1,", + ",field2" + }; + + for (String filter : specs) { + try { + fspec = new ReturnFields(filter); + Assert.fail("Parsing of fields spec should have failed! : " + filter); + } catch (Exception e) { + //e.printStackTrace(); + } + } + } + + @Test + public void testExclude() { + ReturnFields spec = new ReturnFields("*,-name,dog(*,-color)"); + + Assert.assertTrue(spec.included("foo")); + Assert.assertTrue(spec.included("bar")); + Assert.assertFalse(spec.included("name")); + Assert.assertTrue(spec.included("dog")); + Assert.assertTrue(spec.child("dog").included("breed")); + Assert.assertFalse(spec.child("dog").included("color")); + + Assert.assertTrue(spec.excluded("name")); + Assert.assertFalse(spec.excluded("foo")); + Assert.assertFalse(spec.excluded("bar")); + Assert.assertTrue(spec.child("dog").excluded("color")); + Assert.assertFalse(spec.child("dog").excluded("breed")); + } + + @Test + public void testNestedWithGlob() { + ReturnFields spec = new ReturnFields("name,dog(*)"); + + Assert.assertTrue(spec.included("name")); + Assert.assertFalse(spec.included("tacos")); + + Assert.assertNotNull(spec.child("dog")); + Assert.assertTrue(spec.child("dog").included("dogname")); + + Assert.assertNotNull(spec.child("cat")); + Assert.assertFalse(spec.child("cat").included("name")); + } + + @Test + public void testNested() { + String spec = "field1,field2(sub1,sub2(subsub1)),field3"; + ReturnFields fspec = new ReturnFields(spec); + + String val = traverse(fspec); + Assert.assertEquals(spec, val.toString()); + + + // check catching errors + + String[] specs = { + "(", + ")", + "field1,(", + "field1,)", + "field1,field2(", + "field1,field2)", + "field1,field2()", + "field1,field2(sub1)(", + "field1,field2(sub1))", + "field1,field2(sub1)," + }; + + for (String filter : specs) { + try { + fspec = new ReturnFields(filter); + Assert.fail("Parsing of fields spec should have failed! : " + filter); + } catch (Exception e) { + //e.printStackTrace(); + } + } + } + + private String traverse(ReturnFields fspec) { + StringBuilder buf = new StringBuilder(); + for (String field : fspec) { + if (buf.length() > 0) + buf.append(','); + buf.append(field); + + ReturnFields cspec = fspec.child(field); + if (cspec != null && cspec != ReturnFields.NONE) { + buf.append('('); + buf.append(traverse(cspec)); + buf.append(')'); + } + } + return buf.toString(); + } +} diff --git a/integration/client-cli/client-cli-dist/assembly.xml b/integration/client-cli/client-cli-dist/assembly.xml index ee27cb2c812..fc74f207704 100755 --- a/integration/client-cli/client-cli-dist/assembly.xml +++ b/integration/client-cli/client-cli-dist/assembly.xml @@ -36,11 +36,23 @@ keycloak-client-tools/bin true + + ../admin-cli/src/main/bin/kcadm.sh + keycloak-client-tools/bin + 0755 + true + + + ../admin-cli/src/main/bin/kcadm.bat + keycloak-client-tools/bin + true + org.keycloak:keycloak-client-registration-cli + org.keycloak:keycloak-admin-cli keycloak-client-tools/bin/client diff --git a/integration/client-cli/client-cli-dist/pom.xml b/integration/client-cli/client-cli-dist/pom.xml index 1b567cb5297..daabefc4d30 100755 --- a/integration/client-cli/client-cli-dist/pom.xml +++ b/integration/client-cli/client-cli-dist/pom.xml @@ -34,6 +34,10 @@ org.keycloak keycloak-client-registration-cli + + org.keycloak + keycloak-admin-cli + diff --git a/integration/client-cli/client-registration-cli/pom.xml b/integration/client-cli/client-registration-cli/pom.xml index dc5c2102374..63a66732dee 100755 --- a/integration/client-cli/client-registration-cli/pom.xml +++ b/integration/client-cli/client-registration-cli/pom.xml @@ -33,7 +33,6 @@ org.jboss.aesh aesh - 0.66.10 org.keycloak diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigTruststoreCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigTruststoreCmd.java index b8f1f34c255..ecadf62643c 100644 --- a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigTruststoreCmd.java +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigTruststoreCmd.java @@ -1,3 +1,19 @@ +/* + * Copyright 2016 Red Hat, Inc. and/or its affiliates + * and other contributors as indicated by the @author tags. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ package org.keycloak.client.registration.cli.commands; import org.jboss.aesh.cl.CommandDefinition; @@ -153,7 +169,7 @@ public class ConfigTruststoreCmd extends AbstractAuthOptionsCmd implements Comma public static String usage() { StringWriter sb = new StringWriter(); PrintWriter out = new PrintWriter(sb); - out.println("Usage: " + CMD + " config truststore [TRUSTSTORE | --delete] [--trustpass PASSWOD] [ARGUMENTS]"); + out.println("Usage: " + CMD + " config truststore [TRUSTSTORE | --delete] [--trustpass PASSWORD] [ARGUMENTS]"); out.println(); out.println("Command to configure a global truststore to use when using https to connect to Keycloak server."); out.println(); @@ -174,7 +190,7 @@ public class ConfigTruststoreCmd extends AbstractAuthOptionsCmd implements Comma out.println("Specify a truststore - you will be prompted for truststore password every time it is used:"); out.println(" " + PROMPT + " " + CMD + " config truststore " + OS_ARCH.path("~/.keycloak/truststore.jks")); out.println(); - out.println("Specify a truststore, and password - truststore will automatically without prompting for password:"); + out.println("Specify a truststore, and password - truststore will automatically be used without prompting for password:"); out.println(" " + PROMPT + " " + CMD + " config truststore --storepass " + OS_ARCH.envVar("PASSWORD") + " " + OS_ARCH.path("~/.keycloak/truststore.jks")); out.println(); out.println("Remove truststore configuration:"); diff --git a/integration/client-cli/pom.xml b/integration/client-cli/pom.xml index 4759514b25d..20753c99d71 100644 --- a/integration/client-cli/pom.xml +++ b/integration/client-cli/pom.xml @@ -30,8 +30,19 @@ keycloak-client-cli-parent pom + + + + org.jboss.aesh + aesh + 0.66.10 + + + + client-registration-cli + admin-cli client-cli-dist \ No newline at end of file diff --git a/pom.xml b/pom.xml index 23c277d723c..5a9a3f20d73 100755 --- a/pom.xml +++ b/pom.xml @@ -1321,6 +1321,11 @@ keycloak-client-registration-cli ${project.version} + + org.keycloak + keycloak-admin-cli + ${project.version} + org.keycloak keycloak-client-cli-dist diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcAdmExec.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcAdmExec.java new file mode 100644 index 00000000000..3ab6b342b01 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcAdmExec.java @@ -0,0 +1,58 @@ +package org.keycloak.testsuite.cli; + +import org.keycloak.testsuite.cli.exec.AbstractExec; +import org.keycloak.testsuite.cli.exec.AbstractExecBuilder; + +import java.io.InputStream; + +/** + * @author Marko Strukelj + */ +public class KcAdmExec extends AbstractExec { + + public static final String WORK_DIR = System.getProperty("user.dir") + "/target/containers/keycloak-client-tools"; + + public static final String CMD = OS_ARCH.isWindows() ? "kcadm.bat" : "kcadm.sh"; + + private KcAdmExec(String workDir, String argsLine, InputStream stdin) { + this(workDir, argsLine, null, stdin); + } + + private KcAdmExec(String workDir, String argsLine, String env, InputStream stdin) { + super(workDir, argsLine, env, stdin); + } + + @Override + public String getCmd() { + return "bin/" + CMD; + } + + public static KcAdmExec.Builder newBuilder() { + return (KcAdmExec.Builder) new KcAdmExec.Builder().workDir(WORK_DIR); + } + + public static KcAdmExec execute(String args) { + return newBuilder() + .argsLine(args) + .execute(); + } + + public static class Builder extends AbstractExecBuilder { + + @Override + public KcAdmExec execute() { + KcAdmExec exe = new KcAdmExec(workDir, argsLine, env, stdin); + exe.dumpStreams = dumpStreams; + exe.execute(); + return exe; + } + + @Override + public KcAdmExec executeAsync() { + KcAdmExec exe = new KcAdmExec(workDir, argsLine, env, stdin); + exe.dumpStreams = dumpStreams; + exe.executeAsync(); + return exe; + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcRegExec.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcRegExec.java index 5f20c2a006f..56039afefc7 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcRegExec.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcRegExec.java @@ -1,5 +1,8 @@ package org.keycloak.testsuite.cli; +import org.keycloak.testsuite.cli.exec.AbstractExec; +import org.keycloak.testsuite.cli.exec.AbstractExecBuilder; + import java.io.BufferedReader; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -18,57 +21,27 @@ import java.util.concurrent.TimeUnit; /** * @author Marko Strukelj */ -public class KcRegExec { +public class KcRegExec extends AbstractExec { public static final String WORK_DIR = System.getProperty("user.dir") + "/target/containers/keycloak-client-tools"; - public static final OsArch OS_ARCH = OsUtils.determineOSAndArch(); - public static final String CMD = OS_ARCH.isWindows() ? "kcreg.bat" : "kcreg.sh"; - private long waitTimeout = 30000; - - private Process process; - - private int exitCode = -1; - - private boolean logStreams = Boolean.valueOf(System.getProperty("cli.log.output", "true")); - - private boolean dumpStreams; - - private String workDir = WORK_DIR; - - private String env; - - private String argsLine; - - private ByteArrayOutputStream stdout = new ByteArrayOutputStream(); - - private ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - - private InputStream stdin = new InteractiveInputStream(); - - private Throwable err; - private KcRegExec(String workDir, String argsLine, InputStream stdin) { this(workDir, argsLine, null, stdin); } private KcRegExec(String workDir, String argsLine, String env, InputStream stdin) { - if (workDir != null) { - this.workDir = workDir; - } - - this.argsLine = argsLine; - this.env = env; - - if (stdin != null) { - this.stdin = stdin; - } + super(workDir, argsLine, env, stdin); } - public static Builder newBuilder() { - return new Builder(); + @Override + public String getCmd() { + return "bin/" + CMD; + } + + public static KcRegExec.Builder newBuilder() { + return (KcRegExec.Builder) new KcRegExec.Builder().workDir(WORK_DIR); } public static KcRegExec execute(String args) { @@ -77,225 +50,9 @@ public class KcRegExec { .execute(); } - public void execute() { - executeAsync(); - if (err == null) { - waitCompletion(); - } - } - - - public void executeAsync() { - - try { - if (OS_ARCH.isWindows()) { - String cmd = (env != null ? "set " + env + " & " : "") + "bin\\" + CMD + " " + fixQuotes(argsLine); - System.out.println("Executing: cmd.exe /c " + cmd); - process = Runtime.getRuntime().exec(new String[]{"cmd.exe", "/c", cmd}, null, new File(workDir)); - } else { - String cmd = (env != null ? env + " " : "") + "bin/" + CMD + " " + argsLine; - System.out.println("Executing: sh -c " + cmd); - process = Runtime.getRuntime().exec(new String[]{"sh", "-c", cmd}, null, new File(workDir)); - } - - new StreamReaderThread(process.getInputStream(), logStreams ? new LoggingOutputStream("STDOUT", stdout) : stdout) - .start(); - - new StreamReaderThread(process.getErrorStream(), logStreams ? new LoggingOutputStream("STDERR", stderr) : stderr) - .start(); - - new StreamReaderThread(stdin, process.getOutputStream()) - .start(); - - } catch (Throwable t) { - err = t; - } - } - - private String fixQuotes(String argsLine) { - argsLine = argsLine + " "; - argsLine = argsLine.replaceAll("\"", "\\\\\""); - argsLine = argsLine.replaceAll(" '", " \""); - argsLine = argsLine.replaceAll("' ", "\" "); - return argsLine; - } - - public void waitCompletion() { - - //if (stdin instanceof InteractiveInputStream) { - // ((InteractiveInputStream) stdin).close(); - //} - try { - if (process.waitFor(waitTimeout, TimeUnit.MILLISECONDS)) { - exitCode = process.exitValue(); - if (exitCode != 0) { - dumpStreams = true; - } - } else { - if (process.isAlive()) { - process.destroyForcibly(); - } - throw new RuntimeException("Timeout after " + (waitTimeout / 1000) + " seconds."); - } - } catch (InterruptedException e) { - dumpStreams = true; - throw new RuntimeException("Interrupted ...", e); - } catch (Throwable t) { - dumpStreams = true; - err = t; - } finally { - if (!logStreams && dumpStreams) try { - System.out.println("STDOUT: "); - copyStream(new ByteArrayInputStream(stdout.toByteArray()), System.out); - System.out.println("STDERR: "); - copyStream(new ByteArrayInputStream(stderr.toByteArray()), System.out); - } catch (Exception ignored) { - } - } - } - - public int exitCode() { - return exitCode; - } - - public Throwable error() { - return err; - } - - public InputStream stdout() { - return new ByteArrayInputStream(stdout.toByteArray()); - } - - public List stdoutLines() { - return parseStreamAsLines(new ByteArrayInputStream(stdout.toByteArray())); - } - - public String stdoutString() { - return new String(stdout.toByteArray()); - } - - public InputStream stderr() { - return new ByteArrayInputStream(stderr.toByteArray()); - } - - public List stderrLines() { - return parseStreamAsLines(new ByteArrayInputStream(stderr.toByteArray())); - } - - public String stderrString() { - return new String(stderr.toByteArray()); - } - - static List parseStreamAsLines(InputStream stream) { - List lines = new ArrayList<>(); - try { - BufferedReader reader = new BufferedReader(new InputStreamReader(stream)); - - String line; - while ((line = reader.readLine()) != null) { - lines.add(line); - } - return lines; - } catch (IOException e) { - throw new RuntimeException("Unexpected I/O error", e); - } - } - - public void waitForStdout(String content) { - long start = System.currentTimeMillis(); - while (System.currentTimeMillis() - start < waitTimeout) { - if (stdoutString().indexOf(content) != -1) { - return; - } - try { - Thread.sleep(10); - } catch (InterruptedException e) { - throw new RuntimeException("Interrupted ...", e); - } - } - - throw new RuntimeException("Timed while waiting for content to appear in stdout"); - } - - public void sendToStdin(String s) { - if (stdin instanceof InteractiveInputStream) { - ((InteractiveInputStream) stdin).pushBytes(s.getBytes()); - } else { - throw new RuntimeException("Can't push to stdin - not interactive"); - } - } - - static class StreamReaderThread extends Thread { - - private InputStream is; - private OutputStream os; - - StreamReaderThread(InputStream is, OutputStream os) { - this.is = is; - this.os = os; - } - - public void run() { - try { - copyStream(is, os); - } catch (IOException e) { - throw new RuntimeException("Unexpected I/O error", e); - } finally { - try { - os.close(); - } catch (IOException ignored) { - System.err.print("IGNORED: error while closing output stream: "); - ignored.printStackTrace(); - } - } - } - } - - static void copyStream(InputStream is, OutputStream os) throws IOException { - byte [] buf = new byte[8192]; - - try (InputStream iss = is) { - int c; - while ((c = iss.read(buf)) != -1) { - os.write(buf, 0, c); - os.flush(); - } - } - } - - public static class Builder { - - private String workDir; - private String argsLine; - private InputStream stdin; - private String env; - private boolean dumpStreams; - - public Builder workDir(String path) { - this.workDir = path; - return this; - } - - public Builder argsLine(String cmd) { - this.argsLine = cmd; - return this; - } - - public Builder stdin(InputStream is) { - this.stdin = is; - return this; - } - - public Builder env(String env) { - this.env = env; - return this; - } - - public Builder fullStreamDump() { - this.dumpStreams = true; - return this; - } + public static class Builder extends AbstractExecBuilder { + @Override public KcRegExec execute() { KcRegExec exe = new KcRegExec(workDir, argsLine, env, stdin); exe.dumpStreams = dumpStreams; @@ -303,6 +60,7 @@ public class KcRegExec { return exe; } + @Override public KcRegExec executeAsync() { KcRegExec exe = new KcRegExec(workDir, argsLine, env, stdin); exe.dumpStreams = dumpStreams; @@ -311,177 +69,4 @@ public class KcRegExec { } } - static class NullInputStream extends InputStream { - - @Override - public int read() throws IOException { - return -1; - } - } - - static class InteractiveInputStream extends InputStream { - - private LinkedList queue = new LinkedList<>(); - - private Thread consumer; - - private boolean closed; - - @Override - public int read(byte b[]) throws IOException { - return read(b, 0, b.length); - } - - @Override - public synchronized int read(byte[] b, int off, int len) throws IOException { - - Byte current = null; - int rc = 0; - try { - consumer = Thread.currentThread(); - - do { - current = queue.poll(); - if (current == null) { - if (rc > 0) { - return rc; - } else { - do { - if (closed) { - return -1; - } - wait(); - } - while ((current = queue.poll()) == null); - } - } - - b[off + rc] = current; - rc++; - } while (rc < len); - - } catch (InterruptedException e) { - throw new InterruptedIOException("Signalled to exit"); - } finally { - consumer = null; - } - return rc; - } - - @Override - public long skip(long n) throws IOException { - return super.skip(n); - } - - @Override - public int available() throws IOException { - return super.available(); - } - - @Override - public synchronized void mark(int readlimit) { - super.mark(readlimit); - } - - @Override - public synchronized void reset() throws IOException { - super.reset(); - } - - @Override - public boolean markSupported() { - return super.markSupported(); - } - - @Override - public synchronized int read() throws IOException { - if (closed) { - return -1; - } - - // when input is available pass it on - Byte current; - try { - consumer = Thread.currentThread(); - - while ((current = queue.poll()) == null) { - wait(); - if (closed) { - return -1; - } - } - - } catch (InterruptedException e) { - throw new InterruptedIOException("Signalled to exit"); - } finally { - consumer = null; - } - return current; - } - - @Override - public synchronized void close() { - closed = true; - new RuntimeException("IIS || close").printStackTrace(); - if (consumer != null) { - consumer.interrupt(); - } - } - - public synchronized void pushBytes(byte [] buff) { - for (byte b : buff) { - queue.add(b); - } - notify(); - } - } - - - static class LoggingOutputStream extends FilterOutputStream { - - private ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - private String name; - - public LoggingOutputStream(String name, OutputStream os) { - super(os); - this.name = name; - } - - @Override - public void write(int b) throws IOException { - super.write(b); - if (b == 10) { - log(); - } else { - buffer.write(b); - } - } - - @Override - public void write(byte[] buf) throws IOException { - write(buf, 0, buf.length); - } - - @Override - public void write(byte[] buf, int offs, int len) throws IOException { - for (int i = 0; i < len; i++) { - write(buf[offs+i]); - } - } - - @Override - public void close() throws IOException { - super.close(); - if (buffer.size() > 0) { - log(); - } - } - - private void log() { - String log = new String(buffer.toByteArray()); - buffer.reset(); - System.out.println("[" + name + "] " + log); - } - } - } \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExec.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExec.java new file mode 100644 index 00000000000..91fcb1318d8 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExec.java @@ -0,0 +1,245 @@ +package org.keycloak.testsuite.cli.exec; + +import org.keycloak.testsuite.cli.OsArch; +import org.keycloak.testsuite.cli.OsUtils; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * @author Marko Strukelj + */ +public abstract class AbstractExec { + + public static final String WORK_DIR = System.getProperty("user.dir"); + + public static final OsArch OS_ARCH = OsUtils.determineOSAndArch(); + + private long waitTimeout = 30000; + + private Process process; + + private int exitCode = -1; + + private boolean logStreams = Boolean.valueOf(System.getProperty("cli.log.output", "true")); + + protected boolean dumpStreams; + + protected String workDir = WORK_DIR; + + private String env; + + private String argsLine; + + private ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + + private ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + + private InputStream stdin = new InteractiveInputStream(); + + private Throwable err; + + private Thread stdoutRunner; + + private Thread stderrRunner; + + public AbstractExec(String workDir, String argsLine, InputStream stdin) { + this(workDir, argsLine, null, stdin); + } + + public AbstractExec(String workDir, String argsLine, String env, InputStream stdin) { + if (workDir != null) { + this.workDir = workDir; + } + + this.argsLine = argsLine; + this.env = env; + + if (stdin != null) { + this.stdin = stdin; + } + } + + public abstract String getCmd(); + + public void execute() { + executeAsync(); + if (err == null) { + waitCompletion(); + } + } + + + public void executeAsync() { + + try { + if (OS_ARCH.isWindows()) { + String cmd = (env != null ? "set " + env + " & " : "") + fixPath(getCmd()) + " " + fixQuotes(argsLine); + System.out.println("Executing: cmd.exe /c " + cmd); + process = Runtime.getRuntime().exec(new String[]{"cmd.exe", "/c", cmd}, null, new File(workDir)); + } else { + String cmd = (env != null ? env + " " : "") + getCmd() + " " + argsLine; + System.out.println("Executing: sh -c " + cmd); + process = Runtime.getRuntime().exec(new String[]{"sh", "-c", cmd}, null, new File(workDir)); + } + + stdoutRunner = new StreamReaderThread(process.getInputStream(), logStreams ? new LoggingOutputStream("STDOUT", stdout) : stdout); + stdoutRunner.start(); + + stderrRunner = new StreamReaderThread(process.getErrorStream(), logStreams ? new LoggingOutputStream("STDERR", stderr) : stderr); + stderrRunner.start(); + + new StreamReaderThread(stdin, process.getOutputStream()) + .start(); + + } catch (Throwable t) { + err = t; + } + } + + private String fixPath(String cmd) { + return cmd.replaceAll("/", "\\\\"); + } + + private String fixQuotes(String argsLine) { + argsLine = argsLine + " "; + argsLine = argsLine.replaceAll("\"", "\\\\\""); + argsLine = argsLine.replaceAll(" '", " \""); + argsLine = argsLine.replaceAll("' ", "\" "); + return argsLine; + } + + public void waitCompletion() { + + // This is necessary to make sure the process isn't stuck reading from stdin + if (stdin instanceof InteractiveInputStream) { + ((InteractiveInputStream) stdin).close(); + } + try { + if (process.waitFor(waitTimeout, TimeUnit.MILLISECONDS)) { + exitCode = process.exitValue(); + if (exitCode != 0) { + dumpStreams = true; + } + // make sure reading output is really done (just in case) + stdoutRunner.join(5000); + stderrRunner.join(5000); + } else { + if (process.isAlive()) { + process.destroyForcibly(); + } + throw new RuntimeException("Timeout after " + (waitTimeout / 1000) + " seconds."); + } + } catch (InterruptedException e) { + dumpStreams = true; + throw new RuntimeException("Interrupted ...", e); + } catch (Throwable t) { + dumpStreams = true; + err = t; + } finally { + if (!logStreams && dumpStreams) try { + System.out.println("STDOUT: "); + copyStream(new ByteArrayInputStream(stdout.toByteArray()), System.out); + System.out.println("STDERR: "); + copyStream(new ByteArrayInputStream(stderr.toByteArray()), System.out); + } catch (Exception ignored) { + } + } + } + + public int exitCode() { + return exitCode; + } + + public Throwable error() { + return err; + } + + public InputStream stdout() { + return new ByteArrayInputStream(stdout.toByteArray()); + } + + public List stdoutLines() { + return parseStreamAsLines(new ByteArrayInputStream(stdout.toByteArray())); + } + + public String stdoutString() { + return new String(stdout.toByteArray()); + } + + public InputStream stderr() { + return new ByteArrayInputStream(stderr.toByteArray()); + } + + public List stderrLines() { + return parseStreamAsLines(new ByteArrayInputStream(stderr.toByteArray())); + } + + public String stderrString() { + return new String(stderr.toByteArray()); + } + + static List parseStreamAsLines(InputStream stream) { + List lines = new ArrayList<>(); + try { + BufferedReader reader = new BufferedReader(new InputStreamReader(stream)); + + String line; + while ((line = reader.readLine()) != null) { + lines.add(line); + } + return lines; + } catch (IOException e) { + throw new RuntimeException("Unexpected I/O error", e); + } + } + + public void waitForStdout(String content) { + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() - start < waitTimeout) { + if (stdoutString().indexOf(content) != -1) { + return; + } + try { + Thread.sleep(10); + } catch (InterruptedException e) { + throw new RuntimeException("Interrupted ...", e); + } + } + + throw new RuntimeException("Timed while waiting for content to appear in stdout"); + } + + public void sendToStdin(String s) { + if (stdin instanceof InteractiveInputStream) { + ((InteractiveInputStream) stdin).pushBytes(s.getBytes()); + } else { + throw new RuntimeException("Can't push to stdin - not interactive"); + } + } + + + + static void copyStream(InputStream is, OutputStream os) throws IOException { + byte [] buf = new byte[8192]; + + try (InputStream iss = is) { + int c; + while ((c = iss.read(buf)) != -1) { + os.write(buf, 0, c); + os.flush(); + } + } + } + + +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExecBuilder.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExecBuilder.java new file mode 100644 index 00000000000..97ec7724b4e --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/AbstractExecBuilder.java @@ -0,0 +1,44 @@ +package org.keycloak.testsuite.cli.exec; + +import java.io.InputStream; + +/** + * @author Marko Strukelj + */ +public abstract class AbstractExecBuilder { + + protected String workDir; + protected String argsLine; + protected InputStream stdin; + protected String env; + protected boolean dumpStreams; + + public AbstractExecBuilder workDir(String path) { + this.workDir = path; + return this; + } + + public AbstractExecBuilder argsLine(String cmd) { + this.argsLine = cmd; + return this; + } + + public AbstractExecBuilder stdin(InputStream is) { + this.stdin = is; + return this; + } + + public AbstractExecBuilder env(String env) { + this.env = env; + return this; + } + + public AbstractExecBuilder fullStreamDump() { + this.dumpStreams = true; + return this; + } + + public abstract T execute(); + + public abstract T executeAsync(); +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/ExecutionException.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/ExecutionException.java similarity index 93% rename from testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/ExecutionException.java rename to testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/ExecutionException.java index 986f48627a8..f5e053c9985 100644 --- a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/ExecutionException.java +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/ExecutionException.java @@ -1,4 +1,4 @@ -package org.keycloak.testsuite.cli; +package org.keycloak.testsuite.cli.exec; /** * @author Marko Strukelj diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/InteractiveInputStream.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/InteractiveInputStream.java new file mode 100644 index 00000000000..cbbea7242be --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/InteractiveInputStream.java @@ -0,0 +1,120 @@ +package org.keycloak.testsuite.cli.exec; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.util.LinkedList; + +class InteractiveInputStream extends InputStream { + + private LinkedList queue = new LinkedList<>(); + + private Thread consumer; + + private boolean closed; + + @Override + public int read(byte b[]) throws IOException { + return read(b, 0, b.length); + } + + @Override + public synchronized int read(byte[] b, int off, int len) throws IOException { + + Byte current = null; + int rc = 0; + try { + consumer = Thread.currentThread(); + + do { + current = queue.poll(); + if (current == null) { + if (rc > 0) { + return rc; + } else { + do { + if (closed) { + return -1; + } + wait(); + } + while ((current = queue.poll()) == null); + } + } + + b[off + rc] = current; + rc++; + } while (rc < len); + + } catch (InterruptedException e) { + throw new InterruptedIOException("Signalled to exit"); + } finally { + consumer = null; + } + return rc; + } + + @Override + public long skip(long n) throws IOException { + return super.skip(n); + } + + @Override + public int available() throws IOException { + return super.available(); + } + + @Override + public synchronized void mark(int readlimit) { + super.mark(readlimit); + } + + @Override + public synchronized void reset() throws IOException { + super.reset(); + } + + @Override + public boolean markSupported() { + return super.markSupported(); + } + + @Override + public synchronized int read() throws IOException { + // when input is available pass it on + Byte current; + try { + consumer = Thread.currentThread(); + + while ((current = queue.poll()) == null) { + // we don't check for closed before making sure + // that there is nothing more to read + if (closed) { + return -1; + } + wait(); + } + + } catch (InterruptedException e) { + throw new InterruptedIOException("Signalled to exit"); + } finally { + consumer = null; + } + return current; + } + + @Override + public synchronized void close() { + closed = true; + if (consumer != null) { + consumer.interrupt(); + } + } + + public synchronized void pushBytes(byte [] buff) { + for (byte b : buff) { + queue.add(b); + } + notify(); + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/LoggingOutputStream.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/LoggingOutputStream.java new file mode 100644 index 00000000000..e13fbe34870 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/LoggingOutputStream.java @@ -0,0 +1,53 @@ +package org.keycloak.testsuite.cli.exec; + +import java.io.ByteArrayOutputStream; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +class LoggingOutputStream extends FilterOutputStream { + + private ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private String name; + + public LoggingOutputStream(String name, OutputStream os) { + super(os); + this.name = name; + } + + @Override + public void write(int b) throws IOException { + super.write(b); + if (b == 10) { + log(); + } else { + buffer.write(b); + } + } + + @Override + public void write(byte[] buf) throws IOException { + write(buf, 0, buf.length); + } + + @Override + public void write(byte[] buf, int offs, int len) throws IOException { + for (int i = 0; i < len; i++) { + write(buf[offs+i]); + } + } + + @Override + public void close() throws IOException { + super.close(); + if (buffer.size() > 0) { + log(); + } + } + + private void log() { + String log = new String(buffer.toByteArray()); + buffer.reset(); + System.out.println("[" + name + "] " + log); + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/NullInputStream.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/NullInputStream.java new file mode 100644 index 00000000000..cb343141584 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/NullInputStream.java @@ -0,0 +1,12 @@ +package org.keycloak.testsuite.cli.exec; + +import java.io.IOException; +import java.io.InputStream; + +class NullInputStream extends InputStream { + + @Override + public int read() throws IOException { + return -1; + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/StreamReaderThread.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/StreamReaderThread.java new file mode 100644 index 00000000000..c7518d5b561 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/exec/StreamReaderThread.java @@ -0,0 +1,33 @@ +package org.keycloak.testsuite.cli.exec; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import static org.keycloak.testsuite.cli.exec.AbstractExec.copyStream; + +class StreamReaderThread extends Thread { + + private InputStream is; + private OutputStream os; + + StreamReaderThread(InputStream is, OutputStream os) { + this.is = is; + this.os = os; + } + + public void run() { + try { + copyStream(is, os); + } catch (IOException e) { + throw new RuntimeException("Unexpected I/O error", e); + } finally { + try { + os.close(); + } catch (IOException ignored) { + System.err.print("IGNORED: error while closing output stream: "); + ignored.printStackTrace(); + } + } + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/AbstractCliTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/AbstractCliTest.java new file mode 100644 index 00000000000..8df18ae0218 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/AbstractCliTest.java @@ -0,0 +1,54 @@ +package org.keycloak.testsuite.cli; + +import org.junit.Assert; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.cli.exec.AbstractExec; + +import java.util.List; + +/** + * @author Marko Strukelj + */ +public abstract class AbstractCliTest extends AbstractKeycloakTest { + + + public void assertExitCodeAndStdOutSize(AbstractExec exe, int exitCode, int stdOutLineCount) { + assertExitCodeAndStreamSizes(exe, exitCode, stdOutLineCount, -1); + } + + public void assertExitCodeAndStdErrSize(AbstractExec exe, int exitCode, int stdErrLineCount) { + assertExitCodeAndStreamSizes(exe, exitCode, -1, stdErrLineCount); + } + + public void assertExitCodeAndStreamSizes(AbstractExec exe, int exitCode, int stdOutLineCount, int stdErrLineCount) { + Assert.assertEquals("exitCode == " + exitCode, exitCode, exe.exitCode()); + if (stdOutLineCount != -1) { + try { + assertLineCount("stdout output", exe.stdoutLines(), stdOutLineCount); + } catch (Throwable e) { + throw new AssertionError("STDOUT: " + exe.stdoutString(), e); + } + } + if (stdErrLineCount != -1) { + try { + assertLineCount("stderr output", exe.stderrLines(), stdErrLineCount); + } catch (Throwable e) { + throw new AssertionError("STDERR: " + exe.stderrString(), e); + } + } + } + + private void assertLineCount(String label, List lines, int count) { + if (lines.size() == count) { + return; + } + // there is some kind of race condition in 'kcreg' that results in intermittent extra empty line + if (lines.size() == count + 1) { + if ("".equals(lines.get(lines.size()-1))) { + return; + } + } + Assert.assertTrue(label + " has " + lines.size() + " lines (expected: " + count + ")", lines.size() == count); + } + +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/AbstractAdmCliTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/AbstractAdmCliTest.java new file mode 100644 index 00000000000..d86ddf1a096 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/AbstractAdmCliTest.java @@ -0,0 +1,387 @@ +package org.keycloak.testsuite.cli.admin; + +import org.junit.Assert; +import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; +import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator; +import org.keycloak.client.admin.cli.config.ConfigData; +import org.keycloak.client.admin.cli.config.FileConfigHandler; +import org.keycloak.client.admin.cli.config.RealmConfigData; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.testsuite.cli.AbstractCliTest; +import org.keycloak.testsuite.cli.KcAdmExec; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.util.JsonSerialization; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; +import static org.keycloak.testsuite.cli.KcAdmExec.WORK_DIR; +import static org.keycloak.testsuite.cli.KcAdmExec.execute; + +/** + * @author Marko Strukelj + */ +public abstract class AbstractAdmCliTest extends AbstractCliTest { + + protected String serverUrl = isAuthServerSSL() ? + "https://localhost:" + getAuthServerHttpsPort() + "/auth" : + "http://localhost:" + getAuthServerHttpPort() + "/auth"; + + static boolean runIntermittentlyFailingTests() { + return "true".equals(System.getProperty("test.intermittent")); + } + + static boolean isAuthServerSSL() { + return "true".equals(System.getProperty("auth.server.ssl.required")); + } + + static File getDefaultConfigFilePath() { + return new File(System.getProperty("user.home") + "/.keycloak/kcadm.config"); + } + + static int getAuthServerHttpsPort() { + try { + return Integer.valueOf(System.getProperty("auth.server.https.port")); + } catch (Exception e) { + throw new RuntimeException("System property 'auth.server.https.port' not set or invalid: '" + + System.getProperty("auth.server.https.port") + "'"); + } + } + + static int getAuthServerHttpPort() { + try { + return Integer.valueOf(System.getProperty("auth.server.http.port")); + } catch (Exception e) { + throw new RuntimeException("System property 'auth.server.http.port' not set or invalid: '" + + System.getProperty("auth.server.http.port") + "'"); + } + } + + @Override + public void addTestRealms(List testRealms) { + + RealmRepresentation realmRepresentation = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); + testRealms.add(realmRepresentation); + + // create admin user account with permissions to manage clients + UserRepresentation admin = UserBuilder.create() + .username("user1") + .password("userpass") + .enabled(true) + .build(); + HashMap> clientRoles = new HashMap<>(); + clientRoles.put("realm-management", Arrays.asList("realm-admin")); + admin.setClientRoles(clientRoles); + realmRepresentation.getUsers().add(admin); + + + + // create client with service account to use Signed JWT credentials with + ClientRepresentation regClient = ClientBuilder.create() + .clientId("admin-cli-jwt") + .attribute(JWTClientAuthenticator.CERTIFICATE_ATTR, "MIICnTCCAYUCBgFXUhpRTTANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdyZWctY2xpMB4XDTE2MDkyMjEzMzIxOFoXDTI2MDkyMjEzMzM1OFowEjEQMA4GA1UEAwwHcmVnLWNsaTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMHZn/0Bk1M9oKcTHxzn2cGvBWwO1m6OVLQ8LSVwNIf4ixfGkVIkhI5iEGYND+uD8ame54ZPClTVxMra3JldClLIG+L+ymnbT2vKIhEsVvCROs9PnYxbFALt1dXneLIio2uzF+d7/zQWlmeaWfNunSJT1aHNJDkGgDeUuQa25b0IMqsFjsN8Dg4ATkA97r3wKn4Tp3SE7sTM/B2pmra4atNxGeShVrgihqUiQ/PwDiDGwry64AsexkZnQsCR3bJWBAVUiHef3JWzTfWWN5bfCBG6Mnq1xw7YN+YpV1nR3CGmcKJuLe6aTe7Ps8hYejYiQA7Mp7ZQsoImsVFV5HDOlb0CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAZl8XvLfKXTPYvq/QyHOg7EDlAdlV3HkmHP9SBAV4BccmHmorMkm5I6I21UA5mfju+0nhbEd0bm0kvJFxIfNU6lJyyVvQx3Gns37KYUOzIV/ocWZuOTBLp5tfIBYbBwfE/s1J4PhpA/3WhBY9JKiLvdJfxECGIgaLs2M0UsylW/7o04+18Od8j/m7crQc7fpe5gJB5m/+hxUDowIjG5CumffX9OHYGDvHBpaUl7QNSGgjP8Bn9ogmIMUBJ7XSYUcohKuk2Cnj6p+GlLuqHbOISUXLVjf0DxhCu6diVxvacKbgAZmyCIO1tGL/UVRxg9GOYdCiC9vHfPuZ8US+ZB0P9g==") + .authenticatorType(JWTClientAuthenticator.PROVIDER_ID) + .serviceAccount() + .build(); + + realmRepresentation.getClients().add(regClient); + + // create service account for client reg-cli with permissions to manage clients + addServiceAccount(realmRepresentation, "admin-cli-jwt"); + + + + // create client to use with user account - enable direct grants + regClient = ClientBuilder.create() + .clientId("admin-cli-jwt-direct") + .attribute(JWTClientAuthenticator.CERTIFICATE_ATTR, "MIICnTCCAYUCBgFXUhpRTTANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdyZWctY2xpMB4XDTE2MDkyMjEzMzIxOFoXDTI2MDkyMjEzMzM1OFowEjEQMA4GA1UEAwwHcmVnLWNsaTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMHZn/0Bk1M9oKcTHxzn2cGvBWwO1m6OVLQ8LSVwNIf4ixfGkVIkhI5iEGYND+uD8ame54ZPClTVxMra3JldClLIG+L+ymnbT2vKIhEsVvCROs9PnYxbFALt1dXneLIio2uzF+d7/zQWlmeaWfNunSJT1aHNJDkGgDeUuQa25b0IMqsFjsN8Dg4ATkA97r3wKn4Tp3SE7sTM/B2pmra4atNxGeShVrgihqUiQ/PwDiDGwry64AsexkZnQsCR3bJWBAVUiHef3JWzTfWWN5bfCBG6Mnq1xw7YN+YpV1nR3CGmcKJuLe6aTe7Ps8hYejYiQA7Mp7ZQsoImsVFV5HDOlb0CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAZl8XvLfKXTPYvq/QyHOg7EDlAdlV3HkmHP9SBAV4BccmHmorMkm5I6I21UA5mfju+0nhbEd0bm0kvJFxIfNU6lJyyVvQx3Gns37KYUOzIV/ocWZuOTBLp5tfIBYbBwfE/s1J4PhpA/3WhBY9JKiLvdJfxECGIgaLs2M0UsylW/7o04+18Od8j/m7crQc7fpe5gJB5m/+hxUDowIjG5CumffX9OHYGDvHBpaUl7QNSGgjP8Bn9ogmIMUBJ7XSYUcohKuk2Cnj6p+GlLuqHbOISUXLVjf0DxhCu6diVxvacKbgAZmyCIO1tGL/UVRxg9GOYdCiC9vHfPuZ8US+ZB0P9g==") + .authenticatorType(JWTClientAuthenticator.PROVIDER_ID) + .directAccessGrants() + .build(); + + realmRepresentation.getClients().add(regClient); + + + + + // create client with service account to use client secret with + regClient = ClientBuilder.create() + .clientId("admin-cli-secret") + .secret("password") + .authenticatorType(ClientIdAndSecretAuthenticator.PROVIDER_ID) + .serviceAccount() + .build(); + + realmRepresentation.getClients().add(regClient); + + // create service account for client reg-cli with permissions to manage clients + addServiceAccount(realmRepresentation, "admin-cli-secret"); + + + + + // create client to use with user account - enable direct grants + regClient = ClientBuilder.create() + .clientId("admin-cli-secret-direct") + .secret("password") + .authenticatorType(ClientIdAndSecretAuthenticator.PROVIDER_ID) + .directAccessGrants() + .build(); + + realmRepresentation.getClients().add(regClient); + + } + + FileConfigHandler initCustomConfigFile() { + String filename = UUID.randomUUID().toString() + ".config"; + File cfgFile = new File(WORK_DIR + "/" + filename); + FileConfigHandler handler = new FileConfigHandler(); + handler.setConfigFile(cfgFile.getAbsolutePath()); + return handler; + } + + void assertFieldsEqualWithExclusions(ConfigData config1, ConfigData config2, String ... excluded) { + + HashSet exclusions = new HashSet<>(Arrays.asList(excluded)); + + if (!exclusions.contains("serverUrl")) { + Assert.assertEquals("serverUrl", config1.getServerUrl(), config2.getServerUrl()); + } + if (!exclusions.contains("realm")) { + Assert.assertEquals("realm", config1.getRealm(), config2.getRealm()); + } + if (!exclusions.contains("truststore")) { + Assert.assertEquals("truststore", config1.getTruststore(), config2.getTruststore()); + } + if (!exclusions.contains("endpoints")) { + Map> endp1 = config1.getEndpoints(); + Map> endp2 = config2.getEndpoints(); + + Iterator>> it1 = endp1.entrySet().iterator(); + Iterator>> it2 = endp2.entrySet().iterator(); + + while (it1.hasNext()) { + Map.Entry> ent1 = it1.next(); + Map.Entry> ent2 = it2.next(); + + String serverUrl = ent1.getKey(); + String endpskey = "endpoints." + serverUrl; + if (!exclusions.contains(endpskey)) { + Assert.assertEquals(endpskey, ent1.getKey(), ent2.getKey()); + + Map realms1 = ent1.getValue(); + Map realms2 = ent2.getValue(); + + Iterator> rit1 = realms1.entrySet().iterator(); + Iterator> rit2 = realms2.entrySet().iterator(); + + while (rit1.hasNext()) { + Map.Entry rent1 = rit1.next(); + Map.Entry rent2 = rit2.next(); + + String realm = rent1.getKey(); + String rkey = endpskey + "." + realm; + if (!exclusions.contains(endpskey)) { + Assert.assertEquals(rkey, rent1.getKey(), rent2.getKey()); + + RealmConfigData rdata1 = rent1.getValue(); + RealmConfigData rdata2 = rent2.getValue(); + + assertFieldsEqualWithExclusions(serverUrl, realm, rdata1, rdata2, excluded); + } + } + } + } + } + } + + void assertFieldsEqualWithExclusions(String server, String realm, RealmConfigData data1, RealmConfigData data2, String ... excluded) { + + HashSet exclusions = new HashSet<>(Arrays.asList(excluded)); + + String pfix = ""; + if (server != null || realm != null) { + pfix = "endpoints." + server + "." + realm + "."; + } + + String ekey = pfix + "serverUrl"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.serverUrl(), data2.serverUrl()); + } + + ekey = pfix + "realm"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.realm(), data2.realm()); + } + + ekey = pfix + "clientId"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getClientId(), data2.getClientId()); + } + + ekey = pfix + "token"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getToken(), data2.getToken()); + } + + ekey = pfix + "refreshToken"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getRefreshToken(), data2.getRefreshToken()); + } + + ekey = pfix + "expiresAt"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getExpiresAt(), data2.getExpiresAt()); + } + + ekey = pfix + "refreshExpiresAt"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getRefreshExpiresAt(), data2.getRefreshExpiresAt()); + } + + ekey = pfix + "secret"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getSecret(), data2.getSecret()); + } + + ekey = pfix + "signingToken"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getSigningToken(), data2.getSigningToken()); + } + + ekey = pfix + "sigExpiresAt"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getSigExpiresAt(), data2.getSigExpiresAt()); + } + } + + void testCRUDWithOnTheFlyAuth(String serverUrl, String credentials, String extraOptions, String loginMessage) throws IOException { + + File configFile = getDefaultConfigFilePath(); + long lastModified = configFile.exists() ? configFile.lastModified() : 0; + + // This test assumes it is the only user of any instance of on the system + KcAdmExec exe = execute("create clients --no-config --server " + serverUrl + + " --realm test " + credentials + " " + extraOptions + " -s clientId=test-client -o"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertEquals("login message", loginMessage, exe.stderrLines().get(0)); + + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("clientId", "test-client", client.getClientId()); + + long lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + + + + + exe = execute("get clients/" + client.getId() + " --no-config --server " + serverUrl + " --realm test " + credentials + " " + extraOptions); + + assertExitCodeAndStdErrSize(exe, 0, 1); + + ClientRepresentation client2 = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("clientId", "test-client", client2.getClientId()); + + lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + + + + + exe = execute("update clients/" + client.getId() + " --no-config --server " + serverUrl + " --realm test " + + credentials + " " + extraOptions + " -s enabled=false -o"); + + assertExitCodeAndStdErrSize(exe, 0, 1); + + ClientRepresentation client4 = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("clientId", "test-client", client4.getClientId()); + Assert.assertFalse("enabled", client4.isEnabled()); + + lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + + + + + exe = execute("delete clients/" + client.getId() + " --no-config --server " + serverUrl + " --realm test " + credentials + " " + extraOptions); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + + lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + + + + + // subsequent delete should fail + exe = execute("delete clients/" + client.getId() + " --no-config --server " + serverUrl + " --realm test " + credentials + " " + extraOptions); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + String resourceUri = serverUrl + "/admin/realms/test/clients/" + client.getId(); + Assert.assertEquals("error message", "Resource not found for url: " + resourceUri, exe.stderrLines().get(1)); + + lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + } + + File initTempFile(String extension) throws IOException { + return initTempFile(extension, null); + } + + File initTempFile(String extension, String content) throws IOException { + String filename = UUID.randomUUID().toString() + extension; + File file = new File(KcAdmExec.WORK_DIR + "/" + filename); + if (content != null) { + OutputStream os = new FileOutputStream(file); + os.write(content.getBytes(Charset.forName("iso_8859_1"))); + os.close(); + } + return file; + } + + void addServiceAccount(RealmRepresentation realm, String clientId) { + + UserRepresentation account = UserBuilder.create() + .username("service-account-" + clientId) + .enabled(true) + .serviceAccountId(clientId) + .build(); + + HashMap> clientRoles = new HashMap<>(); + clientRoles.put("realm-management", Arrays.asList("realm-admin")); + + account.setClientRoles(clientRoles); + + realm.getUsers().add(account); + } + + void loginAsUser(File configFile, String server, String realm, String user, String password) { + + KcAdmExec exe = KcAdmExec.execute("config credentials --server " + server + " --realm " + realm + + " --user " + user + " --password " + password + " --config " + configFile.getAbsolutePath()); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + List lines = exe.stdoutLines(); + Assert.assertTrue("stdout output empty", lines.size() == 0); + + lines = exe.stderrLines(); + Assert.assertTrue("stderr output one line", lines.size() == 1); + Assert.assertEquals("stderr first line", "Logging into " + server + " as user " + user + " of realm " + realm, lines.get(0)); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmCreateTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmCreateTest.java new file mode 100644 index 00000000000..2481b588163 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmCreateTest.java @@ -0,0 +1,131 @@ +package org.keycloak.testsuite.cli.admin; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.client.admin.cli.config.FileConfigHandler; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.testsuite.cli.KcAdmExec; +import org.keycloak.testsuite.util.TempFileResource; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.Arrays; + +import static org.keycloak.testsuite.cli.KcAdmExec.execute; + +/** + * @author Marko Strukelj + */ +public class KcAdmCreateTest extends AbstractAdmCliTest { + + @Test + public void testCreateWithRealmOverride() throws IOException { + + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + // authenticate as a regular user against one realm + KcAdmExec exe = execute("config credentials -x --config '" + configFile.getName() + + "' --server " + serverUrl + " --realm master --user admin --password admin"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + + exe = execute("create clients --config '" + configFile.getName() + "' --server " + serverUrl + " -r test -s clientId=my_first_client"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + } + } + + + @Test + public void testCreateThoroughly() throws IOException { + + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + final String realm = "test"; + + // authenticate as a regular user against one realm + KcAdmExec exe = KcAdmExec.execute("config credentials -x --config '" + configFile.getName() + + "' --server " + serverUrl + " --realm master --user admin --password admin"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + + // create configuration from file using stdin redirect ... output an object + String content = "{\n" + + " \"clientId\": \"my_client\",\n" + + " \"enabled\": true,\n" + + " \"redirectUris\": [\"http://localhost:8980/myapp/*\"],\n" + + " \"serviceAccountsEnabled\": true,\n" + + " \"name\": \"My Client App\",\n" + + " \"implicitFlowEnabled\": false,\n" + + " \"publicClient\": true,\n" + + " \"webOrigins\": [\"http://localhost:8980/myapp\"],\n" + + " \"consentRequired\": false,\n" + + " \"baseUrl\": \"http://localhost:8980/myapp\",\n" + + " \"bearerOnly\": true,\n" + + " \"standardFlowEnabled\": true\n" + + "}"; + + try (TempFileResource tmpFile = new TempFileResource(initTempFile(".json", content))) { + + exe = execute("create clients --config '" + configFile.getName() + "' -o -f - < '" + tmpFile.getName() + "'"); + + assertExitCodeAndStdErrSize(exe, 0, 0); + + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertNotNull("id", client.getId()); + Assert.assertEquals("clientId", "my_client", client.getClientId()); + Assert.assertEquals("enabled", true, client.isEnabled()); + Assert.assertEquals("redirectUris", Arrays.asList("http://localhost:8980/myapp/*"), client.getRedirectUris()); + Assert.assertEquals("serviceAccountsEnabled", true, client.isServiceAccountsEnabled()); + Assert.assertEquals("name", "My Client App", client.getName()); + Assert.assertEquals("implicitFlowEnabled", false, client.isImplicitFlowEnabled()); + Assert.assertEquals("publicClient", true, client.isPublicClient()); + // note there is no server-side check if protocol is supported + Assert.assertEquals("webOrigins", Arrays.asList("http://localhost:8980/myapp"), client.getWebOrigins()); + Assert.assertEquals("consentRequired", false, client.isConsentRequired()); + Assert.assertEquals("baseUrl", "http://localhost:8980/myapp", client.getBaseUrl()); + Assert.assertEquals("bearerOnly", true, client.isStandardFlowEnabled()); + Assert.assertFalse("mappers not empty", client.getProtocolMappers().isEmpty()); + + // create configuration from file as a template and override clientId and other attributes ... output an object + exe = execute("create clients --config '" + configFile.getName() + "' -o -f '" + tmpFile.getName() + + "' -s clientId=my_client2 -s enabled=false -s 'redirectUris=[\"http://localhost:8980/myapp2/*\"]'" + + " -s 'name=My Client App II' -s 'webOrigins=[\"http://localhost:8980/myapp2\"]'" + + " -s baseUrl=http://localhost:8980/myapp2 -s rootUrl=http://localhost:8980/myapp2"); + + assertExitCodeAndStdErrSize(exe, 0, 0); + + ClientRepresentation client2 = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertNotNull("id", client2.getId()); + Assert.assertEquals("clientId", "my_client2", client2.getClientId()); + Assert.assertEquals("enabled", false, client2.isEnabled()); + Assert.assertEquals("redirectUris", Arrays.asList("http://localhost:8980/myapp2/*"), client2.getRedirectUris()); + Assert.assertEquals("serviceAccountsEnabled", true, client2.isServiceAccountsEnabled()); + Assert.assertEquals("name", "My Client App II", client2.getName()); + Assert.assertEquals("implicitFlowEnabled", false, client2.isImplicitFlowEnabled()); + Assert.assertEquals("publicClient", true, client2.isPublicClient()); + Assert.assertEquals("webOrigins", Arrays.asList("http://localhost:8980/myapp2"), client2.getWebOrigins()); + Assert.assertEquals("consentRequired", false, client2.isConsentRequired()); + Assert.assertEquals("baseUrl", "http://localhost:8980/myapp2", client2.getBaseUrl()); + Assert.assertEquals("rootUrl", "http://localhost:8980/myapp2", client2.getRootUrl()); + Assert.assertEquals("bearerOnly", true, client2.isStandardFlowEnabled()); + Assert.assertFalse("mappers not empty", client2.getProtocolMappers().isEmpty()); + } + + // simple create, output an id + exe = execute("create clients --config '" + configFile.getName() + "' -i -s clientId=my_client3"); + + assertExitCodeAndStreamSizes(exe, 0, 1, 0); + + // simple create, default output + exe = execute("create clients --config '" + configFile.getName() + "' -s clientId=my_client4"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + Assert.assertTrue("only id returned", exe.stderrLines().get(0).startsWith("Created new client with id '")); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmTest.java new file mode 100644 index 00000000000..fe2caa4b582 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmTest.java @@ -0,0 +1,561 @@ +package org.keycloak.testsuite.cli.admin; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.client.admin.cli.config.ConfigData; +import org.keycloak.client.admin.cli.config.FileConfigHandler; +import org.keycloak.client.admin.cli.config.RealmConfigData; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.testsuite.cli.KcAdmExec; +import org.keycloak.testsuite.util.TempFileResource; +import org.keycloak.util.JsonSerialization; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +import static org.keycloak.client.admin.cli.util.OsUtil.CMD; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.testsuite.cli.KcAdmExec.execute; + +/** + * @author Marko Strukelj + */ +public class KcAdmTest extends AbstractAdmCliTest { + + @Test + public void testBadCommand() { + /* + * Test most basic execution with non-existent command + */ + KcAdmExec exe = execute("nonexistent"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("stderr first line", "Unknown command: nonexistent", exe.stderrLines().get(0)); + } + + + @Test + public void testNoArgs() { + /* + * Test (sub)commands without any arguments + */ + KcAdmExec exe = KcAdmExec.execute(""); + + assertExitCodeAndStdErrSize(exe, 1, 0); + + List lines = exe.stdoutLines(); + Assert.assertTrue("stdout output not empty", lines.size() > 0); + Assert.assertEquals("stdout first line", "Keycloak Admin CLI", lines.get(0)); + Assert.assertEquals("stdout one but last line", "Use '" + KcAdmExec.CMD + " help ' for more information about a given command.", lines.get(lines.size() - 2)); + Assert.assertEquals("stdout last line", "", lines.get(lines.size() - 1)); + + + /* + * Test commands without arguments + */ + exe = KcAdmExec.execute("config"); + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("error message", + "Sub-command required by '" + CMD + " config' - one of: 'credentials', 'truststore'", + exe.stderrLines().get(0)); + + exe = KcAdmExec.execute("config credentials"); + assertExitCodeAndStdErrSize(exe, 1, 0); + Assert.assertTrue("help message returned", exe.stdoutLines().size() > 10); + Assert.assertEquals("help message", "Usage: " + CMD + " config credentials --server SERVER_URL --realm REALM --user USER [--password PASSWORD] [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("config truststore"); + assertExitCodeAndStdErrSize(exe, 1, 0); + Assert.assertTrue("help message returned", exe.stdoutLines().size() > 10); + Assert.assertEquals("help message", "Usage: " + CMD + " config truststore [TRUSTSTORE | --delete] [--trustpass PASSWORD] [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("create"); + assertExitCodeAndStdErrSize(exe, 1, 0); + Assert.assertTrue("help message returned", exe.stdoutLines().size() > 10); + Assert.assertEquals("help message", "Usage: " + CMD + " create ENDPOINT_URI [ARGUMENTS]", exe.stdoutLines().get(0)); + //Assert.assertEquals("error message", "No file nor attribute values specified", exe.stderrLines().get(0)); + + exe = KcAdmExec.execute("get"); + assertExitCodeAndStdErrSize(exe, 1, 0); + Assert.assertTrue("help message returned", exe.stdoutLines().size() > 10); + Assert.assertEquals("help message", "Usage: " + CMD + " get ENDPOINT_URI [ARGUMENTS]", exe.stdoutLines().get(0)); + //Assert.assertEquals("error message", "CLIENT not specified", exe.stderrLines().get(0)); + + exe = KcAdmExec.execute("update"); + assertExitCodeAndStdErrSize(exe, 1, 0); + Assert.assertTrue("help message returned", exe.stdoutLines().size() > 10); + Assert.assertEquals("help message", "Usage: " + CMD + " update ENDPOINT_URI [ARGUMENTS]", exe.stdoutLines().get(0)); + //Assert.assertEquals("error message", "No file nor attribute values specified", exe.stderrLines().get(0)); + + exe = KcAdmExec.execute("delete"); + assertExitCodeAndStdErrSize(exe, 1, 0); + Assert.assertTrue("help message returned", exe.stdoutLines().size() > 10); + Assert.assertEquals("help message", "Usage: " + CMD + " delete ENDPOINT_URI [ARGUMENTS]", exe.stdoutLines().get(0)); + //Assert.assertEquals("error message", "CLIENT not specified", exe.stderrLines().get(0)); + + //exe = KcAdmExec.execute("get-roles"); + //assertExitCodeAndStdErrSize(exe, 0, 0); + //try { + // JsonNode node = JsonSerialization.readValue(exe.stdout(), JsonNode.class); + // Assert.assertTrue("is JSON array", node.isArray()); + //} catch (IOException e) { + // throw new AssertionError("Response should be a JSON array", e); + //} + + //Assert.assertTrue("JSON message returned", exe.stdoutLines().size() > 10); + //Assert.assertTrue("help message returned", exe.stdoutLines().size() > 10); + //Assert.assertEquals("help message", "Usage: " + CMD + " get-roles [--cclientid CLIENT_ID | --cid ID] [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("add-roles"); + assertExitCodeAndStdErrSize(exe, 1, 0); + Assert.assertTrue("help message returned", exe.stdoutLines().size() > 10); + Assert.assertEquals("help message", "Usage: " + CMD + " add-roles (--uusername USERNAME | --uid ID) [--cclientid CLIENT_ID | --cid ID] (--rolename NAME | --roleid ID)+ [ARGUMENTS]", exe.stdoutLines().get(0)); + //Assert.assertEquals("error message", "CLIENT not specified", exe.stderrLines().get(0)); + + exe = KcAdmExec.execute("remove-roles"); + assertExitCodeAndStdErrSize(exe, 1, 0); + Assert.assertTrue("help message returned", exe.stdoutLines().size() > 10); + Assert.assertEquals("help message", "Usage: " + CMD + " remove-roles (--uusername USERNAME | --uid ID) [--cclientid CLIENT_ID | --cid ID] (--rolename NAME | --roleid ID)+ [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("set-password"); + assertExitCodeAndStdErrSize(exe, 1, 0); + Assert.assertTrue("help message returned", exe.stdoutLines().size() > 10); + Assert.assertEquals("help message", "Usage: " + CMD + " set-password (--username USERNAME | --userid ID) [--password PASSWORD] [ARGUMENTS]", exe.stdoutLines().get(0)); + //Assert.assertEquals("error message", "CLIENT not specified", exe.stderrLines().get(0)); + + exe = KcAdmExec.execute("help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + lines = exe.stdoutLines(); + Assert.assertTrue("stdout output not empty", lines.size() > 0); + Assert.assertEquals("stdout first line", "Keycloak Admin CLI", lines.get(0)); + Assert.assertEquals("stdout one but last line", "Use '" + KcAdmExec.CMD + " help ' for more information about a given command.", lines.get(lines.size() - 2)); + Assert.assertEquals("stdout last line", "", lines.get(lines.size() - 1)); + } + + @Test + public void testHelpGlobalOption() { + /* + * Test --help for all commands + */ + KcAdmExec exe = KcAdmExec.execute("--help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", "Keycloak Admin CLI", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("create --help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", "Usage: " + CMD + " create ENDPOINT_URI [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("get --help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", "Usage: " + CMD + " get ENDPOINT_URI [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("update --help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", "Usage: " + CMD + " update ENDPOINT_URI [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("delete --help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", "Usage: " + CMD + " delete ENDPOINT_URI [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("get-roles --help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", "Usage: " + CMD + " get-roles [--cclientid CLIENT_ID | --cid ID] [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("add-roles --help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", "Usage: " + CMD + " add-roles (--uusername USERNAME | --uid ID) [--cclientid CLIENT_ID | --cid ID] (--rolename NAME | --roleid ID)+ [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("remove-roles --help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", "Usage: " + CMD + " remove-roles (--uusername USERNAME | --uid ID) [--cclientid CLIENT_ID | --cid ID] (--rolename NAME | --roleid ID)+ [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("set-password --help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", "Usage: " + CMD + " set-password (--username USERNAME | --userid ID) [--password PASSWORD] [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("config --help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", "Usage: " + CMD + " config SUB_COMMAND [ARGUMENTS]", exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("config credentials --help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", + "Usage: " + CMD + " config credentials --server SERVER_URL --realm REALM --user USER [--password PASSWORD] [ARGUMENTS]", + exe.stdoutLines().get(0)); + + exe = KcAdmExec.execute("config truststore --help"); + assertExitCodeAndStdErrSize(exe, 0, 0); + Assert.assertEquals("stdout first line", + "Usage: " + CMD + " config truststore [TRUSTSTORE | --delete] [--trustpass PASSWORD] [ARGUMENTS]", + exe.stdoutLines().get(0)); + } + + @Test + public void testBadOptionInPlaceOfCommand() { + /* + * Test most basic execution with non-existent option + */ + KcAdmExec exe = KcAdmExec.execute("--nonexistent"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("stderr first line", "Unknown command: --nonexistent", exe.stderrLines().get(0)); + } + + @Test + public void testBadOption() { + /* + * Test sub-command execution with non-existent option + */ + + KcAdmExec exe = KcAdmExec.execute("get users --nonexistent"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("stderr first line", "Invalid option: --nonexistent", exe.stderrLines().get(0)); + Assert.assertEquals("try help", "Try '" + CMD + " help get' for more information", exe.stderrLines().get(1)); + + // set-password doesn't use @Arguments injection thus unsupported options are handled by Aesh + exe = KcAdmExec.execute("set-password --nonexistent"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("stderr first line", "Invalid option: --nonexistent", exe.stderrLines().get(0)); + Assert.assertEquals("try help", "Try '" + CMD + " help set-password' for more information", exe.stderrLines().get(1)); + } + + @Test + public void testCredentialsServerAndRealmWithDefaultConfig() { + /* + * Test without --server specified + */ + KcAdmExec exe = KcAdmExec.execute("config credentials --server " + serverUrl + " --realm master"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + } + + @Test + public void testCredentialsNoServerWithDefaultConfig() { + /* + * Test without --server specified + */ + KcAdmExec exe = KcAdmExec.execute("config credentials --realm master --user admin --password admin"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("stderr first line", "Required option not specified: --server", exe.stderrLines().get(0)); + Assert.assertEquals("try help", "Try '" + CMD + " help config credentials' for more information", exe.stderrLines().get(1)); + } + + @Test + public void testCredentialsNoRealmWithDefaultConfig() { + /* + * Test without --server specified + */ + KcAdmExec exe = KcAdmExec.execute("config credentials --server " + serverUrl + " --user admin --password admin"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("stderr first line", "Required option not specified: --realm", exe.stderrLines().get(0)); + Assert.assertEquals("try help", "Try '" + CMD + " help config credentials' for more information", exe.stderrLines().get(1)); + } + + @Test + public void testUserLoginWithDefaultConfig() { + /* + * Test most basic user login, using the default admin-cli as a client + */ + KcAdmExec exe = KcAdmExec.execute("config credentials --server " + serverUrl + " --realm master --user admin --password admin"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as user admin of realm master", exe.stderrLines().get(0)); + } + + @Test + public void testUserLoginWithDefaultConfigInteractive() throws IOException { + /* + * Test user login with interaction - provide user password after prompted for it + */ + + if (!runIntermittentlyFailingTests()) { + System.out.println("TEST SKIPPED - This test currently suffers from intermittent failures. Use -Dtest.intermittent=true to run it."); + return; + } + + KcAdmExec exe = KcAdmExec.newBuilder() + .argsLine("config credentials --server " + serverUrl + " --realm master --user admin") + .executeAsync(); + + exe.waitForStdout("Enter password: "); + exe.sendToStdin("admin" + EOL); + exe.waitCompletion(); + + assertExitCodeAndStreamSizes(exe, 0, 1, 1); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as user admin of realm master", exe.stderrLines().get(0)); + + + /* + * Run the test one more time with stdin redirect + */ + File tmpFile = new File(KcAdmExec.WORK_DIR + "/" + UUID.randomUUID().toString() + ".tmp"); + try { + FileOutputStream tmpos = new FileOutputStream(tmpFile); + tmpos.write("admin".getBytes()); + tmpos.write(EOL.getBytes()); + tmpos.close(); + + exe = KcAdmExec.execute("config credentials --server " + serverUrl + " --realm master --user admin < '" + tmpFile.getName() + "'"); + + assertExitCodeAndStreamSizes(exe, 0, 1, 1); + Assert.assertTrue("Enter password prompt", exe.stdoutLines().get(0).startsWith("Enter password: ")); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as user admin of realm master", exe.stderrLines().get(0)); + + } finally { + tmpFile.delete(); + } + } + + @Test + public void testClientLoginWithDefaultConfigInteractive() throws IOException { + /* + * Test client login with interaction - login using service account, and provide a client secret after prompted for it + */ + + if (!runIntermittentlyFailingTests()) { + System.out.println("TEST SKIPPED - This test currently suffers from intermittent failures. Use -Dtest.intermittent=true to run it."); + return; + } + + // use -Dtest.intermittent=true to run this test + KcAdmExec exe = KcAdmExec.newBuilder() + .argsLine("config credentials --server " + serverUrl + " --realm test --client admin-cli-secret") + .executeAsync(); + + exe.waitForStdout("Enter client secret: "); + exe.sendToStdin("password" + EOL); + exe.waitCompletion(); + + assertExitCodeAndStreamSizes(exe, 0, 1, 1); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as service-account-admin-cli-secret of realm test", exe.stderrLines().get(0)); + + /* + * Run the test one more time with stdin redirect + */ + File tmpFile = new File(KcAdmExec.WORK_DIR + "/" + UUID.randomUUID().toString() + ".tmp"); + try { + FileOutputStream tmpos = new FileOutputStream(tmpFile); + tmpos.write("password".getBytes()); + tmpos.write(EOL.getBytes()); + tmpos.close(); + + exe = KcAdmExec.newBuilder() + .argsLine("config credentials --server " + serverUrl + " --realm test --client admin-cli-secret < '" + tmpFile.getName() + "'") + .execute(); + + assertExitCodeAndStreamSizes(exe, 0, 1, 1); + Assert.assertTrue("Enter client secret prompt", exe.stdoutLines().get(0).startsWith("Enter client secret: ")); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as service-account-admin-cli-secret of realm test", exe.stderrLines().get(0)); + } finally { + tmpFile.delete(); + } + } + + @Test + public void testUserLoginWithCustomConfig() { + /* + * Test user login using a custom config file + */ + FileConfigHandler handler = initCustomConfigFile(); + + File configFile = new File(handler.getConfigFile()); + try { + KcAdmExec exe = KcAdmExec.execute("config credentials --server " + serverUrl + " --realm master" + + " --user admin --password admin --config '" + configFile.getName() + "'"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as user admin of realm master", exe.stderrLines().get(0)); + + // make sure the config file exists, and has the right content + ConfigData config = handler.loadConfig(); + Assert.assertEquals("serverUrl", serverUrl, config.getServerUrl()); + Assert.assertEquals("realm", "master", config.getRealm()); + RealmConfigData realmcfg = config.sessionRealmConfigData(); + Assert.assertNotNull("realm config data no null", realmcfg); + Assert.assertEquals("realm cfg serverUrl", serverUrl, realmcfg.serverUrl()); + Assert.assertEquals("realm cfg realm", "master", realmcfg.realm()); + Assert.assertEquals("client id", "admin-cli", realmcfg.getClientId()); + Assert.assertNotNull("token not null", realmcfg.getToken()); + Assert.assertNotNull("refresh token not null", realmcfg.getRefreshToken()); + Assert.assertNotNull("token expires not null", realmcfg.getExpiresAt()); + Assert.assertNotNull("token expires in future", realmcfg.getExpiresAt() > System.currentTimeMillis()); + Assert.assertNotNull("refresh token expires not null", realmcfg.getRefreshExpiresAt()); + Assert.assertNotNull("refresh token expires in future", realmcfg.getRefreshExpiresAt() > System.currentTimeMillis()); + + } finally { + configFile.delete(); + } + } + + @Test + public void testCustomConfigLoginCreateDelete() throws IOException { + /* + * Test user login, create, delete session using a custom config file + */ + + // prepare for loading a config file + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + KcAdmExec exe = KcAdmExec.execute("config credentials --server " + serverUrl + + " --realm master --user admin --password admin --config '" + configFile.getName() + "'"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + + // remember the state of config file + ConfigData config1 = handler.loadConfig(); + + + + + exe = KcAdmExec.execute("create --config '" + configFile.getName() + "' clients -s clientId=test-client -o"); + + assertExitCodeAndStdErrSize(exe, 0, 0); + + // check changes to config file + ConfigData config2 = handler.loadConfig(); + assertFieldsEqualWithExclusions(config1, config2); + + + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("clientId", "test-client", client.getClientId()); + + + + exe = KcAdmExec.execute("delete clients/" + client.getId() + " --config '" + configFile.getName() + "'"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + // check changes to config file + ConfigData config3 = handler.loadConfig(); + assertFieldsEqualWithExclusions(config2, config3); + } + } + + @Test + public void testCRUDWithOnTheFlyUserAuth() throws IOException { + /* + * Test create, get, update, and delete using on-the-fly authentication - without using any config file. + * Login is performed by each operation again, and again using username, and password. + */ + testCRUDWithOnTheFlyAuth(serverUrl, "--user user1 --password userpass", "", + "Logging into " + serverUrl + " as user user1 of realm test"); + } + + @Test + public void testCRUDWithOnTheFlyUserAuthWithClientSecret() throws IOException { + /* + * Test create, get, update, and delete using on-the-fly authentication - without using any config file. + * Login is performed by each operation again, and again using username, password, and client secret. + */ + // try client without direct grants enabled + KcAdmExec exe = KcAdmExec.execute("get clients --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password userpass --client admin-cli-secret --secret password"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Client not allowed for direct access grants [invalid_grant]", exe.stderrLines().get(1)); + + + // try wrong user password + exe = KcAdmExec.execute("get clients --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password wrong --client admin-cli-secret-direct --secret password"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Invalid user credentials [invalid_grant]", exe.stderrLines().get(1)); + + + // try wrong client secret + exe = KcAdmExec.execute("get clients --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password userpass --client admin-cli-secret-direct --secret wrong"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Invalid client secret [unauthorized_client]", exe.stderrLines().get(1)); + + + // try whole CRUD + testCRUDWithOnTheFlyAuth(serverUrl, "--user user1 --password userpass --client admin-cli-secret-direct --secret password", "", + "Logging into " + serverUrl + " as user user1 of realm test"); + } + + @Test + public void testCRUDWithOnTheFlyUserAuthWithSignedJwtClient() throws IOException { + /* + * Test create, get, update, and delete using on-the-fly authentication - without using any config file. + * Login is performed by each operation again, and again using username, password, and client JWT signature. + */ + File keystore = new File(System.getProperty("user.dir") + "/src/test/resources/cli/kcadm/admin-cli-keystore.jks"); + Assert.assertTrue("admin-cli-keystore.jks exists", keystore.isFile()); + + // try client without direct grants enabled + KcAdmExec exe = KcAdmExec.execute("get clients --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password userpass --client admin-cli-jwt --keystore '" + keystore.getAbsolutePath() + "'" + + " --storepass storepass --keypass keypass --alias admin-cli"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Client not allowed for direct access grants [invalid_grant]", exe.stderrLines().get(1)); + + + // try wrong user password + exe = KcAdmExec.execute("get clients --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password wrong --client admin-cli-jwt-direct --keystore '" + keystore.getAbsolutePath() + "'" + + " --storepass storepass --keypass keypass --alias admin-cli"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Invalid user credentials [invalid_grant]", exe.stderrLines().get(1)); + + + // try wrong storepass + exe = KcAdmExec.execute("get clients --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password userpass --client admin-cli-jwt-direct --keystore '" + keystore.getAbsolutePath() + "'" + + " --storepass wrong --keypass keypass --alias admin-cli"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Failed to load private key: Keystore was tampered with, or password was incorrect", exe.stderrLines().get(1)); + + + // try whole CRUD + testCRUDWithOnTheFlyAuth(serverUrl, + "--user user1 --password userpass --client admin-cli-jwt-direct --keystore '" + keystore.getAbsolutePath() + "'" + + " --storepass storepass --keypass keypass --alias admin-cli", "", + "Logging into " + serverUrl + " as user user1 of realm test"); + + } + + @Test + public void testCRUDWithOnTheFlyServiceAccountWithClientSecret() throws IOException { + /* + * Test create, get, update, and delete using on-the-fly authentication - without using any config file. + * Login is performed by each operation again, and again using only client secret - service account is used. + */ + testCRUDWithOnTheFlyAuth(serverUrl, "--client admin-cli-secret --secret password", "", + "Logging into " + serverUrl + " as service-account-admin-cli-secret of realm test"); + } + + @Test + public void testCRUDWithOnTheFlyServiceAccountWithSignedJwtClient() throws IOException { + /* + * Test create, get, update, and delete using on-the-fly authentication - without using any config file. + * Login is performed by each operation again, and again using only client JWT signature - service account is used. + */ + File keystore = new File(System.getProperty("user.dir") + "/src/test/resources/cli/kcadm/admin-cli-keystore.jks"); + Assert.assertTrue("admin-cli-keystore.jks exists", keystore.isFile()); + + testCRUDWithOnTheFlyAuth(serverUrl, + "--client admin-cli-jwt --keystore '" + keystore.getAbsolutePath() + "' --storepass storepass --keypass keypass --alias admin-cli", "", + "Logging into " + serverUrl + " as service-account-admin-cli-jwt of realm test"); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmTruststoreTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmTruststoreTest.java new file mode 100644 index 00000000000..1346442b794 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmTruststoreTest.java @@ -0,0 +1,115 @@ +package org.keycloak.testsuite.cli.admin; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.client.admin.cli.config.ConfigData; +import org.keycloak.client.admin.cli.config.FileConfigHandler; +import org.keycloak.testsuite.cli.KcAdmExec; +import org.keycloak.testsuite.util.TempFileResource; + +import java.io.File; +import java.io.IOException; + +import static org.keycloak.client.admin.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_PATH; +import static org.keycloak.client.admin.cli.util.OsUtil.EOL; +import static org.keycloak.testsuite.cli.KcAdmExec.CMD; +import static org.keycloak.testsuite.cli.KcAdmExec.execute; + +/** + * @author Marko Strukelj + */ +public class KcAdmTruststoreTest extends AbstractAdmCliTest { + + @Test + public void testTruststore() throws IOException { + + // only run this test if ssl protected keycloak server is available + if (!isAuthServerSSL()) { + System.out.println("TEST SKIPPED - This test requires HTTPS. Run with '-Pauth-server-wildfly -Dauth.server.ssl.required=true'"); + return; + } + + File truststore = new File("src/test/resources/keystore/keycloak.truststore"); + + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + if (runIntermittentlyFailingTests()) { + // configure truststore + KcAdmExec exe = execute("config truststore --config '" + configFile.getName() + "' '" + truststore.getAbsolutePath() + "'"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + + // perform authentication against server - asks for password, then for truststore password + exe = KcAdmExec.newBuilder() + .argsLine("config credentials --server " + serverUrl + " --realm test --user user1" + + " --config '" + configFile.getName() + "'") + .executeAsync(); + + exe.waitForStdout("Enter password: "); + exe.sendToStdin("userpass" + EOL); + exe.waitForStdout("Enter truststore password: "); + exe.sendToStdin("secret" + EOL); + exe.waitCompletion(); + + assertExitCodeAndStreamSizes(exe, 0, 2, 1); + + + // configure truststore with password + exe = execute("config truststore --config '" + configFile.getName() + "' --trustpass secret '" + truststore.getAbsolutePath() + "'"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + // perform authentication against server - asks for password, then for truststore password + exe = KcAdmExec.newBuilder() + .argsLine("config credentials --server " + serverUrl + " --realm test --user user1" + + " --config '" + configFile.getName() + "'") + .executeAsync(); + + exe.waitForStdout("Enter password: "); + exe.sendToStdin("userpass" + EOL); + exe.waitCompletion(); + + assertExitCodeAndStreamSizes(exe, 0, 1, 1); + + } else { + System.out.println("TEST SKIPPED PARTIALLY - This test currently suffers from intermittent failures. Use -Dtest.intermittent=true to run it in full."); + } + } + + // configure truststore with password + KcAdmExec exe = execute("config truststore --trustpass secret '" + truststore.getAbsolutePath() + "'"); + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + // perform authentication against server - asks for password, then for truststore password + exe = execute("config credentials --server " + serverUrl + " --realm test --user user1 --password userpass"); + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + + exe = execute("config truststore --delete"); + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + exe = execute("config truststore --delete '" + truststore.getAbsolutePath() + "'"); + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("incompatible", "Option --delete is mutually exclusive with specifying a TRUSTSTORE", exe.stderrLines().get(0)); + Assert.assertEquals("try help", "Try '" + CMD + " help config truststore' for more information", exe.stderrLines().get(1)); + + exe = execute("config truststore --delete --trustpass secret"); + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("no truststore error", "Options --trustpass and --delete are mutually exclusive", exe.stderrLines().get(0)); + Assert.assertEquals("try help", "Try '" + CMD + " help config truststore' for more information", exe.stderrLines().get(1)); + + FileConfigHandler cfghandler = new FileConfigHandler(); + cfghandler.setConfigFile(DEFAULT_CONFIG_FILE_PATH); + ConfigData config = cfghandler.loadConfig(); + Assert.assertNull("truststore null", config.getTruststore()); + Assert.assertNull("trustpass null", config.getTrustpass()); + + + // perform no-config CRUD test against ssl protected endpoint + testCRUDWithOnTheFlyAuth(serverUrl, + "--user user1 --password userpass", " --truststore '" + truststore.getAbsolutePath() + "' --trustpass secret", + "Logging into " + serverUrl + " as user user1 of realm test"); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmUpdateTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmUpdateTest.java new file mode 100644 index 00000000000..6e75f599ee9 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/admin/KcAdmUpdateTest.java @@ -0,0 +1,130 @@ +package org.keycloak.testsuite.cli.admin; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.client.admin.cli.config.FileConfigHandler; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.testsuite.cli.KcAdmExec; +import org.keycloak.testsuite.util.TempFileResource; +import org.keycloak.util.JsonSerialization; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Arrays; + +import static org.keycloak.testsuite.cli.KcAdmExec.CMD; +import static org.keycloak.testsuite.cli.KcAdmExec.execute; + +/** + * @author Marko Strukelj + */ +public class KcAdmUpdateTest extends AbstractAdmCliTest { + + @Test + public void testUpdateThoroughly() throws IOException { + + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + final String realm = "test"; + + loginAsUser(configFile.getFile(), serverUrl, realm, "user1", "userpass"); + + + // create an object so we can update it + KcAdmExec exe = execute("create clients --config '" + configFile.getName() + "' -o -s clientId=my_client"); + + assertExitCodeAndStdErrSize(exe, 0, 0); + + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + + Assert.assertEquals("enabled", true, client.isEnabled()); + Assert.assertEquals("publicClient", false, client.isPublicClient()); + Assert.assertEquals("bearerOnly", false, client.isBearerOnly()); + Assert.assertTrue("redirectUris is empty", client.getRedirectUris().isEmpty()); + + + // Merge update + exe = execute("update clients/" + client.getId() + " --config '" + configFile.getName() + "' -o " + + " -s enabled=false -s 'redirectUris=[\"http://localhost:8980/myapp/*\"]'"); + + assertExitCodeAndStdErrSize(exe, 0, 0); + + client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("enabled", false, client.isEnabled()); + Assert.assertEquals("redirectUris", Arrays.asList("http://localhost:8980/myapp/*"), client.getRedirectUris()); + + + + // Another merge update - test deleting an attribute, deleting a list item and adding a list item + exe = execute("update clients/" + client.getId() + " --config '" + configFile.getName() + "' -o -d redirectUris[0] -s webOrigins+=http://localhost:8980/myapp -s webOrigins+=http://localhost:8981/myapp -d webOrigins[0]"); + + assertExitCodeAndStdErrSize(exe, 0, 0); + + client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + + Assert.assertTrue("redirectUris is empty", client.getRedirectUris().isEmpty()); + Assert.assertEquals("webOrigins", Arrays.asList("http://localhost:8981/myapp"), client.getWebOrigins()); + + + + // Another merge update - test nested attributes and setting an attribute using json format + // TODO KEYCLOAK-3705 Updating protocolMapper config via client registration endpoint has no effect + /* + exe = execute("update my_client --config '" + configFile.getName() + "' -o -s 'protocolMappers[0].config.\"id.token.claim\"=false' " + + "-s 'protocolMappers[4].config={\"single\": \"true\", \"attribute.nameformat\": \"Basic\", \"attribute.name\": \"Role\"}'"); + + assertExitCodeAndStdErrSize(exe, 0, 0); + + client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("protocolMapper[0].config.\"id.token.claim\"", "false", client.getProtocolMappers().get(0).getConfig().get("id.token.claim")); + Assert.assertEquals("protocolMappers[4].config.single", "true", client.getProtocolMappers().get(4).getConfig().get("single")); + Assert.assertEquals("protocolMappers[4].config.\"attribute.nameformat\"", "Basic", client.getProtocolMappers().get(4).getConfig().get("attribute.nameformat")); + Assert.assertEquals("protocolMappers[4].config.\"attribute.name\"", "Role", client.getProtocolMappers().get(4).getConfig().get("attribute.name")); + */ + + // update using oidc format + + + // check that using an invalid attribute key is not ignored + exe = execute("update clients/" + client.getId() + " --nonexisting --config '" + configFile.getName() + "'"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("error message", "Invalid option: --nonexisting", exe.stderrLines().get(0)); + Assert.assertEquals("try help", "Try '" + CMD + " help update' for more information", exe.stderrLines().get(1)); + + + // test overwrite from file + exe = KcAdmExec.newBuilder() + .argsLine("update clients/" + client.getId() + " --config '" + configFile.getName() + + "' -o -s clientId=my_client -s 'redirectUris=[\"http://localhost:8980/myapp/*\"]' -f -") + .stdin(new ByteArrayInputStream("{ \"enabled\": false }".getBytes())) + .execute(); + + assertExitCodeAndStdErrSize(exe, 0, 0); + + client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + // web origin is not sent to the server, thus it retains the current value + Assert.assertEquals("webOrigins", Arrays.asList("http://localhost:8981/myapp"), client.getWebOrigins()); + Assert.assertFalse("enabled is false", client.isEnabled()); + Assert.assertEquals("redirectUris", Arrays.asList("http://localhost:8980/myapp/*"), client.getRedirectUris()); + + + // test using merge with file + exe = KcAdmExec.newBuilder() + .argsLine("update clients/" + client.getId() + " --config '" + configFile.getName() + + "' -o -s enabled=true -m -f -") + .stdin(new ByteArrayInputStream("{ \"webOrigins\": [\"http://localhost:8980/myapp\"] }".getBytes())) + .execute(); + + assertExitCodeAndStdErrSize(exe, 0, 0); + + + client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("webOrigins", Arrays.asList("http://localhost:8980/myapp"), client.getWebOrigins()); + Assert.assertTrue("enabled is true", client.isEnabled()); + Assert.assertEquals("redirectUris", Arrays.asList("http://localhost:8980/myapp/*"), client.getRedirectUris()); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/AbstractCliTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/AbstractRegCliTest.java similarity index 93% rename from testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/AbstractCliTest.java rename to testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/AbstractRegCliTest.java index 1098053657a..40d755a090f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/AbstractCliTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/AbstractRegCliTest.java @@ -19,7 +19,7 @@ import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy; import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicyManager; import org.keycloak.services.clientregistration.policy.RegistrationAuth; import org.keycloak.services.clientregistration.policy.impl.TrustedHostClientRegistrationPolicyFactory; -import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.cli.AbstractCliTest; import org.keycloak.testsuite.cli.KcRegExec; import org.keycloak.testsuite.util.ClientBuilder; import org.keycloak.testsuite.util.UserBuilder; @@ -45,7 +45,7 @@ import static org.keycloak.testsuite.cli.KcRegExec.execute; /** * @author Marko Strukelj */ -public abstract class AbstractCliTest extends AbstractKeycloakTest { +public abstract class AbstractRegCliTest extends AbstractCliTest { protected String serverUrl = isAuthServerSSL() ? "https://localhost:" + getAuthServerHttpsPort() + "/auth" : @@ -527,43 +527,4 @@ public abstract class AbstractCliTest extends AbstractKeycloakTest { lastModified2 = configFile.exists() ? configFile.lastModified() : 0; Assert.assertEquals("config file not modified", lastModified, lastModified2); } - - void assertExitCodeAndStdOutSize(KcRegExec exe, int exitCode, int stdOutLineCount) { - assertExitCodeAndStreamSizes(exe, exitCode, stdOutLineCount, -1); - } - - void assertExitCodeAndStdErrSize(KcRegExec exe, int exitCode, int stdErrLineCount) { - assertExitCodeAndStreamSizes(exe, exitCode, -1, stdErrLineCount); - } - - void assertExitCodeAndStreamSizes(KcRegExec exe, int exitCode, int stdOutLineCount, int stdErrLineCount) { - Assert.assertEquals("exitCode == " + exitCode, exitCode, exe.exitCode()); - if (stdOutLineCount != -1) { - try { - assertLineCount("stdout output", exe.stdoutLines(), stdOutLineCount); - } catch (Throwable e) { - throw new AssertionError("STDOUT: " + exe.stdoutString(), e); - } - } - if (stdErrLineCount != -1) { - try { - assertLineCount("stderr output", exe.stderrLines(), stdErrLineCount); - } catch (Throwable e) { - throw new AssertionError("STDERR: " + exe.stderrString(), e); - } - } - } - - void assertLineCount(String label, List lines, int count) { - if (lines.size() == count) { - return; - } - // there is some kind of race condition in 'kcreg' that results in intermittent extra empty line - if (lines.size() == count + 1) { - if ("".equals(lines.get(lines.size()-1))) { - return; - } - } - Assert.assertTrue(label + " has " + lines.size() + " lines (expected: " + count + ")", lines.size() == count); - } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegConfigTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegConfigTest.java index b91296ceadb..1bf7b3896e9 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegConfigTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegConfigTest.java @@ -15,7 +15,7 @@ import static org.keycloak.testsuite.cli.KcRegExec.execute; /** * @author Marko Strukelj */ -public class KcRegConfigTest extends AbstractCliTest { +public class KcRegConfigTest extends AbstractRegCliTest { @Test public void testRegistrationToken() throws IOException { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegCreateTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegCreateTest.java index 5db5d86cd0e..d916a9b149e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegCreateTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegCreateTest.java @@ -19,7 +19,7 @@ import static org.keycloak.testsuite.cli.KcRegExec.execute; /** * @author Marko Strukelj */ -public class KcRegCreateTest extends AbstractCliTest { +public class KcRegCreateTest extends AbstractRegCliTest { @Test public void testCreateWithRealmOverride() throws IOException { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTest.java index 5647ec881f4..872ed320d0f 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTest.java @@ -23,7 +23,7 @@ import static org.keycloak.testsuite.cli.KcRegExec.execute; /** * @author Marko Strukelj */ -public class KcRegTest extends AbstractCliTest { +public class KcRegTest extends AbstractRegCliTest { @Test public void testNoArgs() { @@ -68,7 +68,7 @@ public class KcRegTest extends AbstractCliTest { exe = execute("config truststore"); assertExitCodeAndStdErrSize(exe, 1, 0); Assert.assertTrue("help message returned", exe.stdoutLines().size() > 10); - Assert.assertEquals("help message", "Usage: " + CMD + " config truststore [TRUSTSTORE | --delete] [--trustpass PASSWOD] [ARGUMENTS]", exe.stdoutLines().get(0)); + Assert.assertEquals("help message", "Usage: " + CMD + " config truststore [TRUSTSTORE | --delete] [--trustpass PASSWORD] [ARGUMENTS]", exe.stdoutLines().get(0)); exe = execute("create"); assertExitCodeAndStdErrSize(exe, 1, 0); @@ -172,7 +172,7 @@ public class KcRegTest extends AbstractCliTest { exe = execute("config truststore --help"); assertExitCodeAndStdErrSize(exe, 0, 0); Assert.assertEquals("stdout first line", - "Usage: " + CMD + " config truststore [TRUSTSTORE | --delete] [--trustpass PASSWOD] [ARGUMENTS]", + "Usage: " + CMD + " config truststore [TRUSTSTORE | --delete] [--trustpass PASSWORD] [ARGUMENTS]", exe.stdoutLines().get(0)); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTruststoreTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTruststoreTest.java index e3f77292647..34c9584fa28 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTruststoreTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTruststoreTest.java @@ -18,7 +18,7 @@ import static org.keycloak.testsuite.cli.KcRegExec.execute; /** * @author Marko Strukelj */ -public class KcRegTruststoreTest extends AbstractCliTest { +public class KcRegTruststoreTest extends AbstractRegCliTest { @Test public void testTruststore() throws IOException { diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTest.java index 79e670793ec..419d75469a0 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTest.java @@ -18,7 +18,7 @@ import static org.keycloak.testsuite.cli.KcRegExec.execute; /** * @author Marko Strukelj */ -public class KcRegUpdateTest extends AbstractCliTest { +public class KcRegUpdateTest extends AbstractRegCliTest { @Test diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTokenTest.java index b2aebf77c40..a4b6c2cbd48 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTokenTest.java @@ -18,7 +18,7 @@ import static org.keycloak.testsuite.cli.KcRegExec.execute; /** * @author Marko Strukelj */ -public class KcRegUpdateTokenTest extends AbstractCliTest { +public class KcRegUpdateTokenTest extends AbstractRegCliTest { @Test public void testUpdateToken() throws IOException { diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/cli/kcadm/admin-cli-keystore.jks b/testsuite/integration-arquillian/tests/base/src/test/resources/cli/kcadm/admin-cli-keystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..5c789f378f0694225189ec4a93915c0ae3d1d671 GIT binary patch literal 2029 zcmVKiJY-s=h0a+5CR1g3F1p+XF1pY7% z1_~<%0R#am0uccL1pows1nNrE8JLws^l=ES@y7oc=NSeap%cv(D_R}0@{Jv z1r|Ig5n!2UZGdm3yKulzzg8Q7%RCEWjMJ~$@#~e5oD;Gz*>!_An=T-aZyD4K-p2^R z><>#uU04u}Ex3YM9@utFtj4DId(x%LeAQdPX=3*Xq6KrX`_c*IhlL5&ZGlWK(EjefY;AYhrG@V!{}!ea_11BvM5`Bd_3cZb_cRwkqN z>h5k3`fz$VMz5#oEqJR>|A=cjxtCQqn$m;Y$0~8Rb7A+L4@?3>cw*xP{@V)eSLI&3 zuX?bW;Q7xNa$R&k09L8DtRuw3tz-PJQijcdp=M5PS1{+4RAO@Qf&CVWhH7s%e-Gi28?5Z&sdwa!IY4NRZpZr=vH)S5H3iIU>L7~4asOFVM_GB+SlK^0@oQ-9iH+!J z>^gulB<~0R*Nd74(5dg|Ns1nBb<`_aRZRATZhU|STyVAvm)y8(-P(7a5eSY-*yao` zZ;lS{b}a7VD+$L2`-ojHsXT2!`{7>BiVNQ6%^z99LqYhkzwDTb*2TWyj-}8DT9i@V zyDixY%O0qiKsr7A(}bF=?IDlI1x70m1em(YM{>O^>Qi*>KEiI(Z80h>0yo-tyrnqw z?Ki$Ql>rO!%DDxRILJ;h6C3hWiV3bnBFysTia<9wZ*QbpWGxy2#GB>SuO`PG*O5AX z){xrEssy}!cB+E$PM7Qg;Efo0hf6@NJ-5MpxrRH_(d~ulcVVIjrd)A%0biqw`L{G7 z^NuC*^@M+=1DHH_2sBm5U*!IH?r0uM*y$N#CG%ACd9-7@LN{7fA{Q2wMCYRv{gxqv zkSES7p3P~>x02<)I*K7_Edwj35v@$i!Smf)sPNc7N%5uD zvKG|%)BvNFv?4`byapHuo|brb^*5QVE^Tc>Ne;{YO}8y3@YHXF81gP>L)~dz1`jKn z6j+bCl85O!hz3p23~hi;ho3o0OHk&>LQ>6h$cL&=t~{7Y(Ig4!%ooaL3O8rYDSp6L zdemQ0z^gUA#{@i3D4S4p76jUJ8%{P8JZSE!tcfX4jlLhmpCr?ed91g7rZq&<kK5x{iw zE&L zNQU=h?xp1#Ax1ZjMy6JS`<~ zG>7<$7sioNB!rG)5M~Vz>x1#Bp67;73RKm^%G%tST?$gj8{+=TY1>b0$|4agR`8KJ z&rh9xFUw7B17}=4j{(Dyt?2E9>zvz zLco#TvQ`8IRET$<+?BIU^_Dl5-v|-9GJ3Vg4%j#5DOXw1+##lLqHZnjnoaJ{v&dL_ zHX=X{%%`?cvVtbDQB~w{&Xv6a0|5X5FbxI?Duzgg_YDC73k3iJf&l>lW?vV)x5`~J z*uJmO$aA3VK;;41Ror#(8qD7vwq59L$e`Y=y7IlIWSA7n_nOn8wUr({>@3?_-OV*gQ8af8v0VbG!r+sAzSnejxvWE~Io9=wqsFBBs!f LA75j}k*;AfN&>M6 literal 0 HcmV?d00001 From ad11d6e76b60c52e2d509b664258608b485ef081 Mon Sep 17 00:00:00 2001 From: Marko Strukelj Date: Mon, 19 Dec 2016 11:39:18 +0100 Subject: [PATCH 2/2] KEYCLOAK-912 Admin CLI - Fix relative URI resolution not working for https --- .../main/java/org/keycloak/client/admin/cli/util/HttpUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpUtil.java b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpUtil.java index 6de14ce0d1c..f1261ab6d53 100644 --- a/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpUtil.java +++ b/integration/client-cli/admin-cli/src/main/java/org/keycloak/client/admin/cli/util/HttpUtil.java @@ -308,7 +308,7 @@ public class HttpUtil { } public static String composeResourceUrl(String adminRoot, String realm, String uri) { - if (!uri.startsWith("http:")) { + if (!uri.startsWith("http:") && !uri.startsWith("https:")) { if ("realms".equals(uri) || uri.startsWith("realms/")) { uri = normalize(adminRoot) + uri; } else if ("serverinfo".equals(uri)) {