diff --git a/.travis.yml b/.travis.yml index 53a9440385..35c406117f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,7 +19,7 @@ before_script: - export MAVEN_SKIP_RC=true install: - - travis_wait 30 mvn install -Pdistribution -DskipTests=true -B -V -q + - travis_wait 60 mvn install -Pdistribution -DskipTests=true -B -V -q script: - mvn test -B diff --git a/adapters/oidc/js/src/main/resources/keycloak.js b/adapters/oidc/js/src/main/resources/keycloak.js index 2d8f4218c8..01f0523511 100755 --- a/adapters/oidc/js/src/main/resources/keycloak.js +++ b/adapters/oidc/js/src/main/resources/keycloak.js @@ -29,7 +29,7 @@ var loginIframe = { enable: true, - callbackMap: [], + callbackList: [], interval: 5 }; @@ -824,7 +824,7 @@ setTimeout(check, loginIframe.interval * 1000); } - var src = getRealmUrl() + '/protocol/openid-connect/login-status-iframe.html?client_id=' + encodeURIComponent(kc.clientId) + '&origin=' + getOrigin(); + var src = getRealmUrl() + '/protocol/openid-connect/login-status-iframe.html'; iframe.setAttribute('src', src ); iframe.style.display = 'none'; document.body.appendChild(iframe); @@ -834,30 +834,21 @@ return; } - try { - var data = JSON.parse(event.data); - } catch (err) { - return; - } - - if (!data.callbackId) { - return; - } - - var promise = loginIframe.callbackMap[data.callbackId]; - if (!promise) { - return; - } - - delete loginIframe.callbackMap[data.callbackId]; - - if ((!kc.sessionId || kc.sessionId == data.session) && data.loggedIn) { - promise.setSuccess(); - } else { + if (event.data != "unchanged") { kc.clearToken(); - promise.setError(); + } + + for (var i = loginIframe.callbackList.length - 1; i >= 0; --i) { + var promise = loginIframe.callbackList[i]; + if (event.data == "unchanged") { + promise.setSuccess(); + } else { + promise.setError(); + } + loginIframe.callbackList.splice(i, 1); } }; + window.addEventListener('message', messageCallback, false); var check = function() { @@ -873,12 +864,13 @@ function checkLoginIframe() { var promise = createPromise(); - if (loginIframe.iframe && loginIframe.iframeOrigin) { - var msg = {}; - msg.callbackId = createCallbackId(); - loginIframe.callbackMap[msg.callbackId] = promise; + if (loginIframe.iframe && loginIframe.iframeOrigin ) { + var msg = kc.clientId + ' ' + kc.sessionId; + loginIframe.callbackList.push(promise); var origin = loginIframe.iframeOrigin; - loginIframe.iframe.contentWindow.postMessage(JSON.stringify(msg), origin); + if (loginIframe.callbackList.length == 1) { + loginIframe.iframe.contentWindow.postMessage(msg, origin); + } } else { promise.setSuccess(); } diff --git a/adapters/oidc/js/src/main/resources/login-status-iframe.html b/adapters/oidc/js/src/main/resources/login-status-iframe.html index fe7eda1cc5..8794d54795 100755 --- a/adapters/oidc/js/src/main/resources/login-status-iframe.html +++ b/adapters/oidc/js/src/main/resources/login-status-iframe.html @@ -15,10 +15,56 @@ ~ limitations under the License. --> + + \ No newline at end of file + + + \ No newline at end of file diff --git a/core/src/main/java/org/keycloak/json/StringListMapDeserializer.java b/core/src/main/java/org/keycloak/json/StringListMapDeserializer.java new file mode 100644 index 0000000000..cd8ab4cf28 --- /dev/null +++ b/core/src/main/java/org/keycloak/json/StringListMapDeserializer.java @@ -0,0 +1,57 @@ +/* + * 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.json; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ArrayNode; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +public class StringListMapDeserializer extends JsonDeserializer { + + @Override + public Object deserialize(JsonParser jsonParser, DeserializationContext deserializationContext) throws IOException { + JsonNode jsonNode = jsonParser.readValueAsTree(); + Iterator> itr = jsonNode.fields(); + Map> map = new HashMap<>(); + while (itr.hasNext()) { + Map.Entry e = itr.next(); + List values = new LinkedList<>(); + if (!e.getValue().isArray()) { + values.add(e.getValue().asText()); + } else { + ArrayNode a = (ArrayNode) e.getValue(); + Iterator vitr = a.elements(); + while (vitr.hasNext()) { + values.add(vitr.next().asText()); + } + } + map.put(e.getKey(), values); + } + return map; + } + +} diff --git a/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java b/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java index b715bf5c95..26bcdf6f6f 100755 --- a/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java +++ b/core/src/main/java/org/keycloak/representations/idm/UserRepresentation.java @@ -17,7 +17,8 @@ package org.keycloak.representations.idm; -import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import org.keycloak.json.StringListMapDeserializer; import java.util.Arrays; import java.util.HashMap; @@ -43,8 +44,8 @@ public class UserRepresentation { protected String federationLink; protected String serviceAccountClientId; // For rep, it points to clientId (not DB ID) - // Currently there is Map> but for backwards compatibility, we also need to support Map - protected Map attributes; + @JsonDeserialize(using = StringListMapDeserializer.class) + protected Map> attributes; protected List credentials; protected List requiredActions; protected List federatedIdentities; @@ -141,17 +142,11 @@ public class UserRepresentation { this.emailVerified = emailVerified; } - public Map getAttributes() { + public Map> getAttributes() { return attributes; } - // This method can be removed once we can remove backwards compatibility with Keycloak 1.3 (then getAttributes() can be changed to return Map> ) - @JsonIgnore - public Map> getAttributesAsListValues() { - return (Map) attributes; - } - - public void setAttributes(Map attributes) { + public void setAttributes(Map> attributes) { this.attributes = attributes; } diff --git a/distribution/demo-dist/src/main/xslt/standalone.xsl b/distribution/demo-dist/src/main/xslt/standalone.xsl index 6099aa27e6..b75b775dc1 100755 --- a/distribution/demo-dist/src/main/xslt/standalone.xsl +++ b/distribution/demo-dist/src/main/xslt/standalone.xsl @@ -42,14 +42,14 @@ - - jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE + + jdbc:h2:${jboss.server.data.dir}/keycloak;AUTO_SERVER=TRUE h2 sa sa - + 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/examples/cors/angular-product-app/src/main/webapp/index.html b/examples/cors/angular-product-app/src/main/webapp/index.html index 462745232d..3ba134492e 100755 --- a/examples/cors/angular-product-app/src/main/webapp/index.html +++ b/examples/cors/angular-product-app/src/main/webapp/index.html @@ -92,9 +92,20 @@

Realm info

-
- Realm name: {{realm.realm}}
- Public key: {{realm.public_key}}
+
+ Realm issuer: {{realmOIDCInfo.issuer}}
+ + + + + + + + + + + +
Public Key KIDs
{{pk.kid}}

diff --git a/examples/cors/angular-product-app/src/main/webapp/js/app.js b/examples/cors/angular-product-app/src/main/webapp/js/app.js index 70db67e77e..5ddf077c65 100755 --- a/examples/cors/angular-product-app/src/main/webapp/js/app.js +++ b/examples/cors/angular-product-app/src/main/webapp/js/app.js @@ -87,8 +87,13 @@ module.controller('GlobalCtrl', function($scope, $http) { }; $scope.loadPublicRealmInfo = function() { - $http.get("http://localhost-auth:8080/auth/realms/cors").success(function(data) { - $scope.realm = angular.fromJson(data); + $http.get("http://localhost-auth:8080/auth/realms/cors/.well-known/openid-configuration").success(function(data) { + $scope.realmOIDCInfo = angular.fromJson(data); + + var jwksUri = $scope.realmOIDCInfo.jwks_uri; + $http.get(jwksUri).success(function(data) { + $scope.publicKeys = angular.fromJson(data); + }); }); }; 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 @@ - - + /*[local-name()='datasource' and starts-with(namespace-uri(), $nsDS) and @pool-name=$pool.name] + /*[local-name()='connection-url' and starts-with(namespace-uri(), $nsDS)]"> + - + diff --git a/testsuite/integration-arquillian/servers/auth-server/jboss/common/datasource.xsl b/testsuite/integration-arquillian/servers/auth-server/jboss/common/datasource.xsl index 72b0403f2f..f5ac4cde7a 100644 --- a/testsuite/integration-arquillian/servers/auth-server/jboss/common/datasource.xsl +++ b/testsuite/integration-arquillian/servers/auth-server/jboss/common/datasource.xsl @@ -29,42 +29,24 @@ + /*[local-name()='datasource' and starts-with(namespace-uri(), $nsDS) and @pool-name='KeycloakDS']"> - - - - + + + + - - - - - - - - - - - - - - - - - - - 4 - - - + + + + @@ -76,16 +58,23 @@ - + + + + + + + + + + + + - - - - - + diff --git a/testsuite/integration-arquillian/servers/auth-server/jboss/pom.xml b/testsuite/integration-arquillian/servers/auth-server/jboss/pom.xml index dc6587037d..303da20456 100644 --- a/testsuite/integration-arquillian/servers/auth-server/jboss/pom.xml +++ b/testsuite/integration-arquillian/servers/auth-server/jboss/pom.xml @@ -428,9 +428,6 @@ jdbc.mvn.version - - datasource.class.xa - keycloak.connectionsJpa.user @@ -440,15 +437,6 @@ keycloak.connectionsJpa.url - - db.hostname - - - db.name - - - db.port - @@ -523,22 +511,6 @@ db.jdbc_url ${keycloak.connectionsJpa.url} - - db.hostname - ${db.hostname} - - - db.port - ${db.port} - - - db.name - ${db.name} - - - datasource.class.xa - ${datasource.class.xa} - driver ${jdbc.mvn.artifactId} diff --git a/testsuite/integration-arquillian/servers/migration/pom.xml b/testsuite/integration-arquillian/servers/migration/pom.xml index d6091dd989..b08f84adc2 100644 --- a/testsuite/integration-arquillian/servers/migration/pom.xml +++ b/testsuite/integration-arquillian/servers/migration/pom.xml @@ -66,9 +66,6 @@ jdbc.mvn.version - - datasource.class.xa - keycloak.connectionsJpa.user @@ -78,15 +75,6 @@ keycloak.connectionsJpa.url - - db.hostname - - - db.name - - - db.port - @@ -179,22 +167,6 @@ db.jdbc_url ${keycloak.connectionsJpa.url} - - db.hostname - ${db.hostname} - - - db.port - ${db.port} - - - db.name - ${db.name} - - - datasource.class.xa - ${datasource.class.xa} - driver ${jdbc.mvn.artifactId} diff --git a/testsuite/integration-arquillian/servers/migration/src/main/xslt/datasource.xsl b/testsuite/integration-arquillian/servers/migration/src/main/xslt/datasource.xsl index 909c797d32..3ca8aa4f28 100644 --- a/testsuite/integration-arquillian/servers/migration/src/main/xslt/datasource.xsl +++ b/testsuite/integration-arquillian/servers/migration/src/main/xslt/datasource.xsl @@ -25,51 +25,27 @@ - - - - - + - - - - + + + + - - - - - - - - - - - - - - - - - - - 4 - - - + + + + @@ -81,16 +57,23 @@ - + + + + + + + + + + + + - - - - - + diff --git a/testsuite/integration-arquillian/tests/base/pom.xml b/testsuite/integration-arquillian/tests/base/pom.xml index 687710e93b..10a0f74d6c 100644 --- a/testsuite/integration-arquillian/tests/base/pom.xml +++ b/testsuite/integration-arquillian/tests/base/pom.xml @@ -32,6 +32,8 @@ + false + false - - - @@ -149,7 +151,30 @@ - + + + maven-dependency-plugin + + + unpack-client-cli-dist + generate-test-resources + + unpack + + + + + org.keycloak + keycloak-client-cli-dist + ${project.version} + zip + ${containers.home} + + + + + + diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/ExecutionException.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/ExecutionException.java new file mode 100644 index 0000000000..986f48627a --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/ExecutionException.java @@ -0,0 +1,27 @@ +package org.keycloak.testsuite.cli; + +/** + * @author Marko Strukelj + */ +public class ExecutionException extends RuntimeException { + + private int exitCode = -1; + + public ExecutionException(int exitCode) { + this.exitCode = exitCode; + } + + public ExecutionException(String message, int exitCode) { + super(message); + this.exitCode = exitCode; + } + + public int exitCode() { + return exitCode; + } + + @Override + public String toString() { + return super.toString() + ", exitCode: " + exitCode; + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcRegExec.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcRegExec.java new file mode 100644 index 0000000000..5f20c2a006 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/KcRegExec.java @@ -0,0 +1,487 @@ +package org.keycloak.testsuite.cli; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FilterOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * @author Marko Strukelj + */ +public class KcRegExec { + + public static final String WORK_DIR = System.getProperty("user.dir") + "/target/containers/keycloak-client-tools"; + + public static final OsArch OS_ARCH = OsUtils.determineOSAndArch(); + + public static final String CMD = OS_ARCH.isWindows() ? "kcreg.bat" : "kcreg.sh"; + + private long waitTimeout = 30000; + + private Process process; + + private int exitCode = -1; + + private boolean logStreams = Boolean.valueOf(System.getProperty("cli.log.output", "true")); + + private boolean dumpStreams; + + private String workDir = WORK_DIR; + + private String env; + + private String argsLine; + + private ByteArrayOutputStream stdout = new ByteArrayOutputStream(); + + private ByteArrayOutputStream stderr = new ByteArrayOutputStream(); + + private InputStream stdin = new InteractiveInputStream(); + + private Throwable err; + + private KcRegExec(String workDir, String argsLine, InputStream stdin) { + this(workDir, argsLine, null, stdin); + } + + private KcRegExec(String workDir, String argsLine, String env, InputStream stdin) { + if (workDir != null) { + this.workDir = workDir; + } + + this.argsLine = argsLine; + this.env = env; + + if (stdin != null) { + this.stdin = stdin; + } + } + + public static Builder newBuilder() { + return new Builder(); + } + + public static KcRegExec execute(String args) { + return newBuilder() + .argsLine(args) + .execute(); + } + + public void execute() { + executeAsync(); + if (err == null) { + waitCompletion(); + } + } + + + public void executeAsync() { + + try { + if (OS_ARCH.isWindows()) { + String cmd = (env != null ? "set " + env + " & " : "") + "bin\\" + CMD + " " + fixQuotes(argsLine); + System.out.println("Executing: cmd.exe /c " + cmd); + process = Runtime.getRuntime().exec(new String[]{"cmd.exe", "/c", cmd}, null, new File(workDir)); + } else { + String cmd = (env != null ? env + " " : "") + "bin/" + CMD + " " + argsLine; + System.out.println("Executing: sh -c " + cmd); + process = Runtime.getRuntime().exec(new String[]{"sh", "-c", cmd}, null, new File(workDir)); + } + + new StreamReaderThread(process.getInputStream(), logStreams ? new LoggingOutputStream("STDOUT", stdout) : stdout) + .start(); + + new StreamReaderThread(process.getErrorStream(), logStreams ? new LoggingOutputStream("STDERR", stderr) : stderr) + .start(); + + new StreamReaderThread(stdin, process.getOutputStream()) + .start(); + + } catch (Throwable t) { + err = t; + } + } + + private String fixQuotes(String argsLine) { + argsLine = argsLine + " "; + argsLine = argsLine.replaceAll("\"", "\\\\\""); + argsLine = argsLine.replaceAll(" '", " \""); + argsLine = argsLine.replaceAll("' ", "\" "); + return argsLine; + } + + public void waitCompletion() { + + //if (stdin instanceof InteractiveInputStream) { + // ((InteractiveInputStream) stdin).close(); + //} + try { + if (process.waitFor(waitTimeout, TimeUnit.MILLISECONDS)) { + exitCode = process.exitValue(); + if (exitCode != 0) { + dumpStreams = true; + } + } else { + if (process.isAlive()) { + process.destroyForcibly(); + } + throw new RuntimeException("Timeout after " + (waitTimeout / 1000) + " seconds."); + } + } catch (InterruptedException e) { + dumpStreams = true; + throw new RuntimeException("Interrupted ...", e); + } catch (Throwable t) { + dumpStreams = true; + err = t; + } finally { + if (!logStreams && dumpStreams) try { + System.out.println("STDOUT: "); + copyStream(new ByteArrayInputStream(stdout.toByteArray()), System.out); + System.out.println("STDERR: "); + copyStream(new ByteArrayInputStream(stderr.toByteArray()), System.out); + } catch (Exception ignored) { + } + } + } + + public int exitCode() { + return exitCode; + } + + public Throwable error() { + return err; + } + + public InputStream stdout() { + return new ByteArrayInputStream(stdout.toByteArray()); + } + + public List stdoutLines() { + return parseStreamAsLines(new ByteArrayInputStream(stdout.toByteArray())); + } + + public String stdoutString() { + return new String(stdout.toByteArray()); + } + + public InputStream stderr() { + return new ByteArrayInputStream(stderr.toByteArray()); + } + + public List stderrLines() { + return parseStreamAsLines(new ByteArrayInputStream(stderr.toByteArray())); + } + + public String stderrString() { + return new String(stderr.toByteArray()); + } + + static List parseStreamAsLines(InputStream stream) { + List lines = new ArrayList<>(); + try { + BufferedReader reader = new BufferedReader(new InputStreamReader(stream)); + + String line; + while ((line = reader.readLine()) != null) { + lines.add(line); + } + return lines; + } catch (IOException e) { + throw new RuntimeException("Unexpected I/O error", e); + } + } + + public void waitForStdout(String content) { + long start = System.currentTimeMillis(); + while (System.currentTimeMillis() - start < waitTimeout) { + if (stdoutString().indexOf(content) != -1) { + return; + } + try { + Thread.sleep(10); + } catch (InterruptedException e) { + throw new RuntimeException("Interrupted ...", e); + } + } + + throw new RuntimeException("Timed while waiting for content to appear in stdout"); + } + + public void sendToStdin(String s) { + if (stdin instanceof InteractiveInputStream) { + ((InteractiveInputStream) stdin).pushBytes(s.getBytes()); + } else { + throw new RuntimeException("Can't push to stdin - not interactive"); + } + } + + static class StreamReaderThread extends Thread { + + private InputStream is; + private OutputStream os; + + StreamReaderThread(InputStream is, OutputStream os) { + this.is = is; + this.os = os; + } + + public void run() { + try { + copyStream(is, os); + } catch (IOException e) { + throw new RuntimeException("Unexpected I/O error", e); + } finally { + try { + os.close(); + } catch (IOException ignored) { + System.err.print("IGNORED: error while closing output stream: "); + ignored.printStackTrace(); + } + } + } + } + + static void copyStream(InputStream is, OutputStream os) throws IOException { + byte [] buf = new byte[8192]; + + try (InputStream iss = is) { + int c; + while ((c = iss.read(buf)) != -1) { + os.write(buf, 0, c); + os.flush(); + } + } + } + + public static class Builder { + + private String workDir; + private String argsLine; + private InputStream stdin; + private String env; + private boolean dumpStreams; + + public Builder workDir(String path) { + this.workDir = path; + return this; + } + + public Builder argsLine(String cmd) { + this.argsLine = cmd; + return this; + } + + public Builder stdin(InputStream is) { + this.stdin = is; + return this; + } + + public Builder env(String env) { + this.env = env; + return this; + } + + public Builder fullStreamDump() { + this.dumpStreams = true; + return this; + } + + public KcRegExec execute() { + KcRegExec exe = new KcRegExec(workDir, argsLine, env, stdin); + exe.dumpStreams = dumpStreams; + exe.execute(); + return exe; + } + + public KcRegExec executeAsync() { + KcRegExec exe = new KcRegExec(workDir, argsLine, env, stdin); + exe.dumpStreams = dumpStreams; + exe.executeAsync(); + return exe; + } + } + + static class NullInputStream extends InputStream { + + @Override + public int read() throws IOException { + return -1; + } + } + + static class InteractiveInputStream extends InputStream { + + private LinkedList queue = new LinkedList<>(); + + private Thread consumer; + + private boolean closed; + + @Override + public int read(byte b[]) throws IOException { + return read(b, 0, b.length); + } + + @Override + public synchronized int read(byte[] b, int off, int len) throws IOException { + + Byte current = null; + int rc = 0; + try { + consumer = Thread.currentThread(); + + do { + current = queue.poll(); + if (current == null) { + if (rc > 0) { + return rc; + } else { + do { + if (closed) { + return -1; + } + wait(); + } + while ((current = queue.poll()) == null); + } + } + + b[off + rc] = current; + rc++; + } while (rc < len); + + } catch (InterruptedException e) { + throw new InterruptedIOException("Signalled to exit"); + } finally { + consumer = null; + } + return rc; + } + + @Override + public long skip(long n) throws IOException { + return super.skip(n); + } + + @Override + public int available() throws IOException { + return super.available(); + } + + @Override + public synchronized void mark(int readlimit) { + super.mark(readlimit); + } + + @Override + public synchronized void reset() throws IOException { + super.reset(); + } + + @Override + public boolean markSupported() { + return super.markSupported(); + } + + @Override + public synchronized int read() throws IOException { + if (closed) { + return -1; + } + + // when input is available pass it on + Byte current; + try { + consumer = Thread.currentThread(); + + while ((current = queue.poll()) == null) { + wait(); + if (closed) { + return -1; + } + } + + } catch (InterruptedException e) { + throw new InterruptedIOException("Signalled to exit"); + } finally { + consumer = null; + } + return current; + } + + @Override + public synchronized void close() { + closed = true; + new RuntimeException("IIS || close").printStackTrace(); + if (consumer != null) { + consumer.interrupt(); + } + } + + public synchronized void pushBytes(byte [] buff) { + for (byte b : buff) { + queue.add(b); + } + notify(); + } + } + + + static class LoggingOutputStream extends FilterOutputStream { + + private ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private String name; + + public LoggingOutputStream(String name, OutputStream os) { + super(os); + this.name = name; + } + + @Override + public void write(int b) throws IOException { + super.write(b); + if (b == 10) { + log(); + } else { + buffer.write(b); + } + } + + @Override + public void write(byte[] buf) throws IOException { + write(buf, 0, buf.length); + } + + @Override + public void write(byte[] buf, int offs, int len) throws IOException { + for (int i = 0; i < len; i++) { + write(buf[offs+i]); + } + } + + @Override + public void close() throws IOException { + super.close(); + if (buffer.size() > 0) { + log(); + } + } + + private void log() { + String log = new String(buffer.toByteArray()); + buffer.reset(); + System.out.println("[" + name + "] " + log); + } + } + +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/OsArch.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/OsArch.java new file mode 100644 index 0000000000..cdd6b86465 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/OsArch.java @@ -0,0 +1,42 @@ +/* + * 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.testsuite.cli; + +/** + * @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); + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/OsUtils.java b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/OsUtils.java new file mode 100644 index 0000000000..4944603ae9 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/main/java/org/keycloak/testsuite/cli/OsUtils.java @@ -0,0 +1,45 @@ +/* + * 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.testsuite.cli; + +/** + * @author Marko Strukelj + */ +public class OsUtils { + + public static OsArch determineOSAndArch() { + String os = System.getProperty("os.name").toLowerCase(); + String arch = System.getProperty("os.arch"); + + //System.out.println("OS: " + os + ", architecture: " + 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/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java index 676a40f052..89d5c5b36e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/AbstractKeycloakTest.java @@ -18,6 +18,27 @@ package org.keycloak.testsuite; import org.apache.commons.configuration.ConfigurationException; import org.apache.commons.configuration.PropertiesConfiguration; +import org.apache.http.ssl.SSLContexts; +import org.keycloak.common.util.KeycloakUriBuilder; +import org.keycloak.common.util.Time; +import org.keycloak.testsuite.arquillian.TestContext; + +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import javax.net.ssl.SSLContext; +import javax.ws.rs.NotFoundException; import org.jboss.arquillian.container.test.api.RunAsClient; import org.jboss.arquillian.drone.api.annotation.Drone; import org.jboss.arquillian.graphene.page.Page; @@ -33,8 +54,6 @@ import org.keycloak.admin.client.resource.RealmResource; import org.keycloak.admin.client.resource.RealmsResource; import org.keycloak.admin.client.resource.UserResource; import org.keycloak.admin.client.resource.UsersResource; -import org.keycloak.common.util.KeycloakUriBuilder; -import org.keycloak.common.util.Time; import org.keycloak.models.Constants; import org.keycloak.representations.idm.RealmRepresentation; import org.keycloak.representations.idm.RequiredActionProviderRepresentation; @@ -42,7 +61,6 @@ import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.testsuite.admin.ApiUtil; import org.keycloak.testsuite.arquillian.AuthServerTestEnricher; import org.keycloak.testsuite.arquillian.SuiteContext; -import org.keycloak.testsuite.arquillian.TestContext; import org.keycloak.testsuite.auth.page.AuthRealm; import org.keycloak.testsuite.auth.page.AuthServer; import org.keycloak.testsuite.auth.page.AuthServerContextRoot; @@ -56,16 +74,6 @@ import org.keycloak.testsuite.util.TestEventsLogger; import org.keycloak.testsuite.util.WaitUtils; import org.openqa.selenium.WebDriver; -import javax.ws.rs.NotFoundException; -import java.net.URI; -import java.net.URISyntaxException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.concurrent.TimeUnit; - import static org.keycloak.testsuite.admin.Users.setPasswordFor; import static org.keycloak.testsuite.auth.page.AuthRealm.ADMIN; import static org.keycloak.testsuite.auth.page.AuthRealm.MASTER; @@ -124,8 +132,12 @@ public abstract class AbstractKeycloakTest { @Before public void beforeAbstractKeycloakTest() throws Exception { + SSLContext ssl = null; + if ("true".equals(System.getProperty("auth.server.ssl.required"))) { + ssl = getSSLContextWithTrustore(new File("src/test/resources/keystore/keycloak.truststore"), "secret"); + } adminClient = Keycloak.getInstance(AuthServerTestEnricher.getAuthServerContextRoot() + "/auth", - MASTER, ADMIN, ADMIN, Constants.ADMIN_CLI_CLIENT_ID); + MASTER, ADMIN, ADMIN, Constants.ADMIN_CLI_CLIENT_ID, null, ssl); getTestingClient(); @@ -366,4 +378,14 @@ public abstract class AbstractKeycloakTest { } } + public static SSLContext getSSLContextWithTrustore(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(); + return theContext; + } } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/ProfileTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/ProfileTest.java index 4d83bd94c6..7e2614b212 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/ProfileTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/ProfileTest.java @@ -72,13 +72,8 @@ public class ProfileTest extends TestRealmKeycloakTest { UserRepresentation user = RealmRepUtil.findUser(testRealm, "test-user@localhost"); user.setFirstName("First"); user.setLastName("Last"); - Map attributes = user.getAttributes(); - if (attributes == null) { - attributes = new HashMap<>(); - user.setAttributes(attributes); - } - attributes.put("key1", "value1"); - attributes.put("key2", "value2"); + user.singleAttribute("key1", "value1"); + user.singleAttribute("key2", "value2"); UserRepresentation user2 = UserBuilder.create() .enabled(true) diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/custom/CustomAuthFlowOTPTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/custom/CustomAuthFlowOTPTest.java index 8d34b0233e..02308420ff 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/custom/CustomAuthFlowOTPTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/custom/CustomAuthFlowOTPTest.java @@ -159,11 +159,7 @@ public class CustomAuthFlowOTPTest extends AbstractCustomAccountManagementTest { setConditionalOTPForm(config); //add skip user attribute to user - Map userAttributes = new HashMap<>(); - List attributeValues = new ArrayList<>(); - attributeValues.add("skip"); - userAttributes.put("userSkipAttribute", attributeValues); - testUser.setAttributes(userAttributes); + testUser.singleAttribute("userSkipAttribute", "skip"); testRealmResource().users().get(testUser.getId()).update(testUser); //test OTP is skipped @@ -182,11 +178,7 @@ public class CustomAuthFlowOTPTest extends AbstractCustomAccountManagementTest { setConditionalOTPForm(config); //add force user attribute to user - Map userAttributes = new HashMap<>(); - List attributeValues = new ArrayList<>(); - attributeValues.add("force"); - userAttributes.put("userSkipAttribute", attributeValues); - testUser.setAttributes(userAttributes); + testUser.singleAttribute("userSkipAttribute", "force"); testRealmResource().users().get(testUser.getId()).update(testUser); //test OTP is required diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/TermsAndConditionsTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/TermsAndConditionsTest.java index 6fbd6141df..c7da3a9735 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/TermsAndConditionsTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/actions/TermsAndConditionsTest.java @@ -94,7 +94,7 @@ public class TermsAndConditionsTest extends TestRealmKeycloakTest { // assert user attribute is properly set UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost"); - Map> attributes = user.getAttributesAsListValues(); + Map> attributes = user.getAttributes(); assertNotNull("timestamp for terms acceptance was not stored in user attributes", attributes); List termsAndConditions = attributes.get(TermsAndConditions.USER_ATTRIBUTE); assertTrue("timestamp for terms acceptance was not stored in user attributes as " @@ -128,7 +128,7 @@ public class TermsAndConditionsTest extends TestRealmKeycloakTest { // assert user attribute is properly removed UserRepresentation user = ActionUtil.findUserWithAdminClient(adminClient, "test-user@localhost"); - Map> attributes = user.getAttributesAsListValues(); + Map> attributes = user.getAttributes(); if (attributes != null) { assertNull("expected null for terms acceptance user attribute " + TermsAndConditions.USER_ATTRIBUTE, attributes.get(TermsAndConditions.USER_ATTRIBUTE)); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractSAMLServletsAdapterTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractSAMLServletsAdapterTest.java index 9db0e516ac..290677848d 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractSAMLServletsAdapterTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/adapter/servlet/AbstractSAMLServletsAdapterTest.java @@ -61,13 +61,25 @@ import org.keycloak.testsuite.page.AbstractPage; import org.keycloak.testsuite.util.IOUtil; import org.openqa.selenium.By; import org.w3c.dom.Document; +import org.xml.sax.SAXException; import javax.ws.rs.client.Client; import javax.ws.rs.client.ClientBuilder; import javax.ws.rs.client.Entity; +import javax.ws.rs.client.WebTarget; import javax.ws.rs.core.Form; +import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.Response; +import javax.xml.XMLConstants; +import javax.xml.transform.Source; +import javax.xml.transform.stream.StreamSource; +import javax.xml.validation.Schema; +import javax.xml.validation.SchemaFactory; +import javax.xml.validation.Validator; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.net.URI; +import java.net.URL; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -724,6 +736,42 @@ public abstract class AbstractSAMLServletsAdapterTest extends AbstractServletsAd checkLoggedOut(employee2ServletPage, testRealmSAMLPostLoginPage); } + @Test + public void idpMetadataValidation() throws Exception { + driver.navigate().to(authServerPage.toString() + "/realms/" + SAMLSERVLETDEMO + "/protocol/saml/descriptor"); + validateXMLWithSchema(driver.getPageSource(), "/adapter-test/keycloak-saml/metadata-schema/saml-schema-metadata-2.0.xsd"); + } + + + @Test + public void spMetadataValidation() throws Exception { + ClientResource clientResource = ApiUtil.findClientResourceByClientId(testRealmResource(), "http://localhost:8081/sales-post-sig/"); + ClientRepresentation representation = clientResource.toRepresentation(); + Client client = ClientBuilder.newClient(); + WebTarget target = client.target(authServerPage.toString() + "/admin/realms/" + SAMLSERVLETDEMO + "/clients/" + representation.getId() + "/installation/providers/saml-sp-descriptor"); + Response response = target.request().header(HttpHeaders.AUTHORIZATION, "Bearer " + adminClient.tokenManager().getAccessToken().getToken()).get(); + validateXMLWithSchema(response.readEntity(String.class), "/adapter-test/keycloak-saml/metadata-schema/saml-schema-metadata-2.0.xsd"); + response.close(); + } + + private void validateXMLWithSchema(String xml, String schemaFileName) throws SAXException, IOException { + URL schemaFile = getClass().getResource(schemaFileName); + + Source xmlFile = new StreamSource(new ByteArrayInputStream(xml.getBytes()), xml); + SchemaFactory schemaFactory = SchemaFactory + .newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); + Schema schema = schemaFactory.newSchema(schemaFile); + Validator validator = schema.newValidator(); + try { + validator.validate(xmlFile); + System.out.println(xmlFile.getSystemId() + " is valid"); + } catch (SAXException e) { + System.out.println(xmlFile.getSystemId() + " is NOT valid"); + System.out.println("Reason: " + e.getLocalizedMessage()); + Assert.fail(); + } + } + private void createProtocolMapper(ProtocolMappersResource resource, String name, String protocol, String protocolMapper, Map config) { ProtocolMapperRepresentation representation = new ProtocolMapperRepresentation(); representation.setName(name); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java index c3f6c0d86d..81af8c6843 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/admin/UserTest.java @@ -417,19 +417,19 @@ public class UserTest extends AbstractAdminTest { List vals = new ArrayList<>(); vals.add("value2user2"); vals.add("value2user2_2"); - user2.getAttributesAsListValues().put("attr2", vals); + user2.getAttributes().put("attr2", vals); String user2Id = createUser(user2); user1 = realm.users().get(user1Id).toRepresentation(); - assertEquals(2, user1.getAttributesAsListValues().size()); - assertAttributeValue("value1user1", user1.getAttributesAsListValues().get("attr1")); - assertAttributeValue("value2user1", user1.getAttributesAsListValues().get("attr2")); + assertEquals(2, user1.getAttributes().size()); + assertAttributeValue("value1user1", user1.getAttributes().get("attr1")); + assertAttributeValue("value2user1", user1.getAttributes().get("attr2")); user2 = realm.users().get(user2Id).toRepresentation(); - assertEquals(2, user2.getAttributesAsListValues().size()); - assertAttributeValue("value1user2", user2.getAttributesAsListValues().get("attr1")); - vals = user2.getAttributesAsListValues().get("attr2"); + assertEquals(2, user2.getAttributes().size()); + assertAttributeValue("value1user2", user2.getAttributes().get("attr1")); + vals = user2.getAttributes().get("attr2"); assertEquals(2, vals.size()); assertTrue(vals.contains("value2user2") && vals.contains("value2user2_2")); @@ -439,18 +439,18 @@ public class UserTest extends AbstractAdminTest { updateUser(realm.users().get(user1Id), user1); user1 = realm.users().get(user1Id).toRepresentation(); - assertEquals(3, user1.getAttributesAsListValues().size()); - assertAttributeValue("value3user1", user1.getAttributesAsListValues().get("attr1")); - assertAttributeValue("value2user1", user1.getAttributesAsListValues().get("attr2")); - assertAttributeValue("value4user1", user1.getAttributesAsListValues().get("attr3")); + assertEquals(3, user1.getAttributes().size()); + assertAttributeValue("value3user1", user1.getAttributes().get("attr1")); + assertAttributeValue("value2user1", user1.getAttributes().get("attr2")); + assertAttributeValue("value4user1", user1.getAttributes().get("attr3")); user1.getAttributes().remove("attr1"); updateUser(realm.users().get(user1Id), user1); user1 = realm.users().get(user1Id).toRepresentation(); - assertEquals(2, user1.getAttributesAsListValues().size()); - assertAttributeValue("value2user1", user1.getAttributesAsListValues().get("attr2")); - assertAttributeValue("value4user1", user1.getAttributesAsListValues().get("attr3")); + assertEquals(2, user1.getAttributes().size()); + assertAttributeValue("value2user1", user1.getAttributes().get("attr2")); + assertAttributeValue("value4user1", user1.getAttributes().get("attr3")); user1.getAttributes().clear(); updateUser(realm.users().get(user1Id), user1); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/AbstractCliTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/AbstractCliTest.java new file mode 100644 index 0000000000..01ef40480f --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/AbstractCliTest.java @@ -0,0 +1,539 @@ +package org.keycloak.testsuite.cli.registration; + +import org.junit.Assert; +import org.junit.Before; +import org.keycloak.admin.client.resource.ClientInitialAccessResource; +import org.keycloak.admin.client.resource.ClientRegistrationTrustedHostResource; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.authentication.authenticators.client.ClientIdAndSecretAuthenticator; +import org.keycloak.authentication.authenticators.client.JWTClientAuthenticator; +import org.keycloak.client.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.config.FileConfigHandler; +import org.keycloak.client.registration.cli.config.RealmConfigData; +import org.keycloak.representations.idm.ClientInitialAccessCreatePresentation; +import org.keycloak.representations.idm.ClientInitialAccessPresentation; +import org.keycloak.representations.idm.ClientRegistrationTrustedHostRepresentation; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.idm.ComponentRepresentation; +import org.keycloak.representations.idm.RealmRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy; +import org.keycloak.services.clientregistration.policy.ClientRegistrationPolicyManager; +import org.keycloak.services.clientregistration.policy.RegistrationAuth; +import org.keycloak.services.clientregistration.policy.impl.TrustedHostClientRegistrationPolicyFactory; +import org.keycloak.testsuite.AbstractKeycloakTest; +import org.keycloak.testsuite.cli.KcRegExec; +import org.keycloak.testsuite.util.ClientBuilder; +import org.keycloak.testsuite.util.UserBuilder; +import org.keycloak.util.JsonSerialization; + +import javax.ws.rs.core.Response; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.keycloak.testsuite.admin.AbstractAdminTest.loadJson; +import static org.keycloak.testsuite.cli.KcRegExec.execute; + +/** + * @author Marko Strukelj + */ +public abstract class AbstractCliTest extends AbstractKeycloakTest { + + protected String serverUrl = isAuthServerSSL() ? + "https://localhost:" + getAuthServerHttpsPort() + "/auth" : + "http://localhost:" + getAuthServerHttpPort() + "/auth"; + + + @Before + public void deleteDefaultConfig() { + getDefaultConfigFilePath().delete(); + } + + static boolean runIntermittentlyFailingTests() { + return "true".equals(System.getProperty("test.intermittent")); + } + + static boolean isAuthServerSSL() { + return "true".equals(System.getProperty("auth.server.ssl.required")); + } + + static int getAuthServerHttpsPort() { + try { + return Integer.valueOf(System.getProperty("auth.server.https.port")); + } catch (Exception e) { + throw new RuntimeException("System property 'auth.server.https.port' not set or invalid: '" + + System.getProperty("auth.server.https.port") + "'"); + } + } + + static int getAuthServerHttpPort() { + try { + return Integer.valueOf(System.getProperty("auth.server.http.port")); + } catch (Exception e) { + throw new RuntimeException("System property 'auth.server.http.port' not set or invalid: '" + + System.getProperty("auth.server.http.port") + "'"); + } + } + + static File getDefaultConfigFilePath() { + return new File(System.getProperty("user.home") + "/.keycloak/kcreg.config"); + } + + + @Override + public void addTestRealms(List testRealms) { + RealmRepresentation realmRepresentation = loadJson(getClass().getResourceAsStream("/testrealm.json"), RealmRepresentation.class); + testRealms.add(realmRepresentation); + + // create admin user account with permissions to manage clients + UserRepresentation admin = UserBuilder.create() + .username("user1") + .password("userpass") + .enabled(true) + .build(); + HashMap> clientRoles = new HashMap<>(); + clientRoles.put("realm-management", Arrays.asList("manage-clients")); + admin.setClientRoles(clientRoles); + realmRepresentation.getUsers().add(admin); + + + + // create client with service account to use Signed JWT credentials with + ClientRepresentation regClient = ClientBuilder.create() + .clientId("reg-cli-jwt") + .attribute(JWTClientAuthenticator.CERTIFICATE_ATTR, "MIICnTCCAYUCBgFXUhpRTTANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdyZWctY2xpMB4XDTE2MDkyMjEzMzIxOFoXDTI2MDkyMjEzMzM1OFowEjEQMA4GA1UEAwwHcmVnLWNsaTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMHZn/0Bk1M9oKcTHxzn2cGvBWwO1m6OVLQ8LSVwNIf4ixfGkVIkhI5iEGYND+uD8ame54ZPClTVxMra3JldClLIG+L+ymnbT2vKIhEsVvCROs9PnYxbFALt1dXneLIio2uzF+d7/zQWlmeaWfNunSJT1aHNJDkGgDeUuQa25b0IMqsFjsN8Dg4ATkA97r3wKn4Tp3SE7sTM/B2pmra4atNxGeShVrgihqUiQ/PwDiDGwry64AsexkZnQsCR3bJWBAVUiHef3JWzTfWWN5bfCBG6Mnq1xw7YN+YpV1nR3CGmcKJuLe6aTe7Ps8hYejYiQA7Mp7ZQsoImsVFV5HDOlb0CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAZl8XvLfKXTPYvq/QyHOg7EDlAdlV3HkmHP9SBAV4BccmHmorMkm5I6I21UA5mfju+0nhbEd0bm0kvJFxIfNU6lJyyVvQx3Gns37KYUOzIV/ocWZuOTBLp5tfIBYbBwfE/s1J4PhpA/3WhBY9JKiLvdJfxECGIgaLs2M0UsylW/7o04+18Od8j/m7crQc7fpe5gJB5m/+hxUDowIjG5CumffX9OHYGDvHBpaUl7QNSGgjP8Bn9ogmIMUBJ7XSYUcohKuk2Cnj6p+GlLuqHbOISUXLVjf0DxhCu6diVxvacKbgAZmyCIO1tGL/UVRxg9GOYdCiC9vHfPuZ8US+ZB0P9g==") + .authenticatorType(JWTClientAuthenticator.PROVIDER_ID) + .serviceAccount() + .build(); + + realmRepresentation.getClients().add(regClient); + + // create service account for client reg-cli with permissions to manage clients + addServiceAccount(realmRepresentation, "reg-cli-jwt"); + + + + // create client to use with user account - enable direct grants + regClient = ClientBuilder.create() + .clientId("reg-cli-jwt-direct") + .attribute(JWTClientAuthenticator.CERTIFICATE_ATTR, "MIICnTCCAYUCBgFXUhpRTTANBgkqhkiG9w0BAQsFADASMRAwDgYDVQQDDAdyZWctY2xpMB4XDTE2MDkyMjEzMzIxOFoXDTI2MDkyMjEzMzM1OFowEjEQMA4GA1UEAwwHcmVnLWNsaTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMHZn/0Bk1M9oKcTHxzn2cGvBWwO1m6OVLQ8LSVwNIf4ixfGkVIkhI5iEGYND+uD8ame54ZPClTVxMra3JldClLIG+L+ymnbT2vKIhEsVvCROs9PnYxbFALt1dXneLIio2uzF+d7/zQWlmeaWfNunSJT1aHNJDkGgDeUuQa25b0IMqsFjsN8Dg4ATkA97r3wKn4Tp3SE7sTM/B2pmra4atNxGeShVrgihqUiQ/PwDiDGwry64AsexkZnQsCR3bJWBAVUiHef3JWzTfWWN5bfCBG6Mnq1xw7YN+YpV1nR3CGmcKJuLe6aTe7Ps8hYejYiQA7Mp7ZQsoImsVFV5HDOlb0CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAZl8XvLfKXTPYvq/QyHOg7EDlAdlV3HkmHP9SBAV4BccmHmorMkm5I6I21UA5mfju+0nhbEd0bm0kvJFxIfNU6lJyyVvQx3Gns37KYUOzIV/ocWZuOTBLp5tfIBYbBwfE/s1J4PhpA/3WhBY9JKiLvdJfxECGIgaLs2M0UsylW/7o04+18Od8j/m7crQc7fpe5gJB5m/+hxUDowIjG5CumffX9OHYGDvHBpaUl7QNSGgjP8Bn9ogmIMUBJ7XSYUcohKuk2Cnj6p+GlLuqHbOISUXLVjf0DxhCu6diVxvacKbgAZmyCIO1tGL/UVRxg9GOYdCiC9vHfPuZ8US+ZB0P9g==") + .authenticatorType(JWTClientAuthenticator.PROVIDER_ID) + .directAccessGrants() + .build(); + + realmRepresentation.getClients().add(regClient); + + + + + // create client with service account to use client secret with + regClient = ClientBuilder.create() + .clientId("reg-cli-secret") + .secret("password") + .authenticatorType(ClientIdAndSecretAuthenticator.PROVIDER_ID) + .serviceAccount() + .build(); + + realmRepresentation.getClients().add(regClient); + + // create service account for client reg-cli with permissions to manage clients + addServiceAccount(realmRepresentation, "reg-cli-secret"); + + + + + // create client to use with user account - enable direct grants + regClient = ClientBuilder.create() + .clientId("reg-cli-secret-direct") + .secret("password") + .authenticatorType(ClientIdAndSecretAuthenticator.PROVIDER_ID) + .directAccessGrants() + .build(); + + realmRepresentation.getClients().add(regClient); + + } + + + void loginAsUser(File configFile, String server, String realm, String user, String password) { + + KcRegExec exe = execute("config credentials --server " + server + " --realm " + realm + + " --user " + user + " --password " + password + " --config " + configFile.getAbsolutePath()); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + List lines = exe.stdoutLines(); + Assert.assertTrue("stdout output empty", lines.size() == 0); + + lines = exe.stderrLines(); + Assert.assertTrue("stderr output one line", lines.size() == 1); + Assert.assertEquals("stderr first line", "Logging into " + server + " as user " + user + " of realm " + realm, lines.get(0)); + } + + void assertFieldsEqualWithExclusions(ConfigData config1, ConfigData config2, String ... excluded) { + + HashSet exclusions = new HashSet<>(Arrays.asList(excluded)); + + if (!exclusions.contains("serverUrl")) { + Assert.assertEquals("serverUrl", config1.getServerUrl(), config2.getServerUrl()); + } + if (!exclusions.contains("realm")) { + Assert.assertEquals("realm", config1.getRealm(), config2.getRealm()); + } + if (!exclusions.contains("truststore")) { + Assert.assertEquals("truststore", config1.getTruststore(), config2.getTruststore()); + } + if (!exclusions.contains("endpoints")) { + Map> endp1 = config1.getEndpoints(); + Map> endp2 = config2.getEndpoints(); + + Iterator>> it1 = endp1.entrySet().iterator(); + Iterator>> it2 = endp2.entrySet().iterator(); + + while (it1.hasNext()) { + Map.Entry> ent1 = it1.next(); + Map.Entry> ent2 = it2.next(); + + String serverUrl = ent1.getKey(); + String endpskey = "endpoints." + serverUrl; + if (!exclusions.contains(endpskey)) { + Assert.assertEquals(endpskey, ent1.getKey(), ent2.getKey()); + + Map realms1 = ent1.getValue(); + Map realms2 = ent2.getValue(); + + Iterator> rit1 = realms1.entrySet().iterator(); + Iterator> rit2 = realms2.entrySet().iterator(); + + while (rit1.hasNext()) { + Map.Entry rent1 = rit1.next(); + Map.Entry rent2 = rit2.next(); + + String realm = rent1.getKey(); + String rkey = endpskey + "." + realm; + if (!exclusions.contains(endpskey)) { + Assert.assertEquals(rkey, rent1.getKey(), rent2.getKey()); + + RealmConfigData rdata1 = rent1.getValue(); + RealmConfigData rdata2 = rent2.getValue(); + + assertFieldsEqualWithExclusions(serverUrl, realm, rdata1, rdata2, excluded); + } + } + } + } + } + } + + void assertFieldsEqualWithExclusions(RealmConfigData data1, RealmConfigData data2, String ... excluded) { + assertFieldsEqualWithExclusions(null, null, data1, data2, excluded); + } + + void assertFieldsEqualWithExclusions(String server, String realm, RealmConfigData data1, RealmConfigData data2, String ... excluded) { + + HashSet exclusions = new HashSet<>(Arrays.asList(excluded)); + + String pfix = ""; + if (server != null || realm != null) { + pfix = "endpoints." + server + "." + realm + "."; + } + + String ekey = pfix + "serverUrl"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.serverUrl(), data2.serverUrl()); + } + + ekey = pfix + "realm"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.realm(), data2.realm()); + } + + ekey = pfix + "clientId"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getClientId(), data2.getClientId()); + } + + ekey = pfix + "initialToken"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getInitialToken(), data2.getInitialToken()); + } + + ekey = pfix + "token"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getToken(), data2.getToken()); + } + + ekey = pfix + "refreshToken"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getRefreshToken(), data2.getRefreshToken()); + } + + ekey = pfix + "expiresAt"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getExpiresAt(), data2.getExpiresAt()); + } + + ekey = pfix + "refreshExpiresAt"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getRefreshExpiresAt(), data2.getRefreshExpiresAt()); + } + + ekey = pfix + "secret"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getSecret(), data2.getSecret()); + } + + ekey = pfix + "signingToken"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getSigningToken(), data2.getSigningToken()); + } + + ekey = pfix + "sigExpiresAt"; + if (!exclusions.contains(ekey)) { + Assert.assertEquals(ekey, data1.getSigExpiresAt(), data2.getSigExpiresAt()); + } + + ekey = pfix + "clients"; + if (!exclusions.contains(ekey)) { + Map clients1 = data1.getClients(); + Map clients2 = data2.getClients(); + + Iterator> cit1 = clients1.entrySet().iterator(); + Iterator> cit2 = clients2.entrySet().iterator(); + + while (cit1.hasNext() || cit2.hasNext()) { + Map.Entry ckey1 = cit1.hasNext() ? cit1.next() : null; + Map.Entry ckey2 = cit2.hasNext() ? cit2.next() : null; + + String ckey = ekey + "." + (ckey1 != null ? ckey1.getKey() : ckey2.getKey()); + if (!exclusions.contains(ckey)) { + Assert.assertNotNull(ckey + " left not null", ckey1); + Assert.assertNotNull(ckey + " right not null", ckey2); + Assert.assertEquals(ckey, ckey1.getKey(), ckey2.getKey()); + Assert.assertEquals(ckey + " value", ckey1.getValue(), ckey2.getValue()); + } + } + } + } + + void addServiceAccount(RealmRepresentation realm, String clientId) { + + UserRepresentation account = UserBuilder.create() + .username("service-account-" + clientId) + .enabled(true) + .serviceAccountId(clientId) + .build(); + + HashMap> clientRoles = new HashMap<>(); + clientRoles.put("realm-management", Arrays.asList("manage-clients")); + + account.setClientRoles(clientRoles); + + realm.getUsers().add(account); + } + + + void waitFor(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + throw new RuntimeException("Interrupted"); + } + } + + + FileConfigHandler initCustomConfigFile() { + String filename = UUID.randomUUID().toString() + ".config"; + File cfgFile = new File(KcRegExec.WORK_DIR + "/" + filename); + FileConfigHandler handler = new FileConfigHandler(); + handler.setConfigFile(cfgFile.getAbsolutePath()); + return handler; + } + + File initTempFile(String extension) throws IOException { + return initTempFile(extension, null); + } + + File initTempFile(String extension, String content) throws IOException { + String filename = UUID.randomUUID().toString() + extension; + File file = new File(KcRegExec.WORK_DIR + "/" + filename); + if (content != null) { + OutputStream os = new FileOutputStream(file); + os.write(content.getBytes(Charset.forName("iso_8859_1"))); + os.close(); + } + return file; + } + + String issueInitialAccessToken(String realm) { + ClientInitialAccessResource resource = adminClient.realm(realm).clientInitialAccess(); + + ClientInitialAccessCreatePresentation rep = new ClientInitialAccessCreatePresentation(); + rep.setCount(10); + rep.setExpiration(100); + + ClientInitialAccessPresentation response = resource.create(rep); + + String token = response.getToken(); + Assert.assertNotNull("Issued initial access token not null", token); + return token; + } + + private ComponentRepresentation findPolicyByProviderAndAuth(String realm, String providerId, String authType) { + // Change the policy to avoid checking hosts + List reps = adminClient.realm(realm).components().query(realm, ClientRegistrationPolicy.class.getName()); + for (ComponentRepresentation rep : reps) { + if (rep.getSubType().equals(authType) && rep.getProviderId().equals(providerId)) { + return rep; + } + } + return null; + } + + void addLocalhostToAllowedHosts(String realm) { + RealmResource realmResource = adminClient.realm(realm); + String anonPolicy = ClientRegistrationPolicyManager.getComponentTypeKey(RegistrationAuth.ANONYMOUS); + + ComponentRepresentation trustedHostRep = findPolicyByProviderAndAuth(realm, TrustedHostClientRegistrationPolicyFactory.PROVIDER_ID, anonPolicy); + trustedHostRep.getConfig().putSingle(TrustedHostClientRegistrationPolicyFactory.TRUSTED_HOSTS, "localhost"); + realmResource.components().component(trustedHostRep.getId()).update(trustedHostRep); + } + + void testCRUDWithOnTheFlyAuth(String serverUrl, String credentials, String extraOptions, String loginMessage) throws IOException { + + File configFile = getDefaultConfigFilePath(); + long lastModified = configFile.exists() ? configFile.lastModified() : 0; + + // This test assumes it is the only user of any instance of on the system + KcRegExec exe = execute("create --no-config --server " + serverUrl + + " --realm test " + credentials + " " + extraOptions + " -s clientId=test-client -o"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertEquals("login message", loginMessage, exe.stderrLines().get(0)); + + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("clientId", "test-client", client.getClientId()); + Assert.assertNotNull("registrationAccessToken not null", client.getRegistrationAccessToken()); + + long lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + + + + + exe = execute("get test-client --no-config --server " + serverUrl + " --realm test " + credentials + " " + extraOptions); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + ClientRepresentation client2 = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("clientId", "test-client", client2.getClientId()); + + // we did not provide a token, thus no registrationAccessToken is present + Assert.assertNull("registrationAccessToken is null", client2.getRegistrationAccessToken()); + + lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + + + + + + // the token works even though an intermediary invocation was performed, + // because the previous invocation didn't use a registration access token + exe = execute("get test-client --no-config --server " + serverUrl + " --realm test " + extraOptions + " -t " + client.getRegistrationAccessToken()); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + ClientRepresentation client3 = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("clientId", "test-client", client3.getClientId()); + + Assert.assertNotEquals("registrationAccessToken in returned json is different than one returned by create", + client.getRegistrationAccessToken(), client3.getRegistrationAccessToken()); + + lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + + + + + + + exe = execute("update test-client --no-config --server " + serverUrl + " --realm test " + + credentials + " " + extraOptions + " -s enabled=false -o --unsafe"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + ClientRepresentation client4 = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("clientId", "test-client", client4.getClientId()); + Assert.assertFalse("enabled", client4.isEnabled()); + + Assert.assertNull("registrationAccessToken in null", client4.getRegistrationAccessToken()); + + lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + + + + + + + + exe = execute("update test-client --no-config --server " + serverUrl + " --realm test " + extraOptions + + " -s enabled=true -o -t " + client3.getRegistrationAccessToken()); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + ClientRepresentation client5 = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("clientId", "test-client", client5.getClientId()); + Assert.assertTrue("enabled", client5.isEnabled()); + + Assert.assertNotEquals("registrationAccessToken in returned json is different than one returned by get", + client3.getRegistrationAccessToken(), client5.getRegistrationAccessToken()); + + lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + + + + + + + + exe = execute("delete test-client --no-config --server " + serverUrl + " --realm test " + credentials + " " + extraOptions); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + + lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + + + + + + // subsequent delete should fail + exe = execute("delete test-client --no-config --server " + serverUrl + " --realm test " + credentials + " " + extraOptions); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("error message", "Client not found [invalid_request]", exe.stderrLines().get(1)); + + lastModified2 = configFile.exists() ? configFile.lastModified() : 0; + Assert.assertEquals("config file not modified", lastModified, lastModified2); + } + + void assertExitCodeAndStreamSizes(KcRegExec exe, int exitCode, int stdOutLineCount, int stdErrLineCount) { + Assert.assertEquals("exitCode == " + exitCode, exitCode, exe.exitCode()); + Assert.assertTrue("stdout output has " + stdOutLineCount + " lines", exe.stdoutLines().size() == stdOutLineCount); + Assert.assertTrue("stderr output has " + stdErrLineCount + " lines", exe.stderrLines().size() == stdErrLineCount); + + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegConfigTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegConfigTest.java new file mode 100644 index 0000000000..bd8fc7c28b --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegConfigTest.java @@ -0,0 +1,69 @@ +package org.keycloak.testsuite.cli.registration; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.client.registration.cli.config.FileConfigHandler; +import org.keycloak.testsuite.cli.KcRegExec; +import org.keycloak.testsuite.util.TempFileResource; + +import java.io.IOException; + +import static org.keycloak.client.registration.cli.util.OsUtil.EOL; +import static org.keycloak.testsuite.cli.KcRegExec.execute; + +/** + * @author Marko Strukelj + */ +public class KcRegConfigTest extends AbstractCliTest { + + @Test + public void testRegistrationToken() throws IOException { + + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + // forget --server + KcRegExec exe = execute("config registration-token --config '" + configFile.getName() + "' "); + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("error message", "Required option not specified: --server", exe.stderrLines().get(0)); + + // forget --realm + exe = execute("config registration-token --config '" + configFile.getName() + "' --server http://localhost:8080/auth"); + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("error message", "Required option not specified: --realm", exe.stderrLines().get(0)); + + // forget --client + exe = execute("config registration-token --config '" + configFile.getName() + "' --server http://localhost:8080/auth --realm test"); + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("error message", "Required option not specified: --client", exe.stderrLines().get(0)); + + // specify token on cmdline + exe = execute("config registration-token --config '" + configFile.getName() + "' --server http://localhost:8080/auth --realm test --client my_client NEWTOKEN"); + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + if (runIntermittentlyFailingTests()) { + // don't specify token - must be prompted for it + exe = KcRegExec.newBuilder() + .argsLine("config registration-token --config '" + configFile.getName() + "' --server http://localhost:8080/auth --realm test --client my_client") + .executeAsync(); + + exe.waitForStdout("Enter Registration Access Token:"); + exe.sendToStdin("NEWTOKEN" + EOL); + exe.waitCompletion(); + assertExitCodeAndStreamSizes(exe, 0, 1, 0); + + } else { + System.out.println("TEST SKIPPED PARTIALLY - This test currently suffers from intermittent failures. Use -Dtest.intermittent=true to run it in full."); + } + + // delete non-existent token + exe = execute("config registration-token --config '" + configFile.getName() + "' --server http://localhost:8080/auth --realm test --client nonexistent --delete"); + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + // delete token + exe = execute("config registration-token --config '" + configFile.getName() + "' --server http://localhost:8080/auth --realm test --client my_client --delete"); + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + } + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegCreateTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegCreateTest.java new file mode 100644 index 0000000000..6b6cb276c5 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegCreateTest.java @@ -0,0 +1,231 @@ +package org.keycloak.testsuite.cli.registration; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.client.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.config.FileConfigHandler; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.representations.oidc.OIDCClientRepresentation; +import org.keycloak.testsuite.cli.KcRegExec; +import org.keycloak.testsuite.util.TempFileResource; +import org.keycloak.util.JsonSerialization; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; + +import static org.keycloak.testsuite.cli.KcRegExec.execute; + +/** + * @author Marko Strukelj + */ +public class KcRegCreateTest extends AbstractCliTest { + + @Test + public void testCreateWithRealmOverride() throws IOException { + + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + // authenticate as a regular user against one realm + KcRegExec exe = execute("config credentials -x --config '" + configFile.getName() + + "' --server " + serverUrl + " --realm master --user admin --password admin"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + // use initial token of another realm with server, and realm override + String token = issueInitialAccessToken("test"); + exe = execute("create --config '" + configFile.getName() + "' --server " + serverUrl + " --realm test -s clientId=my_first_client -t " + token); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + } + } + + + @Test + public void testCreateThoroughly() throws IOException { + + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + // set initial access token in config + String token = issueInitialAccessToken("test"); + + final String realm = "test"; + + KcRegExec exe = execute("config initial-token -x --config '" + configFile.getName() + + "' --server " + serverUrl + " --realm " + realm + " " + token); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + // check that current server, realm, and initial token are saved in the file + ConfigData config = handler.loadConfig(); + Assert.assertEquals("Config serverUrl", serverUrl, config.getServerUrl()); + Assert.assertEquals("Config realm", realm, config.getRealm()); + Assert.assertEquals("Config initial access token", token, config.ensureRealmConfigData(serverUrl, realm).getInitialToken()); + + // create configuration from file using stdin redirect ... output an object + String content = "{\n" + + " \"clientId\": \"my_client\",\n" + + " \"enabled\": true,\n" + + " \"redirectUris\": [\"http://localhost:8980/myapp/*\"],\n" + + " \"serviceAccountsEnabled\": true,\n" + + " \"name\": \"My Client App\",\n" + + " \"implicitFlowEnabled\": false,\n" + + " \"publicClient\": true,\n" + + " \"protocol\": \"leycloak-oidc\",\n" + + " \"webOrigins\": [\"http://localhost:8980/myapp\"],\n" + + " \"consentRequired\": false,\n" + + " \"baseUrl\": \"http://localhost:8980/myapp\",\n" + + " \"rootUrl\": \"http://localhost:8980/myapp\",\n" + + " \"bearerOnly\": true,\n" + + " \"standardFlowEnabled\": true\n" + + "}"; + + try (TempFileResource tmpFile = new TempFileResource(initTempFile(".json", content))) { + + exe = execute("create --config '" + configFile.getName() + "' -o -f - < '" + tmpFile.getName() + "'"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertTrue("stderr is empty", exe.stderrLines().isEmpty()); + + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertNotNull("id", client.getId()); + Assert.assertEquals("clientId", "my_client", client.getClientId()); + Assert.assertEquals("enabled", true, client.isEnabled()); + Assert.assertEquals("redirectUris", Arrays.asList("http://localhost:8980/myapp/*"), client.getRedirectUris()); + Assert.assertEquals("serviceAccountsEnabled", true, client.isServiceAccountsEnabled()); + Assert.assertEquals("name", "My Client App", client.getName()); + Assert.assertEquals("implicitFlowEnabled", false, client.isImplicitFlowEnabled()); + Assert.assertEquals("publicClient", true, client.isPublicClient()); + // note there is no server-side check if protocol is supported + Assert.assertEquals("protocol", "leycloak-oidc", client.getProtocol()); + Assert.assertEquals("webOrigins", Arrays.asList("http://localhost:8980/myapp"), client.getWebOrigins()); + Assert.assertEquals("consentRequired", false, client.isConsentRequired()); + Assert.assertEquals("baseUrl", "http://localhost:8980/myapp", client.getBaseUrl()); + Assert.assertEquals("rootUrl", "http://localhost:8980/myapp", client.getRootUrl()); + Assert.assertEquals("bearerOnly", true, client.isStandardFlowEnabled()); + Assert.assertFalse("mappers not empty", client.getProtocolMappers().isEmpty()); + + // create configuration from file as a template and override clientId and other attributes ... output an object + exe = execute("create --config '" + configFile.getName() + "' -o -f '" + tmpFile.getName() + + "' -s clientId=my_client2 -s enabled=false -s 'redirectUris=[\"http://localhost:8980/myapp2/*\"]'" + + " -s 'name=My Client App II' -s protocol=keycloak-oidc -s 'webOrigins=[\"http://localhost:8980/myapp2\"]'" + + " -s baseUrl=http://localhost:8980/myapp2 -s rootUrl=http://localhost:8980/myapp2"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertTrue("stderr is empty", exe.stderrLines().isEmpty()); + + ClientRepresentation client2 = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertNotNull("id", client2.getId()); + Assert.assertEquals("clientId", "my_client2", client2.getClientId()); + Assert.assertEquals("enabled", false, client2.isEnabled()); + Assert.assertEquals("redirectUris", Arrays.asList("http://localhost:8980/myapp2/*"), client2.getRedirectUris()); + Assert.assertEquals("serviceAccountsEnabled", true, client2.isServiceAccountsEnabled()); + Assert.assertEquals("name", "My Client App II", client2.getName()); + Assert.assertEquals("implicitFlowEnabled", false, client2.isImplicitFlowEnabled()); + Assert.assertEquals("publicClient", true, client2.isPublicClient()); + Assert.assertEquals("protocol", "keycloak-oidc", client2.getProtocol()); + Assert.assertEquals("webOrigins", Arrays.asList("http://localhost:8980/myapp2"), client2.getWebOrigins()); + Assert.assertEquals("consentRequired", false, client2.isConsentRequired()); + Assert.assertEquals("baseUrl", "http://localhost:8980/myapp2", client2.getBaseUrl()); + Assert.assertEquals("rootUrl", "http://localhost:8980/myapp2", client2.getRootUrl()); + Assert.assertEquals("bearerOnly", true, client2.isStandardFlowEnabled()); + Assert.assertFalse("mappers not empty", client2.getProtocolMappers().isEmpty()); + + + // check that using an invalid attribute key is not ignored + exe = execute("create --config '" + configFile.getName() + "' -o -f '" + tmpFile.getName() + "' -s client_id=my_client3"); + + Assert.assertEquals("exitCode == 1", 1, exe.exitCode()); + Assert.assertEquals("stderr has one line", 1, exe.stderrLines().size()); + Assert.assertEquals("Failed to set attribute 'client_id' on document type 'default'", exe.stderrLines().get(0)); + } + + // simple create, output an id + exe = execute("create --config '" + configFile.getName() + "' -i -s clientId=my_client3"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertEquals("stderr is empty", 0, exe.stderrLines().size()); + + Assert.assertEquals("stdout has 1 line", 1, exe.stdoutLines().size()); + Assert.assertEquals("only clientId returned", "my_client3", exe.stdoutLines().get(0)); + + // simple create, default output + exe = execute("create --config '" + configFile.getName() + "' -s clientId=my_client4"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertEquals("stderr has 1 line", 1, exe.stderrLines().size()); + Assert.assertEquals("only clientId returned", "Registered new client with client_id 'my_client4'", exe.stderrLines().get(0)); + + Assert.assertEquals("stdout is empty", 0, exe.stdoutLines().size()); + + + + // create using oidc endpoint - autodetect format + content = " {\n" + + " \"redirect_uris\" : [ \"http://localhost:8980/myapp/*\" ],\n" + + " \"grant_types\" : [ \"authorization_code\", \"client_credentials\", \"refresh_token\" ],\n" + + " \"response_types\" : [ \"code\", \"none\" ],\n" + + " \"client_name\" : \"My Client App\",\n" + + " \"client_uri\" : \"http://localhost:8980/myapp\"\n" + + " }"; + + try (TempFileResource tmpFile = new TempFileResource(initTempFile(".json", content))) { + + exe = execute("create --config '" + configFile.getName() + "' -s 'client_name=My Client App V' " + + " -s 'redirect_uris=[\"http://localhost:8980/myapp5/*\"]' -s client_uri=http://localhost:8980/myapp5" + + " -o -f - < '" + tmpFile.getName() + "'"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertTrue("stderr is empty", exe.stderrLines().isEmpty()); + + OIDCClientRepresentation client = JsonSerialization.readValue(exe.stdout(), OIDCClientRepresentation.class); + + Assert.assertNotNull("clientId", client.getClientId()); + Assert.assertEquals("redirect_uris", Arrays.asList("http://localhost:8980/myapp5/*"), client.getRedirectUris()); + Assert.assertEquals("grant_types", Arrays.asList("authorization_code", "client_credentials", "refresh_token"), client.getGrantTypes()); + Assert.assertEquals("response_types", Arrays.asList("code", "none"), client.getResponseTypes()); + Assert.assertEquals("client_name", "My Client App V", client.getClientName()); + Assert.assertEquals("client_uri", "http://localhost:8980/myapp5", client.getClientUri()); + + + + // try use incompatible endpoint override + exe = execute("create --config '" + configFile.getName() + "' -e default -f '" + tmpFile.getName() + "'"); + + Assert.assertEquals("exitCode == 1", 1, exe.exitCode()); + Assert.assertFalse("stderr not empty", exe.stderrLines().isEmpty()); + Assert.assertEquals("Error message", "Attribute 'redirect_uris' not supported on document type 'default'", exe.stderrLines().get(0)); + + } + + + // test create saml formated xml - format autodetection + File samlSpMetaFile = new File(System.getProperty("user.dir") + "/src/test/resources/cli/kcreg/saml-sp-metadata.xml"); + Assert.assertTrue("saml-sp-metadata.xml exists", samlSpMetaFile.isFile()); + + exe = execute("create --config '" + configFile.getName() + "' -o -f - < '" + samlSpMetaFile.getAbsolutePath() + "'"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertTrue("stderr is empty", exe.stderrLines().isEmpty()); + + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertNotNull("id", client.getId()); + Assert.assertEquals("clientId", "http://localhost:8080/sales-post-enc/", client.getClientId()); + Assert.assertEquals("redirectUris", Arrays.asList("http://localhost:8081/sales-post-enc/saml"), client.getRedirectUris()); + Assert.assertEquals("attributes.saml_name_id_format", "username", client.getAttributes().get("saml_name_id_format")); + Assert.assertEquals("attributes.saml_assertion_consumer_url_post", "http://localhost:8081/sales-post-enc/saml", client.getAttributes().get("saml_assertion_consumer_url_post")); + Assert.assertEquals("attributes.saml.signature.algorithm", "RSA_SHA256", client.getAttributes().get("saml.signature.algorithm")); + + + // delete initial token + exe = execute("config initial-token --config '" + configFile.getName() + "' --server " + serverUrl + " --realm " + realm + " --delete"); + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + config = handler.loadConfig(); + Assert.assertNull("initial token == null", config.ensureRealmConfigData(serverUrl, realm).getInitialToken()); + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTest.java new file mode 100644 index 0000000000..b4ea473488 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTest.java @@ -0,0 +1,512 @@ +package org.keycloak.testsuite.cli.registration; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.client.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.config.FileConfigHandler; +import org.keycloak.client.registration.cli.config.RealmConfigData; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.testsuite.cli.KcRegExec; +import org.keycloak.testsuite.util.TempFileResource; +import org.keycloak.util.JsonSerialization; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +import static org.keycloak.client.registration.cli.util.OsUtil.EOL; +import static org.keycloak.testsuite.cli.KcRegExec.execute; + +/** + * @author Marko Strukelj + */ +public class KcRegTest extends AbstractCliTest { + + @Test + public void testNoArgs() { + /* + * Test most basic execution that returns the initial help + */ + KcRegExec exe = execute(""); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + List lines = exe.stdoutLines(); + Assert.assertTrue("stdout output not empty", lines.size() > 0); + Assert.assertEquals("stdout first line", "Keycloak Client Registration CLI", lines.get(0)); + Assert.assertEquals("stdout one but last line", "Use '" + KcRegExec.CMD + " help ' for more information about a given command.", lines.get(lines.size() - 2)); + Assert.assertEquals("stdout last line", "", lines.get(lines.size() - 1)); + + lines = exe.stderrLines(); + Assert.assertTrue("stderr output empty", lines.size() == 0); + } + + @Test + public void testBadCommand() { + /* + * Test most basic execution with non-existent command + */ + KcRegExec exe = execute("nonexistent"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("stderr first line", "Unknown command: nonexistent", exe.stderrLines().get(0)); + } + + @Test + public void testBadOptionInPlaceOfCommand() { + /* + * Test most basic execution with non-existent option + */ + KcRegExec exe = execute("--nonexistent"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("stderr first line", "Unknown command: --nonexistent", exe.stderrLines().get(0)); + } + + @Test + public void testBadOption() { + /* + * Test sub-command execution with non-existent option + */ + + KcRegExec exe = execute("get my_client --nonexistent"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("stderr first line", "Invalid option: --nonexistent", exe.stderrLines().get(0)); + } + + @Test + public void testCredentialsServerAndRealmWithDefaultConfig() { + /* + * Test without --server specified + */ + KcRegExec exe = execute("config credentials --server " + serverUrl + " --realm master"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + } + + @Test + public void testCredentialsNoServerWithDefaultConfig() { + /* + * Test without --server specified + */ + KcRegExec exe = execute("config credentials --realm master --user admin --password admin"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("stderr first line", "Required option not specified: --server", exe.stderrLines().get(0)); + } + + @Test + public void testCredentialsNoRealmWithDefaultConfig() { + /* + * Test without --server specified + */ + KcRegExec exe = execute("config credentials --server " + serverUrl + " --user admin --password admin"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("stderr first line", "Required option not specified: --realm", exe.stderrLines().get(0)); + } + + @Test + public void testUserLoginWithDefaultConfig() { + /* + * Test most basic user login, using the default admin-cli as a client + */ + KcRegExec exe = execute("config credentials --server " + serverUrl + " --realm master --user admin --password admin"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as user admin of realm master", exe.stderrLines().get(0)); + } + + @Test + public void testUserLoginWithDefaultConfigInteractive() throws IOException { + /* + * Test user login with interaction - provide user password after prompted for it + */ + + if (!runIntermittentlyFailingTests()) { + System.out.println("TEST SKIPPED - This test currently suffers from intermittent failures. Use -Dtest.intermittent=true to run it."); + return; + } + + KcRegExec exe = KcRegExec.newBuilder() + .argsLine("config credentials --server " + serverUrl + " --realm master --user admin") + .executeAsync(); + + exe.waitForStdout("Enter password: "); + exe.sendToStdin("admin" + EOL); + exe.waitCompletion(); + + assertExitCodeAndStreamSizes(exe, 0, 1, 1); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as user admin of realm master", exe.stderrLines().get(0)); + + + /* + * Run the test one more time with stdin redirect + */ + File tmpFile = new File(KcRegExec.WORK_DIR + "/" + UUID.randomUUID().toString() + ".tmp"); + try { + FileOutputStream tmpos = new FileOutputStream(tmpFile); + tmpos.write("admin".getBytes()); + tmpos.write(EOL.getBytes()); + tmpos.close(); + + exe = execute("config credentials --server " + serverUrl + " --realm master --user admin < '" + tmpFile.getName() + "'"); + + assertExitCodeAndStreamSizes(exe, 0, 1, 1); + Assert.assertTrue("Enter password prompt", exe.stdoutLines().get(0).startsWith("Enter password: ")); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as user admin of realm master", exe.stderrLines().get(0)); + + } finally { + tmpFile.delete(); + } + } + + @Test + public void testClientLoginWithDefaultConfigInteractive() throws IOException { + /* + * Test client login with interaction - login using service account, and provide a client secret after prompted for it + */ + + if (!runIntermittentlyFailingTests()) { + System.out.println("TEST SKIPPED - This test currently suffers from intermittent failures. Use -Dtest.intermittent=true to run it."); + return; + } + + // use -Dtest.intermittent=true to run this test + KcRegExec exe = KcRegExec.newBuilder() + .argsLine("config credentials --server " + serverUrl + " --realm test --client reg-cli-secret") + .executeAsync(); + + exe.waitForStdout("Enter client secret: "); + exe.sendToStdin("password" + EOL); + exe.waitCompletion(); + + assertExitCodeAndStreamSizes(exe, 0, 1, 1); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as service-account-reg-cli-secret of realm test", exe.stderrLines().get(0)); + + /* + * Run the test one more time with stdin redirect + */ + File tmpFile = new File(KcRegExec.WORK_DIR + "/" + UUID.randomUUID().toString() + ".tmp"); + try { + FileOutputStream tmpos = new FileOutputStream(tmpFile); + tmpos.write("password".getBytes()); + tmpos.write(EOL.getBytes()); + tmpos.close(); + + exe = KcRegExec.newBuilder() + .argsLine("config credentials --server " + serverUrl + " --realm test --client reg-cli-secret < '" + tmpFile.getName() + "'") + .execute(); + + assertExitCodeAndStreamSizes(exe, 0, 1, 1); + Assert.assertTrue("Enter client secret prompt", exe.stdoutLines().get(0).startsWith("Enter client secret: ")); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as service-account-reg-cli-secret of realm test", exe.stderrLines().get(0)); + } finally { + tmpFile.delete(); + } + } + + @Test + public void testUserLoginWithCustomConfig() { + /* + * Test user login using a custom config file + */ + FileConfigHandler handler = initCustomConfigFile(); + + File configFile = new File(handler.getConfigFile()); + try { + KcRegExec exe = execute("config credentials --server " + serverUrl + " --realm master" + + " --user admin --password admin --config '" + configFile.getName() + "'"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + Assert.assertEquals("stderr first line", "Logging into " + serverUrl + " as user admin of realm master", exe.stderrLines().get(0)); + + // make sure the config file exists, and has the right content + ConfigData config = handler.loadConfig(); + Assert.assertEquals("serverUrl", serverUrl, config.getServerUrl()); + Assert.assertEquals("realm", "master", config.getRealm()); + RealmConfigData realmcfg = config.sessionRealmConfigData(); + Assert.assertNotNull("realm config data no null", realmcfg); + Assert.assertEquals("realm cfg serverUrl", serverUrl, realmcfg.serverUrl()); + Assert.assertEquals("realm cfg realm", "master", realmcfg.realm()); + Assert.assertEquals("client id", "admin-cli", realmcfg.getClientId()); + Assert.assertNotNull("token not null", realmcfg.getToken()); + Assert.assertNotNull("refresh token not null", realmcfg.getRefreshToken()); + Assert.assertNotNull("token expires not null", realmcfg.getExpiresAt()); + Assert.assertNotNull("token expires in future", realmcfg.getExpiresAt() > System.currentTimeMillis()); + Assert.assertNotNull("refresh token expires not null", realmcfg.getRefreshExpiresAt()); + Assert.assertNotNull("refresh token expires in future", realmcfg.getRefreshExpiresAt() > System.currentTimeMillis()); + Assert.assertTrue("clients is empty", realmcfg.getClients().isEmpty()); + + } finally { + configFile.delete(); + } + } + + @Test + public void testCustomConfigLoginCreateDelete() throws IOException { + /* + * Test user login, create, delete session using a custom config file + */ + + // prepare for loading a config file + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + KcRegExec exe = execute("config credentials --server " + serverUrl + + " --realm master --user admin --password admin --config '" + configFile.getName() + "'"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + // remember the state of config file + ConfigData config1 = handler.loadConfig(); + + + + + exe = execute("create --config '" + configFile.getName() + "' -s clientId=test-client -o"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + // check changes to config file + ConfigData config2 = handler.loadConfig(); + assertFieldsEqualWithExclusions(config1, config2, "endpoints." + serverUrl + ".master.clients.test-client"); + + // check that registration access token is now set + Assert.assertNotNull(config2.sessionRealmConfigData().getClients().get("test-client")); + + + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("clientId", "test-client", client.getClientId()); + Assert.assertNotNull("registrationAccessToken", client.getRegistrationAccessToken()); + Assert.assertEquals("registrationAccessToken in returned json same as in config", + config2.sessionRealmConfigData().getClients().get("test-client"), client.getRegistrationAccessToken()); + + + + + exe = execute("delete test-client --config '" + configFile.getName() + "'"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + // check changes to config file + ConfigData config3 = handler.loadConfig(); + assertFieldsEqualWithExclusions(config2, config3, "endpoints." + serverUrl + ".master.clients.test-client"); + + // check that registration access token is no longer there + Assert.assertTrue("clients empty", config3.sessionRealmConfigData().getClients().isEmpty()); + } + } + + @Test + public void testCRUDWithOnTheFlyUserAuth() throws IOException { + /* + * Test create, get, update, and delete using on-the-fly authentication - without using any config file. + * Login is performed by each operation again, and again using username, and password. + */ + testCRUDWithOnTheFlyAuth(serverUrl, "--user user1 --password userpass", "", + "Logging into " + serverUrl + " as user user1 of realm test"); + } + + @Test + public void testCRUDWithOnTheFlyUserAuthWithClientSecret() throws IOException { + /* + * Test create, get, update, and delete using on-the-fly authentication - without using any config file. + * Login is performed by each operation again, and again using username, password, and client secret. + */ + // try client without direct grants enabled + KcRegExec exe = execute("get test-client --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password userpass --client reg-cli-secret --secret password"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Client not allowed for direct access grants [invalid_grant]", exe.stderrLines().get(1)); + + + // try wrong user password + exe = execute("get test-client --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password wrong --client reg-cli-secret-direct --secret password"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Invalid user credentials [invalid_grant]", exe.stderrLines().get(1)); + + + // try wrong client secret + exe = execute("get test-client --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password userpass --client reg-cli-secret-direct --secret wrong"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Invalid client secret [unauthorized_client]", exe.stderrLines().get(1)); + + + // try whole CRUD + testCRUDWithOnTheFlyAuth(serverUrl, "--user user1 --password userpass --client reg-cli-secret-direct --secret password", "", + "Logging into " + serverUrl + " as user user1 of realm test"); + } + + @Test + public void testCRUDWithOnTheFlyUserAuthWithSignedJwtClient() throws IOException { + /* + * Test create, get, update, and delete using on-the-fly authentication - without using any config file. + * Login is performed by each operation again, and again using username, password, and client JWT signature. + */ + File keystore = new File(System.getProperty("user.dir") + "/src/test/resources/cli/kcreg/reg-cli-keystore.jks"); + Assert.assertTrue("reg-cli-keystore.jks exists", keystore.isFile()); + + // try client without direct grants enabled + KcRegExec exe = execute("get test-client --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password userpass --client reg-cli-jwt --keystore '" + keystore.getAbsolutePath() + "'" + + " --storepass storepass --keypass keypass --alias reg-cli"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Client not allowed for direct access grants [invalid_grant]", exe.stderrLines().get(1)); + + + // try wrong user password + exe = execute("get test-client --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password wrong --client reg-cli-jwt-direct --keystore '" + keystore.getAbsolutePath() + "'" + + " --storepass storepass --keypass keypass --alias reg-cli"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Invalid user credentials [invalid_grant]", exe.stderrLines().get(1)); + + + // try wrong storepass + exe = execute("get test-client --no-config --server " + serverUrl + " --realm test" + + " --user user1 --password userpass --client reg-cli-jwt-direct --keystore '" + keystore.getAbsolutePath() + "'" + + " --storepass wrong --keypass keypass --alias reg-cli"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 2); + Assert.assertEquals("login message", "Logging into " + serverUrl + " as user user1 of realm test", exe.stderrLines().get(0)); + Assert.assertEquals("error message", "Failed to load private key: Keystore was tampered with, or password was incorrect", exe.stderrLines().get(1)); + + + // try whole CRUD + testCRUDWithOnTheFlyAuth(serverUrl, + "--user user1 --password userpass --client reg-cli-jwt-direct --keystore '" + keystore.getAbsolutePath() + "'" + + " --storepass storepass --keypass keypass --alias reg-cli", "", + "Logging into " + serverUrl + " as user user1 of realm test"); + + } + + @Test + public void testCRUDWithOnTheFlyServiceAccountWithClientSecret() throws IOException { + /* + * Test create, get, update, and delete using on-the-fly authentication - without using any config file. + * Login is performed by each operation again, and again using only client secret - service account is used. + */ + testCRUDWithOnTheFlyAuth(serverUrl, "--client reg-cli-secret --secret password", "", + "Logging into " + serverUrl + " as service-account-reg-cli-secret of realm test"); + } + + @Test + public void testCRUDWithOnTheFlyServiceAccountWithSignedJwtClient() throws IOException { + /* + * Test create, get, update, and delete using on-the-fly authentication - without using any config file. + * Login is performed by each operation again, and again using only client JWT signature - service account is used. + */ + File keystore = new File(System.getProperty("user.dir") + "/src/test/resources/cli/kcreg/reg-cli-keystore.jks"); + Assert.assertTrue("reg-cli-keystore.jks exists", keystore.isFile()); + + testCRUDWithOnTheFlyAuth(serverUrl, + "--client reg-cli-jwt --keystore '" + keystore.getAbsolutePath() + "' --storepass storepass --keypass keypass --alias reg-cli", "", + "Logging into " + serverUrl + " as service-account-reg-cli-jwt of realm test"); + } + + @Test + public void testCreateDeleteWithInitialAndRegistrationTokens() throws IOException { + /* + * Test create using initial client token, and subsequent delete using registration access token. + * A config file is used to save registration access token for newly created client. + */ + testCreateDeleteWithInitialAndRegistrationTokens(true); + } + + @Test + public void testCreateDeleteWithInitialAndRegistrationTokensNoConfig() throws IOException { + /* + * Test create using initial client token, and subsequent delete using registration access token. + * No config file is used so registration access token for newly created client is not saved to config. + */ + testCreateDeleteWithInitialAndRegistrationTokens(false); + } + + private void testCreateDeleteWithInitialAndRegistrationTokens(boolean useConfig) throws IOException { + + // prepare for loading a config file + // only used when useConfig is true + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + String token = issueInitialAccessToken("master"); + + final String realm = "master"; + + KcRegExec exe = execute("create " + (useConfig ? ("--config '" + configFile.getAbsolutePath()) + "'" : "--no-config") + + " --server " + serverUrl + " --realm " + realm + " -s clientId=test-client2 -o -t " + token); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + + Assert.assertEquals("clientId", "test-client2", client.getClientId()); + Assert.assertNotNull("registrationAccessToken", client.getRegistrationAccessToken()); + + + if (useConfig) { + ConfigData config = handler.loadConfig(); + Assert.assertEquals("Registration Access Token in config file", client.getRegistrationAccessToken(), + config.ensureRealmConfigData(serverUrl, realm).getClients().get("test-client2")); + } else { + Assert.assertFalse("There should be no config file", configFile.isFile()); + } + + exe = execute("delete test-client2 " + (useConfig ? ("--config '" + configFile.getAbsolutePath()) + "'" : "--no-config") + + " --server " + serverUrl + " --realm " + realm + " -t " + client.getRegistrationAccessToken()); + + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + } + } + + @Test + public void testCreateWithAllowedHostsWithoutAuthenticationNoConfig() throws IOException { + + testCreateWithAllowedHostsWithoutAuthentication("test", false); + } + + @Test + public void testCreateWithAllowedHostsWithoutAuthentication() throws IOException { + + testCreateWithAllowedHostsWithoutAuthentication("test", true); + } + + private void testCreateWithAllowedHostsWithoutAuthentication(String realm, boolean useConfig) throws IOException { + + addLocalhostToAllowedHosts(realm); + + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + KcRegExec exe = execute("create " + (useConfig ? ("--config '" + configFile.getAbsolutePath()) + "'" : "--no-config") + + " --server " + serverUrl + " --realm " + realm + " -s clientId=test-client -o"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + + Assert.assertEquals("clientId", "test-client", client.getClientId()); + Assert.assertNotNull("registrationAccessToken", client.getRegistrationAccessToken()); + } + } + +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTruststoreTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTruststoreTest.java new file mode 100644 index 0000000000..58854590f3 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegTruststoreTest.java @@ -0,0 +1,123 @@ +package org.keycloak.testsuite.cli.registration; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.client.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.config.FileConfigHandler; +import org.keycloak.client.registration.cli.util.ConfigUtil; +import org.keycloak.testsuite.cli.KcRegExec; +import org.keycloak.testsuite.util.TempFileResource; + +import java.io.File; +import java.io.IOException; + +import static org.keycloak.client.registration.cli.util.OsUtil.EOL; +import static org.keycloak.testsuite.cli.KcRegExec.execute; + +/** + * @author Marko Strukelj + */ +public class KcRegTruststoreTest extends AbstractCliTest { + + @Test + public void testTruststore() throws IOException { + + // only run this test if ssl protected keycloak server is available + if (!isAuthServerSSL()) { + System.out.println("TEST SKIPPED - This test requires HTTPS. Run with '-Pauth-server-wildfly -Dauth.server.ssl.required=true'"); + return; + } + + File truststore = new File("src/test/resources/keystore/keycloak.truststore"); + + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + if (runIntermittentlyFailingTests()) { + // configure truststore + KcRegExec exe = execute("config truststore --config '" + configFile.getName() + "' '" + truststore.getAbsolutePath() + "'"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + + // perform authentication against server - asks for password, then for truststore password + exe = KcRegExec.newBuilder() + .argsLine("config credentials --server " + serverUrl + " --realm test --user user1" + + " --config '" + configFile.getName() + "'") + .executeAsync(); + + exe.waitForStdout("Enter password: "); + exe.sendToStdin("userpass" + EOL); + exe.waitForStdout("Enter truststore password: "); + exe.sendToStdin("secret" + EOL); + exe.waitCompletion(); + + assertExitCodeAndStreamSizes(exe, 0, 2, 1); + + + // configure truststore with password + exe = execute("config truststore --config '" + configFile.getName() + "' --trustpass secret '" + truststore.getAbsolutePath() + "'"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + // perform authentication against server - asks for password, then for truststore password + exe = KcRegExec.newBuilder() + .argsLine("config credentials --server " + serverUrl + " --realm test --user user1" + + " --config '" + configFile.getName() + "'") + .executeAsync(); + + exe.waitForStdout("Enter password: "); + exe.sendToStdin("userpass" + EOL); + exe.waitCompletion(); + + assertExitCodeAndStreamSizes(exe, 0, 1, 1); + + } else { + System.out.println("TEST SKIPPED PARTIALLY - This test currently suffers from intermittent failures. Use -Dtest.intermittent=true to run it in full."); + } + } + + // Check missing argument error + KcRegExec exe = execute("config truststore"); + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("no truststore error", "No truststore specified", exe.stderrLines().get(0)); + + // configure truststore with password + exe = execute("config truststore --trustpass secret '" + truststore.getAbsolutePath() + "'"); + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + // perform authentication against server - asks for password, then for truststore password + exe = execute("config credentials --server " + serverUrl + " --realm test --user user1 --password userpass"); + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + + exe = execute("config truststore --delete"); + assertExitCodeAndStreamSizes(exe, 0, 0, 0); + + exe = execute("config truststore --delete '" + truststore.getAbsolutePath() + "'"); + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("incompatible", "Option --delete is mutually exclusive with specifying a TRUSTSTORE", exe.stderrLines().get(0)); + + exe = execute("config truststore --delete --trustpass secret"); + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("no truststore error", "Options --trustpass and --delete are mutually exclusive", exe.stderrLines().get(0)); + + FileConfigHandler cfghandler = new FileConfigHandler(); + cfghandler.setConfigFile(ConfigUtil.DEFAULT_CONFIG_FILE_PATH); + ConfigData config = cfghandler.loadConfig(); + Assert.assertNull("truststore null", config.getTruststore()); + Assert.assertNull("trustpass null", config.getTrustpass()); + + + // perform no-config CRUD test against ssl protected endpoint + testCRUDWithOnTheFlyAuth(serverUrl, + "--user user1 --password userpass", " --truststore '" + truststore.getAbsolutePath() + "' --trustpass secret", + "Logging into " + serverUrl + " as user user1 of realm test"); + } + + + @Test + public void testUpdateTokenTruststore() { + // TODO + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTest.java new file mode 100644 index 0000000000..d21c1a3e62 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTest.java @@ -0,0 +1,170 @@ +package org.keycloak.testsuite.cli.registration; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.client.registration.cli.config.FileConfigHandler; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.testsuite.cli.KcRegExec; +import org.keycloak.testsuite.util.TempFileResource; +import org.keycloak.util.JsonSerialization; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.Arrays; + +import static org.keycloak.testsuite.cli.KcRegExec.execute; + +/** + * @author Marko Strukelj + */ +public class KcRegUpdateTest extends AbstractCliTest { + + + @Test + public void testUpdateThoroughly() throws IOException { + + FileConfigHandler handler = initCustomConfigFile(); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + final String realm = "test"; + + loginAsUser(configFile.getFile(), serverUrl, realm, "user1", "userpass"); + + + // create an object so we can update it + KcRegExec exe = execute("create --config '" + configFile.getName() + "' -o -s clientId=my_client"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + + Assert.assertEquals("enabled", true, client.isEnabled()); + Assert.assertEquals("publicClient", false, client.isPublicClient()); + Assert.assertEquals("bearerOnly", false, client.isBearerOnly()); + Assert.assertTrue("redirectUris is empty", client.getRedirectUris().isEmpty()); + + + // Merge update + exe = execute("update my_client --config '" + configFile.getName() + "' -o " + + " -s enabled=false -s 'redirectUris=[\"http://localhost:8980/myapp/*\"]'"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertTrue("stderr is empty", exe.stderrLines().isEmpty()); + + client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("enabled", false, client.isEnabled()); + Assert.assertEquals("redirectUris", Arrays.asList("http://localhost:8980/myapp/*"), client.getRedirectUris()); + + + + // Another merge update - test deleting an attribute, deleting a list item and adding a list item + exe = execute("update my_client --config '" + configFile.getName() + "' -o -d redirectUris -s webOrigins+=http://localhost:8980/myapp -s webOrigins+=http://localhost:8981/myapp -d webOrigins[0]"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertTrue("stderr is empty", exe.stderrLines().isEmpty()); + + client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + + Assert.assertTrue("redirectUris is empty", client.getRedirectUris().isEmpty()); + Assert.assertEquals("webOrigins", Arrays.asList("http://localhost:8981/myapp"), client.getWebOrigins()); + + + + // Another merge update - test nested attributes and setting an attribute using json format + // TODO KEYCLOAK-3705 Updating protocolMapper config via client registration endpoint has no effect + /* + exe = execute("update my_client --config '" + configFile.getName() + "' -o -s 'protocolMappers[0].config.\"id.token.claim\"=false' " + + "-s 'protocolMappers[4].config={\"single\": \"true\", \"attribute.nameformat\": \"Basic\", \"attribute.name\": \"Role\"}'"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertTrue("stderr is empty", exe.stderrLines().isEmpty()); + + client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("protocolMapper[0].config.\"id.token.claim\"", "false", client.getProtocolMappers().get(0).getConfig().get("id.token.claim")); + Assert.assertEquals("protocolMappers[4].config.single", "true", client.getProtocolMappers().get(4).getConfig().get("single")); + Assert.assertEquals("protocolMappers[4].config.\"attribute.nameformat\"", "Basic", client.getProtocolMappers().get(4).getConfig().get("attribute.nameformat")); + Assert.assertEquals("protocolMappers[4].config.\"attribute.name\"", "Role", client.getProtocolMappers().get(4).getConfig().get("attribute.name")); + */ + + // update using oidc format + + + // check that using an invalid attribute key is not ignored + exe = execute("update my_client --nonexisting --config '" + configFile.getName() + "'"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("error message", "Unsupported option: --nonexisting", exe.stderrLines().get(0)); + + + + // try use incompatible endpoint + exe = execute("update my_client --config '" + configFile.getName() + "' -o -s enabled=true -e oidc"); + + assertExitCodeAndStreamSizes(exe, 1, 0, 1); + Assert.assertEquals("error message", "Failed to set attribute 'enabled' on document type 'oidc'", exe.stderrLines().get(0)); + + + + // test overwrite from file + exe = KcRegExec.newBuilder() + .argsLine("update my_client --config '" + configFile.getName() + + "' -o -s clientId=my_client -s 'redirectUris=[\"http://localhost:8980/myapp/*\"]' -f -") + .stdin(new ByteArrayInputStream("{ \"enabled\": false }".getBytes())) + .execute(); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertTrue("stderr has error", exe.stderrLines().isEmpty()); + + client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + // web origin is not sent to the server, thus it retains the current value + Assert.assertEquals("webOrigins", Arrays.asList("http://localhost:8981/myapp"), client.getWebOrigins()); + Assert.assertFalse("enabled is false", client.isEnabled()); + Assert.assertEquals("redirectUris", Arrays.asList("http://localhost:8980/myapp/*"), client.getRedirectUris()); + + + + // test using merge with file + exe = KcRegExec.newBuilder() + .argsLine("update my_client --config '" + configFile.getName() + + "' -o -s enabled=true -m -f -") + .stdin(new ByteArrayInputStream("{ \"webOrigins\": [\"http://localhost:8980/myapp\"] }".getBytes())) + .execute(); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertTrue("stderr has error", exe.stderrLines().isEmpty()); + + client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("webOrigins", Arrays.asList("http://localhost:8980/myapp"), client.getWebOrigins()); + Assert.assertTrue("enabled is true", client.isEnabled()); + Assert.assertEquals("redirectUris", Arrays.asList("http://localhost:8980/myapp/*"), client.getRedirectUris()); + + + + // remove registration access token + exe = execute("config registration-token --config '" + configFile.getName() + "' --server " + serverUrl + + " --realm " + realm + " --client my_client -d"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertTrue("stderr is empty", exe.stderrLines().isEmpty()); + + Assert.assertNull("my_client registration token", handler.loadConfig().ensureRealmConfigData(serverUrl, realm).getClients().get("my_client")); + + + + // test update without registration access token to produce 'unsafe' error + exe = execute("update my_client --config '" + configFile.getName() + "' -o -s bearerOnly=true"); + + Assert.assertEquals("exitCode == 1", 1, exe.exitCode()); + Assert.assertFalse("stderr is not empty", exe.stderrLines().isEmpty()); + Assert.assertEquals("error message", "No Registration Access Token found for client: my_client. Provide one or use --unsafe.", exe.stderrLines().get(0)); + + + // test using unsafe to perform update + exe = execute("update my_client --config '" + configFile.getName() + "' -o -s bearerOnly=true --unsafe"); + + Assert.assertEquals("exitCode == 0", 0, exe.exitCode()); + Assert.assertTrue("stderr is empty", exe.stderrLines().isEmpty()); + + } + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTokenTest.java new file mode 100644 index 0000000000..e2b4193bbc --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/cli/registration/KcRegUpdateTokenTest.java @@ -0,0 +1,68 @@ +package org.keycloak.testsuite.cli.registration; + +import org.junit.Assert; +import org.junit.Test; +import org.keycloak.client.registration.cli.config.ConfigData; +import org.keycloak.client.registration.cli.config.FileConfigHandler; +import org.keycloak.client.registration.cli.config.RealmConfigData; +import org.keycloak.client.registration.cli.util.ConfigUtil; +import org.keycloak.representations.idm.ClientRepresentation; +import org.keycloak.testsuite.cli.KcRegExec; +import org.keycloak.testsuite.util.TempFileResource; +import org.keycloak.util.JsonSerialization; + +import java.io.IOException; + +import static org.keycloak.client.registration.cli.util.OsUtil.EOL; +import static org.keycloak.testsuite.cli.KcRegExec.execute; + +/** + * @author Marko Strukelj + */ +public class KcRegUpdateTokenTest extends AbstractCliTest { + + @Test + public void testUpdateToken() throws IOException { + + FileConfigHandler handler = initCustomConfigFile(); + ConfigUtil.setHandler(handler); + + try (TempFileResource configFile = new TempFileResource(handler.getConfigFile())) { + + KcRegExec exe = execute("config credentials --config '" + configFile.getName() + "' --server " + serverUrl + " --realm master --user admin --password admin"); + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + + // read current registration access token + ConfigData data = ConfigUtil.loadConfig(); + RealmConfigData rdata = data.getRealmConfigData(serverUrl, "test"); + Assert.assertNull("realm info set", rdata); + + // update registration access token + exe = execute("update-token --config '" + configFile.getName() + "' reg-cli-secret-direct --server " + serverUrl + " --realm test --user user1 --password userpass"); + + assertExitCodeAndStreamSizes(exe, 0, 0, 1); + + // read current registration token + data = ConfigUtil.loadConfig(); + rdata = data.getRealmConfigData(serverUrl, "test"); + Assert.assertEquals("current session realm unchanged", "master", data.getRealm()); + Assert.assertNotNull("realm info set", rdata); + Assert.assertNull("on the fly login was transient", rdata.getToken()); + Assert.assertNotNull("client info has registration access token", rdata.getClients().get("reg-cli-secret-direct")); + + // use --no-config and on-the-fly auth + exe = execute("update-token reg-cli-secret-direct --no-config --server " + serverUrl + " --realm test --user user1 --password userpass"); + assertExitCodeAndStreamSizes(exe, 0, 1, 1); + + // save the token + String token = exe.stdoutLines().get(0); + + // test that the token works + exe = execute("get reg-cli-secret-direct --no-config --server " + serverUrl + " --realm test -t " + token); + Assert.assertEquals("exit code", 0, exe.exitCode()); + + ClientRepresentation client = JsonSerialization.readValue(exe.stdout(), ClientRepresentation.class); + Assert.assertEquals("client representation returned", "reg-cli-secret-direct", client.getClientId()); + } + } +} \ No newline at end of file diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/RegistrationAccessTokenTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/RegistrationAccessTokenTest.java index 23de485b8e..491470ebfe 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/RegistrationAccessTokenTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/client/RegistrationAccessTokenTest.java @@ -49,6 +49,14 @@ public class RegistrationAccessTokenTest extends AbstractClientRegistrationTest client = createClient(c); + c = new ClientRepresentation(); + c.setEnabled(true); + c.setClientId("SomeOtherClient"); + c.setSecret("RegistrationAccessTokenTestClientSecret"); + c.setRootUrl("http://root"); + + createClient(c); + reg.auth(Auth.token(client.getRegistrationAccessToken())); } @@ -81,6 +89,24 @@ public class RegistrationAccessTokenTest extends AbstractClientRegistrationTest assertRead(client.getClientId(), rep.getRegistrationAccessToken(), true); } + @Test + public void getClientWrongClient() throws ClientRegistrationException { + try { + reg.get("SomeOtherClient"); + } catch (ClientRegistrationException e) { + assertEquals(401, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } + } + + @Test + public void getClientMissingClient() throws ClientRegistrationException { + try { + reg.get("nosuch"); + } catch (ClientRegistrationException e) { + assertEquals(401, ((HttpErrorException) e.getCause()).getStatusLine().getStatusCode()); + } + } + @Test public void getClientWithBadRegistrationToken() throws ClientRegistrationException { reg.auth(Auth.token("invalid")); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java index 030330c665..386b291659 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/exportimport/ExportImportUtil.java @@ -164,13 +164,13 @@ public class ExportImportUtil { Assert.assertEquals("app-admin", appRoles.iterator().next().getName()); // Test attributes - Map> attrs = wburke.getAttributesAsListValues(); + Map> attrs = wburke.getAttributes(); Assert.assertEquals(1, attrs.size()); List attrVals = attrs.get("email"); Assert.assertEquals(1, attrVals.size()); Assert.assertEquals("bburke@redhat.com", attrVals.get(0)); - attrs = admin.getAttributesAsListValues(); + attrs = admin.getAttributes(); Assert.assertEquals(2, attrs.size()); attrVals = attrs.get("key1"); Assert.assertEquals(1, attrVals.size()); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java index d42a91b6f9..42f85e6e70 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/i18n/EmailTest.java @@ -52,8 +52,7 @@ public class EmailTest extends AbstractI18NTest { private void changeUserLocale(String locale) { UserRepresentation user = findUser("login-test"); - if (user.getAttributes() == null) user.setAttributes(new HashMap()); - user.getAttributes().put(UserModel.LOCALE, Collections.singletonList(locale)); + user.singleAttribute(UserModel.LOCALE, locale); ApiUtil.findUserByUsernameId(testRealm(), "login-test").update(user); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LoginStatusIframeEndpointTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LoginStatusIframeEndpointTest.java index 437e7d88ab..2d0a269c8e 100644 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LoginStatusIframeEndpointTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/LoginStatusIframeEndpointTest.java @@ -53,7 +53,7 @@ import static org.junit.Assert.assertTrue; public class LoginStatusIframeEndpointTest extends AbstractKeycloakTest { @Test - public void checkIframeP3PHeader() throws IOException { + public void checkIframe() throws IOException { CookieStore cookieStore = new BasicCookieStore(); CloseableHttpClient client = HttpClients.custom().setDefaultCookieStore(cookieStore).build(); @@ -115,17 +115,66 @@ public class LoginStatusIframeEndpointTest extends AbstractKeycloakTest { } assertNotNull(sessionCookie); - get = new HttpGet( - suiteContext.getAuthServerInfo().getContextRoot() + "/auth/realms/master/protocol/openid-connect/login-status-iframe.html?client_id=" + Constants.ADMIN_CONSOLE_CLIENT_ID + "&origin=" + suiteContext.getAuthServerInfo().getContextRoot()); + get = new HttpGet(suiteContext.getAuthServerInfo().getContextRoot() + "/auth/realms/master/protocol/openid-connect/login-status-iframe.html"); response = client.execute(get); assertEquals(200, response.getStatusLine().getStatusCode()); s = IOUtils.toString(response.getEntity().getContent()); - assertTrue(s.contains("function getCookie(cname)")); + assertTrue(s.contains("function getCookie()")); assertEquals("CP=\"This is not a P3P policy!\"", response.getFirstHeader("P3P").getValue()); response.close(); + + get = new HttpGet(suiteContext.getAuthServerInfo().getContextRoot() + "/auth/realms/master/protocol/openid-connect/login-status-iframe.html/init"); + response = client.execute(get); + assertEquals(403, response.getStatusLine().getStatusCode()); + response.close(); + + get = new HttpGet(suiteContext.getAuthServerInfo().getContextRoot() + "/auth/realms/master/protocol/openid-connect/login-status-iframe.html/init?" + + "client_id=invalid" + + "&session_state=" + sessionCookie.getValue() + + "&origin=" + suiteContext.getAuthServerInfo().getContextRoot() + ); + response = client.execute(get); + assertEquals(403, response.getStatusLine().getStatusCode()); + response.close(); + + get = new HttpGet(suiteContext.getAuthServerInfo().getContextRoot() + "/auth/realms/master/protocol/openid-connect/login-status-iframe.html/init?" + + "client_id=" + Constants.ADMIN_CONSOLE_CLIENT_ID + + "&session_state=invalid" + + "&origin=" + suiteContext.getAuthServerInfo().getContextRoot() + ); + response = client.execute(get); + assertEquals(403, response.getStatusLine().getStatusCode()); + response.close(); + + get = new HttpGet(suiteContext.getAuthServerInfo().getContextRoot() + "/auth/realms/master/protocol/openid-connect/login-status-iframe.html/init?" + + "client_id=" + Constants.ADMIN_CONSOLE_CLIENT_ID + + "&session_state=" + sessionCookie.getValue() + + "&origin=http://invalid" + ); + response = client.execute(get); + assertEquals(403, response.getStatusLine().getStatusCode()); + response.close(); + + get = new HttpGet(suiteContext.getAuthServerInfo().getContextRoot() + "/auth/realms/master/protocol/openid-connect/login-status-iframe.html/init?" + + "client_id=" + Constants.ADMIN_CONSOLE_CLIENT_ID + + "&session_state=master/random/random" + + "&origin=" + suiteContext.getAuthServerInfo().getContextRoot() + ); + response = client.execute(get); + assertEquals(404, response.getStatusLine().getStatusCode()); + response.close(); + + get = new HttpGet(suiteContext.getAuthServerInfo().getContextRoot() + "/auth/realms/master/protocol/openid-connect/login-status-iframe.html/init?" + + "client_id=" + Constants.ADMIN_CONSOLE_CLIENT_ID + + "&session_state=" + sessionCookie.getValue() + + "&origin=" + suiteContext.getAuthServerInfo().getContextRoot() + ); + response = client.execute(get); + assertEquals(204, response.getStatusLine().getStatusCode()); + response.close(); } finally { client.close(); } diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java index 85bd77fda2..cae907e7cc 100755 --- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/oauth/TokenIntrospectionTest.java @@ -188,6 +188,24 @@ public class TokenIntrospectionTest extends TestRealmKeycloakTest { assertNull(rep.getSubject()); } + @Test + public void testUnsupportedToken() throws Exception { + oauth.doLogin("test-user@localhost", "password"); + String inactiveAccessToken = "unsupported"; + String tokenResponse = oauth.introspectAccessTokenWithClientCredential("confidential-cli", "secret1", inactiveAccessToken); + ObjectMapper objectMapper = new ObjectMapper(); + JsonNode jsonNode = objectMapper.readTree(tokenResponse); + + assertFalse(jsonNode.get("active").asBoolean()); + + TokenMetadataRepresentation rep = objectMapper.readValue(tokenResponse, TokenMetadataRepresentation.class); + + assertFalse(rep.isActive()); + assertNull(rep.getUserName()); + assertNull(rep.getClientId()); + assertNull(rep.getSubject()); + } + @Test public void testIntrospectAccessToken() throws Exception { oauth.doLogin("test-user@localhost", "password"); diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/TempFileResource.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/TempFileResource.java new file mode 100644 index 0000000000..9050687de9 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/util/TempFileResource.java @@ -0,0 +1,43 @@ +package org.keycloak.testsuite.util; + +import java.io.Closeable; +import java.io.File; +import java.io.IOException; + +/** + * @author Marko Strukelj + */ +public class TempFileResource implements Closeable { + + private File file; + + public TempFileResource(String filepath) { + file = new File(filepath); + } + + public TempFileResource(File file) { + this.file = file; + } + + public File getFile() { + return file; + } + + public String getName() { + return file.getName(); + } + + public String getAbsolutePath() { + return file.getAbsolutePath(); + } + + public boolean isFile() { + return file.isFile(); + } + + @Override + public void close() throws IOException { + // delete file if it exists + file.delete(); + } +} diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/metadata-schema/saml-schema-assertion-2.0.xsd b/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/metadata-schema/saml-schema-assertion-2.0.xsd new file mode 100644 index 0000000000..cdd365d885 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/metadata-schema/saml-schema-assertion-2.0.xsd @@ -0,0 +1,283 @@ + + + + + + + Document identifier: saml-schema-assertion-2.0 + Location: http://docs.oasis-open.org/security/saml/v2.0/ + Revision history: + V1.0 (November, 2002): + Initial Standard Schema. + V1.1 (September, 2003): + Updates within the same V1.0 namespace. + V2.0 (March, 2005): + New assertion schema for SAML V2.0 namespace. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/metadata-schema/saml-schema-metadata-2.0.xsd b/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/metadata-schema/saml-schema-metadata-2.0.xsd new file mode 100644 index 0000000000..5c8d217190 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/adapter-test/keycloak-saml/metadata-schema/saml-schema-metadata-2.0.xsd @@ -0,0 +1,337 @@ + + + + + + + + + Document identifier: saml-schema-metadata-2.0 + Location: http://docs.oasis-open.org/security/saml/v2.0/ + Revision history: + V2.0 (March, 2005): + Schema for SAML metadata, first published in SAML 2.0. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/cli/kcreg/reg-cli-keystore.jks b/testsuite/integration-arquillian/tests/base/src/test/resources/cli/kcreg/reg-cli-keystore.jks new file mode 100644 index 0000000000..feb5e8252c Binary files /dev/null and b/testsuite/integration-arquillian/tests/base/src/test/resources/cli/kcreg/reg-cli-keystore.jks differ diff --git a/testsuite/integration-arquillian/tests/base/src/test/resources/cli/kcreg/saml-sp-metadata.xml b/testsuite/integration-arquillian/tests/base/src/test/resources/cli/kcreg/saml-sp-metadata.xml new file mode 100644 index 0000000000..2af8e01097 --- /dev/null +++ b/testsuite/integration-arquillian/tests/base/src/test/resources/cli/kcreg/saml-sp-metadata.xml @@ -0,0 +1,20 @@ + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified + + + + + + + MIIB1DCCAT0CBgFJGVacCDANBgkqhkiG9w0BAQsFADAwMS4wLAYDVQQDEyVodHRwOi8vbG9jYWxob3N0OjgwODAvc2FsZXMtcG9zdC1lbmMvMB4XDTE0MTAxNjE0MjA0NloXDTI0MTAxNjE0MjIyNlowMDEuMCwGA1UEAxMlaHR0cDovL2xvY2FsaG9zdDo4MDgwL3NhbGVzLXBvc3QtZW5jLzCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA2+5MCT5BnVN+IYnKZcH6ev1pjXGi4feE0nOycq/VJ3aeaZMi4G9AxOxCBPupErOC7Kgm/Bw5AdJyw+Q12wSRXfJ9FhqCrLXpb7YOhbVSTJ8De5O8mW35DxAlh/cxe9FXjqPb286wKTUZ3LfGYR+X235UQeCTAPS/Ufi21EXaEikCAwEAATANBgkqhkiG9w0BAQsFAAOBgQBMrfGD9QFfx5v7ld/OAto5rjkTe3R1Qei8XRXfcs83vLaqEzjEtTuLGrJEi55kXuJgBpVmQpnwCCkkjSy0JxbqLDdVi9arfWUxEGmOr01ZHycELhDNaQcFqVMPr5kRHIHgktT8hK2IgCvd3Fy9/JCgUgCPxKfhwecyEOKxUc857g== + + + + + + diff --git a/testsuite/integration-arquillian/tests/pom.xml b/testsuite/integration-arquillian/tests/pom.xml index d30721ca6e..8dcdc2a6a8 100755 --- a/testsuite/integration-arquillian/tests/pom.xml +++ b/testsuite/integration-arquillian/tests/pom.xml @@ -172,6 +172,8 @@ ${migration.import.properties} ${testsuite.constants} + ${cli.log.output} + ${test.intermittent} ${browser} ${firefox_binary} @@ -781,6 +783,18 @@ ${mariadb.version} + + + + org.keycloak + keycloak-client-cli-dist + zip + + diff --git a/testsuite/integration/src/test/java/org/keycloak/testsuite/authorization/ScopeManagementTest.java b/testsuite/integration/src/test/java/org/keycloak/testsuite/authorization/ScopeManagementTest.java index 4695e55dd9..b2e1a421ea 100644 --- a/testsuite/integration/src/test/java/org/keycloak/testsuite/authorization/ScopeManagementTest.java +++ b/testsuite/integration/src/test/java/org/keycloak/testsuite/authorization/ScopeManagementTest.java @@ -18,9 +18,12 @@ package org.keycloak.testsuite.authorization; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import org.junit.Test; import org.keycloak.authorization.model.Scope; +import org.keycloak.representations.idm.UserRepresentation; import org.keycloak.representations.idm.authorization.ScopeRepresentation; +import org.keycloak.util.JsonSerialization; import javax.ws.rs.client.Entity; import javax.ws.rs.client.Invocation.Builder; @@ -28,6 +31,9 @@ import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.Response.Status; +import java.io.IOException; +import java.util.LinkedList; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; diff --git a/themes/src/main/resources/theme/base/admin/messages/admin-messages_lt.properties b/themes/src/main/resources/theme/base/admin/messages/admin-messages_lt.properties index b65bb7cc1b..c40c438911 100644 --- a/themes/src/main/resources/theme/base/admin/messages/admin-messages_lt.properties +++ b/themes/src/main/resources/theme/base/admin/messages/admin-messages_lt.properties @@ -164,6 +164,10 @@ usermodel.clientRoleMapping.rolePrefix.label=Kliento rol\u0117s prefiksas usermodel.clientRoleMapping.rolePrefix.tooltip=Prefiksas, pridedamas prie\u0161 kiekvien\u0105 kliento rol\u0119 (neprivalomas) usermodel.realmRoleMapping.rolePrefix.label=Srities rol\u0117s prefiksas usermodel.realmRoleMapping.rolePrefix.tooltip=Prefiksas, pridedamas prie\u0161 kiekvien\u0105 srities rol\u0119 (neprivalomas) +sectorIdentifierUri.label=Sektoriaus identifikatoriaus URI +sectorIdentifierUri.tooltip=Paslaug\u0173 teik\u0117jai, kurie naudoja porines subreik\u0161mes ir palaiko dinamin\u0119 klient\u0173 registracij\u0105 (Dynamic Client Registration) tur\u0117t\u0173 naudoti sector_identifier_uri parametr\u0105. Teikiamas funkcionalumas leid\u017Eia svetaini\u0173 grup\u0117ms, valdomoms centralizuotos administravimo panel\u0117s, tur\u0117ti pastovias porines subreik\u0161mes nepriklausomas nuo domeno vard\u0173. Tokiu b\u016Bdu klientai gali keisti domen\u0173 redirect_uri neperregistruojant vis\u0173 naudotoj\u0173. +pairwiseSubAlgorithmSalt.label=Druska +pairwiseSubAlgorithmSalt.tooltip=Druska naudojama porinio objekto identifikatoriaus skai\u010Diavimo metu. Jei paliekama tu\u0161\u010Dia reik\u0161m\u0117, tuomet druskos reik\u0161m\u0117 bus automatik\u0161ai sugeneruota. # client details clients.tooltip=Klientai - tai srities nar\u0161ykl\u0117s program\u0117l\u0117s arba tinklin\u0117s paslaugos, kuriomis pasitikima. Klientai gali jungtis prie sistemos. Klientams galima nurodyti specifines roles. @@ -287,6 +291,12 @@ gen-new-keys-and-cert=Nauj\u0173 rakt\u0173 ir sertifikat\u0173 generavimas import-certificate=Importuoti sertifikat\u0105 gen-client-private-key=Generuoti kliento privat\u0173 rakt\u0105 generate-private-key=Generuoti privat\u0173 rakt\u0105 +kid=Kid +kid.tooltip=Kliento vie\u0161ojo rakto identifikatorius (Key ID) importuotas i\u0161 JWKS. +use-jwks-url=Naudoti JWKS URL +use-jwks-url.tooltip=Jei \u012Fgalinta, tuomet kliento vie\u0161asis raktas atsiun\u010Diamas i\u0161 pateiktos JWKS URL. \u012Egalinimas suteikia lankstumo, nes klientui pergeneravus raktus jie automati\u0161kai atsiun\u010Diami. Jei \u0161i nuostata i\u0161jungta, tuomet naudojamas Keycloak DB saugomas vie\u0161asis raktas (arba sertifikatas) ir klientui sugeneravus naujus raktus juos rankiniu b\u016Bdu reik\u0117s importuoti \u012F Keycloak DB. +jwks-url=JWKS URL +jwks-url.tooltip=URL, kuriuo pasiekiami kliento JWK formatu saugomi raktai. \u017Di\u016Br\u0117kite JWK specifikacij\u0105 detalesnei informacijai. Jei naudojamas kliento adapteris su "jwt" kredencialais, tuomet galite naudoti j\u016Bs\u0173 programos URL su '/k_jwks' sufiksu. Pavyzd\u017Eiui 'http://www.myhost.com/myapp/k_jwks' . archive-format=Archyvo formatas archive-format.tooltip=Java rakt\u0173 saugykla (keystore) arba PKCS12 formato rinkmena. key-alias=Rakto pseudonimas @@ -412,7 +422,9 @@ post-broker-login-flow=Sekan\u010Di\u0173 prisijungim\u0173 eiga redirect-uri=Nukreipimo URI redirect-uri.tooltip=Tapatyb\u0117s teik\u0117jo konfig\u016Bravimo nuoroda. alias=Pseudonimas +display-name=Rodomas vardas identity-provider.alias.tooltip=Pseudonimas, kuris vienareik\u0161mi\u0161kai identifikuoja tapatyb\u0117s teik\u0117j\u0105 ir yra naudojamas konstruojant nukreipimo nuorod\u0105. +identity-provider.display-name.tooltip=\u017Dmogui suprantamas, draugi\u0161kas tapatyb\u0117s teik\u0117jo pavadinimas. identity-provider.enabled.tooltip=\u012Egalinti \u0161\u012F tapatyb\u0117s teik\u0117j\u0105. authenticate-by-default=Autentifikuoti i\u0161 karto identity-provider.authenticate-by-default.tooltip=Jei \u012Fgalinta, tuomet bus bandoma autentifikuoti naudotoj\u0105 prie\u0161 parodant prisijungimo lang\u0105. @@ -460,6 +472,8 @@ select-account.option=paskyros pasirinkimas prompt.tooltip=Nurodo, ar autorizacijos serveris galutini\u0173 naudotoj\u0173 reikalauja pakartotinai patvirtinti sutikim\u0105 ar prisijungti. validate-signatures=Para\u0161o tikrinimas identity-provider.validate-signatures.tooltip=\u012Egalinamas i\u0161orini\u0173 IDP para\u0161\u0173 tikrinimas. +identity-provider.use-jwks-url.tooltip=Jei \u012Fgalinta, tuomet tapatyb\u0117s teik\u0117jo vie\u0161asis raktas atsiun\u010Diamas i\u0161 pateiktos JWKS URL. \u012Egalinimas suteikia lankstumo, nes tapatyb\u0117s teik\u0117jui pergeneravus raktus jie automati\u0161kai atsiun\u010Diami. Jei \u0161i nuostata i\u0161jungta, tuomet naudojamas Keycloak DB saugomas vie\u0161asis raktas (arba sertifikatas) ir klientui sugeneravus naujus raktus juos rankiniu b\u016Bdu reik\u0117s importuoti \u012F Keycloak DB. +identity-provider.jwks-url.tooltip=URL, kuriuo pasiekiami tapatyb\u0117s teik\u0117jo JWK formatu saugomi raktai. \u017Di\u016Br\u0117kite JWK specifikacij\u0105 detalesnei informacijai. Jei naudojamas i\u0161orinis Keycloak tapatyb\u0117s teik\u0117jas, tuomet galite naudoti 'http://broker-keycloak:8180/auth/realms/test/protocol/openid-connect/certs' URL (pavyzdyje darome prielaida, kad Keycloak veikia 'http://broker-keycloak:8180' adresu ir naudojama 'test' sritis) validating-public-key=Vie\u0161as raktas para\u0161o tikrinimui identity-provider.validating-public-key.tooltip=PEM formato vie\u0161asis raktas, kuris turi b\u016Bti naudojamas i\u0161orinio IDP para\u0161t\u0173 tikrinimui. import-external-idp-config=Importuoti i\u0161orinio IDP konfig\u016Bracij\u0105 @@ -839,6 +853,7 @@ include-representation=I\u0161saugoti reprezentacij\u0105 include-representation.tooltip=I\u0161saugoti kur\u016Bmo ir redagavimo u\u017Eklaus\u0173 JSON reprezentacij\u0105. clear-admin-events.tooltip=I\u0161trina visus su administravimu susijusius veiksmus i\u0161 duomen\u0173 baz\u0117s. server-version=Serverio versija +server-profile=Serverio profilis info=Informacija providers=Teik\u0117jai server-time=Serverio laikas @@ -972,9 +987,9 @@ authz-select-user=Parinkite naudotoj\u0105 authz-entitlements=Teis\u0117s authz-no-resources=Resurs\u0173 n\u0117ra authz-result=Rezultatas -authz-authorization-services-enabled=Autorizacija \u012Fgalinta +authz-authorization-services-enabled=\u012Egalinti autorizacij\u0105 authz-authorization-services-enabled.tooltip=\u012Egalinti detal\u0173 kliento autorizacijos palaikym\u0105 -authz-required=Privalimas +authz-required=Privalomas # Authz Settings authz-import-config.tooltip=Importuoti \u0161io resurs\u0173 serverio autorizacijos nustatym\u0173 JSON rinkmen\u0105. diff --git a/themes/src/main/resources/theme/base/admin/messages/messages_lt.properties b/themes/src/main/resources/theme/base/admin/messages/messages_lt.properties index 49089257be..5cc51a2a6e 100644 --- a/themes/src/main/resources/theme/base/admin/messages/messages_lt.properties +++ b/themes/src/main/resources/theme/base/admin/messages/messages_lt.properties @@ -14,4 +14,11 @@ ldapErrorCantWriteOnlyForReadOnlyLdap=Negalima nustatyti ra\u0161ymo r\u0117\u01 ldapErrorCantWriteOnlyAndReadOnly=Negalima nustatyti tik ra\u0161yti ir tik skaityti kartu clientRedirectURIsFragmentError=Nurodykite URI fragment\u0105, kurio negali b\u016Bti peradresuojamuose URI adresuose -clientRootURLFragmentError=Nurodykite URL fragment\u0105, kurio negali b\u016Bti \u0161akniniame URL adrese \ No newline at end of file +clientRootURLFragmentError=Nurodykite URL fragment\u0105, kurio negali b\u016Bti \u0161akniniame URL adrese + +pairwiseMalformedClientRedirectURI=Klientas pateik\u0117 neteising\u0105 nukreipimo nuorod\u0105. +pairwiseClientRedirectURIsMissingHost=Kliento nukreipimo nuorodos privalo b\u016Bti nurodytos su serverio vardo komponentu. +pairwiseClientRedirectURIsMultipleHosts=Kuomet nesukonfig\u016Bruotas sektoriaus identifikatoriaus URL, kliento nukreipimo nuorodos privalo talpinti ne daugiau kaip vien\u0105 skirting\u0105 serverio vardo komponent\u0105. +pairwiseMalformedSectorIdentifierURI=Neteisinga sektoriaus identifikatoriaus URI. +pairwiseFailedToGetRedirectURIs=Nepavyko gauti nukreipimo nuorod\u0173 i\u0161 sektoriaus identifikatoriaus URI. +pairwiseRedirectURIsMismatch=Kliento nukreipimo nuoroda neatitinka nukreipimo nuorodo\u0173 i\u0161 sektoriaus identifikatoriaus URI. \ No newline at end of file diff --git a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js index a7135f6e0e..6ea2154caa 100644 --- a/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js +++ b/themes/src/main/resources/theme/base/admin/resources/js/controllers/realm.js @@ -1955,7 +1955,7 @@ module.controller('CreateExecutionCtrl', function($scope, realm, parentFlow, for -module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flows, selectedFlow, LastFlowSelected, +module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flows, selectedFlow, LastFlowSelected, Dialog, AuthenticationFlows, AuthenticationFlowsCopy, AuthenticationFlowExecutions, AuthenticationExecution, AuthenticationExecutionRaisePriority, AuthenticationExecutionLowerPriority, $modal, Notifications, CopyDialog, $location) { @@ -2034,6 +2034,12 @@ module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flo }) }; + $scope.deleteFlow = function() { + Dialog.confirmDelete($scope.flow.alias, 'flow', function() { + $scope.removeFlow(); + }); + }; + $scope.removeFlow = function() { console.log('Remove flow:' + $scope.flow.alias); if (realm.browserFlow == $scope.flow.alias) { @@ -2053,9 +2059,8 @@ module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flo } else { AuthenticationFlows.remove({realm: realm.realm, flow: $scope.flow.id}, function () { - $location.url("/realms/" + realm.realm + '/authentication/flows'); + $location.url("/realms/" + realm.realm + '/authentication/flows/' + flows[0].alias); Notifications.success("Flow removed"); - }) } @@ -2100,10 +2105,14 @@ module.controller('AuthenticationFlowsCtrl', function($scope, $route, realm, flo $scope.removeExecution = function(execution) { console.log('removeExecution: ' + execution.id); - AuthenticationExecution.remove({realm: realm.realm, execution: execution.id}, function() { - Notifications.success("Execution removed"); - setupForm(); - }) + var exeOrFlow = execution.authenticationFlow ? 'flow' : 'execution'; + Dialog.confirmDelete(execution.displayName, exeOrFlow, function() { + AuthenticationExecution.remove({realm: realm.realm, execution: execution.id}, function() { + Notifications.success("The " + exeOrFlow + " was removed."); + setupForm(); + }); + }); + } $scope.raisePriority = function(execution) { diff --git a/themes/src/main/resources/theme/base/admin/resources/partials/authentication-flows.html b/themes/src/main/resources/theme/base/admin/resources/partials/authentication-flows.html index da867b0f38..3ef54b398a 100755 --- a/themes/src/main/resources/theme/base/admin/resources/partials/authentication-flows.html +++ b/themes/src/main/resources/theme/base/admin/resources/partials/authentication-flows.html @@ -17,7 +17,7 @@
- +
diff --git a/themes/src/main/resources/theme/base/email/messages/messages_lt.properties b/themes/src/main/resources/theme/base/email/messages/messages_lt.properties index f0eb51c5b7..a1b59ade93 100644 --- a/themes/src/main/resources/theme/base/email/messages/messages_lt.properties +++ b/themes/src/main/resources/theme/base/email/messages/messages_lt.properties @@ -1,6 +1,6 @@ emailVerificationSubject=El. pa\u0161to patvirtinimas -emailVerificationBody=Paskyra {2} sukurta naudojant \u0161\u012F el. pa\u0161to adres\u0105. Jei tau buvote J\u016Bs, tuomet paspauskite \u017Eemiau esan\u010Di\u0105 nuorod\u0105\n\n{0}\n\n\u0160i nuoroda galioja {1} min.\n\nJei paskyros nek\u016Br\u0117te, tuomet ignuoruokite \u0161\u012F lai\u0161k\u0105. -emailVerificationBodyHtml=

Paskyra {2} sukurta naudojant \u0161\u012F el. pa\u0161to adres\u0105. Jei tau buvote J\u016Bs, tuomet paspauskite \u017Eemiau esan\u010Di\u0105 nuorod\u0105

{0}

\u0160i nuoroda galioja {1} min.

nJei paskyros nek\u016Br\u0117te, tuomet ignuoruokite \u0161\u012F lai\u0161k\u0105.

+emailVerificationBody=Paskyra {2} sukurta naudojant \u0161\u012F el. pa\u0161to adres\u0105. Jei tai buvote J\u016Bs, tuomet paspauskite \u017Eemiau esan\u010Di\u0105 nuorod\u0105\n\n{0}\n\n\u0160i nuoroda galioja {1} min.\n\nJei paskyros nek\u016Br\u0117te, tuomet ignuoruokite \u0161\u012F lai\u0161k\u0105. +emailVerificationBodyHtml=

Paskyra {2} sukurta naudojant \u0161\u012F el. pa\u0161to adres\u0105. Jei tao buvote J\u016Bs, tuomet paspauskite \u017Eemiau esan\u010Di\u0105 nuorod\u0105

{0}

\u0160i nuoroda galioja {1} min.

nJei paskyros nek\u016Br\u0117te, tuomet ignuoruokite \u0161\u012F lai\u0161k\u0105.

identityProviderLinkSubject=S\u0105saja {0} identityProviderLinkBody=Ka\u017Eas pageidauja susieti J\u016Bs\u0173 "{1}" paskyr\u0105 su "{0}" {2} naudotojo paskyr\u0105. Jei tai buvote J\u016Bs, tuomet paspauskite \u017Eemiau esan\u010Di\u0105 nuorod\u0105 nor\u0117dami susieti paskyras\n\n{3}\n\n\u0160i nuoroda galioja {4} min.\n\nJei paskyr\u0173 susieti nenorite, tuomet ignoruokite \u0161\u012F lai\u0161k\u0105. Jei paskyras susiesite, tuomet prie {1} gal\u0117siste prisijungti per {0}. identityProviderLinkBodyHtml=

\u017Eas pageidauja susieti J\u016Bs\u0173 {1} paskyr\u0105 su {0} {2} naudotojo paskyr\u0105. Jei tai buvote J\u016Bs, tuomet paspauskite \u017Eemiau esan\u010Di\u0105 nuorod\u0105 nor\u0117dami susieti paskyras

{3}

\u0160i nuoroda galioja {4} min.

Jei paskyr\u0173 susieti nenorite, tuomet ignoruokite \u0161\u012F lai\u0161k\u0105. Jei paskyras susiesite, tuomet prie {1} gal\u0117siste prisijungti per {0}.

@@ -10,15 +10,15 @@ passwordResetBodyHtml=

Ka\u017Ekas pageidauja pakeisti J\u016Bs\u0173 paskyros executeActionsSubject=Atnaujinkite savo paskyr\u0105 executeActionsBody=Sistemos administratorius pageidauja, kad J\u016Bs atnaujintum\u0117te savo {2} paskyr\u0105. Paspauskite \u017Eemiau esan\u010Di\u0105 nuorod\u0105 paskyros duomen\u0173 atnaujinimui.\n\n{0}\n\n\u0160i nuoroda galioja {1} min.\n\nJei J\u016Bs neasate tikri, kad tai administratoriaus pageidavimas, tuomet ignoruokite \u0161\u012F lai\u0161k\u0105 ir niekas nebus pakeista. executeActionsBodyHtml=

Sistemos administratorius pageidauja, kad J\u016Bs atnaujintum\u0117te savo {2} paskyr\u0105. Paspauskite \u017Eemiau esan\u010Di\u0105 nuorod\u0105 paskyros duomen\u0173 atnaujinimui.

{0}

\u0160i nuoroda galioja {1} min.

Jei J\u016Bs neasate tikri, kad tai administratoriaus pageidavimas, tuomet ignoruokite \u0161\u012F lai\u0161k\u0105 ir niekas nebus pakeista.

-eventLoginErrorSubject=Bandymas prisijungti prie J\u016Bs\u0173 paskyros -eventLoginErrorBody=Bandymas prisijungti prie J\u016Bs\u0173 paskyros {0} i\u0161 {1} nes\u012Fkmingas. Jei tai nebuvote J\u016Bs, tuomet susisiekite su administratoriumi -eventLoginErrorBodyHtml=

Bandymas prisijungti prie J\u016Bs\u0173 paskyros {0} i\u0161 {1} nes\u012Fkmingas. Jei tai nebuvote J\u016Bs, tuomet susisiekite su administratoriumi

+eventLoginErrorSubject=Nes\u0117kmingas bandymas prisijungti prie j\u016Bs\u0173 paskyros +eventLoginErrorBody=Bandymas prisijungti prie j\u016Bs\u0173 paskyros {0} i\u0161 {1} nes\u0117kmingas. Jei tai nebuvote j\u016Bs, tuomet susisiekite su administratoriumi +eventLoginErrorBodyHtml=

Bandymas prisijungti prie j\u016Bs\u0173 paskyros {0} i\u0161 {1} nes\u0117kmingas. Jei tai nebuvote j\u016Bs, tuomet susisiekite su administratoriumi

eventRemoveTotpSubject=TOTP pa\u0161alinimas eventRemoveTotpBody=Ka\u017Ekas pageidauja atsieti TOPT J\u016Bs\u0173 {1} paskyroje su {0}. Jei tai nebuvote J\u016Bs, tuomet susisiekite su administratoriumi eventRemoveTotpBodyHtml=

Ka\u017Ekas pageidauja atsieti TOPT J\u016Bs\u0173 {1} paskyroje su {0}. Jei tai nebuvote J\u016Bs, tuomet susisiekite su administratoriumi

eventUpdatePasswordSubject=Slapta\u017Eod\u017Eio atnaujinimas -eventUpdatePasswordBody=J\u016Bs\u0173 slapta\u017Eodis J\u016Bs\u0173 {1} paskyroje su {0} buvo pakeisas. Jei tai nebuvote J\u016Bs, tuomet susisiekite su administratoriumi -eventUpdatePasswordBodyHtml=

J\u016Bs\u0173 {1} paskyroje su {0. Jei tai nebuvote J\u016Bs, tuomet susisiekite su administratoriumi

+eventUpdatePasswordBody={1} paskyroje {0} pakeisas j\u016Bs\u0173 slapta\u017Eodis. Jei J\u016Bs nekeit\u0117te, tuomet susisiekite su administratoriumi +eventUpdatePasswordBodyHtml=

{1} paskyroje {0} pakeisas j\u016Bs\u0173 slapta\u017Eodis. Jei J\u016Bs nekeit\u0117te, tuomet susisiekite su administratoriumi

eventUpdateTotpSubject=TOTP atnaujinimas eventUpdateTotpBody=TOTP J\u016Bs\u0173 {1} paskyroje su {0} buvo atnaujintas. Jei tai nebuvote J\u016Bs, tuomet susisiekite su administratoriumi -eventUpdateTotpBodyHtml=

TOTP J\u016Bs\u0173 {1} paskyroje su {0} buvo atnaujintas. Jei tai nebuvote J\u016Bs, tuomet susisiekite su administratoriumi

+eventUpdateTotpBodyHtml=

TOTP J\u016Bs\u0173 {1} paskyroje su {0} buvo atnaujintas. Jei tai nebuvote J\u016Bs, tuomet susisiekite su administratoriumi

\ No newline at end of file diff --git a/themes/src/main/resources/theme/keycloak/welcome/index.ftl b/themes/src/main/resources/theme/keycloak/welcome/index.ftl index 43bec622ae..cbb08d605b 100755 --- a/themes/src/main/resources/theme/keycloak/welcome/index.ftl +++ b/themes/src/main/resources/theme/keycloak/welcome/index.ftl @@ -73,7 +73,7 @@ <#else>

You need local access to create the initial admin user. Open http://localhost:8080/auth - or use the add-user script. + or use the add-user-keycloak script.

diff --git a/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-datasources.xml b/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-datasources.xml index cd7fc81abd..11d1240d42 100755 --- a/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-datasources.xml +++ b/wildfly/server-subsystem/src/main/resources/subsystem-templates/keycloak-datasources.xml @@ -21,22 +21,22 @@ org.jboss.as.connector - - jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + + jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE h2 sa sa - - - + + + h2 sa sa - + org.h2.jdbcx.JdbcDataSource