From c912f941e746465818a1ccb13f79cfac2289d519 Mon Sep 17 00:00:00 2001 From: Marko Strukelj Date: Tue, 12 Jul 2016 15:30:33 +0200 Subject: [PATCH] KEYCLOAK-2084 Client Registration CLI --- distribution/server-dist/assembly.xml | 8 + distribution/server-dist/pom.xml | 23 + .../org/keycloak/admin/client/Keycloak.java | 11 + .../client-cli/client-cli-dist/assembly.xml | 49 ++ .../client-cli/client-cli-dist/pom.xml | 69 +++ .../client-registration-cli/pom.xml | 143 +++++ .../src/main/bin/kcreg.bat | 8 + .../src/main/bin/kcreg.sh | 3 + .../client/registration/cli/KcRegMain.java | 75 +++ .../cli/aesh/AeshConsoleCallbackImpl.java | 97 ++++ .../registration/cli/aesh/AeshEnhancer.java | 25 + .../cli/aesh/EndpointTypeConverter.java | 17 + .../client/registration/cli/aesh/Globals.java | 15 + .../cli/aesh/ValveInputStream.java | 71 +++ .../cli/commands/AbstractAuthOptionsCmd.java | 235 ++++++++ .../commands/AbstractGlobalOptionsCmd.java | 22 + .../registration/cli/commands/AttrsCmd.java | 152 +++++ .../registration/cli/commands/ConfigCmd.java | 84 +++ .../cli/commands/ConfigCredentialsCmd.java | 246 ++++++++ .../cli/commands/ConfigInitialTokenCmd.java | 169 ++++++ .../commands/ConfigRegistrationTokenCmd.java | 158 +++++ .../cli/commands/ConfigTruststoreCmd.java | 168 ++++++ .../registration/cli/commands/CreateCmd.java | 295 ++++++++++ .../registration/cli/commands/DeleteCmd.java | 145 +++++ .../registration/cli/commands/GetCmd.java | 215 +++++++ .../registration/cli/commands/HelpCmd.java | 90 +++ .../registration/cli/commands/KcRegCmd.java | 105 ++++ .../registration/cli/commands/UpdateCmd.java | 410 +++++++++++++ .../cli/commands/UpdateTokenCmd.java | 164 ++++++ .../registration/cli/common/AttributeKey.java | 154 +++++ .../cli/common/AttributeOperation.java | 42 ++ .../cli/common/CmdStdinContext.java | 75 +++ .../registration/cli/common/EndpointType.java | 63 ++ .../cli/common/ParsingContext.java | 113 ++++ .../registration/cli/config/ConfigData.java | 177 ++++++ .../cli/config/ConfigHandler.java | 12 + .../cli/config/ConfigUpdateOperation.java | 10 + .../cli/config/FileConfigHandler.java | 119 ++++ .../cli/config/InMemoryConfigHandler.java | 24 + .../cli/config/RealmConfigData.java | 220 +++++++ .../cli/util/AttributeException.java | 23 + .../registration/cli/util/AuthUtil.java | 206 +++++++ .../registration/cli/util/ConfigUtil.java | 114 ++++ .../cli/util/DebugBufferedInputStream.java | 78 +++ .../registration/cli/util/HttpUtil.java | 188 ++++++ .../client/registration/cli/util/IoUtil.java | 235 ++++++++ .../client/registration/cli/util/OsArch.java | 60 ++ .../client/registration/cli/util/OsUtil.java | 48 ++ .../registration/cli/util/ParseUtil.java | 167 ++++++ .../registration/cli/util/ReflectionUtil.java | 498 ++++++++++++++++ .../cli/util/ReflectionUtilTest.java | 355 ++++++++++++ .../pom.xml | 36 +- .../cli/ClientRegistrationCLI.java | 72 --- .../client/registration/cli/Context.java | 37 -- .../cli/commands/CreateCommand.java | 64 --- .../cli/commands/ExitCommand.java | 29 - .../cli/commands/SetupCommand.java | 44 -- integration/pom.xml | 1 + pom.xml | 11 + .../integration-arquillian/tests/base/pom.xml | 27 +- .../testsuite/cli/ExecutionException.java | 27 + .../org/keycloak/testsuite/cli/KcRegExec.java | 487 ++++++++++++++++ .../org/keycloak/testsuite/cli/OsArch.java | 42 ++ .../org/keycloak/testsuite/cli/OsUtils.java | 45 ++ .../testsuite/AbstractKeycloakTest.java | 50 +- .../cli/registration/AbstractCliTest.java | 539 ++++++++++++++++++ .../cli/registration/KcRegConfigTest.java | 69 +++ .../cli/registration/KcRegCreateTest.java | 231 ++++++++ .../testsuite/cli/registration/KcRegTest.java | 512 +++++++++++++++++ .../cli/registration/KcRegTruststoreTest.java | 123 ++++ .../cli/registration/KcRegUpdateTest.java | 170 ++++++ .../registration/KcRegUpdateTokenTest.java | 68 +++ .../testsuite/util/TempFileResource.java | 43 ++ .../resources/cli/kcreg/reg-cli-keystore.jks | Bin 0 -> 2027 bytes .../resources/cli/kcreg/saml-sp-metadata.xml | 20 + .../integration-arquillian/tests/pom.xml | 14 + 76 files changed, 8728 insertions(+), 286 deletions(-) create mode 100755 integration/client-cli/client-cli-dist/assembly.xml create mode 100755 integration/client-cli/client-cli-dist/pom.xml create mode 100755 integration/client-cli/client-registration-cli/pom.xml create mode 100644 integration/client-cli/client-registration-cli/src/main/bin/kcreg.bat create mode 100755 integration/client-cli/client-registration-cli/src/main/bin/kcreg.sh create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/KcRegMain.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/AeshConsoleCallbackImpl.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/AeshEnhancer.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/EndpointTypeConverter.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/Globals.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/ValveInputStream.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractAuthOptionsCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractGlobalOptionsCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AttrsCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCredentialsCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigInitialTokenCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigRegistrationTokenCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigTruststoreCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/CreateCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/DeleteCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/GetCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/HelpCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/KcRegCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/UpdateCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/UpdateTokenCmd.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/AttributeKey.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/AttributeOperation.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/CmdStdinContext.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/EndpointType.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/ParsingContext.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigData.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigHandler.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigUpdateOperation.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/FileConfigHandler.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/InMemoryConfigHandler.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/RealmConfigData.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AttributeException.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AuthUtil.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ConfigUtil.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/DebugBufferedInputStream.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/HttpUtil.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/IoUtil.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/OsArch.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/OsUtil.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ParseUtil.java create mode 100644 integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ReflectionUtil.java create mode 100644 integration/client-cli/client-registration-cli/src/test/java/org/keycloak/client/registration/cli/util/ReflectionUtilTest.java rename integration/{client-registration-cli => client-cli}/pom.xml (58%) mode change 100755 => 100644 delete mode 100644 integration/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/ClientRegistrationCLI.java delete mode 100644 integration/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/Context.java delete mode 100644 integration/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/CreateCommand.java delete mode 100644 integration/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ExitCommand.java delete mode 100644 integration/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/SetupCommand.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/ExecutionException.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcRegExec.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/OsArch.java create mode 100644 testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/OsUtils.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/AbstractCliTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegConfigTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegCreateTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTruststoreTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTokenTest.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/TempFileResource.java create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/cli/kcreg/reg-cli-keystore.jks create mode 100644 testsuite/integration-arquillian/tests/base/src/test/resources/cli/kcreg/saml-sp-metadata.xml diff --git a/distribution/server-dist/assembly.xml b/distribution/server-dist/assembly.xml index 0e58c83344..b7881589b1 100755 --- a/distribution/server-dist/assembly.xml +++ b/distribution/server-dist/assembly.xml @@ -79,5 +79,13 @@ layers.conf + + target/unpacked/keycloak-client-tools + + false + + **/* + + diff --git a/distribution/server-dist/pom.xml b/distribution/server-dist/pom.xml index 1bee96b8be..1b13474659 100755 --- a/distribution/server-dist/pom.xml +++ b/distribution/server-dist/pom.xml @@ -81,6 +81,29 @@ + + org.apache.maven.plugins + maven-dependency-plugin + + + unpack-client-cli-dist + prepare-package + + unpack + + + + + org.keycloak + keycloak-client-cli-dist + zip + ${project.build.directory}/unpacked + + + + + + 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 f86170e521..d267d17431 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 @@ -26,7 +26,9 @@ import org.keycloak.admin.client.resource.RealmsResource; import org.keycloak.admin.client.resource.ServerInfoResource; import org.keycloak.admin.client.token.TokenManager; +import javax.net.ssl.SSLContext; import java.net.URI; +import java.security.KeyStore; import static org.keycloak.OAuth2Constants.PASSWORD; @@ -55,6 +57,15 @@ public class Keycloak { target.register(new BearerAuthFilter(tokenManager)); } + public static Keycloak getInstance(String serverUrl, String realm, String username, String password, String clientId, String clientSecret, SSLContext sslContext) { + ResteasyClient client = new ResteasyClientBuilder() + .sslContext(sslContext) + .hostnameVerification(ResteasyClientBuilder.HostnameVerificationPolicy.WILDCARD) + .connectionPoolSize(10).build(); + + return new Keycloak(serverUrl, realm, username, password, clientId, clientSecret, PASSWORD, client); + } + 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); } diff --git a/integration/client-cli/client-cli-dist/assembly.xml b/integration/client-cli/client-cli-dist/assembly.xml new file mode 100755 index 0000000000..ee27cb2c81 --- /dev/null +++ b/integration/client-cli/client-cli-dist/assembly.xml @@ -0,0 +1,49 @@ + + + + keycloak-client-cli-dist + + + zip + + + false + + + + ../client-registration-cli/src/main/bin/kcreg.sh + keycloak-client-tools/bin + 0755 + true + + + ../client-registration-cli/src/main/bin/kcreg.bat + keycloak-client-tools/bin + true + + + + + + org.keycloak:keycloak-client-registration-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 new file mode 100755 index 0000000000..046a790637 --- /dev/null +++ b/integration/client-cli/client-cli-dist/pom.xml @@ -0,0 +1,69 @@ + + + + 4.0.0 + + keycloak-client-cli-parent + org.keycloak + 2.3.0-SNAPSHOT + + + keycloak-client-cli-dist + pom + Keycloak Client CLI Distribution + + + + + org.keycloak + keycloak-client-registration-cli + + + + + keycloak-client-cli-${project.version} + + + maven-assembly-plugin + + + assemble + package + + single + + + + assembly.xml + + + target + + + target/assembly/work + + false + + + + + + + + diff --git a/integration/client-cli/client-registration-cli/pom.xml b/integration/client-cli/client-registration-cli/pom.xml new file mode 100755 index 0000000000..a140690f97 --- /dev/null +++ b/integration/client-cli/client-registration-cli/pom.xml @@ -0,0 +1,143 @@ + + + + + + keycloak-client-cli-parent + org.keycloak + 2.3.0-SNAPSHOT + + 4.0.0 + + keycloak-client-registration-cli + Keycloak Client Registration CLI + + + + + org.jboss.aesh + aesh + 0.66.10 + + + 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/AccessTokenResponse.class + org/keycloak/representations/idm/ClientRepresentation.class + org/keycloak/representations/idm/ProtocolMapperRepresentation.class + org/keycloak/representations/oidc/OIDCClientRepresentation.class + org/keycloak/representations/idm/authorization/** + 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/** + + + + + *:* + + 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/client-registration-cli/src/main/bin/kcreg.bat b/integration/client-cli/client-registration-cli/src/main/bin/kcreg.bat new file mode 100644 index 0000000000..04364145a7 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/bin/kcreg.bat @@ -0,0 +1,8 @@ +@echo off + +if "%OS%" == "Windows_NT" ( + set "DIRNAME=%~dp0%" +) else ( + set DIRNAME=.\ +) +java %KC_OPTS% -cp %DIRNAME%\client\keycloak-client-registration-cli-${project.version}.jar org.keycloak.client.registration.cli.KcRegMain %* diff --git a/integration/client-cli/client-registration-cli/src/main/bin/kcreg.sh b/integration/client-cli/client-registration-cli/src/main/bin/kcreg.sh new file mode 100755 index 0000000000..2684c77663 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/bin/kcreg.sh @@ -0,0 +1,3 @@ +#!/bin/sh +DIRNAME=`dirname "$0"` +java $KC_OPTS -cp $DIRNAME/client/keycloak-client-registration-cli-${project.version}.jar org.keycloak.client.registration.cli.KcRegMain "$@" diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/KcRegMain.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/KcRegMain.java new file mode 100644 index 0000000000..57fe0f6550 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/KcRegMain.java @@ -0,0 +1,75 @@ +package org.keycloak.client.registration.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.registration.cli.aesh.AeshEnhancer; +import org.keycloak.client.registration.cli.aesh.ValveInputStream; +import org.keycloak.client.registration.cli.aesh.Globals; +import org.keycloak.client.registration.cli.commands.KcRegCmd; + +import java.util.ArrayList; +import java.util.Arrays; + +/** + * @author Marko Strukelj + */ +public class KcRegMain { + + 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(KcRegCmd.class) + .create(); + + AeshConsoleImpl console = (AeshConsoleImpl) new AeshConsoleBuilder() + .settings(settings) + .commandRegistry(registry) + .prompt(new Prompt("")) + .create(); + + AeshEnhancer.enhance(console); + + // work around parser issues with quotes and brackets + ArrayList arguments = new ArrayList<>(); + arguments.add("kcreg"); + 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("kcreg" + b.toString()); + + console.start(); + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/AeshConsoleCallbackImpl.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/AeshConsoleCallbackImpl.java new file mode 100644 index 0000000000..8235f69dba --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/AeshConsoleCallbackImpl.java @@ -0,0 +1,97 @@ +package org.keycloak.client.registration.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); + + } 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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/AeshEnhancer.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/AeshEnhancer.java new file mode 100644 index 0000000000..d68e392395 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/AeshEnhancer.java @@ -0,0 +1,25 @@ +package org.keycloak.client.registration.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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/EndpointTypeConverter.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/EndpointTypeConverter.java new file mode 100644 index 0000000000..eb1c16a931 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/EndpointTypeConverter.java @@ -0,0 +1,17 @@ +package org.keycloak.client.registration.cli.aesh; + +import org.jboss.aesh.cl.converter.Converter; +import org.jboss.aesh.cl.validator.OptionValidatorException; +import org.jboss.aesh.console.command.converter.ConverterInvocation; +import org.keycloak.client.registration.cli.common.EndpointType; + +/** + * @author Marko Strukelj + */ +public class EndpointTypeConverter implements Converter { + + @Override + public EndpointType convert(ConverterInvocation converterInvocation) throws OptionValidatorException { + return EndpointType.of(converterInvocation.getInput()); + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/Globals.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/Globals.java new file mode 100644 index 0000000000..bdeab201e5 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/Globals.java @@ -0,0 +1,15 @@ +package org.keycloak.client.registration.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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/ValveInputStream.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/ValveInputStream.java new file mode 100644 index 0000000000..76691dcc77 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/aesh/ValveInputStream.java @@ -0,0 +1,71 @@ +package org.keycloak.client.registration.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 some stream in the queue. + * It reads all streams from the queue, and then blocks until it receives more. + */ +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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractAuthOptionsCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractAuthOptionsCmd.java new file mode 100644 index 0000000000..8ad0430c2d --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractAuthOptionsCmd.java @@ -0,0 +1,235 @@ +package org.keycloak.client.registration.cli.commands; + +import org.jboss.aesh.cl.Option; +import org.jboss.aesh.console.command.invocation.CommandInvocation; +import org.keycloak.client.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.config.ConfigHandler; +import org.keycloak.client.registration.cli.config.FileConfigHandler; +import org.keycloak.client.registration.cli.config.InMemoryConfigHandler; +import org.keycloak.client.registration.cli.config.RealmConfigData; +import org.keycloak.client.registration.cli.util.ConfigUtil; +import org.keycloak.client.registration.cli.util.HttpUtil; +import org.keycloak.client.registration.cli.util.IoUtil; + +import java.io.File; + +import static org.keycloak.client.registration.cli.config.FileConfigHandler.setConfigFile; +import static org.keycloak.client.registration.cli.util.ConfigUtil.checkAuthInfo; +import static org.keycloak.client.registration.cli.util.ConfigUtil.checkServerInfo; +import static org.keycloak.client.registration.cli.util.ConfigUtil.loadConfig; + +/** + * @author Marko Strukelj + */ +public abstract class AbstractAuthOptionsCmd extends AbstractGlobalOptionsCmd { + + static final String DEFAULT_CLIENT = "admin-cli"; + + + @Option(name = "config", description = "Path to the config file (~/.keycloak/kcreg.config by default)", hasValue = true) + protected String config; + + @Option(name = "no-config", description = "No configuration file should be used, no authentication info should be saved", hasValue = false) + protected boolean noconfig; + + @Option(name = "server", description = "Server endpoint url (e.g. 'http://localhost:8080/auth')", hasValue = true) + protected String server; + + @Option(name = "realm", description = "Realm name to authenticate against", hasValue = true) + protected String realm; + + @Option(name = "client", description = "Realm name to authenticate against", hasValue = true) + protected String clientId; + + @Option(name = "user", description = "Username to login with", hasValue = true) + protected String user; + + @Option(name = "password", description = "Password to login with (prompted for if not specified and --user is used)", hasValue = true) + protected String password; + + @Option(name = "secret", description = "Secret to authenticate the client (prompted for if no --user or --keystore is specified)", hasValue = true) + protected String secret; + + @Option(name = "keystore", description = "Path to a keystore containing private key", hasValue = true) + protected String keystore; + + @Option(name = "storepass", description = "Keystore password (prompted for if not specified and --keystore is used)", hasValue = true) + protected 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)", hasValue = true) + protected String keyPass; + + @Option(name = "alias", description = "Alias of the key inside a keystore (defaults to the value of ClientId)", hasValue = true) + protected String alias; + + @Option(name = "truststore", description = "Path to a truststore", hasValue = true) + protected String trustStore; + + @Option(name = "trustpass", description = "Truststore password (prompted for if not specified and --truststore is used)", hasValue = true) + protected String trustPass; + + @Option(shortName = 't', name = "token", description = "Initial / Registration access token to use)", hasValue = true) + protected String token; + + protected void init(AbstractAuthOptionsCmd parent) { + + super.init(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; + token = parent.token; + } + + protected void applyDefaultOptionValues() { + if (clientId == null) { + clientId = DEFAULT_CLIENT; + } + } + + 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(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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractGlobalOptionsCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractGlobalOptionsCmd.java new file mode 100644 index 0000000000..a3dc6e16f5 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AbstractGlobalOptionsCmd.java @@ -0,0 +1,22 @@ +package org.keycloak.client.registration.cli.commands; + +import org.jboss.aesh.cl.Option; +import org.jboss.aesh.console.command.Command; +import org.keycloak.client.registration.cli.aesh.Globals; + +/** + * @author Marko Strukelj + */ +public abstract class AbstractGlobalOptionsCmd implements Command { + + @Option(shortName = 'x', description = "Print full stack trace when exiting with error", hasValue = false) + protected boolean dumpTrace; + + protected void init(AbstractGlobalOptionsCmd parent) { + dumpTrace = parent.dumpTrace; + } + + protected void processGlobalOptions() { + Globals.dumpTrace = dumpTrace; + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AttrsCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AttrsCmd.java new file mode 100644 index 0000000000..fb52357232 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/AttrsCmd.java @@ -0,0 +1,152 @@ +package org.keycloak.client.registration.cli.commands; + +import org.jboss.aesh.cl.Arguments; +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.registration.cli.common.AttributeKey; +import org.keycloak.client.registration.cli.common.EndpointType; +import org.keycloak.client.registration.cli.util.ReflectionUtil; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.oidc.OIDCClientRepresentation; + +import java.io.PrintStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.keycloak.client.registration.cli.util.OsUtil.CMD; +import static org.keycloak.client.registration.cli.util.OsUtil.PROMPT; +import static org.keycloak.client.registration.cli.util.ReflectionUtil.getAttributeListWithJSonTypes; +import static org.keycloak.client.registration.cli.util.ReflectionUtil.isBasicType; +import static org.keycloak.client.registration.cli.util.ReflectionUtil.isListType; +import static org.keycloak.client.registration.cli.util.ReflectionUtil.isMapType; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "attrs", description = "[ATTRIBUTE] [--endpoint TYPE]") +public class AttrsCmd extends AbstractGlobalOptionsCmd { + + @Option(shortName = 'e', name = "endpoint", description = "Endpoint type to use", hasValue = true) + protected String endpoint; + + @Arguments + protected List args; + + protected String attr; + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + processGlobalOptions(); + + + EndpointType regType = EndpointType.DEFAULT; + PrintStream out = commandInvocation.getShell().out(); + + if (endpoint != null) { + regType = EndpointType.of(endpoint); + } + + if (args != null) { + if (args.size() > 1) { + throw new RuntimeException("Invalid option: " + args.get(1)); + } + attr = args.get(0); + } + + Class type = regType == EndpointType.DEFAULT ? ClientRepresentation.class : (regType == EndpointType.OIDC ? OIDCClientRepresentation.class : null); + if (type == null) { + throw new RuntimeException("Endpoint not supported: " + regType); + } + AttributeKey key = attr == null ? new AttributeKey() : new AttributeKey(attr); + + Field f = ReflectionUtil.resolveField(type, key); + String ts = f != null ? ReflectionUtil.getTypeString(null, f) : null; + + if (f == null) { + out.printf("Attributes for %s format:\n", regType.getEndpoint()); + + LinkedHashMap items = getAttributeListWithJSonTypes(type, key); + for (Map.Entry item : items.entrySet()) { + out.printf(" %-40s %s\n", item.getKey(), item.getValue()); + } + + } else { + out.printf("%-40s %s", attr, ts); + boolean eol = false; + + Type t = f.getGenericType(); + if (isListType(f.getType()) && t instanceof ParameterizedType) { + t = ((ParameterizedType) t).getActualTypeArguments()[0]; + if (!isBasicType(t) && t instanceof Class) { + eol = true; + System.out.printf(", where value is:\n", ts); + LinkedHashMap items = ReflectionUtil.getAttributeListWithJSonTypes((Class) t, null); + for (Map.Entry item : items.entrySet()) { + out.printf(" %-36s %s\n", item.getKey(), item.getValue()); + } + } + } else if (isMapType(f.getType()) && t instanceof ParameterizedType) { + t = ((ParameterizedType) t).getActualTypeArguments()[1]; + if (!isBasicType(t) && t instanceof Class) { + eol = true; + out.printf(", where value is:\n", ts); + LinkedHashMap items = ReflectionUtil.getAttributeListWithJSonTypes((Class) t, null); + for (Map.Entry item : items.entrySet()) { + out.printf(" %-36s %s\n", item.getKey(), item.getValue()); + } + } + } + + if (!eol) { + // add end of line + out.println(); + } + } + + return CommandResult.SUCCESS; + + } finally { + commandInvocation.stop(); + } + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " attrs [ATTRIBUTE] [ARGUMENTS]"); + out.println(); + out.println("List available configuration attributes."); + 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(" ATTRIBUTE Attribute key (if omitted all attributes for the endpoint type are listed)"); + out.println(" Dot notation can be used to target sub-attributes."); + out.println(" -e, --endpoint TYPE Endpoint type to use - one of: 'default', 'oidc' (if omitted 'default' is used)"); + out.println(); + out.println("Examples:"); + out.println(); + out.println("List all attributes for default endpoint:"); + out.println(" " + PROMPT + " " + CMD + " attrs"); + out.println(); + out.println("List (sub)attributes of 'protocolMappers' attribute for default endpoint:"); + out.println(" " + PROMPT + " " + CMD + " attrs protocolMappers"); + 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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCmd.java new file mode 100644 index 0000000000..be9358d5dc --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCmd.java @@ -0,0 +1,84 @@ +/* + * 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.Arguments; +import org.jboss.aesh.cl.GroupCommandDefinition; +import org.jboss.aesh.console.command.CommandException; +import org.jboss.aesh.console.command.Command; +import org.jboss.aesh.console.command.CommandResult; +import org.jboss.aesh.console.command.invocation.CommandInvocation; +import org.keycloak.client.registration.cli.util.OsUtil; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.List; + +/** + * @author Marko Strukelj + */ + +@GroupCommandDefinition(name = "config", description = "COMMAND [ARGUMENTS]", groupCommands = {ConfigCredentialsCmd.class} ) +public class ConfigCmd extends AbstractAuthOptionsCmd implements Command { + + @Arguments + protected List args; + + + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + if (args.size() == 0) { + throw new RuntimeException("Sub-command required by '" + OsUtil.CMD + " config' - one of: 'credentials', 'truststore', 'initial-token', 'registration-token'"); + } + + String cmd = args.get(0); + switch (cmd) { + case "credentials": { + return new ConfigCredentialsCmd(this).execute(commandInvocation); + } + case "truststore": { + return new ConfigTruststoreCmd(this).execute(commandInvocation); + } + case "initial-token": { + return new ConfigInitialTokenCmd(this).execute(commandInvocation); + } + case "registration-token": { + return new ConfigRegistrationTokenCmd(this).execute(commandInvocation); + } + default: + throw new RuntimeException("Unknown sub-command: " + cmd); + } + + } finally { + commandInvocation.stop(); + } + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + OsUtil.CMD + " config SUB_COMMAND [ARGUMENTS]"); + out.println(); + out.println("Where SUB_COMMAND is one of: 'credentials', 'truststore', 'initial-token', 'registration-token'"); + out.println(); + out.println(); + out.println("Use '" + OsUtil.CMD + " help config SUB_COMMAND' for more info."); + out.println("Use '" + OsUtil.CMD + " help' for general information and a list of commands."); + return sb.toString(); + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCredentialsCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCredentialsCmd.java new file mode 100644 index 0000000000..6d60530d63 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigCredentialsCmd.java @@ -0,0 +1,246 @@ +package org.keycloak.client.registration.cli.commands; + +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 org.keycloak.client.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.config.RealmConfigData; +import org.keycloak.client.registration.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.registration.cli.util.AuthUtil.getAuthTokens; +import static org.keycloak.client.registration.cli.util.AuthUtil.getAuthTokensByJWT; +import static org.keycloak.client.registration.cli.util.AuthUtil.getAuthTokensBySecret; +import static org.keycloak.client.registration.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.registration.cli.util.ConfigUtil.getHandler; +import static org.keycloak.client.registration.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.registration.cli.util.ConfigUtil.saveTokens; +import static org.keycloak.client.registration.cli.util.IoUtil.printErr; +import static org.keycloak.client.registration.cli.util.IoUtil.readSecret; +import static org.keycloak.client.registration.cli.util.OsUtil.CMD; +import static org.keycloak.client.registration.cli.util.OsUtil.OS_ARCH; +import static org.keycloak.client.registration.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "credentials", description = "--server SERVER_URL --realm REALM [ARGUMENTS]") +public class ConfigCredentialsCmd extends AbstractAuthOptionsCmd implements Command { + + private int sigLifetime = 600; + + public ConfigCredentialsCmd() {} + + public ConfigCredentialsCmd(AbstractAuthOptionsCmd parent) { + init(parent); + } + + 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 { + processGlobalOptions(); + + return process(commandInvocation); + } finally { + commandInvocation.stop(); + } + } + + public CommandResult process(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + // check server + if (server == null) { + throw new RuntimeException("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 RuntimeException("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 RuntimeException("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; + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " config credentials --server SERVER_URL --realm REALM [ARGUMENTS]"); + out.println(" " + 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. That is enough to establish unauthenticated session."); + out.println("If --client is not provided it defaults to 'admin-cli'. The authantication options / requirements depend on how this client is configured."); + out.println(); + out.println("If you have an account configured with the rights to manage clients then you can use username, and password to authenticate."); + 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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigInitialTokenCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigInitialTokenCmd.java new file mode 100644 index 0000000000..5770e33395 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigInitialTokenCmd.java @@ -0,0 +1,169 @@ +package org.keycloak.client.registration.cli.commands; + +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 org.keycloak.client.registration.cli.config.RealmConfigData; +import org.keycloak.client.registration.cli.util.IoUtil; +import org.keycloak.client.registration.cli.util.ParseUtil; + +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.registration.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.registration.cli.util.ConfigUtil.saveMergeConfig; +import static org.keycloak.client.registration.cli.util.IoUtil.warnfOut; +import static org.keycloak.client.registration.cli.util.OsUtil.CMD; +import static org.keycloak.client.registration.cli.util.OsUtil.OS_ARCH; +import static org.keycloak.client.registration.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "initial-token", description = "[--server SERVER] --realm REALM [--delete | TOKEN] [ARGUMENTS]") +public class ConfigInitialTokenCmd extends AbstractAuthOptionsCmd implements Command { + + private ConfigCmd parent; + + private boolean delete; + private boolean keepDomain; + + public ConfigInitialTokenCmd() {} + + public ConfigInitialTokenCmd(ConfigCmd parent) { + this.parent = parent; + init(parent); + } + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + return process(commandInvocation); + } finally { + commandInvocation.stop(); + } + } + + public CommandResult process(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + List args = new ArrayList<>(); + + Iterator it = parent.args.iterator(); + // skip the first argument 'initial-token' + it.next(); + + while (it.hasNext()) { + String arg = it.next(); + switch (arg) { + case "-d": + case "--delete": { + delete = true; + break; + } + case "-k": + case "--keep-domain": { + keepDomain = true; + break; + } + default: { + args.add(arg); + } + } + } + + if (args.size() > 1) { + throw new RuntimeException("Invalid option: " + args.get(1)); + } + + String token = args.size() == 1 ? args.get(0) : null; + + if (realm == null) { + throw new RuntimeException("Realm not specified"); + } + + if (token != null && token.startsWith("-")) { + warnfOut(ParseUtil.TOKEN_OPTION_WARN, token); + } + + checkUnsupportedOptions( + "--client", clientId, + "--user", user, + "--password", password, + "--secret", secret, + "--keystore", keystore, + "--storepass", storePass, + "--keypass", keyPass, + "--alias", alias, + "--truststore", trustStore, + "--trustpass", keyPass); + + + if (!delete && token == null) { + token = IoUtil.readSecret("Enter Initial Access Token: ", commandInvocation); + } + + // now update the config + processGlobalOptions(); + + String initialToken = token; + saveMergeConfig(config -> { + if (!keepDomain && !delete) { + config.setServerUrl(server); + config.setRealm(realm); + } + if (delete) { + RealmConfigData rdata = config.getRealmConfigData(server, realm); + if (rdata != null) { + rdata.setInitialToken(null); + } + } else { + RealmConfigData rdata = config.ensureRealmConfigData(server, realm); + rdata.setInitialToken(initialToken); + } + }); + + return CommandResult.SUCCESS; + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " config initial-token --server SERVER --realm REALM [--delete | TOKEN] [ARGUMENTS]"); + out.println(); + out.println("Command to configure an initial access token to be used with '" + CMD + " create' command. Even if an "); + out.println("authenticated session exists as a result of '" + CMD + " config credentials' its access token will not"); + out.println("be used - initial access token will be used instead. By default, current server, and realm will"); + out.println("be set to the new values thus subsequent commands will use these values as default."); + 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(" --server SERVER Server endpoint url (e.g. 'http://localhost:8080/auth')"); + out.println(" --realm REALM Realm name to use"); + out.println(" -k, --keep-domain Don't overwrite default server and realm"); + out.println(" -d, --delete Indicates that initial access token should be removed"); + out.println(" TOKEN Initial access token (prompted for if not specified, unless -d is used)"); + out.println(); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Specify initial access token for server, and realm. Token is passed via env variable:"); + out.println(" " + PROMPT + " " + CMD + " config initial-token --server http://localhost:9080/auth --realm master " + OS_ARCH.envVar("TOKEN")); + out.println(); + out.println("Remove initial access token:"); + out.println(" " + PROMPT + " " + CMD + " config initial-token --server http://localhost:9080/auth --realm master --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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigRegistrationTokenCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigRegistrationTokenCmd.java new file mode 100644 index 0000000000..facea57f95 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigRegistrationTokenCmd.java @@ -0,0 +1,158 @@ +package org.keycloak.client.registration.cli.commands; + +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 org.keycloak.client.registration.cli.config.RealmConfigData; +import org.keycloak.client.registration.cli.util.IoUtil; + +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.registration.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.registration.cli.util.ConfigUtil.saveMergeConfig; +import static org.keycloak.client.registration.cli.util.OsUtil.CMD; +import static org.keycloak.client.registration.cli.util.OsUtil.OS_ARCH; +import static org.keycloak.client.registration.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "registration-token", description = "[--server SERVER] --realm REALM --client CLIENT [--delete | TOKEN] [ARGUMENTS]") +public class ConfigRegistrationTokenCmd extends AbstractAuthOptionsCmd implements Command { + + private ConfigCmd parent; + + private boolean delete; + + public ConfigRegistrationTokenCmd() {} + + public ConfigRegistrationTokenCmd(ConfigCmd parent) { + this.parent = parent; + init(parent); + } + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + return process(commandInvocation); + } finally { + commandInvocation.stop(); + } + } + + public CommandResult process(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + List args = new ArrayList<>(); + + Iterator it = parent.args.iterator(); + // skip the first argument 'initial-token' + it.next(); + + 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 RuntimeException("Invalid option: " + args.get(1)); + } + + String token = args.size() == 1 ? args.get(0) : null; + + if (server == null) { + throw new RuntimeException("Required option not specified: --server"); + } + + if (realm == null) { + throw new RuntimeException("Required option not specified: --realm"); + } + + if (clientId == null) { + throw new RuntimeException("Required option not specified: --client"); + } + + checkUnsupportedOptions( + "--user", user, + "--password", password, + "--secret", secret, + "--keystore", keystore, + "--storepass", storePass, + "--keypass", keyPass, + "--alias", alias, + "--truststore", trustStore, + "--trustpass", keyPass); + + + if (!delete && token == null) { + token = IoUtil.readSecret("Enter Registration Access Token: ", commandInvocation); + } + + // now update the config + processGlobalOptions(); + + String registrationToken = token; + saveMergeConfig(config -> { + RealmConfigData rdata = config.getRealmConfigData(server, realm); + if (delete) { + if (rdata != null) { + rdata.getClients().remove(clientId); + } + } else { + config.ensureRealmConfigData(server, realm).getClients().put(clientId, registrationToken); + } + }); + + return CommandResult.SUCCESS; + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " config registration-token --server SERVER --realm REALM --client CLIENT [--delete | TOKEN] [ARGUMENTS]"); + out.println(); + out.println("Command to configure a registration access token to be used with 'kcreg get / update / delete' commands. Even if an "); + out.println("authenticated session exists as a result of '" + CMD + " config credentials' its access token will not be used - registration"); + out.println("access token will be used instead."); + 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(" --server SERVER Server endpoint url (e.g. 'http://localhost:8080/auth')"); + out.println(" --realm REALM Realm name to use"); + out.println(" --client CLIENT ClientId of client whose token to set"); + out.println(" -d, --delete Indicates that registration access token should be removed"); + out.println(" TOKEN Registration access token (prompted for if not specified, unless -d is used)"); + out.println(); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Specify registration access token for server, and realm. Token is passed via env variable:"); + out.println(" " + PROMPT + " " + CMD + " config registration-token --server http://localhost:9080/auth --realm master --client my_client " + OS_ARCH.envVar("TOKEN")); + out.println(); + out.println("Remove registration access token:"); + out.println(" " + PROMPT + " " + CMD + " config registration-token --server http://localhost:9080/auth --realm master --client my_client --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/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 new file mode 100644 index 0000000000..99917f34a6 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/ConfigTruststoreCmd.java @@ -0,0 +1,168 @@ +package org.keycloak.client.registration.cli.commands; + +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.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.registration.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.registration.cli.util.ConfigUtil.saveMergeConfig; +import static org.keycloak.client.registration.cli.util.IoUtil.readSecret; +import static org.keycloak.client.registration.cli.util.OsUtil.CMD; +import static org.keycloak.client.registration.cli.util.OsUtil.OS_ARCH; +import static org.keycloak.client.registration.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "truststore", description = "PATH [ARGUMENTS]") +public class ConfigTruststoreCmd extends AbstractAuthOptionsCmd implements Command { + + private ConfigCmd parent; + + private boolean delete; + + public ConfigTruststoreCmd() {} + + public ConfigTruststoreCmd(ConfigCmd parent) { + this.parent = parent; + init(parent); + } + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + return process(commandInvocation); + } finally { + commandInvocation.stop(); + } + } + + public CommandResult process(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + List args = new ArrayList<>(); + + Iterator it = parent.args.iterator(); + // skip the first argument 'truststore' + it.next(); + + 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 RuntimeException("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 RuntimeException("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 RuntimeException("Option --delete is mutually exclusive with specifying a TRUSTSTORE"); + } + if (trustPass != null) { + throw new RuntimeException("Options --trustpass and --delete are mutually exclusive"); + } + store = null; + pass = null; + } + + saveMergeConfig(config -> { + config.setTruststore(store); + config.setTrustpass(pass); + }); + + return CommandResult.SUCCESS; + } + + + 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(); + 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 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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/CreateCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/CreateCmd.java new file mode 100644 index 0000000000..35f18a1273 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/CreateCmd.java @@ -0,0 +1,295 @@ +/* + * 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 com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; +import org.jboss.aesh.cl.Arguments; +import org.jboss.aesh.cl.CommandDefinition; +import org.jboss.aesh.cl.Option; +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 org.keycloak.client.registration.cli.aesh.EndpointTypeConverter; +import org.keycloak.client.registration.cli.common.AttributeOperation; +import org.keycloak.client.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.common.CmdStdinContext; +import org.keycloak.client.registration.cli.common.EndpointType; +import org.keycloak.client.registration.cli.util.HttpUtil; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.oidc.OIDCClientRepresentation; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +import static org.keycloak.client.registration.cli.common.AttributeOperation.Type.SET; +import static org.keycloak.client.registration.cli.common.EndpointType.DEFAULT; +import static org.keycloak.client.registration.cli.common.EndpointType.OIDC; +import static org.keycloak.client.registration.cli.common.EndpointType.SAML2; +import static org.keycloak.client.registration.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.registration.cli.util.ConfigUtil.credentialsAvailable; +import static org.keycloak.client.registration.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.registration.cli.util.ConfigUtil.saveMergeConfig; +import static org.keycloak.client.registration.cli.util.HttpUtil.getExpectedContentType; +import static org.keycloak.client.registration.cli.util.IoUtil.printErr; +import static org.keycloak.client.registration.cli.util.IoUtil.readFully; +import static org.keycloak.client.registration.cli.util.IoUtil.readSecret; +import static org.keycloak.client.registration.cli.util.OsUtil.CMD; +import static org.keycloak.client.registration.cli.util.OsUtil.OS_ARCH; +import static org.keycloak.client.registration.cli.util.OsUtil.PROMPT; +import static org.keycloak.client.registration.cli.util.ParseUtil.mergeAttributes; +import static org.keycloak.client.registration.cli.util.ParseUtil.parseFileOrStdin; +import static org.keycloak.client.registration.cli.util.AuthUtil.ensureToken; +import static org.keycloak.client.registration.cli.util.ConfigUtil.setRegistrationToken; +import static org.keycloak.client.registration.cli.util.HttpUtil.doPost; +import static org.keycloak.client.registration.cli.util.IoUtil.printOut; +import static org.keycloak.client.registration.cli.util.ParseUtil.parseKeyVal; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "create", description = "[ARGUMENTS]") +public class CreateCmd extends AbstractAuthOptionsCmd implements Command { + + @Option(shortName = 'i', name = "clientId", description = "After creation only print clientId to standard output", hasValue = false) + protected boolean returnClientId = false; + + @Option(shortName = 'e', name = "endpoint", description = "Endpoint type / document format to use - one of: 'default', 'oidc', 'saml2'", + hasValue = true, converter = EndpointTypeConverter.class) + protected EndpointType regType; + + @Option(shortName = 'f', name = "file", description = "Read object from file or standard input if FILENAME is set to '-'", hasValue = true) + protected String file; + + @Option(shortName = 'o', name = "output", description = "After creation output the new client configuration to standard output", hasValue = false) + protected boolean outputClient = false; + + @Option(shortName = 'c', name = "compressed", description = "Don't pretty print the output", hasValue = false) + protected boolean compressed = false; + + //@OptionGroup(shortName = 's', name = "set", description = "Set attribute to the specified value") + //Map attributes = new LinkedHashMap<>(); + + @Arguments + protected List args; + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + List attrs = new LinkedList<>(); + + try { + processGlobalOptions(); + + if (args != null) { + Iterator it = args.iterator(); + while (it.hasNext()) { + String option = it.next(); + switch (option) { + case "-s": + case "--set": { + if (!it.hasNext()) { + throw new RuntimeException("Option " + option + " requires a value"); + } + String[] keyVal = parseKeyVal(it.next()); + attrs.add(new AttributeOperation(SET, keyVal[0], keyVal[1])); + break; + } + default: { + throw new RuntimeException("Unsupported option: " + option); + } + } + } + } + + if (file == null && attrs.size() == 0) { + throw new RuntimeException("No file nor attribute values specified"); + } + + if (outputClient && returnClientId) { + throw new RuntimeException("Options -o and -i can't be used together"); + } + + // if --token is specified read it + if ("-".equals(token)) { + token = readSecret("Enter Initial Access Token: ", commandInvocation); + } + + CmdStdinContext ctx = new CmdStdinContext(); + if (file != null) { + ctx = parseFileOrStdin(file, regType); + } + + if (ctx.getEndpointType() == null) { + regType = regType != null ? regType : DEFAULT; + ctx.setEndpointType(regType); + } else if (regType != null && ctx.getEndpointType() != regType) { + throw new RuntimeException("Requested endpoint type not compatible with detected configuration format: " + ctx.getEndpointType()); + } + + if (attrs.size() > 0) { + ctx = mergeAttributes(ctx, attrs); + } + + String contentType = getExpectedContentType(ctx.getEndpointType()); + + ConfigData config = loadConfig(); + config = copyWithServerInfo(config); + + if (token == null) { + // if initial token is not set, try use the one from configuration + token = config.sessionRealmConfigData().getInitialToken(); + } + + setupTruststore(config, commandInvocation); + + String auth = token; + if (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 = config.getRealm(); + + InputStream response = doPost(server + "/realms/" + realm + "/clients-registrations/" + ctx.getEndpointType().getEndpoint(), + contentType, HttpUtil.APPLICATION_JSON, ctx.getContent(), auth); + + try { + if (ctx.getEndpointType() == DEFAULT || ctx.getEndpointType() == SAML2) { + ClientRepresentation client = JsonSerialization.readValue(response, ClientRepresentation.class); + outputResult(client.getClientId(), client); + + saveMergeConfig(cfg -> { + setRegistrationToken(cfg.ensureRealmConfigData(server, realm), client.getClientId(), client.getRegistrationAccessToken()); + }); + } else if (ctx.getEndpointType() == OIDC) { + OIDCClientRepresentation client = JsonSerialization.readValue(response, OIDCClientRepresentation.class); + outputResult(client.getClientId(), client); + + saveMergeConfig(cfg -> { + setRegistrationToken(cfg.ensureRealmConfigData(server, realm), client.getClientId(), client.getRegistrationAccessToken()); + }); + } else { + printOut("Response from server: " + readFully(response)); + } + } catch (UnrecognizedPropertyException e) { + throw new RuntimeException("Failed to process HTTP reponse - " + e.getMessage(), e); + } catch (IOException e) { + throw new RuntimeException("Failed to process HTTP response", e); + } + + return CommandResult.SUCCESS; + + } finally { + commandInvocation.stop(); + } + } + + private void outputResult(String clientId, Object result) throws IOException { + if (returnClientId) { + printOut(clientId); + } else if (outputClient) { + if (compressed) { + printOut(JsonSerialization.writeValueAsString(result)); + } else { + printOut(JsonSerialization.writeValueAsPrettyString(result)); + } + } else { + printErr("Registered new client with client_id '" + clientId + "'"); + } + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " create [ARGUMENTS]"); + out.println(); + out.println("Command to create new client configurations on the server. If Initial Access Token is specified (-t TOKEN)"); + out.println("or has previously been set for the server, and realm in the configuration ('" + CMD + " config initial-token'),"); + out.println("then that will be used, otherwise session access / refresh tokens will be used."); + 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(" -t, --token TOKEN Use the specified Initial Access Token for authorization or read it from standard input "); + out.println(" if '-' is specified. This overrides any token set by '" + CMD + " config initial-token'."); + out.println(" If not specified, session credentials are used - see: CREDENTIALS OPTIONS."); + out.println(" -e, --endpoint TYPE Endpoint type / document format to use - one of: 'default', 'oidc', 'saml2'."); + out.println(" If not specified, the format is deduced from input file or falls back to 'default'."); + 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(" -o, --output After creation output the new client configuration to standard output"); + out.println(" -c, --compressed Don't pretty print the output"); + out.println(" -i, --clientId After creation only print clientId to standard output"); + out.println(); + out.println("Examples:"); + 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 -f -"); + } else { + out.println(" " + PROMPT + " " + CMD + " create -f - << EOF"); + out.println(" {"); + out.println(" \"clientId\": \"my_client\""); + out.println(" }"); + out.println(" EOF"); + } + out.println(); + out.println("Since we didn't specify an endpoint type it will be deduced from configuration format."); + out.println("Supported formats include Keycloak default format, OIDC format, and SAML SP Metadata."); + out.println(); + out.println("Creating a client using file as a template, and overriding some attributes:"); + out.println(" " + PROMPT + " " + CMD + " create -f my_client.json -s clientId=my_client2 -s 'redirectUris=[\"http://localhost:8980/myapp/*\"]'"); + out.println(); + out.println("Creating a client using an Initial Access Token - you'll be prompted for a token:"); + out.println(" " + PROMPT + " " + CMD + " create -s clientId=my_client2 -s 'redirectUris=[\"http://localhost:8980/myapp/*\"]' -t -"); + out.println(); + out.println("Creating a client using 'oidc' endpoint. Without setting endpoint type here it would be 'default':"); + out.println(" " + PROMPT + " " + CMD + " create -e oidc -s 'redirect_uris=[\"http://localhost:8980/myapp/*\"]'"); + out.println(); + out.println("Creating a client using 'saml2' endpoint. In this case setting endpoint type is redundant since it is deduced "); + out.println("from file content:"); + out.println(" " + PROMPT + " " + CMD + " create -e saml2 -f saml-sp-metadata.xml"); + 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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/DeleteCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/DeleteCmd.java new file mode 100644 index 0000000000..1d6e8172ad --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/DeleteCmd.java @@ -0,0 +1,145 @@ +/* + * 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.Arguments; +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.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.util.ParseUtil; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; + +import static org.keycloak.client.registration.cli.util.AuthUtil.ensureToken; +import static org.keycloak.client.registration.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.registration.cli.util.ConfigUtil.credentialsAvailable; +import static org.keycloak.client.registration.cli.util.ConfigUtil.getRegistrationToken; +import static org.keycloak.client.registration.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.registration.cli.util.ConfigUtil.saveMergeConfig; +import static org.keycloak.client.registration.cli.util.HttpUtil.doDelete; +import static org.keycloak.client.registration.cli.util.HttpUtil.urlencode; +import static org.keycloak.client.registration.cli.util.IoUtil.warnfErr; +import static org.keycloak.client.registration.cli.util.OsUtil.CMD; +import static org.keycloak.client.registration.cli.util.OsUtil.PROMPT; + + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "delete", description = "CLIENT_ID [GLOBAL_OPTIONS]") +public class DeleteCmd extends AbstractAuthOptionsCmd { + + @Arguments + private List args = new ArrayList<>(); + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + processGlobalOptions(); + + if (args.isEmpty()) { + throw new RuntimeException("CLIENT_ID not specified"); + } + + if (args.size() > 1) { + throw new RuntimeException("Invalid option: " + args.get(1)); + } + + String clientId = args.get(0); + + if (clientId.startsWith("-")) { + warnfErr(ParseUtil.CLIENTID_OPTION_WARN, clientId); + } + + String regType = "default"; + + ConfigData config = loadConfig(); + config = copyWithServerInfo(config); + + if (token == null) { + // if registration access token is not set via -t, try use the one from configuration + token = getRegistrationToken(config.sessionRealmConfigData(), clientId); + } + + setupTruststore(config, commandInvocation); + + String auth = token; + if (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 = config.getRealm(); + + doDelete(server + "/realms/" + realm + "/clients-registrations/" + regType + "/" + urlencode(clientId), auth); + + saveMergeConfig(cfg -> { + cfg.ensureRealmConfigData(server, realm).getClients().remove(clientId); + }); + return CommandResult.SUCCESS; + + } finally { + commandInvocation.stop(); + } + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " delete CLIENT [ARGUMENTS]"); + out.println(); + out.println("Command to delete a specific client configuration. If registration access token is specified or is available in "); + out.println("configuration file, then it is used. Otherwise, current active session is used."); + 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(" --token TOKEN Registration access token to use"); + 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(" CLIENT ClientId of the client to delete"); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Delete a client:"); + out.println(" " + PROMPT + " " + CMD + " delete my_client"); + 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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/GetCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/GetCmd.java new file mode 100644 index 0000000000..1b6b7ccf0d --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/GetCmd.java @@ -0,0 +1,215 @@ +/* + * 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.Arguments; +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.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.common.EndpointType; +import org.keycloak.client.registration.cli.util.ParseUtil; +import org.keycloak.representations.adapters.config.AdapterConfig; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.oidc.OIDCClientRepresentation; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; + +import static org.keycloak.client.registration.cli.util.AuthUtil.ensureToken; +import static org.keycloak.client.registration.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.registration.cli.util.ConfigUtil.credentialsAvailable; +import static org.keycloak.client.registration.cli.util.ConfigUtil.getRegistrationToken; +import static org.keycloak.client.registration.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.registration.cli.util.ConfigUtil.saveMergeConfig; +import static org.keycloak.client.registration.cli.util.ConfigUtil.setRegistrationToken; +import static org.keycloak.client.registration.cli.util.HttpUtil.APPLICATION_JSON; +import static org.keycloak.client.registration.cli.util.HttpUtil.doGet; +import static org.keycloak.client.registration.cli.util.HttpUtil.urlencode; +import static org.keycloak.client.registration.cli.util.IoUtil.warnfErr; +import static org.keycloak.client.registration.cli.util.IoUtil.printOut; +import static org.keycloak.client.registration.cli.util.IoUtil.readFully; +import static org.keycloak.client.registration.cli.util.OsUtil.CMD; +import static org.keycloak.client.registration.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "get", description = "[ARGUMENTS]") +public class GetCmd extends AbstractAuthOptionsCmd { + + @Option(shortName = 'c', name = "compressed", description = "Print full stack trace when exiting with error", hasValue = false) + private boolean compressed = false; + + @Option(shortName = 'e', name = "endpoint", description = "Endpoint type to use", hasValue = true) + private String endpoint; + + @Arguments + private List args = new ArrayList<>(); + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + try { + processGlobalOptions(); + + if (args == null || args.isEmpty()) { + throw new RuntimeException("CLIENT not specified"); + } + + if (args.size() > 1) { + throw new RuntimeException("Invalid option: " + args.get(1)); + } + + String clientId = args.get(0); + EndpointType regType = endpoint != null ? EndpointType.of(endpoint) : EndpointType.DEFAULT; + + + if (clientId.startsWith("-")) { + warnfErr(ParseUtil.CLIENTID_OPTION_WARN, clientId); + } + + ConfigData config = loadConfig(); + config = copyWithServerInfo(config); + + if (token == null) { + // if registration access token is not set via -t, try use the one from configuration + token = getRegistrationToken(config.sessionRealmConfigData(), clientId); + } + + setupTruststore(config, commandInvocation); + + String auth = token; + if (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 = config.getRealm(); + + InputStream response = doGet(server + "/realms/" + realm + "/clients-registrations/" + regType.getEndpoint() + "/" + urlencode(clientId), + APPLICATION_JSON, auth); + + try { + String json = readFully(response); + Object result = null; + + switch (regType) { + case DEFAULT: { + ClientRepresentation client = JsonSerialization.readValue(json, ClientRepresentation.class); + result = client; + + saveMergeConfig(cfg -> { + setRegistrationToken(cfg.ensureRealmConfigData(server, realm), client.getClientId(), client.getRegistrationAccessToken()); + }); + break; + } + case OIDC: { + OIDCClientRepresentation client = JsonSerialization.readValue(json, OIDCClientRepresentation.class); + result = client; + + saveMergeConfig(cfg -> { + setRegistrationToken(cfg.ensureRealmConfigData(server, realm), client.getClientId(), client.getRegistrationAccessToken()); + }); + break; + } + case INSTALL: { + result = JsonSerialization.readValue(json, AdapterConfig.class); + break; + } + case SAML2: { + break; + } + default: { + throw new RuntimeException("Unexpected type: " + regType); + } + } + + if (!compressed && result != null) { + json = JsonSerialization.writeValueAsPrettyString(result); + } + + printOut(json); + + //} catch (UnrecognizedPropertyException e) { + // throw new RuntimeException("Failed to parse returned JSON - " + e.getMessage(), e); + } catch (IOException e) { + throw new RuntimeException("Failed to process HTTP response", e); + } + return CommandResult.SUCCESS; + + } finally { + commandInvocation.stop(); + } + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " get CLIENT [ARGUMENTS]"); + out.println(); + out.println("Command to retrieve a client configuration description for a specified client. If registration access token"); + out.println("is specified or is available in configuration file, then it is used. Otherwise, current active session is used."); + 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(" -t, --token TOKEN Registration access token to use"); + 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(" CLIENT ClientId of the client to display"); + out.println(" -c, --compressed Don't pretty print the output"); + out.println(" -e, --endpoint TYPE Endpoint type to use - one of: 'default', 'oidc', 'install'"); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Get configuration in default format:"); + out.println(" " + PROMPT + " " + CMD + " get my_client"); + out.println(); + out.println("Get configuration in OIDC format:"); + out.println(" " + PROMPT + " " + CMD + " get my_client -e oidc"); + out.println(); + out.println("Get adapter configuration for the client:"); + out.println(" " + PROMPT + " " + CMD + " get my_client -e install"); + 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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/HelpCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/HelpCmd.java new file mode 100644 index 0000000000..820f84ac7a --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/HelpCmd.java @@ -0,0 +1,90 @@ +package org.keycloak.client.registration.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.registration.cli.util.IoUtil.printOut; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "help", description = "This help") +public class HelpCmd implements Command { + + @Arguments + private List args; + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + if (args == null || args.size() == 0) { + printOut(KcRegCmd.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 "initial-token": { + printOut(ConfigInitialTokenCmd.usage()); + break outer; + } + case "registration-token": { + printOut(ConfigRegistrationTokenCmd.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 "attrs": { + printOut(AttrsCmd.usage()); + break; + } + case "update-token": { + printOut(UpdateTokenCmd.usage()); + break; + } + default: { + throw new RuntimeException("Unknown command: " + args.get(0)); + } + } + } + + return CommandResult.SUCCESS; + } finally { + commandInvocation.stop(); + } + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/KcRegCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/KcRegCmd.java new file mode 100644 index 0000000000..d2b8a857fe --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/KcRegCmd.java @@ -0,0 +1,105 @@ +/* + * 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.GroupCommandDefinition; +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.registration.cli.util.IoUtil; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import static org.keycloak.client.registration.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.registration.cli.util.OsUtil.CMD; +import static org.keycloak.client.registration.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ + +@GroupCommandDefinition(name = "kcreg", description = "COMMAND [ARGUMENTS]", groupCommands = { + HelpCmd.class, ConfigCmd.class, CreateCmd.class, UpdateCmd.class, GetCmd.class, DeleteCmd.class, AttrsCmd.class, UpdateTokenCmd.class} ) +public class KcRegCmd extends AbstractGlobalOptionsCmd { + + //@Arguments + //private List args; + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + try { + IoUtil.printOut(usage()); + + return CommandResult.SUCCESS; + } finally { + commandInvocation.stop(); + } + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Keycloak Client Registration 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 client registration operations the user"); + out.println("needs proper roles, otherwise attempts to create, update, read, or delete clients will fail."); + out.println("Alternatively, the user without the necessary roles can use an Initial Access Token provided by realm"); + out.println("administrator when creating a new client with 'create' command. For example:"); + out.println(); + out.println(" " + PROMPT + " " + CMD + " create -f my_client.json -t -"); + out.println(" Enter Initial Access Token: "); + out.println(" Registered new client with client_id 'my_client'"); + out.println(); + out.println("When Initial Access Token is used the server issues a Registration Access Token which is automatically"); + out.println("handled by " + CMD + ", saved into a local config file, and automatically used for any follow-up operations"); + out.println("on the same client. For example:"); + out.println(); + out.println(" " + PROMPT + " " + CMD + " get my_client"); + out.println(" " + PROMPT + " " + CMD + " update my_client -s enabled=false"); + out.println(" " + PROMPT + " " + CMD + " delete my_client"); + out.println(); + 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(" -c, --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 Register a new client"); + out.println(" get Get configuration of existing client in Keycloak or OIDC format, or adapter install configuration"); + out.println(" update Update a client configuration"); + out.println(" delete Delete a client"); + out.println(" attrs List available attributes"); + out.println(" update-token Update Registration Access Token for a client"); + 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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/UpdateCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/UpdateCmd.java new file mode 100644 index 0000000000..87ab781289 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/UpdateCmd.java @@ -0,0 +1,410 @@ +/* + * 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 com.fasterxml.jackson.core.JsonParseException; +import org.jboss.aesh.cl.Arguments; +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.registration.cli.aesh.EndpointTypeConverter; +import org.keycloak.client.registration.cli.common.AttributeOperation; +import org.keycloak.client.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.common.CmdStdinContext; +import org.keycloak.client.registration.cli.common.EndpointType; +import org.keycloak.client.registration.cli.util.ParseUtil; +import org.keycloak.client.registration.cli.util.ReflectionUtil; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.oidc.OIDCClientRepresentation; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; + +import static org.keycloak.client.registration.cli.common.AttributeOperation.Type.DELETE; +import static org.keycloak.client.registration.cli.common.AttributeOperation.Type.SET; +import static org.keycloak.client.registration.cli.util.AuthUtil.ensureToken; +import static org.keycloak.client.registration.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.registration.cli.util.ConfigUtil.credentialsAvailable; +import static org.keycloak.client.registration.cli.util.ConfigUtil.getRegistrationToken; +import static org.keycloak.client.registration.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.registration.cli.util.ConfigUtil.saveMergeConfig; +import static org.keycloak.client.registration.cli.util.ConfigUtil.setRegistrationToken; +import static org.keycloak.client.registration.cli.common.EndpointType.DEFAULT; +import static org.keycloak.client.registration.cli.common.EndpointType.OIDC; +import static org.keycloak.client.registration.cli.util.HttpUtil.doGet; +import static org.keycloak.client.registration.cli.util.HttpUtil.doPut; +import static org.keycloak.client.registration.cli.util.HttpUtil.urlencode; +import static org.keycloak.client.registration.cli.util.IoUtil.printOut; +import static org.keycloak.client.registration.cli.util.IoUtil.warnfErr; +import static org.keycloak.client.registration.cli.util.IoUtil.readFully; +import static org.keycloak.client.registration.cli.util.HttpUtil.APPLICATION_JSON; +import static org.keycloak.client.registration.cli.util.OsUtil.CMD; +import static org.keycloak.client.registration.cli.util.OsUtil.PROMPT; +import static org.keycloak.client.registration.cli.util.ParseUtil.mergeAttributes; +import static org.keycloak.client.registration.cli.util.ParseUtil.parseFileOrStdin; +import static org.keycloak.client.registration.cli.util.ParseUtil.parseKeyVal; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "update", description = "CLIENT_ID [ARGUMENTS]") +public class UpdateCmd extends AbstractAuthOptionsCmd { + + @Option(shortName = 'e', name = "endpoint", description = "Endpoint type to use - one of: 'default', 'oidc'", hasValue = true, converter = EndpointTypeConverter.class) + private EndpointType regType = null; + + //@GroupOption(shortName = 's', name = "set", description = "Set specific attribute to a specified value", hasValue = true) + //private List attributes = new ArrayList<>(); + + @Option(shortName = 'f', name = "file", description = "Use the file or standard input if '-' is specified", hasValue = true) + private String file = null; + + @Option(shortName = 'm', name = "merge", description = "Merge new values with existing configuration on the server", hasValue = false) + private boolean mergeMode = true; + + @Option(shortName = 'u', name = "unsafe", description = "Allow updating without registration access token - no optimistic locking", hasValue = false) + private boolean allowUnsafe = true; + + @Option(shortName = 'o', name = "output", description = "After update output the new client configuration", hasValue = false) + private boolean outputClient = false; + + @Option(shortName = 'c', name = "compressed", description = "Don't pretty print the output", hasValue = false) + private boolean compressed = false; + + @Arguments + private List args; + + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + List attrs = new LinkedList<>(); + + try { + processGlobalOptions(); + + String clientId = null; + + if (args != null) { + Iterator it = args.iterator(); + if (!it.hasNext()) { + throw new RuntimeException("CLIENT_ID not specified"); + } + + clientId = it.next(); + + if (clientId.startsWith("-")) { + warnfErr(ParseUtil.CLIENTID_OPTION_WARN, clientId); + } + + while (it.hasNext()) { + String option = it.next(); + switch (option) { + case "-s": + case "--set": { + if (!it.hasNext()) { + throw new RuntimeException("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; + } + default: { + throw new RuntimeException("Unsupported option: " + option); + } + } + } + } + + if (file == null && attrs.size() == 0) { + throw new RuntimeException("No file nor attribute values specified"); + } + + // We have several options for update: + // + // A) if a file is specified, then we can overwrite server state with that file + // (that's the normal flow - get and save locally, edit, post to server) + // + // update my_client -f new_client.json + // + // B) if a file is specified, and overrides are specified, then we override the file values with those from command line + // (that allows us to have a local file as a template, it's also batch job friendly) + // + // update my_client -s public=true -s enableDirectGrants=false -f new_client.json + // + // C) if no file is specified, then we can fetch the client definition from server, apply changes to it, and post it back + // (again a batch job friendly mode) + // + // update my_client -s public=true -s enableDirectGrants=false + // + // This is merge mode by default - if --merge is additionally specified, it is ignored + // + // D) if a file is specified, then we can merge the file with current state on the server + // (that is similar to previous mode except that the overrides are also taken from a file) + // + // update my_client --merge -f new_client.json + // update my_client --merge -s public=true -s enableDirectGrants=false -f new_client.json + // + // We could also support environment variables in input file, and apply them before parsing it. + // + // One problem - what if it is SAML XML? No problem as we don't support update for SAML - only create. + // + if (file == null && attrs.size() > 0) { + mergeMode = true; + } + + CmdStdinContext ctx = new CmdStdinContext(); + if (file != null) { + ctx = parseFileOrStdin(file, regType); + regType = ctx.getEndpointType(); + } + + if (regType == null) { + regType = DEFAULT; + ctx.setEndpointType(regType); + } else if (regType != DEFAULT && regType != OIDC) { + throw new RuntimeException("Update not supported for endpoint type: " + regType.getEndpoint()); + } + + // initialize config only after reading from stdin, + // to allow proper operation when piping 'get' - which consumes the old + // registration access token, and saves the new one to the config + ConfigData config = loadConfig(); + config = copyWithServerInfo(config); + + final String server = config.getServerUrl(); + final String realm = config.getRealm(); + + if (token == null) { + // if registration access token is not set via --token, see if it's in the body of any input file + // but first see if it's overridden by --set, or maybe deliberately muted via -d registrationAccessToken + boolean processed = false; + for (AttributeOperation op: attrs) { + if ("registrationAccessToken".equals(op.getKey().toString())) { + processed = true; + if (op.getType() == AttributeOperation.Type.SET) { + token = op.getValue(); + } + // otherwise it's delete - meaning it should stay null + break; + } + } + if (!processed) { + token = ctx.getRegistrationAccessToken(); + } + } + + if (token == null) { + // if registration access token is not set, try use the one from configuration + token = getRegistrationToken(config.sessionRealmConfigData(), clientId); + } + + setupTruststore(config, commandInvocation); + + String auth = token; + if (auth == null) { + config = ensureAuthInfo(config, commandInvocation); + config = copyWithServerInfo(config); + if (credentialsAvailable(config)) { + auth = ensureToken(config); + } + } + + auth = auth != null ? "Bearer " + auth : null; + + + if (mergeMode) { + InputStream response = doGet(server + "/realms/" + realm + "/clients-registrations/" + regType.getEndpoint() + "/" + urlencode(clientId), + APPLICATION_JSON, auth); + + String json = readFully(response); + + CmdStdinContext ctxremote = new CmdStdinContext(); + ctxremote.setContent(json); + ctxremote.setEndpointType(regType); + try { + + if (regType == DEFAULT) { + ctxremote.setClient(JsonSerialization.readValue(json, ClientRepresentation.class)); + token = ctxremote.getClient().getRegistrationAccessToken(); + } else if (regType == OIDC) { + ctxremote.setOidcClient(JsonSerialization.readValue(json, OIDCClientRepresentation.class)); + token = ctxremote.getOidcClient().getRegistrationAccessToken(); + } + } catch (JsonParseException e) { + throw new RuntimeException("Not a valid JSON document. " + e.getMessage(), e); + } catch (IOException e) { + throw new RuntimeException("Not a valid JSON document", e); + } + + // we have to use registration access token retrieved from previous operation + // that ensures optimistic locking semantics + if (token != null) { + // we use auth with doPost later + auth = "Bearer " + token; + + String newToken = token; + String clientToUpdate = clientId; + saveMergeConfig(cfg -> { + setRegistrationToken(cfg.ensureRealmConfigData(server, realm), clientToUpdate, newToken); + }); + } else if (!allowUnsafe) { + throw new RuntimeException("No Registration Access Token found for client: " + clientId + ". Provide one or use --unsafe."); + } + + // merge local representation over remote one + if (ctx.getClient() != null) { + ReflectionUtil.merge(ctx.getClient(), ctxremote.getClient()); + } else if (ctx.getOidcClient() != null) { + ReflectionUtil.merge(ctx.getOidcClient(), ctxremote.getOidcClient()); + } + ctx = ctxremote; + } + + if (attrs.size() > 0) { + ctx = mergeAttributes(ctx, attrs); + } + + // now update + InputStream response = doPut(server + "/realms/" + realm + "/clients-registrations/" + regType.getEndpoint() + "/" + urlencode(clientId), + APPLICATION_JSON, APPLICATION_JSON, ctx.getContent(), auth); + try { + if (regType == DEFAULT) { + ClientRepresentation clirep = JsonSerialization.readValue(response, ClientRepresentation.class); + outputResult(clirep); + token = clirep.getRegistrationAccessToken(); + } else if (regType == OIDC) { + OIDCClientRepresentation clirep = JsonSerialization.readValue(response, OIDCClientRepresentation.class); + outputResult(clirep); + token = clirep.getRegistrationAccessToken(); + } + + String newToken = token; + String clientToUpdate = clientId; + saveMergeConfig(cfg -> { + setRegistrationToken(cfg.ensureRealmConfigData(server, realm), clientToUpdate, newToken); + }); + + } catch (IOException e) { + throw new RuntimeException("Failed to process HTTP response", e); + } + + return CommandResult.SUCCESS; + + } finally { + commandInvocation.stop(); + } + } + + private void outputResult(Object result) throws IOException { + if (outputClient) { + if (compressed) { + printOut(JsonSerialization.writeValueAsString(result)); + } else { + printOut(JsonSerialization.writeValueAsPrettyString(result)); + } + } + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " update CLIENT [ARGUMENTS]"); + out.println(); + out.println("Command to update an existing client configuration. If registration access token is specified it is used."); + out.println("Otherwise, if 'registrationAccessToken' attribute is set, that is used. Otherwise, if registration access"); + out.println("token is available in configuration file, we use that. Finally, if it's not available anywhere, the current "); + out.println("active session is used."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" -c, --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(" --token TOKEN Registration access token to use"); + 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(" CLIENT ClientId of the client to update"); + out.println(" -s, --set KEY=VALUE Set specific attribute to a specified value"); + out.println(" KEY+=VALUE Add item to an array"); + out.println(" -d, --delete NAME Delete the specific attribute, or array item"); + out.println(" -e, --endpoint TYPE Endpoint type to use - one of: 'default', 'oidc'"); + out.println(" -f, --file FILENAME Use the file or standard input if '-' is specified"); + 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(" -u, --unsafe Allow updating without registration access token - no optimistic locking"); + out.println(" -o, --output After update output the new client configuration"); + out.println(" -c, --compressed Don't pretty print the output"); + 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 current configuration from the server, applies attribute changes to it, and sends it"); + out.println("back to the server, overwriting existing configuration there. To ensure there are no unexpected changes"); + out.println("Registration Access Token is used for authorization when doing changes. Alternatively, one can specify to use"); + out.println("unsafe mode in which case login session's authorization is used - user requires manage-clients permission."); + out.println(); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Update a client by fetching current configuration from server, and applying specified changes."); + out.println(" " + PROMPT + " " + CMD + " update my_client -s enabled=true -s 'redirectUris=[\"http://localhost:8080/myapp/*\"]'"); + out.println(); + out.println("Update a client by overwriting existing configuration on the server with a new one:"); + out.println(" " + PROMPT + " " + CMD + " update my_client -f new_my_client.json"); + out.println(); + out.println("Update a client by overwriting existing configuration using local file as a template:"); + out.println(" " + PROMPT + " " + CMD + " update my_client -f new_my_client.json -s enabled=true"); + out.println(); + out.println("Update client by fetching current configuration from server and merging with specified changes:"); + out.println(" " + PROMPT + " " + CMD + " update my_client -f new_my_client.json -s enabled=true --merge"); + out.println(); + out.println("Update a client using 'oidc' endpoint:"); + out.println(" " + PROMPT + " " + CMD + " update my_client -e oidc -s 'redirect_uris=[\"http://localhost:8080/myapp/*\"]'"); + 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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/UpdateTokenCmd.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/UpdateTokenCmd.java new file mode 100644 index 0000000000..e52f5a2d18 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/commands/UpdateTokenCmd.java @@ -0,0 +1,164 @@ +/* + * 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 com.fasterxml.jackson.core.type.TypeReference; +import org.jboss.aesh.cl.Arguments; +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.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.util.ParseUtil; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.List; + +import static org.keycloak.client.registration.cli.util.AuthUtil.ensureToken; +import static org.keycloak.client.registration.cli.util.ConfigUtil.DEFAULT_CONFIG_FILE_STRING; +import static org.keycloak.client.registration.cli.util.ConfigUtil.loadConfig; +import static org.keycloak.client.registration.cli.util.ConfigUtil.saveMergeConfig; +import static org.keycloak.client.registration.cli.util.ConfigUtil.setRegistrationToken; +import static org.keycloak.client.registration.cli.util.HttpUtil.APPLICATION_JSON; +import static org.keycloak.client.registration.cli.util.HttpUtil.doGet; +import static org.keycloak.client.registration.cli.util.HttpUtil.doPost; +import static org.keycloak.client.registration.cli.util.IoUtil.printOut; +import static org.keycloak.client.registration.cli.util.IoUtil.warnfOut; +import static org.keycloak.client.registration.cli.util.OsUtil.CMD; +import static org.keycloak.client.registration.cli.util.OsUtil.PROMPT; + +/** + * @author Marko Strukelj + */ +@CommandDefinition(name = "update-token", description = "CLIENT [ARGUMENTS]") +public class UpdateTokenCmd extends AbstractAuthOptionsCmd { + + @Arguments + private List args = new ArrayList<>(); + + @Override + public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { + + try { + processGlobalOptions(); + + if (args.isEmpty()) { + throw new RuntimeException("CLIENT not specified"); + } + + String clientId = args.get(0); + + if (clientId.startsWith("-")) { + warnfOut(ParseUtil.CLIENTID_OPTION_WARN, clientId); + } + + ConfigData config = loadConfig(); + config = copyWithServerInfo(config); + setupTruststore(config, commandInvocation); + + config = ensureAuthInfo(config, commandInvocation); + String auth = ensureToken(config); + + String cid = null; + + final String server = config.getServerUrl(); + final String realm = config.getRealm(); + + // first we need to get id of the client with client_id == clientId + InputStream response = doGet(server + "/admin/realms/" + realm + "/clients", APPLICATION_JSON, "Bearer " + auth); + try { + List clients = JsonSerialization.readValue(response, new TypeReference>() {}); + for (ClientRepresentation client: clients) { + if (clientId.equals(client.getClientId())) { + cid = client.getId(); + break; + } + } + } catch (IOException e) { + throw new RuntimeException("Failed to process response from server", e); + } + + if (cid == null) { + throw new RuntimeException("No client found for: " + clientId); + } + + response = doPost(server + "/admin/realms/" + realm + "/clients/" + cid + "/registration-access-token", + APPLICATION_JSON, APPLICATION_JSON, null, "Bearer " + auth); + + try { + ClientRepresentation client = JsonSerialization.readValue(response, ClientRepresentation.class); + + if (noconfig) { + // output to stdout + printOut(client.getRegistrationAccessToken()); + } else { + saveMergeConfig(cfg -> { + setRegistrationToken(cfg.ensureRealmConfigData(server, realm), client.getClientId(), client.getRegistrationAccessToken()); + }); + } + } catch (IOException e) { + throw new RuntimeException("Failed to process response from server", e); + } + + //System.out.println("Token updated for client " + clientId); + return CommandResult.SUCCESS; + + } finally { + commandInvocation.stop(); + } + } + + public static String usage() { + StringWriter sb = new StringWriter(); + PrintWriter out = new PrintWriter(sb); + out.println("Usage: " + CMD + " update-token CLIENT [ARGUMENTS]"); + out.println(); + out.println("Command to reissue, and set a new registration access token if an old one is lost or becomes invalid."); + out.println("It requires an authenticated session using an account with administrator priviliges."); + out.println(); + out.println("Arguments:"); + out.println(); + out.println(" Global options:"); + out.println(" -x Print full stack trace when exiting with error"); + out.println(" -c, --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 leaves"); + out.println(" no tokens in config file."); + out.println(); + out.println(" Command specific options:"); + out.println(" CLIENT ClientId of the client to reissue a new Registration Access Token for"); + out.println(" The new token is saved to a config file or printed to stdout if on-the-fly\n"); + out.println(" authentication is used"); + out.println(); + out.println("Examples:"); + out.println(); + out.println("Request a new Registration Access Token from the server using current authenticated session:"); + out.println(" " + PROMPT + " " + CMD + " update-token my_client"); + out.println(); + out.println("Use '" + CMD + " help' for general information and a list of commands"); + return sb.toString(); + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/AttributeKey.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/AttributeKey.java new file mode 100644 index 0000000000..49e3ab4470 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/AttributeKey.java @@ -0,0 +1,154 @@ +package org.keycloak.client.registration.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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/AttributeOperation.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/AttributeOperation.java new file mode 100644 index 0000000000..cfc3a87d02 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/AttributeOperation.java @@ -0,0 +1,42 @@ +package org.keycloak.client.registration.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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/CmdStdinContext.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/CmdStdinContext.java new file mode 100644 index 0000000000..b3b31a91cf --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/CmdStdinContext.java @@ -0,0 +1,75 @@ +/* + * 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.common; + +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.oidc.OIDCClientRepresentation; + +/** + * @author Marko Strukelj + */ +public class CmdStdinContext { + + private EndpointType regType; + private ClientRepresentation client; + private OIDCClientRepresentation oidcClient; + private String content; + + public CmdStdinContext() {} + + public EndpointType getEndpointType() { + return regType; + } + + public void setEndpointType(EndpointType regType) { + this.regType = regType; + } + + public ClientRepresentation getClient() { + return client; + } + + public void setClient(ClientRepresentation client) { + this.client = client; + } + + public OIDCClientRepresentation getOidcClient() { + return oidcClient; + } + + public void setOidcClient(OIDCClientRepresentation oidcClient) { + this.oidcClient = oidcClient; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getRegistrationAccessToken() { + if (client != null) { + return client.getRegistrationAccessToken(); + } else if (oidcClient != null) { + return oidcClient.getRegistrationAccessToken(); + } + return null; + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/EndpointType.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/EndpointType.java new file mode 100644 index 0000000000..5114adacb0 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/EndpointType.java @@ -0,0 +1,63 @@ +/* + * 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.common; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * @author Marko Strukelj + */ +public enum EndpointType { + DEFAULT("default", "default"), + OIDC("openid-connect", "oidc", "oidc"), + INSTALL("install", "install", "adapter"), + SAML2("saml2-entity-descriptor", "saml2", "saml2"); + + private String endpoint; + private String preferredName; + private Set alternativeNames; + + private EndpointType(String endpoint, String preferredName, String ... alternativeNames) { + this.endpoint = endpoint; + this.preferredName = preferredName; + this.alternativeNames = new HashSet(Arrays.asList(alternativeNames)); + } + + public static EndpointType of(String name) { + if (DEFAULT.endpoint.equals(name) || DEFAULT.alternativeNames.contains(name)) { + return DEFAULT; + } else if (OIDC.endpoint.equals(name) || OIDC.alternativeNames.contains(name)) { + return OIDC; + } else if (INSTALL.endpoint.equals(name) || INSTALL.alternativeNames.contains(name)) { + return INSTALL; + } else if (SAML2.endpoint.equals(name) || SAML2.alternativeNames.contains(name)) { + return SAML2; + } + throw new IllegalArgumentException("Endpoint not supported: " + name); + } + + public String getEndpoint() { + return endpoint; + } + + public String getName() { + return preferredName; + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/ParsingContext.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/ParsingContext.java new file mode 100644 index 0000000000..f5fbb78044 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/common/ParsingContext.java @@ -0,0 +1,113 @@ +package org.keycloak.client.registration.cli.common; + + +/** + * An iterator wrapping command line + * + * @author Marko Strukelj + */ +public class ParsingContext { + + private int offset; + private int pos = -1; + private String [] args; + + public ParsingContext(String [] args) { + this(args, 0, -1); + } + + public ParsingContext(String [] args, int offset) { + this(args, offset, -1); + } + + public ParsingContext(String [] args, int offset, int pos) { + this.args = args.clone(); + this.offset = offset; + this.pos = pos; + } + + public boolean hasNext() { + return pos < args.length-1; + } + + + public boolean hasNext(int count) { + return pos < args.length - count; + } + + public boolean hasPrevious() { + return pos > 0; + } + + /** + * Get next argument + * + * @return Next argument or null if beyond the end of arguments + */ + public String next() { + if (hasNext()) { + return args[++pos]; + } else { + pos = args.length; + return null; + } + } + + /** + * Check that a next argument is available + * + * @return Next argument or RuntimeException if next argument is not available + */ + public String nextRequired() { + if (!hasNext()) { + throw new RuntimeException("Option " + current() + " requires a value"); + } + return next(); + } + + /** + * Get next n-th argument + * + * @return Next n-th argument or null if beyond the end of arguments + */ + public String next(int n) { + if (hasNext(n)) { + pos += n; + return args[pos]; + } else { + pos = args.length; + return null; + } + } + + /** + * Get previous argument + * + * @return Previous argument or null if previous call was at the beginning of the arguments (pos == 0) + */ + public String previous() { + if (hasPrevious()) { + return args[--pos]; + } else { + pos = -1; + return null; + } + } + + /** + * Get current argument + * + * @return Current argument or null if current parsing position is beyond end, or before start + */ + public String current() { + if (pos < 0 || pos >= args.length) { + return null; + } else { + return args[pos]; + } + } + + public String [] getArgs() { + return args; + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigData.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigData.java new file mode 100644 index 0000000000..0ae56e0441 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigData.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.registration.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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigHandler.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigHandler.java new file mode 100644 index 0000000000..2b33c04560 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigHandler.java @@ -0,0 +1,12 @@ +package org.keycloak.client.registration.cli.config; + +/** + * @author Marko Strukelj + */ +public interface ConfigHandler { + + void saveMergeConfig(ConfigUpdateOperation op); + + ConfigData loadConfig(); + +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigUpdateOperation.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigUpdateOperation.java new file mode 100644 index 0000000000..98b0c85e33 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/ConfigUpdateOperation.java @@ -0,0 +1,10 @@ +package org.keycloak.client.registration.cli.config; + +/** + * @author Marko Strukelj + */ +public interface ConfigUpdateOperation { + + void update(ConfigData data); + +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/FileConfigHandler.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/FileConfigHandler.java new file mode 100644 index 0000000000..46e7a002ac --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/FileConfigHandler.java @@ -0,0 +1,119 @@ +package org.keycloak.client.registration.cli.config; + +import org.keycloak.client.registration.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.registration.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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/InMemoryConfigHandler.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/InMemoryConfigHandler.java new file mode 100644 index 0000000000..1d572b2005 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/InMemoryConfigHandler.java @@ -0,0 +1,24 @@ +package org.keycloak.client.registration.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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/RealmConfigData.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/RealmConfigData.java new file mode 100644 index 0000000000..58b34fa996 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/config/RealmConfigData.java @@ -0,0 +1,220 @@ +/* + * 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.config; + +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * @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; + + private String initialToken; + + private Map clients = new LinkedHashMap(); + + + 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 String getInitialToken() { + return initialToken; + } + + public void setInitialToken(String initialToken) { + this.initialToken = initialToken; + } + + public Map getClients() { + return clients; + } + + 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; + initialToken = source.initialToken; + + mergeClients(source); + } + + private void mergeClients(RealmConfigData source) { + if (source.clients != null) { + if (clients == null) { + clients = source.clients; + } else { + for (String key: source.clients.keySet()) { + String val = source.clients.get(key); + if (!"".equals(val)) { + clients.put(key, val); + } else { + clients.remove(key); + } + } + } + } + } + + public void mergeRefreshTokens(RealmConfigData source) { + token = source.token; + refreshToken = source.refreshToken; + expiresAt = source.expiresAt; + refreshExpiresAt = source.refreshExpiresAt; + + mergeClients(source); + } + + public void mergeRegistrationTokens(RealmConfigData source) { + initialToken = source.initialToken; + mergeClients(source); + } + + @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; + data.initialToken = initialToken; + data.clients = new LinkedHashMap<>(clients); + return data; + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AttributeException.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AttributeException.java new file mode 100644 index 0000000000..30f3caa775 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AttributeException.java @@ -0,0 +1,23 @@ +package org.keycloak.client.registration.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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AuthUtil.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AuthUtil.java new file mode 100644 index 0000000000..8752e60d13 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/AuthUtil.java @@ -0,0 +1,206 @@ +/* + * 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.util; + +import org.keycloak.client.registration.cli.config.ConfigData; +import org.keycloak.client.registration.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.security.PrivateKey; +import java.util.UUID; + +import static java.lang.System.currentTimeMillis; +import static org.keycloak.client.registration.cli.util.ConfigUtil.checkAuthInfo; +import static org.keycloak.client.registration.cli.util.ConfigUtil.saveMergeConfig; +import static org.keycloak.client.registration.cli.util.HttpUtil.APPLICATION_FORM_URL_ENCODED; +import static org.keycloak.client.registration.cli.util.HttpUtil.APPLICATION_JSON; +import static org.keycloak.client.registration.cli.util.HttpUtil.doPost; +import static org.keycloak.client.registration.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 (UnsupportedEncodingException e) { + throw new RuntimeException("Unexpected error", e); + } catch (IOException e) { + throw new RuntimeException("Failed to read Refresh Token response", 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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ConfigUtil.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ConfigUtil.java new file mode 100644 index 0000000000..96996a2f96 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ConfigUtil.java @@ -0,0 +1,114 @@ +/* + * 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.util; + +import org.keycloak.client.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.config.ConfigHandler; +import org.keycloak.client.registration.cli.config.ConfigUpdateOperation; +import org.keycloak.client.registration.cli.config.InMemoryConfigHandler; +import org.keycloak.client.registration.cli.config.RealmConfigData; +import org.keycloak.representations.AccessTokenResponse; + +/** + * @author Marko Strukelj + */ +public class ConfigUtil { + + public static final String DEFAULT_CONFIG_FILE_STRING = OsUtil.OS_ARCH.isWindows() ? "%HOMEDRIVE%%HOMEPATH%\\.keycloak\\kcreg.config" : "~/.keycloak/kcreg.config"; + + public static final String DEFAULT_CONFIG_FILE_PATH = System.getProperty("user.home") + "/.keycloak/kcreg.config"; + + private static ConfigHandler handler; + + public static ConfigHandler getHandler() { + return handler; + } + + public static void setHandler(ConfigHandler handler) { + ConfigUtil.handler = handler; + } + + public static String getRegistrationToken(RealmConfigData data, String clientId) { + String token = data.getClients().get(clientId); + return token == null || token.length() == 0 ? null : token; + } + + public static void setRegistrationToken(RealmConfigData data, String clientId, String token) { + data.getClients().put(clientId, token == null ? "" : token); + } + + 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); + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/DebugBufferedInputStream.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/DebugBufferedInputStream.java new file mode 100644 index 0000000000..fa200d660e --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/DebugBufferedInputStream.java @@ -0,0 +1,78 @@ +package org.keycloak.client.registration.cli.util; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; + +/** + * @author Marko Strukelj + */ +public class DebugBufferedInputStream extends BufferedInputStream { + + public DebugBufferedInputStream(InputStream in) { + super(in); + } + + @Override + public synchronized int read() throws IOException { + log("read() >>>"); + int b = super.read(); + log("read() <<< " + (char) b + " (" + b + ")"); + return b; + } + + @Override + public synchronized int read(byte[] b, int off, int len) throws IOException { + log("read(buf, off, len) >>>"); + int c = super.read(b, off, len); + log("read(buf, off, len) <<< " + (c != -1 ? "[" + new String(b, off, c) + "]" : "-1")); + return c; + } + + @Override + public synchronized long skip(long n) throws IOException { + log("skip()"); + return super.skip(n); + } + + @Override + public synchronized int available() throws IOException { + log("available() >>>"); + int c = super.available(); + log("available() >>> " + c); + return c; + } + + @Override + public synchronized void mark(int readlimit) { + log("mark()"); + super.mark(readlimit); + } + + @Override + public synchronized void reset() throws IOException { + log("reset()"); + super.reset(); + } + + @Override + public boolean markSupported() { + log("markSupported()"); + return super.markSupported(); + } + + @Override + public void close() throws IOException { + log("close()"); + super.close(); + } + + @Override + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + private void log(String msg) { + System.err.println(msg); + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/HttpUtil.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/HttpUtil.java new file mode 100644 index 0000000000..ec6e9bd131 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/HttpUtil.java @@ -0,0 +1,188 @@ +/* + * 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.util; + +import org.apache.http.Header; +import org.apache.http.HttpHeaders; +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; +import org.apache.http.client.methods.HttpGet; +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.ssl.SSLContexts; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.HttpClientBuilder; +import org.keycloak.client.registration.cli.common.EndpointType; +import org.keycloak.util.JsonSerialization; + +import javax.net.ssl.SSLContext; +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.Map; + +/** + * @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); + } + } + + 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 { + 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 getExpectedContentType(EndpointType type) { + switch (type) { + case DEFAULT: + case OIDC: + return APPLICATION_JSON; + case SAML2: + return APPLICATION_XML; + default: + throw new RuntimeException("Unsupported endpoint type: " + type); + } + } + + 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); + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/IoUtil.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/IoUtil.java new file mode 100644 index 0000000000..7b38505785 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/IoUtil.java @@ -0,0 +1,235 @@ +/* + * 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.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.registration.cli.aesh.Globals; + +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +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.registration.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 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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/OsArch.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/OsArch.java new file mode 100644 index 0000000000..823d1fbb07 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/OsArch.java @@ -0,0 +1,60 @@ +/* + * Copyright 2014 Red Hat, Inc. and/or its affiliates. + * + * Licensed under the Eclipse Public License version 1.0, available at http://www.eclipse.org/legal/epl-v10.html + */ +package org.keycloak.client.registration.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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/OsUtil.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/OsUtil.java new file mode 100644 index 0000000000..c0b9974035 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/OsUtil.java @@ -0,0 +1,48 @@ +package org.keycloak.client.registration.cli.util; + +/** + * @author Marko Strukelj + */ +public class OsUtil { + + public static final OsArch OS_ARCH = determineOSAndArch(); + + public static final String CMD = OS_ARCH.isWindows() ? "kcreg.bat" : "kcreg.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/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ParseUtil.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ParseUtil.java new file mode 100644 index 0000000000..02ed27a686 --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ParseUtil.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.registration.cli.util; + +import com.fasterxml.jackson.core.JsonParseException; +import com.fasterxml.jackson.databind.exc.UnrecognizedPropertyException; +import org.keycloak.client.registration.cli.common.AttributeOperation; +import org.keycloak.client.registration.cli.common.CmdStdinContext; +import org.keycloak.client.registration.cli.common.EndpointType; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.oidc.OIDCClientRepresentation; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.util.List; + +import static java.lang.System.arraycopy; +import static org.keycloak.client.registration.cli.util.IoUtil.readFileOrStdin; +import static org.keycloak.client.registration.cli.util.ReflectionUtil.setAttributes; + +/** + * @author Marko Strukelj + */ +public class ParseUtil { + + public static final String CLIENTID_OPTION_WARN = "You're using what looks like an OPTION as CLIENT_ID: %s"; + public static final String TOKEN_OPTION_WARN = "You're using what looks like an OPTION as TOKEN: %s"; + + public static String[] shift(String[] args) { + if (args.length == 1) + return new String[0]; + String [] nu = new String [args.length-1]; + arraycopy(args, 1, nu, 0, args.length-1); + return nu; + } + + 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, EndpointType type) { + + String content = readFileOrStdin(file).trim(); + ClientRepresentation client = null; + OIDCClientRepresentation oidcClient = null; + + if (type == null) { + // guess the correct endpoint from content of the file + if (content.startsWith("<")) { + // looks like XML + type = EndpointType.SAML2; + } else if (content.startsWith("{")) { + // looks like JSON? + // try parse as ClientRepresentation + try { + client = JsonSerialization.readValue(content, ClientRepresentation.class); + type = EndpointType.DEFAULT; + + } catch (JsonParseException e) { + throw new RuntimeException("Failed to read the input document as JSON: " + e.getMessage(), e); + } catch (Exception ignored) { + // deliberately not logged + } + + if (client == null) { + // try parse as OIDCClientRepresentation + try { + oidcClient = JsonSerialization.readValue(content, OIDCClientRepresentation.class); + type = EndpointType.OIDC; + } catch (IOException ne) { + throw new RuntimeException("Unable to determine input document type. Use -e TYPE to specify the registration endpoint to use"); + } catch (Exception e) { + throw new RuntimeException("Failed to read the input document as JSON", e); + } + } + + } else if (content.length() == 0) { + throw new RuntimeException("Document provided by --file option is empty"); + } else { + throw new RuntimeException("Unable to determine input document type. Use -e TYPE to specify the registration endpoint to use"); + } + } + + // check content type, making sure it can be parsed into .json if it's not saml xml + if (content != null) { + try { + if (type == EndpointType.DEFAULT && client == null) { + client = JsonSerialization.readValue(content, ClientRepresentation.class); + } else if (type == EndpointType.OIDC && oidcClient == null) { + oidcClient = JsonSerialization.readValue(content, OIDCClientRepresentation.class); + } + } catch (JsonParseException e) { + throw new RuntimeException("Not a valid JSON document - " + e.getMessage(), e); + } catch (UnrecognizedPropertyException e) { + throw new RuntimeException("Attribute '" + e.getPropertyName() + "' not supported on document type '" + type.getName() + "'", e); + } catch (IOException e) { + throw new RuntimeException("Not a valid JSON document", e); + } + } + + CmdStdinContext ctx = new CmdStdinContext(); + ctx.setEndpointType(type); + ctx.setContent(content); + ctx.setClient(client); + ctx.setOidcClient(oidcClient); + return ctx; + } + + public static CmdStdinContext mergeAttributes(CmdStdinContext ctx, List attrs) { + String content = ctx.getContent(); + ClientRepresentation client = ctx.getClient(); + OIDCClientRepresentation oidcClient = ctx.getOidcClient(); + EndpointType type = ctx.getEndpointType(); + try { + if (content == null) { + if (type == EndpointType.DEFAULT) { + client = new ClientRepresentation(); + } else if (type == EndpointType.OIDC) { + oidcClient = new OIDCClientRepresentation(); + } + } + Object rep = client != null ? client : oidcClient; + if (rep != null) { + try { + setAttributes(rep, attrs); + } catch (AttributeException e) { + throw new RuntimeException("Failed to set attribute '" + e.getAttributeName() + "' on document type '" + type.getName() + "'", e); + } + content = JsonSerialization.writeValueAsString(rep); + } else { + throw new RuntimeException("Setting attributes is not supported for type: " + type.getName()); + } + } catch (IOException e) { + throw new RuntimeException("Failed to merge set attributes with configuration from file", e); + } + + ctx.setContent(content); + ctx.setClient(client); + ctx.setOidcClient(oidcClient); + return ctx; + } +} diff --git a/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ReflectionUtil.java b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ReflectionUtil.java new file mode 100644 index 0000000000..1fe500b82b --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/main/java/org/keycloak/client/registration/cli/util/ReflectionUtil.java @@ -0,0 +1,498 @@ +/* + * 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.util; + +import com.fasterxml.jackson.core.JsonParseException; +import org.keycloak.client.registration.cli.common.AttributeKey; +import org.keycloak.client.registration.cli.common.AttributeOperation; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * @author Marko Strukelj + */ +public class ReflectionUtil { + + static Map> index = new HashMap<>(); + + static void populateAttributesIndex(Class type) { + // We are using fields rather than getters / setters + // because it seems like JSON mapping sometimes also uses fields as well + // This may have to be changed some day due to reliance on Field.setAccessible() + Map map = new HashMap<>(); + Field [] fields = type.getDeclaredFields(); + for (Field f: fields) { + // make sure to also have access to non-public fields + f.setAccessible(true); + map.put(f.getName(), f); + } + index.put(type, map); + } + + public static Map getAttrFieldsForType(Type gtype) { + Class type; + if (gtype instanceof Class) { + type = (Class) gtype; + } else if (gtype instanceof ParameterizedType) { + type = (Class) ((ParameterizedType) gtype).getRawType(); + } else { + throw new RuntimeException("Unexpected type: " + gtype); + } + + if (isListType(type) || isMapType(type)) { + return Collections.emptyMap(); + } + Map map = index.get(type); + if (map == null) { + populateAttributesIndex(type); + map = index.get(type); + } + return map; + } + + public static boolean isListType(Class type) { + return List.class.isAssignableFrom(type) || type.isArray(); + } + + public static boolean isBasicType(Type type) { + return type == String.class || type == Boolean.class || type == boolean.class + || type == Integer.class || type == int.class || type == Long.class || type == long.class + || type == Float.class || type == float.class || type == Double.class || type == double.class; + } + + public static boolean isMapType(Class type) { + return Map.class.isAssignableFrom(type); + } + + public static Object convertValueToType(Object value, Class type) throws IOException { + + if (value == null) { + return null; + + } else if (value instanceof String) { + if (type == String.class) { + return value; + } else if (type == Boolean.class) { + return Boolean.valueOf((String) value); + } else if (type == Integer.class) { + return Integer.valueOf((String) value); + } else if (type == Long.class) { + return Long.valueOf((String) value); + } else { + return JsonSerialization.readValue((String) value, type); + } + } else if (value instanceof Number) { + if (type == Integer.class) { + return ((Number) value).intValue(); + } else if (type == Long.class) { + return ((Long) value).longValue(); + } else if (type == String.class) { + return String.valueOf(value); + } + } else if (value instanceof Boolean) { + if (type == Boolean.class) { + return value; + } else if (type == String.class) { + return String.valueOf(value); + } + } + + throw new RuntimeException("Unable to handle type [" + type + "]"); + } + + public static void setAttributes(Object client, List attrs) { + + for (AttributeOperation item: attrs) { + + AttributeKey attr = item.getKey(); + Object nested = client; + + List cs = attr.getComponents(); + for (int i = 0; i < cs.size(); i++) { + AttributeKey.Component c = cs.get(i); + + Class type = nested.getClass(); + Field field = null; + + if (!isMapType(type)) { + Map fields = getAttrFieldsForType(type); + if (fields == null) { + throw new AttributeException(attr.toString(), "Unexpected condition - unknown type: " + type); + } + + field = fields.get(c.getName()); + Class parent = type; + while (field == null) { + parent = parent.getSuperclass(); + if (parent == Object.class) { + throw new AttributeException(attr.toString(), "Unknown attribute '" + c.getName() + "' on " + client.getClass()); + } + + fields = getAttrFieldsForType(parent); + field = fields.get(c.getName()); + } + } + // if it's a 'basic' type we directly use setter + type = field == null ? type : field.getType(); + if (isBasicType(type)) { + if (i < cs.size() - 1) { + throw new AttributeException(attr.toString(), "Attribute is of primitive type, and can't be nested further: " + c); + } + + try { + Object val = convertValueToType(item.getValue(), type); + field.set(nested, val); + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to set attribute " + attr, e); + } + } else if (isListType(type)) { + if (i < cs.size() -1) { + // not the target component + try { + nested = field.get(nested); + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to get attribute \"" + c + "\" in " + attr, e); + } + if (c.getIndex() >= 0) { + // list item + // get idx-th item + List l = (List) nested; + if (c.getIndex() >= l.size()) { + throw new AttributeException(attr.toString(), "Array index out of bounds for \"" + c + "\" in " + attr); + } + nested = l.get(c.getIndex()); + } + } else { + // target component + Class itype = type; + Type gtype = field.getGenericType(); + if (gtype instanceof ParameterizedType) { + Type[] typeArgs = ((ParameterizedType) gtype).getActualTypeArguments(); + if (typeArgs.length >= 1 && typeArgs[0] instanceof Class) { + itype = (Class) typeArgs[0]; + } else { + itype = String.class; + } + } + if (c.getIndex() >= 0 || attr.isAppend()) { + // some list item + // get the list first + List target; + try { + target = (List) field.get(nested); + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to get list attribute: " + attr, e); + } + + // now replace or add idx-th item + if (target == null) { + target = createNewList(type); + try { + field.set(nested, target); + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to set list attribute " + attr, e); + } + } + if (c.getIndex() >= target.size()) { + throw new AttributeException(attr.toString(), "Array index out of bounds for \"" + c + "\" in " + attr); + } + + if (attr.isAppend()) { + try { + Object value = convertValueToType(item.getValue(), itype); + if (c.getIndex() >= 0) { + target.add(c.getIndex(), value); + } else { + target.add(value); + } + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to set list attribute " + attr, e); + } + + } else { + if (item.getType() == AttributeOperation.Type.SET) { + try { + Object value = convertValueToType(item.getValue(), itype); + target.set(c.getIndex(), value); + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to set list attribute " + attr, e); + } + } else { + try { + target.remove(c.getIndex()); + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to remove list attribute " + attr, e); + } + } + } + + } else { + // set the whole list field itself + List value = createNewList(type);; + if (item.getType() == AttributeOperation.Type.SET) { + List converted = convertValueToList(item.getValue(), itype); + value.addAll(converted); + } + try { + field.set(nested, value); + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to set list attribute " + attr, e); + } + } + } + } else { + // object type + if (i < cs.size() -1) { + // not the target component + Object value; + if (field == null) { + if (isMapType(nested.getClass())) { + value = ((Map) nested).get(c.getName()); + } else { + throw new RuntimeException("Unexpected condition while processing: " + attr); + } + } else { + try { + value = field.get(nested); + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to get attribute \"" + c + "\" in " + attr, e); + } + } + if (value == null) { + // create the target attribute + if (isMapType(nested.getClass())) { + throw new RuntimeException("Creating nested object trees not supported"); + } else { + try { + value = createNewObject(type); + field.set(nested, value); + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to set attribute " + attr, e); + } + } + } + nested = value; + } else { + // target component + // todo implement map put + if (isMapType(nested.getClass())) { + try { + ((Map) nested).put(c.getName(), item.getValue()); + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to set map key " + attr, e); + } + } else { + try { + Object value = convertValueToType(item.getValue(), type); + field.set(nested, value); + } catch (Exception e) { + throw new AttributeException(attr.toString(), "Failed to set attribute " + attr, e); + } + } + } + } + } + } + } + + private static Object createNewObject(Class type) throws Exception { + return type.newInstance(); + } + + public static List createNewList(Class type) { + + if (type == List.class) { + return new ArrayList(); + } else if (type.isInterface()) { + throw new RuntimeException("Can't instantiate a list type: " + type); + } + + try { + return (List) type.newInstance(); + } catch (Exception e) { + throw new RuntimeException("Failed to instantiate a list type: " + type, e); + } + } + + public static List convertValueToList(String value, Class itemType) { + try { + List result = new LinkedList(); + if (!value.startsWith("[")) { + throw new RuntimeException("List attribute value has to start with '[' - '" + value + "'"); + } + List parsed = JsonSerialization.readValue(value, List.class); + for (Object item: parsed) { + if (itemType.isAssignableFrom(item.getClass())) { + result.add(item); + } else { + result.add(convertValueToType(item, itemType)); + } + } + return result; + + } catch (JsonParseException e) { + throw new RuntimeException("Failed to parse list value: " + e.getMessage(), e); + } catch (IOException e) { + throw new RuntimeException("Failed to parse list value: " + value, e); + } + } + + public static void merge(T source, T dest) { + // Use existing index for type, then iterate over all attributes and + // use setter on dest, and getter on source to copy value over + Map fieldMap = getAttrFieldsForType(source.getClass()); + try { + for (String attrName : fieldMap.keySet()) { + Field field = fieldMap.get(attrName); + Object localValue = field.get(source); + if (localValue != null) { + field.set(dest, localValue); + } + } + } catch (Exception e) { + throw new RuntimeException("Failed to merge changes", e); + } + } + + + public static LinkedHashMap getAttributeListWithJSonTypes(Class type, AttributeKey attr) { + + LinkedHashMap result = new LinkedHashMap<>(); + attr = attr != null ? attr : new AttributeKey(); + + Map fields = getAttrFieldsForType(type); + for (AttributeKey.Component c: attr.getComponents()) { + Field f = fields.get(c.getName()); + if (f == null) { + throw new AttributeException(attr.toString(), "No such attribute: " + attr); + } + + type = f.getType(); + if (isBasicType(type) || isListType(type) || isMapType(type)) { + return result; + } else { + fields = getAttrFieldsForType(type); + } + } + + for (Map.Entry item : fields.entrySet()) { + String key = item.getKey(); + Class clazz = item.getValue().getType(); + String t = getTypeString(clazz, item.getValue()); + + result.put(key, t); + } + return result; + } + + public static Field resolveField(Class type, AttributeKey attr) { + Field f = null; + Type gtype = type; + + for (AttributeKey.Component c: attr.getComponents()) { + if (f != null) { + gtype = f.getGenericType(); + if (gtype instanceof ParameterizedType) { + Type[] typeargs = ((ParameterizedType) gtype).getActualTypeArguments(); + if (typeargs.length > 0) { + gtype = typeargs[typeargs.length-1]; + } + } + } + Map fields = getAttrFieldsForType(gtype); + f = fields.get(c.getName()); + if (f == null) { + throw new AttributeException(attr.toString(), "No such attribute: " + attr); + } + } + return f; + } + + public static String getTypeString(Type type, Field field) { + Class clazz = null; + if (type == null) { + if (field == null) { + throw new IllegalArgumentException("type == null and field == null"); + } + type = field.getGenericType(); + } + if (type instanceof Class) { + clazz = (Class) type; + } else if (type instanceof ParameterizedType) { + StringBuilder sb = new StringBuilder(); + String rtype = getTypeString(((ParameterizedType) type).getRawType(), null); + + sb.append(rtype); + sb.append(" ").append("("); + Type[] typeArgs = ((ParameterizedType) type).getActualTypeArguments(); + + for (int i = 0; i < typeArgs.length; i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(getTypeString(typeArgs[i], null)); + } + sb.append(")"); + return sb.toString(); + } + + if (CharSequence.class.isAssignableFrom(clazz)) { + return "string"; + } else if (Integer.class.isAssignableFrom(clazz) || int.class.isAssignableFrom(clazz)) { + return "int"; + } else if (Long.class.isAssignableFrom(clazz) || long.class.isAssignableFrom(clazz)) { + return "long"; + } else if (Float.class.isAssignableFrom(clazz) || float.class.isAssignableFrom(clazz)) { + return "float"; + } else if (Double.class.isAssignableFrom(clazz) || double.class.isAssignableFrom(clazz)) { + return "double"; + } else if (Number.class.isAssignableFrom(clazz)) { + return "number"; + } else if (Boolean.class.isAssignableFrom(clazz) || boolean.class.isAssignableFrom(clazz)) { + return "boolean"; + } else if (isListType(clazz)) { + if (field != null) { + Type gtype = field.getGenericType(); + if (gtype == clazz && clazz.isArray()) { + return "array (" + getTypeString(clazz.getComponentType(), null) + ")"; + } + return getTypeString(gtype, null); + } + return "array"; + } else if (isMapType(clazz)) { + if (field != null) { + Type gtype = field.getGenericType(); + return getTypeString(gtype, null); + } + return "object"; + } else { + return "object"; + } + } +} diff --git a/integration/client-cli/client-registration-cli/src/test/java/org/keycloak/client/registration/cli/util/ReflectionUtilTest.java b/integration/client-cli/client-registration-cli/src/test/java/org/keycloak/client/registration/cli/util/ReflectionUtilTest.java new file mode 100644 index 0000000000..2a20e54bcd --- /dev/null +++ b/integration/client-cli/client-registration-cli/src/test/java/org/keycloak/client/registration/cli/util/ReflectionUtilTest.java @@ -0,0 +1,355 @@ +package org.keycloak.client.registration.cli.util; + +import org.junit.Assert; +import org.junit.Ignore; +import org.junit.Test; +import org.keycloak.client.registration.cli.common.AttributeKey; +import org.keycloak.client.registration.cli.common.AttributeKey.Component; +import org.keycloak.client.registration.cli.common.AttributeOperation; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.Arrays; +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.registration.cli.common.AttributeOperation.Type.DELETE; +import static org.keycloak.client.registration.cli.common.AttributeOperation.Type.SET; + +/** + * @author Marko Strukelj + */ +public class ReflectionUtilTest { + + @Ignore + @Test + public void testListAttributes() { + LinkedHashMap items = null; +/* + items = getAttributeListWithJSonTypes(Data.class, new AttributeKey("")); + + for (Map.Entry item: items.entrySet()) { + System.out.printf("%-40s %s\n", item.getKey(), item.getValue()); + } +*/ +/* + System.out.println("\n-- nested ------------------------\n"); + + items = getAttributeListWithJSonTypes(Data.class, new AttributeKey("nested")); + for (Map.Entry item: items.entrySet()) { + System.out.printf("%-40s %s\n", item.getKey(), item.getValue()); + } +*/ + + System.out.println("\n-- dataList ----------------------\n"); + + items = ReflectionUtil.getAttributeListWithJSonTypes(Data.class, new AttributeKey("dataList")); + for (Map.Entry item: items.entrySet()) { + System.out.printf("%-40s %s\n", item.getKey(), item.getValue()); + } + + if (items.size() == 0) { + Field f = ReflectionUtil.resolveField(Data.class, new AttributeKey("dataList")); + String ts = ReflectionUtil.getTypeString(null, f); + Type t = f.getGenericType(); + if ((List.class.isAssignableFrom(f.getType()) || f.getType().isArray()) && t instanceof ParameterizedType) { + System.out.printf("%s, where object is:\n", ts); + } + t = ((ParameterizedType) t).getActualTypeArguments()[0]; + if (t instanceof Class) { + items = ReflectionUtil.getAttributeListWithJSonTypes((Class) t, null); + for (Map.Entry item: items.entrySet()) { + System.out.printf(" %-37s %s\n", item.getKey(), item.getValue()); + } + } + } + } + + @Test + public void testSettingAttibutes() { + Data data = new Data(); + + LinkedList attrs = new LinkedList<>(); + + attrs.add(new AttributeOperation(SET, "longAttr", "42")); + attrs.add(new AttributeOperation(SET, "strAttr", "not null")); + attrs.add(new AttributeOperation(SET, "strList+", "two")); + attrs.add(new AttributeOperation(SET, "strList+", "three")); + attrs.add(new AttributeOperation(SET, "strList[0]+", "one")); + attrs.add(new AttributeOperation(SET, "config", "{\"key1\": \"value1\"}")); + attrs.add(new AttributeOperation(SET, "config.key2", "value2")); + attrs.add(new AttributeOperation(SET, "nestedConfig", "{\"key1\": {\"sub key1\": \"sub value1\"}}")); + attrs.add(new AttributeOperation(SET, "nestedConfig.key1.\"sub key2\"", "sub value2")); + attrs.add(new AttributeOperation(SET, "nested.strList", "[1,2,3,4]")); + attrs.add(new AttributeOperation(SET, "nested.dataList+", "{\"baseAttr\": \"item1\", \"strList\": [\"confidential\", \"public\"]}")); + attrs.add(new AttributeOperation(SET, "nested.dataList+", "{\"baseAttr\": \"item2\", \"strList\": [\"external\"]}")); + attrs.add(new AttributeOperation(SET, "nested.dataList[1].baseAttr", "changed item2")); + attrs.add(new AttributeOperation(SET, "nested.nested.strList", "[\"first\",\"second\"]")); + attrs.add(new AttributeOperation(DELETE, "nested.strList[1]")); + attrs.add(new AttributeOperation(SET, "nested.nested.nested", "{\"baseAttr\": \"NEW VALUE\", \"strList\": [true, false]}")); + attrs.add(new AttributeOperation(SET, "nested.strAttr", "NOT NULL")); + attrs.add(new AttributeOperation(DELETE, "nested.strAttr")); + + ReflectionUtil.setAttributes(data, attrs); + + Assert.assertEquals("longAttr", Long.valueOf(42), data.getLongAttr()); + Assert.assertEquals("strAttr", "not null", data.getStrAttr()); + Assert.assertEquals("strList", Arrays.asList("one", "two", "three"), data.getStrList()); + + Map expectedMap = new HashMap<>(); + expectedMap.put("key1", "value1"); + expectedMap.put("key2", "value2"); + Assert.assertEquals("config", expectedMap, data.getConfig()); + + + expectedMap = new HashMap<>(); + expectedMap.put("sub key1", "sub value1"); + expectedMap.put("sub key2", "sub value2"); + + Assert.assertNotNull("nestedConfig", data.getNestedConfig()); + Assert.assertEquals("nestedConfig has one element", 1, data.getNestedConfig().size()); + Assert.assertEquals("nestedConfig.key1", expectedMap, data.getNestedConfig().get("key1")); + + + Data nested = data.getNested(); + Assert.assertEquals("nested.strAttr", null, nested.getStrAttr()); + Assert.assertEquals("nested.strList", Arrays.asList("1", "3", "4"), nested.getStrList()); + Assert.assertEquals("nested.dataList[0].baseAttr", "item1", nested.getDataList().get(0).getBaseAttr()); + Assert.assertEquals("nested.dataList[0].strList", Arrays.asList("confidential", "public"), nested.getDataList().get(0).getStrList()); + Assert.assertEquals("nested.dataList[1].baseAttr", "changed item2", nested.getDataList().get(1).getBaseAttr()); + Assert.assertEquals("nested.dataList[1].strList", Arrays.asList("external"), nested.getDataList().get(1).getStrList()); + + nested = nested.getNested(); + Assert.assertEquals("nested.nested.strList", Arrays.asList("first", "second"), nested.getStrList()); + + nested = nested.getNested(); + Assert.assertEquals("nested.nested.nested.baseAttr", "NEW VALUE", nested.getBaseAttr()); + Assert.assertEquals("nested.nested.nested.strList", Arrays.asList("true", "false"), nested.getStrList()); + } + + @Test + public void testKeyParsing() { + + assertAttributeKey(new AttributeKey("am.bam.pet"), "am", -1, "bam", -1, "pet", -1); + + assertAttributeKey(new AttributeKey("a"), "a", -1); + + assertAttributeKey(new AttributeKey("a.b"), "a", -1, "b", -1); + + assertAttributeKey(new AttributeKey("a.b[1]"), "a", -1, "b", 1); + + assertAttributeKey(new AttributeKey("a[12].b"), "a", 12, "b", -1); + + assertAttributeKey(new AttributeKey("a[10].b[20]"), "a", 10, "b", 20); + + assertAttributeKey(new AttributeKey("\"am\".\"bam\".\"pet\""), "am", -1, "bam", -1, "pet", -1); + + assertAttributeKey(new AttributeKey("\"am\".bam.\"pet\""), "am", -1, "bam", -1, "pet", -1); + + assertAttributeKey(new AttributeKey("\"am.bam\".\"pet\""), "am.bam", -1, "pet", -1); + + assertAttributeKey(new AttributeKey("\"am.bam[2]\".\"pet[6]\""), "am.bam", 2, "pet", 6); + + try { + new AttributeKey("a."); + + Assert.fail("Should have failed"); + } catch (RuntimeException expected) { + } + + try { + new AttributeKey("a[]"); + + Assert.fail("Should have failed"); + } catch (RuntimeException expected) { + } + + try { + new AttributeKey("a[lala]"); + + Assert.fail("Should have failed"); + } catch (RuntimeException expected) { + } + + try { + new AttributeKey("a[\"lala\"]"); + + Assert.fail("Should have failed"); + } catch (RuntimeException expected) { + } + + try { + new AttributeKey(".a"); + + Assert.fail("Should have failed"); + } catch (RuntimeException expected) { + } + + try { + new AttributeKey("\"am\"..\"bam\".\"pet\""); + + Assert.fail("Should have failed"); + } catch (RuntimeException expected) { + } + + try { + new AttributeKey("\"am\"ups.\"bam\".\"pet\""); + + Assert.fail("Should have failed"); + } catch (RuntimeException expected) { + } + + try { + new AttributeKey("ups\"am\"ups.\"bam\".\"pet\""); + + Assert.fail("Should have failed"); + } catch (RuntimeException expected) { + } + } + + private void assertAttributeKey(AttributeKey key, Object ... args) { + Iterator it = key.getComponents().iterator(); + + for (int i = 0; i < args.length; i++) { + String name = String.valueOf(args[i++]); + int idx = Integer.valueOf(String.valueOf(args[i])); + + Component component = it.next(); + Assert.assertEquals(name, component.getName()); + Assert.assertEquals(idx, component.getIndex()); + } + } + + + public static class BaseData { + + String baseAttr; + + public String getBaseAttr() { + return baseAttr; + } + + public void setBaseAttr(String baseAttr) { + this.baseAttr = baseAttr; + } + } + + public static class Data extends BaseData { + + String strAttr; + + Integer intAttr; + + Long longAttr; + + Boolean boolAttr; + + List strList; + + List intList; + + List dataList; + + List> deepList; + + Data nested; + + Map config; + + Map> nestedConfig; + + + public String getStrAttr() { + return strAttr; + } + + public void setStrAttr(String strAttr) { + this.strAttr = strAttr; + } + + public Integer getIntAttr() { + return intAttr; + } + + public void setIntAttr(Integer intAttr) { + this.intAttr = intAttr; + } + + public Long getLongAttr() { + return longAttr; + } + + public void setLongAttr(Long longAttr) { + this.longAttr = longAttr; + } + + public Boolean getBoolAttr() { + return boolAttr; + } + + public void setBoolAttr(Boolean boolAttr) { + this.boolAttr = boolAttr; + } + + public List getStrList() { + return strList; + } + + public void setStrList(List strList) { + this.strList = strList; + } + + public List getIntList() { + return intList; + } + + public void setIntList(List intList) { + this.intList = intList; + } + + public List getDataList() { + return dataList; + } + + public void setDataList(List dataList) { + this.dataList = dataList; + } + + public Data getNested() { + return nested; + } + + public void setNested(Data nested) { + this.nested = nested; + } + + public List> getDeepList() { + return deepList; + } + + public void setDeepList(List> deepList) { + this.deepList = deepList; + } + + public void setConfig(Map config) { + this.config = config; + } + + public Map getConfig() { + return config; + } + + public void setNestedConfig(Map> nestedConfig) { + this.nestedConfig = nestedConfig; + } + + public Map> getNestedConfig() { + return nestedConfig; + } + } +} \ No newline at end of file diff --git a/integration/client-registration-cli/pom.xml b/integration/client-cli/pom.xml old mode 100755 new mode 100644 similarity index 58% rename from integration/client-registration-cli/pom.xml rename to integration/client-cli/pom.xml index af7a2ad4f6..354c7e9aae --- a/integration/client-registration-cli/pom.xml +++ b/integration/client-cli/pom.xml @@ -1,4 +1,3 @@ - + + + org.keycloak + keycloak-client-cli-dist + zip + +