diff --git a/.gitignore b/.gitignore index 6b035e7e..7639304c 100755 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ target/ velocity.log .idea *.iml +.DS_Store diff --git a/.java-version b/.java-version deleted file mode 100644 index 4a4deb25..00000000 --- a/.java-version +++ /dev/null @@ -1 +0,0 @@ -20.0.0.r11-grl diff --git a/.sdkmanrc b/.sdkmanrc new file mode 100644 index 00000000..2d3db57e --- /dev/null +++ b/.sdkmanrc @@ -0,0 +1,3 @@ +# Enable auto-env through the sdkman_auto_env config +# Add key=value pairs of SDKs to use below +java=20.0.0.r11-grl diff --git a/README.md b/README.md index 7dc5cf1e..8814d1ec 100644 --- a/README.md +++ b/README.md @@ -93,23 +93,17 @@ echo `. ~/okta.bash` >> ~/.bash_profile For more details on using bash completion see the [Picocli documentation](https://picocli.info/autocomplete.html#_installing_completion_scripts_permanently_in_bashzsh). -## Building / Contributing +## Contribute -You'll need to use the [GraalVM]() to build this project. That's what supports the native build. +The easiest way to build the project is to use [sdkman](). -If you use [sdkman]() on Mac, it's pretty easy to get the right version installed: +If you have `sdkman_auto_env=true` in your `~/.sdkman/etc/config`, then when you switch to the project folder, the correct +JVM will be selected automatically. -``` -sdk install java $(cat .java-version) && sdk use java $(cat .java-version) -gu install native-image -``` - -If you want to test against a locally running service, you'll need to run the cli with the following environment -variable: +You can also type: `sdk env` and the correct JVM will be used while in the project folder. -``` -OKTA_CLI_BASE_URL=http://localhost:8080/ -``` +Build with: `mvn clean install` -**NOTE**: The backend service code is currently in a private repo. +**NOTE:** On IntelliJ (at least), you'll also need to add in the Lombok plugin to avoid compiler errors on getters and setters for data classes. +You can then run the Okta cli with: `./cli/target/okta` diff --git a/cli/pom.xml b/cli/pom.xml index 4a825289..dbad5e5c 100644 --- a/cli/pom.xml +++ b/cli/pom.xml @@ -20,7 +20,7 @@ com.okta.cli okta-cli-tools - 0.3.2-SNAPSHOT + 0.4.0-SNAPSHOT okta-cli @@ -89,6 +89,18 @@ 1.0-rc6 true + + + org.mockito + mockito-core + 3.4.4 + test + + + org.hamcrest + hamcrest + test + @@ -185,6 +197,7 @@ native-image --no-fallback + --no-server --enable-url-protocols=http,https -H:ResourceConfigurationFiles=src/main/graalvm/resource-config.json -H:ReflectionConfigurationFiles=src/main/graalvm/reflect-config.json,src/main/graalvm/okta-sdk-reflect-config.json @@ -198,6 +211,19 @@ + + maven-jar-plugin + + + + + + com.okta.cli + + + + + org.codehaus.mojo build-helper-maven-plugin diff --git a/cli/src/main/graalvm/reflect-config.json b/cli/src/main/graalvm/reflect-config.json index 6de698d6..dff93d81 100644 --- a/cli/src/main/graalvm/reflect-config.json +++ b/cli/src/main/graalvm/reflect-config.json @@ -42,6 +42,19 @@ } ] }, + { + "name": "com.okta.cli.common.model.ErrorResponse", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allPublicMethods": true, + "allPublicFields": true, + "methods": [ + { + "name": "", + "parameterTypes": [] + } + ] + }, { "name": "org.yaml.snakeyaml.Yaml", "allDeclaredFields": true, diff --git a/cli/src/main/java/com/okta/cli/commands/Register.java b/cli/src/main/java/com/okta/cli/commands/Register.java index 75ca9cb3..9bc37cbb 100644 --- a/cli/src/main/java/com/okta/cli/commands/Register.java +++ b/cli/src/main/java/com/okta/cli/commands/Register.java @@ -17,12 +17,16 @@ import com.okta.cli.OktaCli; import com.okta.cli.common.model.OrganizationRequest; +import com.okta.cli.common.model.OrganizationResponse; +import com.okta.cli.common.model.RegistrationQuestions; import com.okta.cli.common.service.DefaultSetupService; import com.okta.cli.common.service.SetupService; +import com.okta.cli.console.PromptOption; import com.okta.cli.console.Prompter; import picocli.CommandLine; import picocli.CommandLine.Command; +import java.util.List; import java.util.concurrent.Callable; @Command(name = "register", @@ -44,22 +48,58 @@ public class Register implements Callable { @CommandLine.Option(names = "--company", description = "Company / organization used when registering a new Okta account") protected String company; - protected OrganizationRequest organizationRequest() { - Prompter prompter = standardOptions.getEnvironment().prompter(); - return new OrganizationRequest() - .setFirstName(prompter.promptUntilValue(firstName, "First name")) - .setLastName(prompter.promptUntilValue(lastName, "Last name")) - .setEmail(prompter.promptUntilValue(email, "Email address")) - .setOrganization(prompter.promptUntilValue(company, "Company")); + protected CliRegistrationQuestions registrationQuestions() { + return new CliRegistrationQuestions(); } @Override public Integer call() throws Exception { + + CliRegistrationQuestions registrationQuestions = registrationQuestions(); + SetupService setupService = new DefaultSetupService(null); - setupService.createOktaOrg(this::organizationRequest, + OrganizationResponse orgResponse = setupService.createOktaOrg(registrationQuestions, standardOptions.getEnvironment().getOktaPropsFile(), standardOptions.getEnvironment().isDemo(), standardOptions.getEnvironment().isInteractive()); + + String identifier = orgResponse.getId(); + setupService.verifyOktaOrg(identifier, + registrationQuestions, + standardOptions.getEnvironment().getOktaPropsFile()); + return 0; + + +// TODO include demo logic? +// if (demo) { // always prompt for user info in "demo mode", this info will not be used but it makes for a more realistic demo +// organizationRequestSupplier.get(); +// } + } + + private class CliRegistrationQuestions implements RegistrationQuestions { + + private final Prompter prompter = standardOptions.getEnvironment().prompter(); + + @Override + public boolean isOverwriteConfig() { + PromptOption yes = PromptOption.of("Yes", Boolean.TRUE); + PromptOption no = PromptOption.of("No", Boolean.FALSE); + return prompter.promptIfEmpty(null, "Overwrite configuration file?", List.of(yes, no), yes); + } + + @Override + public OrganizationRequest getOrganizationRequest() { + return new OrganizationRequest() + .setFirstName(prompter.promptUntilValue(firstName, "First name")) + .setLastName(prompter.promptUntilValue(lastName, "Last name")) + .setEmail(prompter.promptUntilValue(email, "Email address")) + .setOrganization(prompter.promptUntilValue(company, "Company")); + } + + @Override + public String getVerificationCode() { + return prompter.promptUntilValue("Verification code"); + } } } diff --git a/cli/src/main/java/com/okta/cli/commands/apps/AppsConfig.java b/cli/src/main/java/com/okta/cli/commands/apps/AppsConfig.java index 39bd4511..dc1a2409 100644 --- a/cli/src/main/java/com/okta/cli/commands/apps/AppsConfig.java +++ b/cli/src/main/java/com/okta/cli/commands/apps/AppsConfig.java @@ -19,10 +19,12 @@ import com.okta.cli.common.model.AuthorizationServer; import com.okta.cli.common.model.ClientCredentials; import com.okta.cli.console.ConsoleOutput; +import com.okta.commons.lang.Assert; import com.okta.sdk.client.Client; import com.okta.sdk.client.Clients; import com.okta.sdk.resource.ExtensibleResource; import com.okta.sdk.resource.application.Application; +import com.okta.sdk.resource.application.OpenIdConnectApplication; import picocli.CommandLine; import java.util.concurrent.Callable; @@ -43,8 +45,9 @@ public Integer call() { Application app = client.getApplication(appName); ConsoleOutput out = standardOptions.getEnvironment().getConsoleOutput(); - - // TODO verify this is an OIDC app + + Assert.isInstanceOf(OpenIdConnectApplication.class, app, "Existing application found with name '" + + appName +"' but it is NOT an OIDC application. Only OIDC applications work with the Okta CLI."); ClientCredentials clientCreds = new ClientCredentials(client.http() .get("/api/v1/internal/apps/" + app.getId() + "/settings/clientcreds", ExtensibleResource.class)); diff --git a/cli/src/main/java/com/okta/cli/console/DefaultPrompter.java b/cli/src/main/java/com/okta/cli/console/DefaultPrompter.java index 91b2e3b7..ebca6d05 100644 --- a/cli/src/main/java/com/okta/cli/console/DefaultPrompter.java +++ b/cli/src/main/java/com/okta/cli/console/DefaultPrompter.java @@ -29,16 +29,13 @@ public class DefaultPrompter implements Prompter, Closeable { - private final BufferedReader consoleReader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + private final BufferedReader consoleReader; private final ConsoleOutput out; - public DefaultPrompter() { - this(ConsoleOutput.create(false)); - } - public DefaultPrompter(ConsoleOutput consoleOutput) { this.out = consoleOutput; + this.consoleReader = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); } @Override diff --git a/cli/src/test/groovy/com/okta/cli/console/DefaultPrompterTest.groovy b/cli/src/test/groovy/com/okta/cli/console/DefaultPrompterTest.groovy new file mode 100644 index 00000000..e91cad4c --- /dev/null +++ b/cli/src/test/groovy/com/okta/cli/console/DefaultPrompterTest.groovy @@ -0,0 +1,171 @@ +/* + * Copyright 2020-Present Okta, Inc. + * + * 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 com.okta.cli.console + +import org.testng.Assert +import org.testng.annotations.Listeners +import org.testng.annotations.Test + +import static org.hamcrest.Matchers.nullValue +import static org.mockito.Mockito.mock +import static org.hamcrest.MatcherAssert.assertThat +import static org.hamcrest.Matchers.equalTo +import static org.mockito.Mockito.verify + +@Listeners(RestoreSystemInOut) +class DefaultPrompterTest { + + @Test(timeOut = 2000l) + void basicPrompter() { + + ConsoleOutput out = mock(ConsoleOutput) + + expectInput("test-result") + DefaultPrompter prompter = new DefaultPrompter(out) + String result = prompter.prompt("hello") + + assertThat(result, equalTo("test-result")) + verify(out).write("hello: ") + } + + @Test(timeOut = 2000l) + void nullResult() { + + ConsoleOutput out = mock(ConsoleOutput) + + expectInput("") + DefaultPrompter prompter = new DefaultPrompter(out) + String result = prompter.prompt("hello") + + assertThat(result, nullValue()) + } + + @Test(timeOut = 2000l) + void promptWithOptions_noDefault() { + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream() + ConsoleOutput out = new ConsoleOutput.AnsiConsoleOutput(new PrintStream(outputStream), true) + + expectInput("2") + DefaultPrompter prompter = new DefaultPrompter(out) + String result = prompter.prompt("hello", [new StubPromptOption("one", "one-1"), new StubPromptOption("two", "two-2")], null) + + // ansi colors + String expectedOutput = "hello\n" + + "\u001B[1m> 1: \u001B[0mone\n" + + "\u001B[1m> 2: \u001B[0mtwo\n" + + "Enter your choice: " + + assertThat(result, equalTo("two-2")) + assertThat(outputStream.toString(), equalTo(expectedOutput)) + } + + @Test(timeOut = 2000l) + void promptWithOptions_withDefault() { + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream() + ConsoleOutput out = new ConsoleOutput.AnsiConsoleOutput(new PrintStream(outputStream), true) + + expectInput("2") + DefaultPrompter prompter = new DefaultPrompter(out) + + def options = [new StubPromptOption("one", "one-1"), new StubPromptOption("two", "two-2")] + String result = prompter.prompt("hello", options, options[1]) + + // ansi colors + String expectedOutput = "hello\n" + + "\u001B[1m> 1: \u001B[0mone\n" + + "\u001B[1m> 2: \u001B[0mtwo\n" + + "Enter your choice [two]: " + + assertThat(result, equalTo("two-2")) + assertThat(outputStream.toString(), equalTo(expectedOutput)) + } + + @Test//(timeOut = 2000l) + void promptWithOptions_invalidSelection() { + + ByteArrayOutputStream outputStream = new ByteArrayOutputStream() + ConsoleOutput out = new ConsoleOutput.AnsiConsoleOutput(new PrintStream(outputStream), true) + + expectInput("3") + DefaultPrompter prompter = new DefaultPrompter(out) + + def options = [new StubPromptOption("one", "one-1"), new StubPromptOption("two", "two-2")] + String result = prompter.prompt("hello", options, options[1]) + + // ansi colors + String expectedOutput = "hello\n" + + "\u001B[1m> 1: \u001B[0mone\n" + + "\u001B[1m> 2: \u001B[0mtwo\n" + + "Enter your choice [two]: \u001B[0;31m\n" + + "Invalid choice, try again\n" + + "\n" + + "\u001B[0mhello\n" + + "\u001B[1m> 1: \u001B[0mone\n" + + "\u001B[1m> 2: \u001B[0mtwo\n" + + "Enter your choice [two]: " + + assertThat(result, equalTo("two-2")) + assertThat(outputStream.toString(), equalTo(expectedOutput)) + } + + @Test(timeOut = 2000l) + void failToReadLine() { + ConsoleOutput out = mock(ConsoleOutput) + System.in = mock(InputStream) + + DefaultPrompter prompter = new DefaultPrompter(out) + expectException(PrompterException) { prompter.prompt("hello") } + } + + static void expectInput(String text) { + System.in = new ByteArrayInputStream(text.bytes) + } + + static Throwable expectException(Class catchMe, Closure callMe) { + try { + callMe.call() + Assert.fail("Expected ${catchMe.getName()} to be thrown.") + } catch(e) { + if (!e.class.isAssignableFrom(catchMe)) { + throw e + } + return e + } + } + + static class StubPromptOption implements PromptOption { + + private final name + private final value + + StubPromptOption(name, value) { + this.name = name + this.value = value + } + + @Override + String displayName() { + return name + } + + @Override + String value() { + return this.value + } + } +} diff --git a/cli/src/test/groovy/com/okta/cli/console/RestoreSystemInOut.groovy b/cli/src/test/groovy/com/okta/cli/console/RestoreSystemInOut.groovy new file mode 100644 index 00000000..9162cbbf --- /dev/null +++ b/cli/src/test/groovy/com/okta/cli/console/RestoreSystemInOut.groovy @@ -0,0 +1,34 @@ +/* + * Copyright 2020-Present Okta, Inc. + * + * 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 com.okta.cli.console + +import org.testng.IInvokedMethod +import org.testng.IInvokedMethodListener +import org.testng.ITestResult + +class RestoreSystemInOut implements IInvokedMethodListener { + + private final InputStream originalIn = System.in + private final OutputStream originalOut = System.out + private final OutputStream originalErr = System.err + + @Override + void afterInvocation(IInvokedMethod method, ITestResult testResult) { + System.in = originalIn + System.out = originalOut + System.err = originalErr + } +} diff --git a/common/lombok.config b/common/lombok.config new file mode 100644 index 00000000..8f7e8aa1 --- /dev/null +++ b/common/lombok.config @@ -0,0 +1 @@ +lombok.addLombokGeneratedAnnotation = true \ No newline at end of file diff --git a/common/pom.xml b/common/pom.xml index 3251cd73..ee3f263f 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ com.okta.cli okta-cli-tools - 0.3.2-SNAPSHOT + 0.4.0-SNAPSHOT okta-cli-common @@ -50,6 +50,12 @@ com.google.guava guava + + org.projectlombok + lombok + 1.18.12 + true + org.mockito diff --git a/cli/src/main/java/module-info.java b/common/src/main/java/com/okta/cli/common/FactorVerificationException.java similarity index 61% rename from cli/src/main/java/module-info.java rename to common/src/main/java/com/okta/cli/common/FactorVerificationException.java index aed59030..876500d0 100644 --- a/cli/src/main/java/module-info.java +++ b/common/src/main/java/com/okta/cli/common/FactorVerificationException.java @@ -13,13 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -module com.okta.cli { - requires info.picocli; - requires okta.sdk.api; - requires okta.sdk.impl; - requires okta.commons.lang; - requires okta.config.check; - requires com.okta.cli.common; - opens com.okta.cli to info.picocli; - opens com.okta.cli.commands to info.picocli; -} \ No newline at end of file +package com.okta.cli.common; + +import com.okta.cli.common.model.ErrorResponse; + +public class FactorVerificationException extends RestException { + + public FactorVerificationException(ErrorResponse errorResponse, Throwable t) { + super(errorResponse, t); + } + + public FactorVerificationException(ErrorResponse errorResponse) { + super(errorResponse); + } +} diff --git a/common/src/main/java/com/okta/cli/common/RestException.java b/common/src/main/java/com/okta/cli/common/RestException.java new file mode 100644 index 00000000..3116e825 --- /dev/null +++ b/common/src/main/java/com/okta/cli/common/RestException.java @@ -0,0 +1,37 @@ +/* + * Copyright 2020-Present Okta, Inc. + * + * 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 com.okta.cli.common; + +import com.okta.cli.common.model.ErrorResponse; + +public class RestException extends Exception { + + private final ErrorResponse errorResponse; + + public RestException(ErrorResponse errorResponse) { + super(errorResponse.getMessage()); + this.errorResponse = errorResponse; + } + + public RestException(ErrorResponse errorResponse, Throwable t) { + super(errorResponse.getMessage(), t); + this.errorResponse = errorResponse; + } + + public ErrorResponse getErrorResponse() { + return errorResponse; + } +} diff --git a/common/src/main/java/com/okta/cli/common/model/ErrorResponse.java b/common/src/main/java/com/okta/cli/common/model/ErrorResponse.java new file mode 100644 index 00000000..7e27fc89 --- /dev/null +++ b/common/src/main/java/com/okta/cli/common/model/ErrorResponse.java @@ -0,0 +1,36 @@ +/* + * Copyright 2020-Present Okta, Inc. + * + * 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 com.okta.cli.common.model; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.util.List; + +@Data +@Accessors(chain = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ErrorResponse implements Serializable { + + private static final long serialVersionUID = 7803104338172953590L; + + private String error; + private String message; + private List causes; + private int status; +} \ No newline at end of file diff --git a/common/src/main/java/com/okta/cli/common/model/OrganizationRequest.java b/common/src/main/java/com/okta/cli/common/model/OrganizationRequest.java index 261af40a..b6241ff1 100755 --- a/common/src/main/java/com/okta/cli/common/model/OrganizationRequest.java +++ b/common/src/main/java/com/okta/cli/common/model/OrganizationRequest.java @@ -15,74 +15,15 @@ */ package com.okta.cli.common.model; -import java.util.Objects; +import lombok.Data; +import lombok.experimental.Accessors; +@Data +@Accessors(chain = true) public class OrganizationRequest { private String firstName; private String lastName; private String email; private String organization; - - public String getFirstName() { - return firstName; - } - - public OrganizationRequest setFirstName(String firstName) { - this.firstName = firstName; - return this; - } - - public String getLastName() { - return lastName; - } - - public OrganizationRequest setLastName(String lastName) { - this.lastName = lastName; - return this; - } - - public String getEmail() { - return email; - } - - public OrganizationRequest setEmail(String email) { - this.email = email; - return this; - } - - public String getOrganization() { - return organization; - } - - public OrganizationRequest setOrganization(String organization) { - this.organization = organization; - return this; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - OrganizationRequest that = (OrganizationRequest) o; - return Objects.equals(firstName, that.firstName) && - Objects.equals(lastName, that.lastName) && - Objects.equals(email, that.email) && - Objects.equals(organization, that.organization); - } - - @Override - public int hashCode() { - return Objects.hash(firstName, lastName, email, organization); - } - - @Override - public String toString() { - return "OrganizationRequest{" + - "firstName='" + firstName + '\'' + - ", lastName='" + lastName + '\'' + - ", email='" + email + '\'' + - ", organization='" + organization + '\'' + - '}'; - } } diff --git a/common/src/main/java/com/okta/cli/common/model/OrganizationResponse.java b/common/src/main/java/com/okta/cli/common/model/OrganizationResponse.java index 5f399d59..3ff06fd1 100755 --- a/common/src/main/java/com/okta/cli/common/model/OrganizationResponse.java +++ b/common/src/main/java/com/okta/cli/common/model/OrganizationResponse.java @@ -15,53 +15,18 @@ */ package com.okta.cli.common.model; -import java.util.Objects; +import lombok.Data; +import lombok.experimental.Accessors; +@Data +@Accessors(chain = true) public class OrganizationResponse { + private String id; private String orgUrl; private String email; private String apiToken; + private String factorId; + private String updatePasswordUrl; - public String getOrgUrl() { - return orgUrl; - } - - public OrganizationResponse setOrgUrl(String orgUrl) { - this.orgUrl = orgUrl; - return this; - } - - public String getEmail() { - return email; - } - - public OrganizationResponse setEmail(String email) { - this.email = email; - return this; - } - - public String getApiToken() { - return apiToken; - } - - public OrganizationResponse setApiToken(String apiToken) { - this.apiToken = apiToken; - return this; - } - - @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - OrganizationResponse that = (OrganizationResponse) o; - return Objects.equals(orgUrl, that.orgUrl) && - Objects.equals(email, that.email) && - Objects.equals(apiToken, that.apiToken); - } - - @Override - public int hashCode() { - return Objects.hash(orgUrl, email, apiToken); - } } diff --git a/common/src/main/java/com/okta/cli/common/model/RegistrationQuestions.java b/common/src/main/java/com/okta/cli/common/model/RegistrationQuestions.java new file mode 100644 index 00000000..b77b54d8 --- /dev/null +++ b/common/src/main/java/com/okta/cli/common/model/RegistrationQuestions.java @@ -0,0 +1,53 @@ +/* + * Copyright 2018-Present Okta, Inc. + * + * 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 com.okta.cli.common.model; + +public interface RegistrationQuestions { + + boolean isOverwriteConfig(); + + OrganizationRequest getOrganizationRequest(); + + String getVerificationCode(); + + static RegistrationQuestions answers(boolean overwriteConfig, OrganizationRequest request, String code) { + return new SimpleRegistrationQuestions(overwriteConfig, request, code); + } + + class SimpleRegistrationQuestions implements RegistrationQuestions { + private final boolean overwriteConfig; + private final OrganizationRequest organizationRequest; + private final String verificationCode; + + public SimpleRegistrationQuestions(boolean overwriteConfig, OrganizationRequest organizationRequest, String verificationCode) { + this.overwriteConfig = overwriteConfig; + this.organizationRequest = organizationRequest; + this.verificationCode = verificationCode; + } + + public boolean isOverwriteConfig() { + return overwriteConfig; + } + + public OrganizationRequest getOrganizationRequest() { + return organizationRequest; + } + + public String getVerificationCode() { + return verificationCode; + } + } +} diff --git a/common/src/main/java/com/okta/cli/common/service/DefaultOidcAppCreator.java b/common/src/main/java/com/okta/cli/common/service/DefaultOidcAppCreator.java index ace2a4af..64f97e4e 100644 --- a/common/src/main/java/com/okta/cli/common/service/DefaultOidcAppCreator.java +++ b/common/src/main/java/com/okta/cli/common/service/DefaultOidcAppCreator.java @@ -29,9 +29,13 @@ import com.okta.sdk.resource.application.OpenIdConnectApplicationSettingsClient; import com.okta.sdk.resource.application.OpenIdConnectApplicationType; +import java.net.URI; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; public class DefaultOidcAppCreator implements OidcAppCreator { @@ -43,13 +47,27 @@ public ExtensibleResource createOidcApp(Client client, String oidcAppName, Strin // create a new OIDC app if one does NOT exist Application oidcApplication = existingApp.orElseGet(() -> { + OpenIdConnectApplicationSettingsClient oauthClient = client.instantiate(OpenIdConnectApplicationSettingsClient.class) + .setRedirectUris(Arrays.asList(redirectUris)) + .setResponseTypes(Collections.singletonList(OAuthResponseType.CODE)) + .setGrantTypes(Collections.singletonList(OAuthGrantType.AUTHORIZATION_CODE)) + .setApplicationType(OpenIdConnectApplicationType.WEB); + + // TODO expose this setting to the user + // TODO the post redirect URI should be exposed in v2 of the SDK + Set postLogoutRedirect = Arrays.stream(redirectUris) + .map(redirectUri -> { + URI uri = URI.create(redirectUri).resolve("/"); + return uri.toString(); + }) + .collect(Collectors.toSet()); + if (!postLogoutRedirect.isEmpty()) { + oauthClient.put("post_logout_redirect_uris", new ArrayList<>(postLogoutRedirect)); + } + Application app = client.instantiate(OpenIdConnectApplication.class) .setSettings(client.instantiate(OpenIdConnectApplicationSettings.class) - .setOAuthClient(client.instantiate(OpenIdConnectApplicationSettingsClient.class) - .setRedirectUris(Arrays.asList(redirectUris)) - .setResponseTypes(Collections.singletonList(OAuthResponseType.CODE)) - .setGrantTypes(Collections.singletonList(OAuthGrantType.AUTHORIZATION_CODE)) - .setApplicationType(OpenIdConnectApplicationType.WEB))) + .setOAuthClient(oauthClient)) .setLabel(oidcAppName); app = client.createApplication(app); assignAppToEveryoneGroup(client, app); @@ -69,19 +87,22 @@ public ExtensibleResource createOidcNativeApp(Client client, String oidcAppName, // create a new OIDC app if one does NOT exist Application oidcApplication = existingApp.orElseGet(() -> { + OpenIdConnectApplicationSettingsClient oauthClient = client.instantiate(OpenIdConnectApplicationSettingsClient.class) + .setRedirectUris(Arrays.asList(redirectUris)) + .setResponseTypes(Collections.singletonList(OAuthResponseType.CODE)) + .setGrantTypes(Collections.singletonList(OAuthGrantType.AUTHORIZATION_CODE)) + .setApplicationType(OpenIdConnectApplicationType.NATIVE); + Application app = client.instantiate(OpenIdConnectApplication.class) .setSettings(client.instantiate(OpenIdConnectApplicationSettings.class) - .setOAuthClient(client.instantiate(OpenIdConnectApplicationSettingsClient.class) - .setRedirectUris(Arrays.asList(redirectUris)) - .setResponseTypes(Collections.singletonList(OAuthResponseType.CODE)) - .setGrantTypes(Collections.singletonList(OAuthGrantType.AUTHORIZATION_CODE)) - .setApplicationType(OpenIdConnectApplicationType.NATIVE))) + .setOAuthClient(oauthClient)) .setLabel(oidcAppName) .setCredentials(client.instantiate(OAuthApplicationCredentials.class) .setOAuthClient(client.instantiate(ApplicationCredentialsOAuthClient.class) .setTokenEndpointAuthMethod(OAuthEndpointAuthenticationMethod.NONE))); - // TODO post_logout_redirect_uris + // TODO expose post_logout_redirect_uris setting to the user + // for mobile apps this is likely to be something like protocol://logout app = client.createApplication(app); assignAppToEveryoneGroup(client, app); @@ -101,20 +122,32 @@ public ExtensibleResource createOidcSpaApp(Client client, String oidcAppName, St // create a new OIDC app if one does NOT exist Application oidcApplication = existingApp.orElseGet(() -> { + OpenIdConnectApplicationSettingsClient oauthClient = client.instantiate(OpenIdConnectApplicationSettingsClient.class) + .setRedirectUris(Arrays.asList(redirectUris)) + .setResponseTypes(Collections.singletonList(OAuthResponseType.CODE)) + .setGrantTypes(Collections.singletonList(OAuthGrantType.AUTHORIZATION_CODE)) + .setApplicationType(OpenIdConnectApplicationType.BROWSER); + + // TODO expose this setting to the user + // TODO the post redirect URI should be exposed in v2 of the SDK + Set postLogoutRedirect = Arrays.stream(redirectUris) + .map(redirectUri -> { + URI uri = URI.create(redirectUri).resolve("/"); + return uri.toString(); + }) + .collect(Collectors.toSet()); + if (!postLogoutRedirect.isEmpty()) { + oauthClient.put("post_logout_redirect_uris", new ArrayList<>(postLogoutRedirect)); + } + Application app = client.instantiate(OpenIdConnectApplication.class) .setSettings(client.instantiate(OpenIdConnectApplicationSettings.class) - .setOAuthClient(client.instantiate(OpenIdConnectApplicationSettingsClient.class) - .setRedirectUris(Arrays.asList(redirectUris)) - .setResponseTypes(Collections.singletonList(OAuthResponseType.CODE)) - .setGrantTypes(Collections.singletonList(OAuthGrantType.AUTHORIZATION_CODE)) - .setApplicationType(OpenIdConnectApplicationType.BROWSER))) + .setOAuthClient(oauthClient)) .setLabel(oidcAppName) .setCredentials(client.instantiate(OAuthApplicationCredentials.class) .setOAuthClient(client.instantiate(ApplicationCredentialsOAuthClient.class) .setTokenEndpointAuthMethod(OAuthEndpointAuthenticationMethod.NONE))); - // TODO post_logout_redirect_uris - app = client.createApplication(app); assignAppToEveryoneGroup(client, app); @@ -142,8 +175,6 @@ public ExtensibleResource createOidcServiceApp(Client client, String oidcAppName .setApplicationType(OpenIdConnectApplicationType.SERVICE))) .setLabel(oidcAppName); - // TODO post_logout_redirect_uris - app = client.createApplication(app); assignAppToEveryoneGroup(client, app); diff --git a/common/src/main/java/com/okta/cli/common/service/DefaultOktaOrganizationCreator.java b/common/src/main/java/com/okta/cli/common/service/DefaultOktaOrganizationCreator.java index 791c97ca..9b67e056 100644 --- a/common/src/main/java/com/okta/cli/common/service/DefaultOktaOrganizationCreator.java +++ b/common/src/main/java/com/okta/cli/common/service/DefaultOktaOrganizationCreator.java @@ -15,9 +15,11 @@ */ package com.okta.cli.common.service; -import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.io.CharStreams; +import com.okta.cli.common.FactorVerificationException; +import com.okta.cli.common.RestException; +import com.okta.cli.common.model.ErrorResponse; import com.okta.cli.common.model.OrganizationRequest; import com.okta.cli.common.model.OrganizationResponse; import com.okta.commons.lang.ApplicationInfo; @@ -33,7 +35,6 @@ import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; import java.util.stream.Collectors; @@ -46,16 +47,37 @@ public class DefaultOktaOrganizationCreator implements OktaOrganizationCreator { .map(e -> e.getKey() + "/" + e.getValue()) .collect(Collectors.joining(" ")); + ObjectMapper objectMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + @Override - public OrganizationResponse createNewOrg(String apiBaseUrl, OrganizationRequest orgRequest) throws IOException { + public OrganizationResponse createNewOrg(String apiBaseUrl, OrganizationRequest orgRequest) throws RestException, IOException { - try (CloseableHttpClient httpClient = HttpClients.createDefault()) { - HttpPost post = new HttpPost(apiBaseUrl + "/create"); + String url = apiBaseUrl + "/create"; + String postBody = objectMapper.writeValueAsString(orgRequest); + return post(url, postBody, OrganizationResponse.class); + } + + @Override + public OrganizationResponse verifyNewOrg(String apiBaseUrl, String identifier, String code) throws FactorVerificationException, IOException { + + String url = apiBaseUrl + "/verify/" + identifier; + String postBody = "{\"code\":\"" + code + "\"}"; - ObjectMapper objectMapper = new ObjectMapper(); - String postBody = objectMapper.writeValueAsString(orgRequest); + try { + return post(url, postBody, OrganizationResponse.class); + } catch (RestException e) { + throw new FactorVerificationException(e.getErrorResponse(), e); + } + } - post.setEntity(new StringEntity(postBody, StandardCharsets.UTF_8)); + private T post(String url, String body, Class responseType) throws RestException, IOException { + + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + HttpPost post = new HttpPost(url); + + post.setEntity(new StringEntity(body, StandardCharsets.UTF_8)); post.setHeader(HttpHeaders.CONTENT_TYPE, APPLICATION_JSON); post.setHeader(HttpHeaders.ACCEPT, APPLICATION_JSON); post.setHeader(HttpHeaders.USER_AGENT, USER_AGENT_STRING); @@ -68,8 +90,15 @@ public OrganizationResponse createNewOrg(String apiBaseUrl, OrganizationRequest } InputStream content = response.getEntity().getContent(); - String body = CharStreams.toString(new InputStreamReader(content, StandardCharsets.UTF_8)); // use input stream directly - return objectMapper.reader().readValue(new JsonFactory().createParser(body), OrganizationResponse.class); + + // check for error + if (response.getStatusLine().getStatusCode() == 200) { + return objectMapper.reader().readValue(content, responseType); + } else { + // assume error + ErrorResponse error = objectMapper.reader().readValue(content, ErrorResponse.class); + throw new RestException(error); + } } } } \ No newline at end of file diff --git a/common/src/main/java/com/okta/cli/common/service/DefaultSetupService.java b/common/src/main/java/com/okta/cli/common/service/DefaultSetupService.java index 1a3beeaa..2bbd735d 100644 --- a/common/src/main/java/com/okta/cli/common/service/DefaultSetupService.java +++ b/common/src/main/java/com/okta/cli/common/service/DefaultSetupService.java @@ -15,9 +15,12 @@ */ package com.okta.cli.common.service; +import com.okta.cli.common.FactorVerificationException; +import com.okta.cli.common.RestException; import com.okta.cli.common.config.MutablePropertySource; import com.okta.cli.common.model.OrganizationRequest; import com.okta.cli.common.model.OrganizationResponse; +import com.okta.cli.common.model.RegistrationQuestions; import com.okta.cli.common.progressbar.ProgressBar; import com.okta.commons.configcheck.ConfigurationValidator; import com.okta.commons.lang.Strings; @@ -29,10 +32,14 @@ import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.Map; import java.util.Optional; -import java.util.function.Supplier; public class DefaultSetupService implements SetupService { @@ -73,31 +80,10 @@ public DefaultSetupService(SdkConfigurationService sdkConfigurationService, } @Override - public void configureEnvironment( - Supplier organizationRequestSupplier, - File oktaPropsFile, - MutablePropertySource propertySource, - String oidcAppName, - String groupClaimName, - String issuerUri, - String authorizationServerId, - boolean demo, - boolean interactive, - String... redirectUris) throws IOException, ClientConfigurationException { - - // get current or sign up for new org - String orgUrl = createOktaOrg(organizationRequestSupplier, oktaPropsFile, demo, interactive); - - // Create new Application - createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, issuerUri, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB, redirectUris); - - } - - @Override - public String createOktaOrg(Supplier organizationRequestSupplier, - File oktaPropsFile, - boolean demo, - boolean interactive) throws IOException, ClientConfigurationException { + public OrganizationResponse createOktaOrg(RegistrationQuestions registrationQuestions, + File oktaPropsFile, + boolean demo, + boolean interactive) throws IOException, ClientConfigurationException { // check if okta client config exists? @@ -105,31 +91,68 @@ public String createOktaOrg(Supplier organizationRequestSup String orgUrl; try (ProgressBar progressBar = ProgressBar.create(interactive)) { - if (Strings.isEmpty(clientConfiguration.getBaseUrl())) { - // resolve the request (potentially prompt for input) before starting the progress bar - OrganizationRequest organizationRequest = organizationRequestSupplier.get(); - progressBar.start("Creating new Okta Organization, this may take a minute:"); + if (!Strings.isEmpty(clientConfiguration.getBaseUrl())) { + progressBar.info("An existing Okta Organization (" + clientConfiguration.getBaseUrl() + ") was found in "+ oktaPropsFile.getAbsolutePath()); + + if (!registrationQuestions.isOverwriteConfig()) { + throw new ClientConfigurationException("User canceled"); + } + Instant instant = Instant.now(); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern( "uuuuMMdd'T'HHmm" ).withZone(ZoneId.of("UTC")); + + File backupFile = new File(oktaPropsFile.getParent(), oktaPropsFile.getName() + "." + formatter.format(instant)); + Files.copy(oktaPropsFile.toPath(), backupFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + progressBar.info("Configuration file backed: "+ backupFile.getAbsolutePath()); + } + + // resolve the request (potentially prompt for input) before starting the progress bar + OrganizationRequest organizationRequest = registrationQuestions.getOrganizationRequest(); + progressBar.start("Creating new Okta Organization, this may take a minute:"); + + try { OrganizationResponse newOrg = organizationCreator.createNewOrg(getApiBaseUrl(), organizationRequest); orgUrl = newOrg.getOrgUrl(); progressBar.info("OrgUrl: " + orgUrl); - progressBar.info("Check your email address to verify your account.\n"); + progressBar.info("An email has been sent to you with a verification code."); + return newOrg; + } catch (RestException e) { + throw new ClientConfigurationException("Failed to create Okta Organization. You can register " + + "manually by going to https://developer.okta.com/signup"); + } + } + } - // write ~/.okta/okta.yaml - sdkConfigurationService.writeOktaYaml(orgUrl, newOrg.getApiToken(), oktaPropsFile); - } else { - if (demo) { // always prompt for user info in "demo mode", this info will not be used but it makes for a more realistic demo - organizationRequestSupplier.get(); - } - orgUrl = clientConfiguration.getBaseUrl(); - progressBar.info("Current OrgUrl: " + clientConfiguration.getBaseUrl()); + @Override + public void verifyOktaOrg(String identifier, RegistrationQuestions registrationQuestions, File oktaPropsFile) throws IOException, ClientConfigurationException { + + try (ProgressBar progressBar = ProgressBar.create(true)) { + progressBar.info("Check your email"); + + OrganizationResponse response = null; + while(response == null) { + try { + // prompt for code + String code = registrationQuestions.getVerificationCode(); + response = organizationCreator.verifyNewOrg(getApiBaseUrl(), identifier, code); + } catch (FactorVerificationException e) { + progressBar.info("Invalid Passcode, try again."); + } } + // TODO handle polling in case the org is not ready + + sdkConfigurationService.writeOktaYaml(response.getOrgUrl(), response.getApiToken(), oktaPropsFile); + + progressBar.info("New Okta Account created!"); + progressBar.info("Your Okta Domain: "+ response.getOrgUrl()); + progressBar.info("To set your password open this link:\n" + response.getUpdatePasswordUrl()); + + // TODO demo mode? } - return orgUrl; } @Override diff --git a/common/src/main/java/com/okta/cli/common/service/OktaOrganizationCreator.java b/common/src/main/java/com/okta/cli/common/service/OktaOrganizationCreator.java index e5207f96..df1bfa26 100644 --- a/common/src/main/java/com/okta/cli/common/service/OktaOrganizationCreator.java +++ b/common/src/main/java/com/okta/cli/common/service/OktaOrganizationCreator.java @@ -15,6 +15,8 @@ */ package com.okta.cli.common.service; +import com.okta.cli.common.FactorVerificationException; +import com.okta.cli.common.RestException; import com.okta.cli.common.model.OrganizationRequest; import com.okta.cli.common.model.OrganizationResponse; @@ -22,5 +24,7 @@ public interface OktaOrganizationCreator { - OrganizationResponse createNewOrg(String apiBaseUrl, OrganizationRequest orgRequest) throws IOException; + OrganizationResponse createNewOrg(String apiBaseUrl, OrganizationRequest orgRequest) throws IOException, RestException; + + OrganizationResponse verifyNewOrg(String apiBaseUrl, String identifier, String code) throws FactorVerificationException, IOException; } diff --git a/common/src/main/java/com/okta/cli/common/service/SetupService.java b/common/src/main/java/com/okta/cli/common/service/SetupService.java index 0899bf99..da75040c 100644 --- a/common/src/main/java/com/okta/cli/common/service/SetupService.java +++ b/common/src/main/java/com/okta/cli/common/service/SetupService.java @@ -16,31 +16,23 @@ package com.okta.cli.common.service; import com.okta.cli.common.config.MutablePropertySource; -import com.okta.cli.common.model.OrganizationRequest; +import com.okta.cli.common.model.OrganizationResponse; +import com.okta.cli.common.model.RegistrationQuestions; import com.okta.sdk.resource.application.OpenIdConnectApplicationType; import java.io.File; import java.io.IOException; -import java.util.function.Supplier; public interface SetupService { - void configureEnvironment( - Supplier organizationRequestSupplier, // A supplier, because a Mojo may prompt the user - File oktaPropsFile, - MutablePropertySource propertySource, - String oidcAppName, - String groupClaimName, - String issuerUri, - String authorizationServerId, - boolean demo, - boolean interactive, - String... redirectUris) throws IOException, ClientConfigurationException; + OrganizationResponse createOktaOrg(RegistrationQuestions registrationQuestions, + File oktaPropsFile, + boolean demo, + boolean interactive) throws IOException, ClientConfigurationException; - String createOktaOrg(Supplier organizationRequestSupplier, - File oktaPropsFile, - boolean demo, - boolean interactive) throws IOException, ClientConfigurationException; + void verifyOktaOrg(String identifier, + RegistrationQuestions registrationQuestions, + File oktaPropsFile) throws IOException, ClientConfigurationException; void createOidcApplication(MutablePropertySource propertySource, String oidcAppName, diff --git a/common/src/test/groovy/com/okta/cli/common/service/DefaultOidcAppCreatorTest.groovy b/common/src/test/groovy/com/okta/cli/common/service/DefaultOidcAppCreatorTest.groovy index 24d357e0..9f0cd3ad 100644 --- a/common/src/test/groovy/com/okta/cli/common/service/DefaultOidcAppCreatorTest.groovy +++ b/common/src/test/groovy/com/okta/cli/common/service/DefaultOidcAppCreatorTest.groovy @@ -116,5 +116,6 @@ class DefaultOidcAppCreatorTest { verify(settingsClient).setResponseTypes([OAuthResponseType.CODE]) verify(settingsClient).setGrantTypes([OAuthGrantType.AUTHORIZATION_CODE]) verify(settingsClient).setApplicationType(OpenIdConnectApplicationType.WEB) + verify(settingsClient).put("post_logout_redirect_uris", ["http://localhost:8080/"]) } } diff --git a/common/src/test/groovy/com/okta/cli/common/service/DefaultSetupServiceTest.groovy b/common/src/test/groovy/com/okta/cli/common/service/DefaultSetupServiceTest.groovy index 067fbfa9..1e71c766 100644 --- a/common/src/test/groovy/com/okta/cli/common/service/DefaultSetupServiceTest.groovy +++ b/common/src/test/groovy/com/okta/cli/common/service/DefaultSetupServiceTest.groovy @@ -15,9 +15,12 @@ */ package com.okta.cli.common.service +import com.okta.cli.common.FactorVerificationException import com.okta.cli.common.config.MutablePropertySource +import com.okta.cli.common.model.ErrorResponse import com.okta.cli.common.model.OrganizationRequest import com.okta.cli.common.model.OrganizationResponse +import com.okta.cli.common.model.RegistrationQuestions import com.okta.sdk.client.Client import com.okta.sdk.client.ClientBuilder import com.okta.sdk.client.Clients @@ -32,6 +35,7 @@ import org.testng.IObjectFactory import org.testng.annotations.ObjectFactory import org.testng.annotations.Test +import java.time.Instant import java.util.function.Supplier import static org.hamcrest.MatcherAssert.assertThat @@ -47,109 +51,67 @@ class DefaultSetupServiceTest { } @Test - void configEnvWithExistingOrg() { - - Supplier organizationRequestSupplier = mock(Supplier) - OrganizationRequest request = mock(OrganizationRequest) - File oktaPropsFile = mock(File) - MutablePropertySource propertySource = mock(MutablePropertySource) - String oidcAppName = "test-app-name" - String groupClaimName = "group-claim" - String authorizationServerId = "test-auth-id" - boolean demo = false - boolean interactive = false - String orgUrl = "http://okta.example.com" - - def originalSetupService = setupService() - def setupService = spy(originalSetupService) - + void createOktaOrg() { - ClientConfiguration clientConfiguration = mock(ClientConfiguration) + String newOrgUrl = "https://org.example.com" - when(originalSetupService.sdkConfigurationService.loadUnvalidatedConfiguration()).thenReturn(clientConfiguration) - when(clientConfiguration.getBaseUrl()).thenReturn(orgUrl) - when(organizationRequestSupplier.get()).thenReturn(request) + DefaultSetupService setupService = setupService() - // these methods are tested elsewhere in this class - doNothing().when(setupService).createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB) + OrganizationRequest orgRequest = mock(OrganizationRequest) + RegistrationQuestions registrationQuestions = RegistrationQuestions.answers(true, orgRequest, null) + File oktaPropsFile = mock(File) + OrganizationResponse orgResponse = mock(OrganizationResponse) + when(setupService.organizationCreator.createNewOrg("https://start.okta.dev/", orgRequest)).thenReturn(orgResponse) + when(orgResponse.getOrgUrl()).thenReturn(newOrgUrl) - setupService.configureEnvironment(organizationRequestSupplier, - oktaPropsFile, - propertySource, - oidcAppName, - groupClaimName, - null, - authorizationServerId, - demo, - interactive) + setupService.createOktaOrg(registrationQuestions, oktaPropsFile, false, false) - verify(setupService).createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB) + verify(setupService.organizationCreator).createNewOrg("https://start.okta.dev/", orgRequest) } @Test - void configEnvNewOrg() { - - Supplier organizationRequestSupplier = mock(Supplier) - OrganizationRequest request = mock(OrganizationRequest) - File oktaPropsFile = mock(File) - MutablePropertySource propertySource = mock(MutablePropertySource) - String oidcAppName = "test-app-name" - String groupClaimName = "group-claim" - String authorizationServerId = "test-auth-id" - boolean demo = false - boolean interactive = false - String orgUrl = "http://okta.example.com" - - def originalSetupService = setupService() - def setupService = spy(originalSetupService) - - - ClientConfiguration clientConfiguration = mock(ClientConfiguration) + void verifyOktaOrg() { + String newOrgUrl = "https://org.example.com" - when(originalSetupService.sdkConfigurationService.loadUnvalidatedConfiguration()).thenReturn(clientConfiguration) - when(clientConfiguration.getBaseUrl()).thenReturn(null) - when(organizationRequestSupplier.get()).thenReturn(request) + DefaultSetupService setupService = setupService() + RegistrationQuestions registrationQuestions = RegistrationQuestions.answers(true, null, "123456") - // these methods are tested elsewhere in this class - doNothing().when(setupService).createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB) - doReturn(orgUrl).when(setupService).createOktaOrg(organizationRequestSupplier, oktaPropsFile, demo, interactive) + File oktaPropsFile = mock(File) + OrganizationResponse orgResponse = mock(OrganizationResponse) + when(setupService.organizationCreator.verifyNewOrg("https://start.okta.dev/", "test-id", "123456")).thenReturn(orgResponse) + when(orgResponse.getOrgUrl()).thenReturn(newOrgUrl) + when(orgResponse.getUpdatePasswordUrl()).thenReturn("https://reset.password") - setupService.configureEnvironment(organizationRequestSupplier, - oktaPropsFile, - propertySource, - oidcAppName, - groupClaimName, - null, - authorizationServerId, - demo, - interactive) + setupService.verifyOktaOrg("test-id", registrationQuestions, oktaPropsFile) - verify(setupService).createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB) + verify(setupService.organizationCreator).verifyNewOrg("https://start.okta.dev/", "test-id", "123456") } @Test - void createOktaOrg() { - + void verifyOktaOrg_invalidCode() { String newOrgUrl = "https://org.example.com" - String newOrgToken = "test-token" DefaultSetupService setupService = setupService() - Supplier organizationRequestSupplier = mock(Supplier) - OrganizationRequest orgRequest = mock(OrganizationRequest) File oktaPropsFile = mock(File) OrganizationResponse orgResponse = mock(OrganizationResponse) - when(organizationRequestSupplier.get()).thenReturn(orgRequest) - when(setupService.organizationCreator.createNewOrg("https://start.okta.dev/", orgRequest)).thenReturn(orgResponse) + RegistrationQuestions registrationQuestions = mock(RegistrationQuestions) + when(registrationQuestions.getVerificationCode()).thenReturn("123456").thenReturn("654321") + when(setupService.organizationCreator.verifyNewOrg("https://start.okta.dev/", "test-id", "123456")).thenThrow(new FactorVerificationException(new ErrorResponse() + .setStatus(401) + .setError("test-error") + .setMessage("test-message") + .setCauses(["one", "two"]) + , new Throwable("root-test-cause"))) + when(setupService.organizationCreator.verifyNewOrg("https://start.okta.dev/", "test-id", "654321")).thenReturn(orgResponse) when(orgResponse.getOrgUrl()).thenReturn(newOrgUrl) - when(orgResponse.getApiToken()).thenReturn(newOrgToken) + when(orgResponse.getUpdatePasswordUrl()).thenReturn("https://reset.password") - setupService.createOktaOrg(organizationRequestSupplier, oktaPropsFile, false, false) + setupService.verifyOktaOrg("test-id", registrationQuestions, oktaPropsFile) - verify(setupService.sdkConfigurationService).writeOktaYaml(newOrgUrl, newOrgToken, oktaPropsFile) + verify(setupService.organizationCreator).verifyNewOrg("https://start.okta.dev/", "test-id", "123456") } - @Test void createOidcApplicationExistingClient() { diff --git a/coverage/pom.xml b/coverage/pom.xml index 6ffba818..329a7e50 100644 --- a/coverage/pom.xml +++ b/coverage/pom.xml @@ -21,7 +21,7 @@ com.okta.cli okta-cli-tools - 0.3.2-SNAPSHOT + 0.4.0-SNAPSHOT okta-cli-coverage diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 90b69b6b..a6c097df 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -20,7 +20,7 @@ com.okta.cli okta-cli-tools - 0.3.2-SNAPSHOT + 0.4.0-SNAPSHOT okta-cli-its diff --git a/integration-tests/src/test/groovy/com/okta/cli/test/AppsConfigIT.groovy b/integration-tests/src/test/groovy/com/okta/cli/test/AppsConfigIT.groovy index f793d0b4..3a62b416 100644 --- a/integration-tests/src/test/groovy/com/okta/cli/test/AppsConfigIT.groovy +++ b/integration-tests/src/test/groovy/com/okta/cli/test/AppsConfigIT.groovy @@ -27,9 +27,9 @@ import static org.hamcrest.Matchers.containsString class AppsConfigIT implements MockWebSupport { @Test - void listApps() { + void happyPath() { List responses = [new MockResponse() - .setBody('{ "id": "test-app-id", "label": "test-app-name" }') + .setBody('{ "id": "test-app-id", "label": "test-app-name", "signOnMode": "OPENID_CONNECT" }') .setHeader("Content-Type", "application/json"), new MockResponse() .setBody('{ "client_id": "test-id", "client_secret": "test-secret" }') diff --git a/integration-tests/src/test/groovy/com/okta/cli/test/CommandRunner.groovy b/integration-tests/src/test/groovy/com/okta/cli/test/CommandRunner.groovy index 9dadb796..146d58f3 100644 --- a/integration-tests/src/test/groovy/com/okta/cli/test/CommandRunner.groovy +++ b/integration-tests/src/test/groovy/com/okta/cli/test/CommandRunner.groovy @@ -15,12 +15,31 @@ */ package com.okta.cli.test +import com.okta.commons.lang.Classes import org.hamcrest.Description import org.hamcrest.Matcher +import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.hamcrest.TypeSafeMatcher +import java.nio.charset.StandardCharsets +import java.nio.file.FileSystems +import java.nio.file.FileVisitResult +import java.nio.file.FileVisitor +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.PathMatcher +import java.nio.file.Paths +import java.nio.file.SimpleFileVisitor +import java.nio.file.attribute.BasicFileAttributes import java.time.Duration +import java.util.concurrent.Callable +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import java.util.stream.Collectors class CommandRunner { @@ -64,9 +83,20 @@ class CommandRunner { } Result runCommandWithInput(List input, String... args) { + String[] envVars = ["HOME=${homeDir}", "OKTA_CLI_BASE_URL=${regServiceUrl}"] + + return (isIde()) // if intellij + ? runInIsolatedClassloader(envVars, args, input) + : runProcess(envVars, args, input) + } + + static boolean isIde() { + return System.getProperty("java.class.path").contains("idea_rt.jar") && Classes.isAvailable("com.okta.cli.OktaCli") + } + + Result runProcess(String[] envVars, String[] args, List input) { String command = [getCli(homeDir), "-Duser.home=${homeDir}", "-Dokta.testing.disableHttpsCheck=true", args].flatten().join(" ") - String[] envVars = ["HOME=${homeDir}", "OKTA_CLI_BASE_URL=${regServiceUrl}"] def sout = new StringBuilder() def serr = new StringBuilder() @@ -89,6 +119,66 @@ class CommandRunner { return new Result(process.exitValue(), command, envVars, sout.toString(), serr.toString(), workingDir, homeDir) } + Result runInIsolatedClassloader(String[] envVars, String[] args, List input) { + + RestoreEnvironmentVariables restoreEnvironmentVariables = new RestoreEnvironmentVariables() + restoreEnvironmentVariables.saveValues() + RestoreSystemProperties restoreSystemProperties = new RestoreSystemProperties() + restoreSystemProperties.saveValues() + + envVars.each { + String[] parts = it.split("=") + RestoreEnvironmentVariables.setEnvironmentVariable(parts[0], parts[1]) + } + + System.setProperty("user.home", homeDir.absolutePath) + + PrintStream originalOut = System.out + PrintStream originalErr = System.err + InputStream originalIn = System.in + ByteArrayOutputStream out = new ByteArrayOutputStream() + ByteArrayOutputStream err = new ByteArrayOutputStream() + ByteArrayInputStream testInput = new ByteArrayInputStream(input.join("\n").getBytes(StandardCharsets.UTF_8)) + + ExecutorService executorService = Executors.newFixedThreadPool(1) + + int exitCode = -255 + + try { + System.out = new PrintStream(out) + System.err = new PrintStream(err) + System.in = testInput + + Callable callable = new Callable() { + @Override + Integer call() throws Exception { + // isolate the classpath so the static lookups of the User Home dir are reload + URL[] classPath = Arrays.stream(System.getProperty("java.class.path").split(File.pathSeparator)) + .map { new File(it).toURI().toURL() } + .collect(Collectors.toList()) + Thread.currentThread().setContextClassLoader(new URLClassLoader(classPath)) + + return com.okta.cli.OktaCli.run(args) + } + } + + Future future = executorService.submit(callable) + exitCode = future.get(30, TimeUnit.SECONDS) + executorService.shutdown() + + } catch(TimeoutException e) { + e.printStackTrace(originalErr) + } finally { + System.out = originalOut + System.err = originalErr + System.in = originalIn + restoreEnvironmentVariables.restoreOriginalVariables() + restoreSystemProperties.restoreOriginalVariables() + } + + return new Result(exitCode, "OktaCli.run(${args})", envVars, out.toString(), err.toString(), workingDir, homeDir) + } + protected void setupHomeDir(File homeDir) { if (initHomeDir != null) { initHomeDir.call(homeDir) @@ -109,6 +199,29 @@ class CommandRunner { return cli.replaceAll("##user.home##", homeDir.absolutePath) } + static File jarFile() { + + Path startDir = Paths.get("../cli/target/") + String pattern = "okta-cli-*.jar" + + final PathMatcher matcher = FileSystems.getDefault().getPathMatcher("glob:" + pattern) + List matches = new ArrayList<>() + + FileVisitor matcherVisitor = new SimpleFileVisitor() { + @Override + FileVisitResult visitFile(Path file, BasicFileAttributes attribs) { + if (matcher.matches(file.getFileName())) { + matches.add(file.toFile()) + } + return FileVisitResult.CONTINUE + } + }; + Files.walkFileTree(startDir, matcherVisitor) + MatcherAssert.assertThat(matches, Matchers.hasSize(1)) + + return matches.get(0) + } + static class Result { final int exitCode final String command; diff --git a/integration-tests/src/test/groovy/com/okta/cli/test/CreateAppSupport.groovy b/integration-tests/src/test/groovy/com/okta/cli/test/CreateAppSupport.groovy index bb0bff33..d9b2ea0d 100644 --- a/integration-tests/src/test/groovy/com/okta/cli/test/CreateAppSupport.groovy +++ b/integration-tests/src/test/groovy/com/okta/cli/test/CreateAppSupport.groovy @@ -46,12 +46,6 @@ trait CreateAppSupport { ] } - MockResponse jsonRequest(String json) { - return new MockResponse() - .setBody(json) - .setHeader("Content-Type", "application/json") - } - void verifyOrgCreateRequest(RecordedRequest request, String firstName = "test-first", String lastName = "test-last", String email = "test-email@example.com", String company = "test co") { assertThat request.getRequestLine(), equalTo("POST /create HTTP/1.1") assertThat request.getHeader("Content-Type"), equalTo("application/json") diff --git a/integration-tests/src/test/groovy/com/okta/cli/test/MockWebSupport.groovy b/integration-tests/src/test/groovy/com/okta/cli/test/MockWebSupport.groovy index 5dc3d72b..226173af 100644 --- a/integration-tests/src/test/groovy/com/okta/cli/test/MockWebSupport.groovy +++ b/integration-tests/src/test/groovy/com/okta/cli/test/MockWebSupport.groovy @@ -15,7 +15,7 @@ */ package com.okta.cli.test - +import groovy.json.JsonOutput import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -37,4 +37,18 @@ trait MockWebSupport { } } + MockResponse jsonRequest(String json, int status=200) { + return new MockResponse() + .setResponseCode(status) + .setBody(json) + .setHeader("Content-Type", "application/json") + } + + MockResponse jsonRequest(Object obj, int status=200) { + return new MockResponse() + .setResponseCode(status) + .setBody(JsonOutput.toJson(obj)) + .setHeader("Content-Type", "application/json") + } + } \ No newline at end of file diff --git a/integration-tests/src/test/groovy/com/okta/cli/test/RegisterIT.groovy b/integration-tests/src/test/groovy/com/okta/cli/test/RegisterIT.groovy index bac0819c..6611561b 100644 --- a/integration-tests/src/test/groovy/com/okta/cli/test/RegisterIT.groovy +++ b/integration-tests/src/test/groovy/com/okta/cli/test/RegisterIT.groovy @@ -15,7 +15,7 @@ */ package com.okta.cli.test - +import com.okta.cli.common.model.ErrorResponse import groovy.json.JsonSlurper import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer @@ -33,9 +33,10 @@ class RegisterIT implements MockWebSupport { @Test void happyPath() { - List responses = [new MockResponse() - .setBody('{ "orgUrl": "https://result.example.com", "email": "test-email@example.com", "apiToken": "fake-test-token" }') - .setHeader("Content-Type", "application/json")] + List responses = [ + jsonRequest('{ "orgUrl": "https://result.example.com", "email": "test-email@example.com", "id": "test-id" }'), + jsonRequest('{ "orgUrl": "https://result.example.com", "email": "test-email@example.com", "apiToken": "fake-test-token" }') + ] MockWebServer mockWebServer = createMockServer() mockWebServer.with { @@ -45,12 +46,138 @@ class RegisterIT implements MockWebSupport { "test-first", "test-last", "test-email@example.com", - "test co" + "test co", + "123456" ] def result = new CommandRunner(mockWebServer.url("/").toString()).runCommandWithInput(input, "register") - assertThat result, resultMatches(0, allOf(containsString("Check your email address to verify your account"), containsString("OrgUrl: https://result.example.com")), emptyString()) + assertThat result, resultMatches(0, allOf(containsString("An email has been sent to you with a verification code."), containsString("Verification code")), emptyString()) + + + RecordedRequest request = mockWebServer.takeRequest() + assertThat request.getRequestLine(), equalTo("POST /create HTTP/1.1") + assertThat request.getHeader("Content-Type"), is("application/json") + Map body = new JsonSlurper().parse(request.getBody().readByteArray(), StandardCharsets.UTF_8.toString()) + assertThat body, equalTo([ + firstName: "test-first", + lastName: "test-last", + email: "test-email@example.com", + organization: "test co" + ]) + + File oktaConfigFile = new File(result.homeDir, ".okta/okta.yaml") + assertThat oktaConfigFile, new OktaConfigMatcher("https://result.example.com", "fake-test-token") + } + } + + @Test + void existingConfigFile_overwrite() { + + List responses = [ + jsonRequest('{ "orgUrl": "https://result.example.com", "email": "test-email@example.com", "id": "test-id" }'), + jsonRequest('{ "orgUrl": "https://result.example.com", "email": "test-email@example.com", "apiToken": "fake-test-token" }') + ] + + MockWebServer mockWebServer = createMockServer() + mockWebServer.with { + responses.forEach { mockWebServer.enqueue(it) } + + List input = [ + "1", // overwrite config + "test-first", + "test-last", + "test-email@example.com", + "test co", + "123456" + ] + + CommandRunner runner = new CommandRunner(mockWebServer.url("/").toString()) + .withHomeDirectory { + File oktaYaml = new File(it, ".okta/okta.yaml") + oktaYaml.getParentFile().mkdirs() + oktaYaml.write(""" +okta: + client: + orgUrl: https://test.example.com + token: test-token +""") + } + + def result = runner.runCommandWithInput(input, "register") + assertThat result, resultMatches(0, allOf(containsString("An email has been sent to you with a verification code."), containsString("Verification code")), emptyString()) + + RecordedRequest request = mockWebServer.takeRequest() + assertThat request.getRequestLine(), equalTo("POST /create HTTP/1.1") + assertThat request.getHeader("Content-Type"), is("application/json") + Map body = new JsonSlurper().parse(request.getBody().readByteArray(), StandardCharsets.UTF_8.toString()) + assertThat body, equalTo([ + firstName: "test-first", + lastName: "test-last", + email: "test-email@example.com", + organization: "test co" + ]) + + File oktaConfigFile = new File(result.homeDir, ".okta/okta.yaml") + assertThat oktaConfigFile, new OktaConfigMatcher("https://result.example.com", "fake-test-token") + } + } + + @Test + void existingConfigFile_noOverwrite() { + + List input = [ + "2" // no overwrite + ] + CommandRunner runner = new CommandRunner() + .withHomeDirectory { + File oktaYaml = new File(it, ".okta/okta.yaml") + oktaYaml.getParentFile().mkdirs() + oktaYaml.write(""" +okta: + client: + orgUrl: https://test.example.com + token: test-token +""") + } + + def result = runner.runCommandWithInput(input, "register") + assertThat result, resultMatches(1, containsString("Overwrite configuration file?"), containsString("User canceled")) + + File oktaConfigFile = new File(result.homeDir, ".okta/okta.yaml") + assertThat oktaConfigFile, new OktaConfigMatcher("https://test.example.com", "test-token") + + } + + @Test + void invalidCodeTest() { + List responses = [ + jsonRequest('{ "orgUrl": "https://result.example.com", "email": "test-email@example.com", "id": "test-id" }'), + jsonRequest(new ErrorResponse() + .setError("Invalid passcode") + .setMessage("Test message") + .setCauses(["error 1", "error 2"]) + .setStatus(401), + 401 + ), + jsonRequest('{ "orgUrl": "https://result.example.com", "email": "test-email@example.com", "apiToken": "fake-test-token" }') + ] + + MockWebServer mockWebServer = createMockServer() + mockWebServer.with { + responses.forEach { mockWebServer.enqueue(it) } + + List input = [ + "test-first", + "test-last", + "test-email@example.com", + "test co", + "123456", + "654321" + ] + + def result = new CommandRunner(mockWebServer.url("/").toString()).runCommandWithInput(input, "register") + assertThat result, resultMatches(0, allOf(containsString("An email has been sent to you with a verification code."), containsString("Verification code")), emptyString()) RecordedRequest request = mockWebServer.takeRequest() assertThat request.getRequestLine(), equalTo("POST /create HTTP/1.1") diff --git a/integration-tests/src/test/groovy/com/okta/cli/test/RestoreEnvironmentVariables.java b/integration-tests/src/test/groovy/com/okta/cli/test/RestoreEnvironmentVariables.java new file mode 100644 index 00000000..368e4c48 --- /dev/null +++ b/integration-tests/src/test/groovy/com/okta/cli/test/RestoreEnvironmentVariables.java @@ -0,0 +1,146 @@ +/* + * Copyright 2017 Okta + * + * 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 com.okta.cli.test; + +import org.testng.IInvokedMethod; +import org.testng.IInvokedMethodListener; +import org.testng.ITestResult; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +/** + * TestNG Listener that will restore environment variables after test methods. + * All changes to environment variables are reverted after the test. + *
+ * public class EnvironmentVariablesTest {
+ *   @Test
+ *   public void test() {
+ *     setEnvironmentVariable("name", "value");
+ *     assertEquals("value", System.getenv("name"));
+ *   }
+ * }
+ * 
+ *

