/* * 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.EOL; 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 <a href="mailto:mstrukel@redhat.com">Marko Strukelj</a> */ @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<String> 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 = '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<String> args; @Override public CommandResult execute(CommandInvocation commandInvocation) throws CommandException, InterruptedException { List<AttributeOperation> attrs = new LinkedList<>(); try { if (printHelp()) { return help ? CommandResult.SUCCESS : CommandResult.FAILURE; } processGlobalOptions(); String clientId = null; if (args != null) { Iterator<String> it = args.iterator(); if (!it.hasNext()) { throw new IllegalArgumentException("CLIENT_ID not specified"); } clientId = it.next(); if (clientId.startsWith("-")) { warnfErr(ParseUtil.CLIENT_OPTION_WARN, clientId); } while (it.hasNext()) { String option = it.next(); switch (option) { case "-s": case "--set": { if (!it.hasNext()) { throw new IllegalArgumentException("Option " + option + " requires a value"); } String[] keyVal = parseKeyVal(it.next()); attrs.add(new AttributeOperation(SET, keyVal[0], keyVal[1])); break; } case "-d": case "--delete": { attrs.add(new AttributeOperation(DELETE, it.next())); break; } default: { throw new IllegalArgumentException("Unsupported option: " + option); } } } } if (file == null && attrs.size() == 0) { throw new IllegalArgumentException("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); }); } // 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; } catch (IllegalArgumentException e) { throw new IllegalArgumentException(e.getMessage() + suggestHelp(), e); } finally { commandInvocation.stop(); } } private void outputResult(Object result) throws IOException { if (outputClient) { if (compressed) { printOut(JsonSerialization.writeValueAsString(result)); } else { printOut(JsonSerialization.writeValueAsPrettyString(result)); } } } @Override protected boolean nothingToDo() { return noOptions() && regType == null && file == null && (args == null || args.size() == 0); } protected String suggestHelp() { return EOL + "Try '" + CMD + " help update' for more information"; } protected String help() { return usage(); } public static String usage() { StringWriter sb = new StringWriter(); PrintWriter out = new PrintWriter(sb); out.println("Usage: " + CMD + " update 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(" --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(" -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. If available, Registration Access Token is used "); out.println("for authorization when doing changes. Otherwise current session's authorization is used in which case user needs"); out.println("manage-clients permission for update to work."); 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(); } }