Warning: This rule uses reflection for modifying internals of the + * environment variables map. It fails if your {@code SecurityManager} forbids + * such modifications. + * + * Based on: https://github.com/stefanbirkner/system-rules/blob/master/src/main/java/org/junit/contrib/java/lang/system/EnvironmentVariables.java + */ +public class RestoreEnvironmentVariables implements IInvokedMethodListener { + + private Map originalVariables; + + @Override + public void beforeInvocation(IInvokedMethod method, ITestResult testResult) { + saveValues(); + } + + @Override + public void afterInvocation(IInvokedMethod method, ITestResult testResult) { + restoreOriginalVariables(); + } + + public void saveValues() { + originalVariables = new HashMap<>(System.getenv()); + } + + public void restoreOriginalVariables() { + restoreVariables(getEditableMapOfVariables()); + Map theCaseInsensitiveEnvironment + = getTheCaseInsensitiveEnvironment(); + if (theCaseInsensitiveEnvironment != null) + restoreVariables(theCaseInsensitiveEnvironment); + } + + void restoreVariables(Map variables) { + variables.clear(); + variables.putAll(originalVariables); + } + + /** + * Set the value of an environment variable. You can delete an environment + * variable by setting it to {@code null}. + * + * @param name the environment variable's name. + * @param value the environment variable's new value. + */ + public static void setEnvironmentVariable(String name, String value) { + set(getEditableMapOfVariables(), name, value); + set(getTheCaseInsensitiveEnvironment(), name, value); + } + + /** + * Clears all environment variables. + */ + public static void clearEnvironmentVariables() { + getEditableMapOfVariables().clear(); + } + + private static void set(Map variables, String name, String value) { + if (variables != null) //theCaseInsensitiveEnvironment may be null + if (value == null) + variables.remove(name); + else + variables.put(name, value); + } + + private static Map getEditableMapOfVariables() { + Class classOfMap = System.getenv().getClass(); + try { + return getFieldValue(classOfMap, System.getenv(), "m"); + } catch (IllegalAccessException e) { + throw new RuntimeException("System Rules cannot access the field" + + " 'm' of the map System.getenv().", e); + } catch (NoSuchFieldException e) { + throw new RuntimeException("System Rules expects System.getenv() to" + + " have a field 'm' but it has not.", e); + } + } + + /* + * The names of environment variables are case-insensitive in Windows. + * Therefore it stores the variables in a TreeMap named + * theCaseInsensitiveEnvironment. + */ + private static Map getTheCaseInsensitiveEnvironment() { + try { + Class processEnvironment = Class.forName("java.lang.ProcessEnvironment"); + return getFieldValue( + processEnvironment, null, "theCaseInsensitiveEnvironment"); + } catch (ClassNotFoundException e) { + throw new RuntimeException("System Rules expects the existence of" + + " the class java.lang.ProcessEnvironment but it does not" + + " exist.", e); + } catch (IllegalAccessException e) { + throw new RuntimeException("System Rules cannot access the static" + + " field 'theCaseInsensitiveEnvironment' of the class" + + " java.lang.ProcessEnvironment.", e); + } catch (NoSuchFieldException e) { + //this field is only available for Windows + return null; + } + } + + private static Map getFieldValue(Class klass, + Object object, String name) + throws NoSuchFieldException, IllegalAccessException { + Field field = klass.getDeclaredField(name); + field.setAccessible(true); + return (Map) field.get(object); + } +} diff --git a/integration-tests/src/test/groovy/com/okta/cli/test/RestoreSystemProperties.java b/integration-tests/src/test/groovy/com/okta/cli/test/RestoreSystemProperties.java new file mode 100644 index 00000000..3777a661 --- /dev/null +++ b/integration-tests/src/test/groovy/com/okta/cli/test/RestoreSystemProperties.java @@ -0,0 +1,57 @@ +/* + * Copyright 2017 Okta + * + * 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 com.okta.cli.test; + +import org.testng.IInvokedMethod; +import org.testng.IInvokedMethodListener; +import org.testng.ITestResult; + +import java.util.Properties; + +/** + * TestNG Listener that will restore System properties after test methods. + * + * Based on: https://github.com/stefanbirkner/system-rules/blob/master/src/main/java/org/junit/contrib/java/lang/system/RestoreSystemProperties.java + */ +public class RestoreSystemProperties implements IInvokedMethodListener { + + private Properties originalProperties; + + @Override + public void beforeInvocation(IInvokedMethod method, ITestResult testResult) { + saveValues(); + } + + public void saveValues() { + originalProperties = System.getProperties(); + System.setProperties(copyOf(originalProperties)); + } + + @Override + public void afterInvocation(IInvokedMethod method, ITestResult testResult) { + restoreOriginalVariables(); + } + + public void restoreOriginalVariables() { + System.setProperties(originalProperties); + } + + private Properties copyOf(Properties source) { + Properties copy = new Properties(); + copy.putAll(source); + return copy; + } +} diff --git a/maven-plugin/pom.xml b/maven-plugin/pom.xml index 3b8bca03..d3d2f457 100755 --- a/maven-plugin/pom.xml +++ b/maven-plugin/pom.xml @@ -21,7 +21,7 @@ com.okta.cli okta-cli-tools - 0.3.2-SNAPSHOT + 0.4.0-SNAPSHOT com.okta diff --git a/maven-plugin/src/main/java/com/okta/maven/orgcreation/RegisterMojo.java b/maven-plugin/src/main/java/com/okta/maven/orgcreation/RegisterMojo.java index 3f5181ee..c2e1daeb 100644 --- a/maven-plugin/src/main/java/com/okta/maven/orgcreation/RegisterMojo.java +++ b/maven-plugin/src/main/java/com/okta/maven/orgcreation/RegisterMojo.java @@ -15,6 +15,7 @@ */ package com.okta.maven.orgcreation; +import com.okta.cli.common.model.OrganizationResponse; import com.okta.maven.orgcreation.service.DefaultMavenRegistrationService; import org.apache.maven.plugin.AbstractMojo; import org.apache.maven.plugin.MojoExecutionException; @@ -76,7 +77,8 @@ public class RegisterMojo extends AbstractMojo { @Override public void execute() throws MojoExecutionException, MojoFailureException { - new DefaultMavenRegistrationService(prompter, oktaPropsFile, demo, interactiveMode) - .register(firstName, lastName, email, company); + DefaultMavenRegistrationService registrationService = new DefaultMavenRegistrationService(prompter, oktaPropsFile, demo, interactiveMode); + OrganizationResponse response = registrationService.register(firstName, lastName, email, company); + registrationService.verify(response.getId(), null); } } diff --git a/maven-plugin/src/main/java/com/okta/maven/orgcreation/service/DefaultMavenRegistrationService.java b/maven-plugin/src/main/java/com/okta/maven/orgcreation/service/DefaultMavenRegistrationService.java index efada62e..7ac3b955 100644 --- a/maven-plugin/src/main/java/com/okta/maven/orgcreation/service/DefaultMavenRegistrationService.java +++ b/maven-plugin/src/main/java/com/okta/maven/orgcreation/service/DefaultMavenRegistrationService.java @@ -16,9 +16,13 @@ package com.okta.maven.orgcreation.service; import com.okta.cli.common.model.OrganizationRequest; +import com.okta.cli.common.model.OrganizationResponse; +import com.okta.cli.common.model.RegistrationQuestions; import com.okta.cli.common.service.ClientConfigurationException; import com.okta.cli.common.service.DefaultSetupService; import com.okta.cli.common.service.SetupService; +import lombok.Data; +import lombok.experimental.Accessors; import org.apache.maven.plugin.MojoExecutionException; import org.codehaus.plexus.components.interactivity.Prompter; @@ -26,6 +30,7 @@ import java.io.IOException; import static com.okta.maven.orgcreation.support.PromptUtil.promptIfNull; +import static com.okta.maven.orgcreation.support.PromptUtil.promptYesNo; public class DefaultMavenRegistrationService implements MavenRegistrationService { @@ -42,13 +47,30 @@ public DefaultMavenRegistrationService(Prompter prompter, File oktaPropsFile, bo } @Override - public void register(String firstName, String lastName, String email, String company) throws MojoExecutionException { + public OrganizationResponse register(String firstName, String lastName, String email, String company) throws MojoExecutionException { try { SetupService setupService = new DefaultSetupService(null); - setupService.createOktaOrg(() -> organizationRequest(firstName, lastName, email, company), - oktaPropsFile, - demo, - interactive); + RegistrationQuestions registrationQuestions = new MavenPromptingRegistrationQuestions() + .setFirstName(firstName) + .setLastName(lastName) + .setEmail(email) + .setCompany(company); + return setupService.createOktaOrg(registrationQuestions, + oktaPropsFile, + demo, + interactive); + } catch (IOException | ClientConfigurationException e) { + throw new MojoExecutionException("Failed to register account: " + e.getMessage(), e); + } + } + + @Override + public void verify(String identifier, String code) throws MojoExecutionException { + try { + SetupService setupService = new DefaultSetupService(null); + RegistrationQuestions registrationQuestions = new MavenPromptingRegistrationQuestions() + .setCode(code); + setupService.verifyOktaOrg(identifier, registrationQuestions, oktaPropsFile); } catch (IOException | ClientConfigurationException e) { throw new MojoExecutionException("Failed to register account: " + e.getMessage(), e); } @@ -61,4 +83,41 @@ private OrganizationRequest organizationRequest(String firstName, String lastNam .setEmail(promptIfNull(prompter, interactive, email, "email", "Email address")) .setOrganization(promptIfNull(prompter, interactive, company, "company", "Company")); } + + private String codePrompt(String code) { + return promptIfNull(prompter, interactive, code, "code", "Verification Code"); + } + + private boolean overwritePrompt(Boolean overwrite) { + return promptYesNo(prompter, interactive, overwrite, "overwriteConfig", "Overwrite configuration file?"); + } + + @Data + @Accessors(chain = true) + private class MavenPromptingRegistrationQuestions implements RegistrationQuestions { + + private String firstName; + private String lastName; + private String email; + private String company; + + private String code; + + private Boolean overwriteConfig; + + @Override + public boolean isOverwriteConfig() { + return overwritePrompt(overwriteConfig); + } + + @Override + public OrganizationRequest getOrganizationRequest() { + return organizationRequest(firstName, lastName, email, company); + } + + @Override + public String getVerificationCode() { + return codePrompt(code); + } + } } diff --git a/maven-plugin/src/main/java/com/okta/maven/orgcreation/service/MavenRegistrationService.java b/maven-plugin/src/main/java/com/okta/maven/orgcreation/service/MavenRegistrationService.java index 670b2493..31f691c6 100644 --- a/maven-plugin/src/main/java/com/okta/maven/orgcreation/service/MavenRegistrationService.java +++ b/maven-plugin/src/main/java/com/okta/maven/orgcreation/service/MavenRegistrationService.java @@ -15,9 +15,12 @@ */ package com.okta.maven.orgcreation.service; +import com.okta.cli.common.model.OrganizationResponse; import org.apache.maven.plugin.MojoExecutionException; public interface MavenRegistrationService { - void register(String firstName, String lastName, String email, String company) throws MojoExecutionException; + OrganizationResponse register(String firstName, String lastName, String email, String company) throws MojoExecutionException; + + void verify(String identifier, String code) throws MojoExecutionException; } diff --git a/maven-plugin/src/main/java/com/okta/maven/orgcreation/support/PromptUtil.java b/maven-plugin/src/main/java/com/okta/maven/orgcreation/support/PromptUtil.java index 0ee29700..58899c14 100644 --- a/maven-plugin/src/main/java/com/okta/maven/orgcreation/support/PromptUtil.java +++ b/maven-plugin/src/main/java/com/okta/maven/orgcreation/support/PromptUtil.java @@ -19,6 +19,9 @@ import org.codehaus.plexus.components.interactivity.PrompterException; import org.codehaus.plexus.util.StringUtils; +import java.util.ArrayList; +import java.util.List; + public final class PromptUtil { private PromptUtil() {} @@ -43,4 +46,27 @@ public static String promptIfNull(Prompter prompter, boolean interactive, String } return value; } + + public static boolean promptYesNo(Prompter prompter, boolean interactive, Boolean currentValue, String keyName, String promptText) { + + Boolean value = currentValue; + + if (value == null) { + if (interactive) { + try { + List options = new ArrayList<>(); + options.add("Yes"); + options.add("No"); + value = options.get(0).equals(prompter.prompt(promptText, options, options.get(0))); + } + catch (PrompterException e) { + throw new RuntimeException( e.getMessage(), e ); + } + } else { + throw new IllegalArgumentException( "You must specify the '" + keyName + "' property either on the command line " + + "-D" + keyName + "=... or run in interactive mode" ); + } + } + return value; + } } diff --git a/maven-plugin/src/test/groovy/com/okta/maven/orgcreation/RegisterMojoTest.groovy b/maven-plugin/src/test/groovy/com/okta/maven/orgcreation/RegisterMojoTest.groovy index 1d125b8e..d6d91cd1 100644 --- a/maven-plugin/src/test/groovy/com/okta/maven/orgcreation/RegisterMojoTest.groovy +++ b/maven-plugin/src/test/groovy/com/okta/maven/orgcreation/RegisterMojoTest.groovy @@ -15,6 +15,7 @@ */ package com.okta.maven.orgcreation +import com.okta.cli.common.model.OrganizationResponse import com.okta.maven.orgcreation.service.DefaultMavenRegistrationService import org.codehaus.plexus.components.interactivity.Prompter import org.powermock.api.mockito.PowerMockito @@ -26,6 +27,7 @@ import org.testng.annotations.Test import static org.mockito.Mockito.mock import static org.mockito.Mockito.verify +import static org.mockito.Mockito.when @PrepareForTest(RegisterMojo) class RegisterMojoTest { @@ -47,8 +49,13 @@ class RegisterMojoTest { def lastName = "Coder" def email = "joe.coder@example.com" def company = "Example Co." + def orgResponse = new OrganizationResponse() + .setEmail(email) + .setOrgUrl("https://org.example.com") + .setId("test-id") PowerMockito.whenNew(DefaultMavenRegistrationService).withArguments(prompter, oktaPropsFile, demo, interactive).thenReturn(mavenRegistrationService) + when(mavenRegistrationService.register(firstName, lastName, email, company)).thenReturn(orgResponse) RegisterMojo mojo = new RegisterMojo() mojo.firstName = firstName @@ -62,5 +69,6 @@ class RegisterMojoTest { mojo.execute() verify(mavenRegistrationService).register(firstName, lastName, email, company) + verify(mavenRegistrationService).verify("test-id", null) } } \ No newline at end of file diff --git a/maven-plugin/src/test/groovy/com/okta/maven/orgcreation/service/DefaultMavenRegistrationServiceTest.groovy b/maven-plugin/src/test/groovy/com/okta/maven/orgcreation/service/DefaultMavenRegistrationServiceTest.groovy index 88fb80bb..0d64cf22 100644 --- a/maven-plugin/src/test/groovy/com/okta/maven/orgcreation/service/DefaultMavenRegistrationServiceTest.groovy +++ b/maven-plugin/src/test/groovy/com/okta/maven/orgcreation/service/DefaultMavenRegistrationServiceTest.groovy @@ -27,11 +27,13 @@ import org.testng.annotations.Test import static com.okta.maven.orgcreation.TestUtil.expectException import static org.hamcrest.Matchers.containsString import static org.hamcrest.MatcherAssert.assertThat +import static org.hamcrest.Matchers.equalTo import static org.mockito.ArgumentMatchers.any import static org.mockito.ArgumentMatchers.eq import static org.mockito.Mockito.mock import static org.mockito.Mockito.spy import static org.mockito.Mockito.verify +import static org.mockito.Mockito.when @PrepareForTest(DefaultMavenRegistrationService) class DefaultMavenRegistrationServiceTest { @@ -54,6 +56,20 @@ class DefaultMavenRegistrationServiceTest { verify(setupService).createOktaOrg(any(), eq(propsFile), eq(false), eq(false)) } + @Test + void verifyCode() { + Prompter prompter = mock(Prompter) + File propsFile = mock(File) + DefaultSetupService setupService = mock(DefaultSetupService) + PowerMockito.whenNew(DefaultSetupService).withArguments(null).thenReturn(setupService) + + def registrationService = spy new DefaultMavenRegistrationService(prompter, propsFile, false, false) + registrationService.verify("test-id", null) + + verify(setupService).verifyOktaOrg(eq("test-id"), any(), eq(propsFile)) + } + + @Test void promptNeededNonInteractive() { Prompter prompter = mock(Prompter) @@ -72,4 +88,22 @@ class DefaultMavenRegistrationServiceTest { exception = expectException IllegalArgumentException, { registrationService.organizationRequest("first-name", "last-name", "email@example.com", null) } assertThat exception.message, containsString("-Dcompany") } + + @Test + void promptForCode() { + Prompter prompter = mock(Prompter) + File propsFile = mock(File) + + when(prompter.prompt(any(String))).thenReturn("totp-code-string") + + assertThat new DefaultMavenRegistrationService(prompter, propsFile, false, true).codePrompt(null), equalTo("totp-code-string") + } + + @Test + void promptForCode_nonInteractive() { + Prompter prompter = mock(Prompter) + File propsFile = mock(File) + + expectException IllegalArgumentException, { new DefaultMavenRegistrationService(prompter, propsFile, false, false).codePrompt(null) } + } } diff --git a/pom.xml b/pom.xml index 00fbf737..248a67f5 100644 --- a/pom.xml +++ b/pom.xml @@ -26,7 +26,7 @@ com.okta.cli okta-cli-tools - 0.3.2-SNAPSHOT + 0.4.0-SNAPSHOT pom Okta CLI Tools @@ -75,25 +75,25 @@ com.okta.cli okta-cli-common - 0.3.2-SNAPSHOT + 0.4.0-SNAPSHOT com.okta.cli okta-cli - 0.3.2-SNAPSHOT + 0.4.0-SNAPSHOT com.okta.cli okta-cli-its - 0.3.2-SNAPSHOT + 0.4.0-SNAPSHOT com.okta okta-maven-plugin - 0.3.2-SNAPSHOT + 0.4.0-SNAPSHOT diff --git a/src/findbugs/findbugs-exclude.xml b/src/findbugs/findbugs-exclude.xml index 53ae3c83..51e69910 100644 --- a/src/findbugs/findbugs-exclude.xml +++ b/src/findbugs/findbugs-exclude.xml @@ -31,6 +31,11 @@ + + + + +