From 511c157aa85793da27d7fa7825660c1083bf71ed Mon Sep 17 00:00:00 2001 From: Brian Demers Date: Fri, 9 Dec 2022 18:05:32 -0500 Subject: [PATCH 1/2] Update to recommended SnakeYaml Constructor usage --- .../com/okta/cli/common/config/YamlPropertiesSource.java | 6 ++++-- .../okta/cli/common/service/DefaultSampleConfigParser.java | 6 ++++-- common/src/test/groovy/com/okta/cli/common/TestUtil.groovy | 3 ++- .../test/groovy/com/okta/cli/test/CreateAppSupport.groovy | 3 ++- .../test/groovy/com/okta/cli/test/OktaConfigMatcher.groovy | 3 ++- 5 files changed, 14 insertions(+), 7 deletions(-) diff --git a/common/src/main/java/com/okta/cli/common/config/YamlPropertiesSource.java b/common/src/main/java/com/okta/cli/common/config/YamlPropertiesSource.java index f3ed199a..744785da 100644 --- a/common/src/main/java/com/okta/cli/common/config/YamlPropertiesSource.java +++ b/common/src/main/java/com/okta/cli/common/config/YamlPropertiesSource.java @@ -19,6 +19,8 @@ import com.okta.sdk.impl.io.FileResource; import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.Constructor; +import org.yaml.snakeyaml.representer.Representer; import java.io.File; import java.io.FileInputStream; @@ -47,11 +49,11 @@ public String getName() { @Override public void addProperties(Map properties) throws IOException { - Yaml springAppYaml = new Yaml(yamlOptions()); + Yaml springAppYaml = new Yaml(new Constructor(Map.class), new Representer(yamlOptions())); Map existingProperties = new HashMap<>(); if (yamlFile.exists()) { try (Reader reader = new InputStreamReader(new FileInputStream(yamlFile), StandardCharsets.UTF_8)) { - Map loadedProperties = springAppYaml.loadAs(reader, Map.class); + Map loadedProperties = springAppYaml.load(reader); // null if the file is empty if (loadedProperties != null) { diff --git a/common/src/main/java/com/okta/cli/common/service/DefaultSampleConfigParser.java b/common/src/main/java/com/okta/cli/common/service/DefaultSampleConfigParser.java index 89659e8a..0842d4fa 100644 --- a/common/src/main/java/com/okta/cli/common/service/DefaultSampleConfigParser.java +++ b/common/src/main/java/com/okta/cli/common/service/DefaultSampleConfigParser.java @@ -16,7 +16,9 @@ package com.okta.cli.common.service; import com.okta.cli.common.model.OktaSampleConfig; +import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.Constructor; import org.yaml.snakeyaml.representer.Representer; import java.io.File; @@ -45,10 +47,10 @@ public OktaSampleConfig parseConfig(File configFile, Map context configFileContent = new DefaultInterpolator().interpolate(configFileContent, context); // ignore unknown properties, so we can add additional features and not break older clients - Representer representer = new Representer(); + Representer representer = new Representer(new DumperOptions()); representer.getPropertyUtils().setSkipMissingProperties(true); - OktaSampleConfig config = new Yaml(representer).loadAs(configFileContent, OktaSampleConfig.class); + OktaSampleConfig config = new Yaml(new Constructor(OktaSampleConfig.class), representer).loadAs(configFileContent, OktaSampleConfig.class); // TODO improve validation of configuration if (config.getOAuthClient() == null) { diff --git a/common/src/test/groovy/com/okta/cli/common/TestUtil.groovy b/common/src/test/groovy/com/okta/cli/common/TestUtil.groovy index a4b8b120..c951aab8 100644 --- a/common/src/test/groovy/com/okta/cli/common/TestUtil.groovy +++ b/common/src/test/groovy/com/okta/cli/common/TestUtil.groovy @@ -17,6 +17,7 @@ package com.okta.cli.common import org.testng.Assert import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.constructor.Constructor class TestUtil { @@ -34,7 +35,7 @@ class TestUtil { static Map readYamlFromFile(File configFile) { configFile.withReader { - return new Yaml().loadAs(it, Map.class) + return new Yaml(new Constructor(Map)).loadAs(it, Map) } } 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 e442f6ce..b0b8c246 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 @@ -28,6 +28,7 @@ import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.RecordedRequest import org.hamcrest.Matcher import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.constructor.Constructor import java.nio.charset.StandardCharsets @@ -68,7 +69,7 @@ trait CreateAppSupport { } Map parseYaml(File oktaConfigFile) { - Yaml yaml = new Yaml() + Yaml yaml = new Yaml(new Constructor(Map)) return yaml.load(oktaConfigFile.text) } diff --git a/integration-tests/src/test/groovy/com/okta/cli/test/OktaConfigMatcher.groovy b/integration-tests/src/test/groovy/com/okta/cli/test/OktaConfigMatcher.groovy index b88e7188..5b58cc6f 100644 --- a/integration-tests/src/test/groovy/com/okta/cli/test/OktaConfigMatcher.groovy +++ b/integration-tests/src/test/groovy/com/okta/cli/test/OktaConfigMatcher.groovy @@ -18,6 +18,7 @@ package com.okta.cli.test import org.hamcrest.Description import org.hamcrest.TypeSafeMatcher import org.yaml.snakeyaml.Yaml +import org.yaml.snakeyaml.constructor.Constructor class OktaConfigMatcher extends TypeSafeMatcher { @@ -53,7 +54,7 @@ class OktaConfigMatcher extends TypeSafeMatcher { } private static Map parseYaml(File oktaConfigFile) { - Yaml yaml = new Yaml() + Yaml yaml = new Yaml(new Constructor(Map)) return yaml.load(oktaConfigFile.text) } } From 1de2bc3dd039c17e56ee848f0fe29de0b9c67eb5 Mon Sep 17 00:00:00 2001 From: Brian Demers Date: Fri, 16 Dec 2022 17:09:47 -0500 Subject: [PATCH 2/2] Adds support for device grant * `okta apps create` will show the device grant option * `okta apps create device` works * `okta start` with the `applicationType` set to `device` works too The version of the Okta SDK that the CLI is using does not have the device code value enum, so a String is used instead Minor code clean up in DeviceSdkConfigurationService, reflection is no longer required to get the ClientConfiguraiton field --- .../java/com/okta/cli/commands/Start.java | 11 +-- .../okta/cli/commands/apps/AppsCreate.java | 34 +++++++-- .../cli/commands/apps/templates/AppType.java | 1 + .../apps/templates/DeviceAppTemplate.java | 33 +++++++++ .../service/AuthorizationServerService.java | 6 ++ .../DefaultAuthorizationServerService.java | 33 +++++++++ .../common/service/DefaultOidcAppCreator.java | 30 ++++++++ .../DefaultSdkConfigurationService.java | 17 +---- .../common/service/DefaultSetupService.java | 29 ++++++-- .../cli/common/service/OidcAppCreator.java | 2 + .../okta/cli/common/service/SetupService.java | 16 +++-- .../service/DefaultOidcAppCreatorTest.groovy | 67 ++++++++++++++++++ .../service/DefaultSetupServiceTest.groovy | 70 +++++++++++++++++-- .../com/okta/cli/test/AppsCreateIT.groovy | 67 +++++++++++++++++- 14 files changed, 375 insertions(+), 41 deletions(-) create mode 100644 cli/src/main/java/com/okta/cli/commands/apps/templates/DeviceAppTemplate.java diff --git a/cli/src/main/java/com/okta/cli/commands/Start.java b/cli/src/main/java/com/okta/cli/commands/Start.java index 95843cc5..9a1cc43d 100644 --- a/cli/src/main/java/com/okta/cli/commands/Start.java +++ b/cli/src/main/java/com/okta/cli/commands/Start.java @@ -29,6 +29,7 @@ import com.okta.cli.common.service.DefaultSdkConfigurationService; import com.okta.cli.common.service.DefaultSetupService; import com.okta.cli.common.service.DefaultStartRestClient; +import com.okta.cli.common.service.SetupService; import com.okta.cli.common.service.TarballExtractor; import com.okta.cli.console.ConsoleOutput; import com.okta.cli.console.PromptOption; @@ -36,7 +37,6 @@ import com.okta.commons.lang.Strings; import com.okta.sdk.client.Client; import com.okta.sdk.client.Clients; -import com.okta.sdk.resource.application.OpenIdConnectApplicationType; import picocli.CommandLine; import java.io.File; @@ -48,7 +48,6 @@ import java.nio.file.attribute.BasicFileAttributes; import java.util.Collections; import java.util.List; -import java.util.Locale; import java.util.Map; import java.util.stream.Collectors; @@ -114,8 +113,12 @@ public int runCommand() throws Exception { // parse the `.okta.yaml` file OktaSampleConfig config = new DefaultSampleConfigParser().loadConfig(projectDirectory, sampleContext); - OpenIdConnectApplicationType applicationType = OpenIdConnectApplicationType.valueOf( - config.getOAuthClient().getApplicationType().toUpperCase(Locale.ENGLISH)); + String applicationType = config.getOAuthClient().getApplicationType(); + + // remap device key, because the actual grant name is long + if (applicationType.equals("device")) { + applicationType = SetupService.APP_TYPE_DEVICE; + } // create the Okta application Client client = Clients.builder().build(); diff --git a/cli/src/main/java/com/okta/cli/commands/apps/AppsCreate.java b/cli/src/main/java/com/okta/cli/commands/apps/AppsCreate.java index c90657be..1c180f46 100644 --- a/cli/src/main/java/com/okta/cli/commands/apps/AppsCreate.java +++ b/cli/src/main/java/com/okta/cli/commands/apps/AppsCreate.java @@ -25,6 +25,7 @@ import com.okta.cli.common.service.ClientConfigurationException; import com.okta.cli.common.service.DefaultSdkConfigurationService; import com.okta.cli.common.service.DefaultSetupService; +import com.okta.cli.common.service.SetupService; import com.okta.cli.console.ConsoleOutput; import com.okta.cli.console.Prompter; import com.okta.commons.lang.Assert; @@ -86,6 +87,8 @@ public int runCommand() throws Exception { return createSpaApp(appName); case NATIVE: return createNativeApp(appName); + case DEVICE: + return createDeviceApp(appName); case SERVICE: return createServiceApp(appName,(ServiceAppTemplate) appTemplate); default: @@ -114,7 +117,7 @@ private int createWebApp(String appName, WebAppTemplate webAppTemplate) throws I OidcProperties oidcProperties = appTemplate.getOidcProperties(); MutablePropertySource propertySource = appCreationMixin.getPropertySource(appTemplate.getDefaultConfigFileName()); - new DefaultSetupService(oidcProperties).createOidcApplication(propertySource, appName, baseUrl, groupClaimName, groupsToCreate, issuer.getIssuer(), issuer.getId(), true, OpenIdConnectApplicationType.WEB, redirectUris, postLogoutRedirectUris, client); + new DefaultSetupService(oidcProperties).createOidcApplication(propertySource, appName, baseUrl, groupClaimName, groupsToCreate, issuer.getIssuer(), issuer.getId(), true, OpenIdConnectApplicationType.WEB.toString(), redirectUris, postLogoutRedirectUris, client); out.writeLine("Okta application configuration has been written to: " + propertySource.getName()); @@ -134,7 +137,28 @@ private Integer createNativeApp(String appName) throws IOException { AuthorizationServer issuer = getIssuer(client); MutablePropertySource propertySource = new MapPropertySource(); - new DefaultSetupService(OidcProperties.oktaEnv()).createOidcApplication(propertySource, appName, baseUrl, null, Collections.emptySet(), issuer.getIssuer(), issuer.getId(), getEnvironment().isInteractive(), OpenIdConnectApplicationType.NATIVE, redirectUris, postLogoutRedirectUris, client); + new DefaultSetupService(OidcProperties.oktaEnv()).createOidcApplication(propertySource, appName, baseUrl, null, Collections.emptySet(), issuer.getIssuer(), issuer.getId(), getEnvironment().isInteractive(), OpenIdConnectApplicationType.NATIVE.toString(), redirectUris, postLogoutRedirectUris, client); + + out.writeLine("Okta application configuration: "); + propertySource.getProperties().forEach((key, value) -> { + out.bold(key); + out.write(": "); + out.writeLine(value); + }); + + return 0; + } + + private Integer createDeviceApp(String appName) throws IOException { + + ConsoleOutput out = getConsoleOutput(); + String baseUrl = getBaseUrl(); + + Client client = Clients.builder().build(); + AuthorizationServer issuer = getIssuer(client); + + MutablePropertySource propertySource = new MapPropertySource(); + new DefaultSetupService(OidcProperties.oktaEnv()).createOidcApplication(propertySource, appName, baseUrl, null, Collections.emptySet(), issuer.getIssuer(), issuer.getId(), getEnvironment().isInteractive(), SetupService.APP_TYPE_DEVICE, client); out.writeLine("Okta application configuration: "); propertySource.getProperties().forEach((key, value) -> { @@ -158,7 +182,7 @@ private Integer createServiceApp(String appName, ServiceAppTemplate appTemplate) AuthorizationServer issuer = getIssuer(client); MutablePropertySource propertySource = appCreationMixin.getPropertySource(appTemplate.getDefaultConfigFileName()); - new DefaultSetupService(appTemplate.getOidcProperties()).createOidcApplication(propertySource, appName, baseUrl, null, Collections.emptySet(), issuer.getIssuer(), issuer.getId(), getEnvironment().isInteractive(), OpenIdConnectApplicationType.SERVICE, client); + new DefaultSetupService(appTemplate.getOidcProperties()).createOidcApplication(propertySource, appName, baseUrl, null, Collections.emptySet(), issuer.getIssuer(), issuer.getId(), getEnvironment().isInteractive(), OpenIdConnectApplicationType.SERVICE.toString(), client); out.writeLine("Okta application configuration has been written to: " + propertySource.getName()); @@ -177,7 +201,7 @@ private Integer createSpaApp(String appName) throws IOException { List trustedOrigins = redirectUris.stream().map(URIs::baseUrlOf).collect(Collectors.toList()); MutablePropertySource propertySource = new MapPropertySource(); - new DefaultSetupService(OidcProperties.oktaEnv()).createOidcApplication(propertySource, appName, baseUrl, null, Collections.emptySet(), authorizationServer.getIssuer(), authorizationServer.getId(), getEnvironment().isInteractive(), OpenIdConnectApplicationType.BROWSER, redirectUris, postLogoutRedirectUris, trustedOrigins, client); + new DefaultSetupService(OidcProperties.oktaEnv()).createOidcApplication(propertySource, appName, baseUrl, null, Collections.emptySet(), authorizationServer.getIssuer(), authorizationServer.getId(), getEnvironment().isInteractive(), OpenIdConnectApplicationType.BROWSER.toString(), redirectUris, postLogoutRedirectUris, trustedOrigins, client); out.writeLine("Okta application configuration: "); out.bold("Issuer: "); @@ -267,6 +291,8 @@ private enum QuickTemplate { SPA("spa", AppType.SPA, SpaAppTemplate.GENERIC), // native NATIVE("native", AppType.NATIVE, NativeAppTemplate.GENERIC), + // CLI, TV, other device + DEVICE("device", AppType.DEVICE, DeviceAppTemplate.GENERIC), // service SPRING_BOOT_SERVICE("spring-boot-service", AppType.SERVICE, ServiceAppTemplate.SPRING_BOOT), JHIPSTER_SERVICE("jhipster-service", AppType.SERVICE, ServiceAppTemplate.JHIPSTER), diff --git a/cli/src/main/java/com/okta/cli/commands/apps/templates/AppType.java b/cli/src/main/java/com/okta/cli/commands/apps/templates/AppType.java index 289e92ea..cbad72f7 100644 --- a/cli/src/main/java/com/okta/cli/commands/apps/templates/AppType.java +++ b/cli/src/main/java/com/okta/cli/commands/apps/templates/AppType.java @@ -21,6 +21,7 @@ public enum AppType implements PromptOption { WEB("Web"), SPA("Single Page App"), NATIVE("Native App (mobile)"), + DEVICE("Device App (TV, CLI, or other device)"), SERVICE("Service (Machine-to-Machine)"); private final String friendlyName; diff --git a/cli/src/main/java/com/okta/cli/commands/apps/templates/DeviceAppTemplate.java b/cli/src/main/java/com/okta/cli/commands/apps/templates/DeviceAppTemplate.java new file mode 100644 index 00000000..bdee81bf --- /dev/null +++ b/cli/src/main/java/com/okta/cli/commands/apps/templates/DeviceAppTemplate.java @@ -0,0 +1,33 @@ +/* + * Copyright 2022-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.commands.apps.templates; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public enum DeviceAppTemplate { + + GENERIC("Device Grant App"); + + private static final List names = Arrays.stream(values()).map(it -> it.friendlyName).collect(Collectors.toList()); + + private final String friendlyName; + + DeviceAppTemplate(String friendlyName) { + this.friendlyName = friendlyName; + } +} diff --git a/common/src/main/java/com/okta/cli/common/service/AuthorizationServerService.java b/common/src/main/java/com/okta/cli/common/service/AuthorizationServerService.java index cb9b8587..f8808d15 100644 --- a/common/src/main/java/com/okta/cli/common/service/AuthorizationServerService.java +++ b/common/src/main/java/com/okta/cli/common/service/AuthorizationServerService.java @@ -17,12 +17,18 @@ import com.okta.cli.common.model.AuthorizationServer; import com.okta.sdk.client.Client; +import com.okta.sdk.resource.authorization.server.policy.AuthorizationServerPolicyRule; import java.util.Map; +import java.util.Optional; public interface AuthorizationServerService { Map authorizationServersMap(Client client); void createGroupClaim(Client client, String groupClaimName, String authorizationServerId); + + Optional getSinglePolicyRule(Client client, String authorizationServerId); + + void enableDeviceGrant(Client client, String authorizationServerId, AuthorizationServerPolicyRule rule); } diff --git a/common/src/main/java/com/okta/cli/common/service/DefaultAuthorizationServerService.java b/common/src/main/java/com/okta/cli/common/service/DefaultAuthorizationServerService.java index 02c8d1d2..3e0e4fc7 100644 --- a/common/src/main/java/com/okta/cli/common/service/DefaultAuthorizationServerService.java +++ b/common/src/main/java/com/okta/cli/common/service/DefaultAuthorizationServerService.java @@ -20,10 +20,14 @@ import com.okta.commons.lang.Assert; import com.okta.sdk.client.Client; import com.okta.sdk.resource.ExtensibleResource; +import com.okta.sdk.resource.authorization.server.AuthorizationServerPolicy; +import com.okta.sdk.resource.authorization.server.AuthorizationServerPolicyList; +import com.okta.sdk.resource.authorization.server.policy.AuthorizationServerPolicyRule; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; public class DefaultAuthorizationServerService implements AuthorizationServerService { @@ -57,6 +61,35 @@ public void createGroupClaim(Client client, String groupClaimName, String author } } + @Override + public Optional getSinglePolicyRule(Client client, String authorizationServerId) { + + List policies = client.http() + .get("/api/v1/authorizationServers/" + authorizationServerId + "/policies", AuthorizationServerPolicyList.class) + .stream() + .toList(); + + if (policies.size() != 1) { + return Optional.empty(); + } + + List rules = policies.get(0).listPolicyRules(authorizationServerId).stream().toList(); + if (rules.size() != 1) { + return Optional.empty(); + } + + return Optional.of(rules.get(0)); + } + + @Override + public void enableDeviceGrant(Client client, String authorizationServerId, AuthorizationServerPolicyRule rule) { + List grantTypes = rule.getConditions().getGrantTypes().getInclude(); + if (!grantTypes.contains(SetupService.APP_TYPE_DEVICE)) { + grantTypes.add(SetupService.APP_TYPE_DEVICE); + rule.update(authorizationServerId); + } + } + private void createClaim(Client client, String groupClaimName, String authorizationServerId, String claimType) { ExtensibleResource claimResource = client.instantiate(ExtensibleResource.class); claimResource.put("name", groupClaimName); 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 07fc3faa..863d91ba 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 @@ -172,6 +172,36 @@ public ExtensibleResource createOidcServiceApp(Client client, String oidcAppName return getClientCredentials(client, oidcApplication); } + @Override + public ExtensibleResource createDeviceCodeApp(Client client, String oidcAppName) { + + Optional existingApp = getApplication(client, oidcAppName); + + // create a new OIDC app if one does NOT exist + Application oidcApplication = existingApp.orElseGet(() -> { + + OpenIdConnectApplicationSettingsClient oidcClient = client.instantiate(OpenIdConnectApplicationSettingsClient.class) + .setApplicationType(OpenIdConnectApplicationType.NATIVE); + oidcClient.put("grant_types", List.of("urn:ietf:params:oauth:grant-type:device_code")); + + Application app = client.instantiate(OpenIdConnectApplication.class) + .setSettings(client.instantiate(OpenIdConnectApplicationSettings.class) + .setOAuthClient(oidcClient)) + .setCredentials(client.instantiate(OAuthApplicationCredentials.class) + .setOAuthClient(client.instantiate(ApplicationCredentialsOAuthClient.class) + .setTokenEndpointAuthMethod(OAuthEndpointAuthenticationMethod.NONE))) + .setLabel(oidcAppName); + + app = client.createApplication(app); + assignAppToEveryoneGroup(client, app); + + return app; + }); + + // lookup the credentials for this application + return getClientCredentials(client, oidcApplication); + } + private Optional getApplication(Client client, String appName) { return client.listApplications(appName, null, null, null).stream() .filter(app -> appName.equals(app.getLabel())) diff --git a/common/src/main/java/com/okta/cli/common/service/DefaultSdkConfigurationService.java b/common/src/main/java/com/okta/cli/common/service/DefaultSdkConfigurationService.java index f8229830..48951d80 100644 --- a/common/src/main/java/com/okta/cli/common/service/DefaultSdkConfigurationService.java +++ b/common/src/main/java/com/okta/cli/common/service/DefaultSdkConfigurationService.java @@ -26,13 +26,10 @@ import java.io.IOException; import java.io.OutputStreamWriter; import java.io.Writer; -import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.attribute.PosixFilePermission; -import java.security.AccessController; -import java.security.PrivilegedAction; import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -50,19 +47,7 @@ public boolean isConfigured() throws ClientConfigurationException { @Override public ClientConfiguration loadUnvalidatedConfiguration() throws ClientConfigurationException { - try { - Field field = DefaultClientBuilder.class.getDeclaredField("clientConfig"); - - AccessController.doPrivileged((PrivilegedAction) () -> { - field.setAccessible(true); - return null; - }); - - return (ClientConfiguration) field.get(clientBuilder()); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new ClientConfigurationException("Could not load Okta SDK configuration, ensure okta-sdk-api version has " + - "not been changed in this plugin's configuration: " + e.getMessage(), e); - } + return clientBuilder().getClientConfiguration(); } @Override 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 e52d3483..94adf123 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 @@ -30,7 +30,7 @@ import com.okta.sdk.impl.resource.DefaultGroupBuilder; import com.okta.sdk.resource.ExtensibleResource; import com.okta.sdk.resource.ResourceException; -import com.okta.sdk.resource.application.OpenIdConnectApplicationType; +import com.okta.sdk.resource.authorization.server.policy.AuthorizationServerPolicyRule; import com.okta.sdk.resource.group.Group; import com.okta.sdk.resource.group.GroupList; import com.okta.sdk.resource.role.Scope; @@ -48,6 +48,7 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.List; +import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -166,7 +167,7 @@ public void createOidcApplication(MutablePropertySource propertySource, String issuerUri, String authorizationServerId, boolean interactive, - OpenIdConnectApplicationType appType, + String appType, List redirectUris, List postLogoutRedirectUris, List trustedOrigins, @@ -174,6 +175,7 @@ public void createOidcApplication(MutablePropertySource propertySource, // Create new Application String clientId = propertySource.getProperty(oidcProperties.clientIdPropertyName); + boolean enableDeviceGrant = false; try (ProgressBar progressBar = ProgressBar.create(interactive)) { if (!ConfigurationValidator.validateClientId(clientId).isValid()) { @@ -182,18 +184,22 @@ public void createOidcApplication(MutablePropertySource propertySource, ExtensibleResource clientCredsResponse; switch (appType) { - case WEB: + case APP_TYPE_WEB: clientCredsResponse = oidcAppCreator.createOidcApp(client, oidcAppName, redirectUris, postLogoutRedirectUris); break; - case NATIVE: + case APP_TYPE_NATIVE: clientCredsResponse = oidcAppCreator.createOidcNativeApp(client, oidcAppName, redirectUris, postLogoutRedirectUris); break; - case BROWSER: + case APP_TYPE_BROWSER: clientCredsResponse = oidcAppCreator.createOidcSpaApp(client, oidcAppName, redirectUris, postLogoutRedirectUris); break; - case SERVICE: + case APP_TYPE_SERVICE: clientCredsResponse = oidcAppCreator.createOidcServiceApp(client, oidcAppName, redirectUris); break; + case APP_TYPE_DEVICE: + clientCredsResponse = oidcAppCreator.createDeviceCodeApp(client, oidcAppName); + enableDeviceGrant = true; + break; default: throw new IllegalStateException("Unsupported Application Type: "+ appType); } @@ -225,6 +231,17 @@ public void createOidcApplication(MutablePropertySource propertySource, }); } + if (enableDeviceGrant) { + progressBar.info("Enabling Device Grant"); + Optional optionalRule = authorizationServerService.getSinglePolicyRule(client, authorizationServerId); + optionalRule.ifPresentOrElse(rule -> { + authorizationServerService.enableDeviceGrant(client, authorizationServerId, rule); + },() -> { + progressBar.info("Custom Authorization Server policy detected, if you are going to use an Okta Custom Authorization Server, you must enable the 'Device Authorization' grant manually, see:"); + progressBar.info("https://developer.okta.com/docs/guides/device-authorization-grant/main/#configure-the-authorization-server-policy-rule-for-device-authorization"); + }); + } + // configure trusted origins configureTrustedOrigins(client, trustedOrigins); } else { diff --git a/common/src/main/java/com/okta/cli/common/service/OidcAppCreator.java b/common/src/main/java/com/okta/cli/common/service/OidcAppCreator.java index 60f715d3..4c51acf7 100644 --- a/common/src/main/java/com/okta/cli/common/service/OidcAppCreator.java +++ b/common/src/main/java/com/okta/cli/common/service/OidcAppCreator.java @@ -29,4 +29,6 @@ public interface OidcAppCreator { ExtensibleResource createOidcSpaApp(Client client, String oidcAppName, List redirectUris, List postLogoutRedirectUris); ExtensibleResource createOidcServiceApp(Client client, String oidcAppName, List redirectUris); + + ExtensibleResource createDeviceCodeApp(Client client, String oidcAppName); } 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 a2814802..e0fbb18c 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 @@ -19,7 +19,6 @@ import com.okta.cli.common.model.OrganizationResponse; import com.okta.cli.common.model.RegistrationQuestions; import com.okta.sdk.client.Client; -import com.okta.sdk.resource.application.OpenIdConnectApplicationType; import java.io.File; import java.io.IOException; @@ -29,6 +28,13 @@ public interface SetupService { + // Copied from OpenIdConnectApplicationType, device_code is missing from that class + String APP_TYPE_WEB = "web"; + String APP_TYPE_SERVICE = "service"; + String APP_TYPE_BROWSER = "browser"; + String APP_TYPE_NATIVE = "native"; + String APP_TYPE_DEVICE = "urn:ietf:params:oauth:grant-type:device_code"; + OrganizationResponse createOktaOrg(RegistrationQuestions registrationQuestions, File oktaPropsFile, boolean demo, @@ -46,7 +52,7 @@ default void createOidcApplication(MutablePropertySource propertySource, String issuerUri, String authorizationServerId, boolean interactive, - OpenIdConnectApplicationType appType, + String appType, Client client) throws IOException { createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, groupsToCreate, issuerUri, authorizationServerId, interactive, appType, Collections.emptyList(), client); } @@ -59,7 +65,7 @@ default void createOidcApplication(MutablePropertySource propertySource, String issuerUri, String authorizationServerId, boolean interactive, - OpenIdConnectApplicationType appType, + String appType, List redirectUris, Client client) throws IOException { createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, groupsToCreate, issuerUri, authorizationServerId, interactive, appType, redirectUris, Collections.emptyList(), client); @@ -73,7 +79,7 @@ default void createOidcApplication(MutablePropertySource propertySource, String issuerUri, String authorizationServerId, boolean interactive, - OpenIdConnectApplicationType appType, + String appType, List redirectUris, List postLogoutRedirectUris, Client client) throws IOException { @@ -88,7 +94,7 @@ void createOidcApplication(MutablePropertySource propertySource, String issuerUri, String authorizationServerId, boolean interactive, - OpenIdConnectApplicationType appType, + String appType, List redirectUris, List postLogoutRedirectUris, List trustedOrigins, 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 69db1962..1bafb299 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 @@ -249,4 +249,71 @@ class DefaultOidcAppCreatorTest { verify(settingsClient).setApplicationType(OpenIdConnectApplicationType.NATIVE) verify(settingsClient, never()).setPostLogoutRedirectUris(any(List)) } + + @Test + void createDeviceApp() { + + String appName = "appLabel-createDeviceApp" + String appId = "appId-createDeviceApp" + String groupId = "everyone-id" + + Client client = mock(Client) + ApplicationList appList = mock(ApplicationList) + List apps = [] + RequestBuilder http = mock(RequestBuilder) + ExtensibleResource response = mock(ExtensibleResource) + + OpenIdConnectApplication newApp = mock(OpenIdConnectApplication) + OpenIdConnectApplicationSettings appSettings = mock(OpenIdConnectApplicationSettings) + OpenIdConnectApplicationSettingsClient settingsClient = mock(OpenIdConnectApplicationSettingsClient) + ApplicationGroupAssignment groupAssignment = mock(ApplicationGroupAssignment) + ApplicationCredentialsOAuthClient credentialsOAuthClient = mock(ApplicationCredentialsOAuthClient) + OAuthApplicationCredentials appCreds = mock(OAuthApplicationCredentials) + + GroupList groupList = mock(GroupList) + Group group = mock(Group) + + DefaultOidcAppCreator appCreator = new DefaultOidcAppCreator() + + when(client.listApplications(appName, null, null, null)).thenReturn(appList) + when(newApp.setLabel(appName)).thenReturn(newApp) + when(newApp.setSettings(appSettings)).thenReturn(newApp) + when(newApp.setCredentials((OAuthApplicationCredentials) appCreds)).thenReturn(newApp) + when(appSettings.setOAuthClient(settingsClient)).thenReturn(appSettings) + when(appCreds.setOAuthClient(credentialsOAuthClient)).thenReturn(appCreds) + when(credentialsOAuthClient.setTokenEndpointAuthMethod(OAuthEndpointAuthenticationMethod.NONE)).thenReturn(credentialsOAuthClient) + + when(client.instantiate(OpenIdConnectApplication)).thenReturn(newApp) + when(client.instantiate(OpenIdConnectApplicationSettings)).thenReturn(appSettings) + when(client.instantiate(OpenIdConnectApplicationSettingsClient)).thenReturn(settingsClient) + when(client.instantiate(ApplicationGroupAssignment)).thenReturn(groupAssignment) + when(client.instantiate(OAuthApplicationCredentials)).thenReturn(appCreds) + when(client.instantiate(ApplicationCredentialsOAuthClient)).thenReturn(credentialsOAuthClient) + + when(settingsClient.setRedirectUris(any(List))).thenReturn(settingsClient) + when(settingsClient.setResponseTypes(any(List))).thenReturn(settingsClient) + when(settingsClient.setGrantTypes(any(List))).thenReturn(settingsClient) + when(settingsClient.setApplicationType(OpenIdConnectApplicationType.NATIVE)).thenReturn(settingsClient) + when(groupAssignment.setPriority(any(Integer))).thenReturn(groupAssignment) + + when(client.createApplication(newApp)).thenReturn(newApp) + when(newApp.getId()).thenReturn(appId) + when(appList.stream()).thenReturn(apps.stream()) + + when(groupList.single()).thenReturn(group) + when(group.getId()).thenReturn(groupId) + when(client.listGroups(null, "profile.name eq \"everyone\"", null)).thenReturn(groupList) + + when(client.http()).thenReturn(http) + when(http.get("/api/v1/internal/apps/${appId}/settings/clientcreds", ExtensibleResource)).thenReturn(response) + + ExtensibleResource result = appCreator.createDeviceCodeApp(client, appName) + + assertThat result, is(response) + + verify(settingsClient).put("grant_types", [SetupService.APP_TYPE_DEVICE]) + verify(settingsClient).setApplicationType(OpenIdConnectApplicationType.NATIVE) + verify(settingsClient, never()).setPostLogoutRedirectUris(any(List)) + verify(settingsClient, never()).setRedirectUris(any(List)) + } } 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 2d4ebd61..db163105 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 @@ -24,6 +24,7 @@ import com.okta.sdk.client.Client import com.okta.sdk.impl.config.ClientConfiguration import com.okta.sdk.resource.ExtensibleResource import com.okta.sdk.resource.application.OpenIdConnectApplicationType +import com.okta.sdk.resource.authorization.server.policy.AuthorizationServerPolicyRule import com.okta.sdk.resource.group.Group import com.okta.sdk.resource.group.GroupList import com.okta.sdk.resource.group.GroupProfile @@ -112,7 +113,7 @@ class DefaultSetupServiceTest { when(propertySource.getProperty("okta.oauth2.client-id")).thenReturn("existing-client-id") DefaultSetupService setupService = setupService() - setupService.createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, null, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB, mock(Client)) + setupService.createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, null, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB.toString(), mock(Client)) // verify nothing happened verifyNoMoreInteractions(setupService.organizationCreator, @@ -139,7 +140,7 @@ class DefaultSetupServiceTest { when(resource.getString("client_secret")).thenReturn("test-client-secret") when(setupService.oidcAppCreator.createOidcApp(client, oidcAppName, ["https://test.example.com/callback", "https://test.example.com/callback2"], ["https://test.example.com/logout", "https://test.example.com/logout2"])).thenReturn(resource) - setupService.createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, null, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB, ["https://test.example.com/callback", "https://test.example.com/callback2"], ["https://test.example.com/logout", "https://test.example.com/logout2"], client) + setupService.createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, null, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB.toString(), ["https://test.example.com/callback", "https://test.example.com/callback2"], ["https://test.example.com/logout", "https://test.example.com/logout2"], client) ArgumentCaptor mapCapture = ArgumentCaptor.forClass(Map) verify(propertySource).addProperties(mapCapture.capture()) @@ -171,7 +172,7 @@ class DefaultSetupServiceTest { when(resource.getString("client_secret")).thenReturn("test-client-secret") when(setupService.oidcAppCreator.createOidcApp(client, oidcAppName, [], [])).thenReturn(resource) - setupService.createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, null, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB, client) + setupService.createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, null, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB.toString(), client) ArgumentCaptor mapCapture = ArgumentCaptor.forClass(Map) verify(propertySource).addProperties(mapCapture.capture()) @@ -226,7 +227,7 @@ class DefaultSetupServiceTest { when(resource.getString("client_secret")).thenReturn("test-client-secret") when(setupService.oidcAppCreator.createOidcApp(client, oidcAppName, [], [])).thenReturn(resource) - setupService.createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, ["group-one", "group-two"] as Set, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB, client) + setupService.createOidcApplication(propertySource, oidcAppName, orgUrl, groupClaimName, ["group-one", "group-two"] as Set, null, authorizationServerId, interactive, OpenIdConnectApplicationType.WEB.toString(), client) ArgumentCaptor mapCapture = ArgumentCaptor.forClass(Map) verify(propertySource).addProperties(mapCapture.capture()) @@ -362,6 +363,67 @@ class DefaultSetupServiceTest { verifyNoMoreInteractions(client) } + @Test + void createDeviceGrantApp() { + MutablePropertySource propertySource = mock(MutablePropertySource) + String oidcAppName = "test-app-name" + String orgUrl = "https://org.example.com" + String authorizationServerId = "test-auth-id" + boolean interactive = false + + Client client = mock(Client) + + DefaultSetupService setupService = setupService() + ExtensibleResource resource = mock(ExtensibleResource) + AuthorizationServerPolicyRule rule = mock(AuthorizationServerPolicyRule) + when(resource.getString("client_id")).thenReturn("test-client-id") + when(setupService.oidcAppCreator.createDeviceCodeApp(client, oidcAppName)).thenReturn(resource) + when(setupService.authorizationServerService.getSinglePolicyRule(client, authorizationServerId)).thenReturn(Optional.of(rule)) + + setupService.createOidcApplication(propertySource, oidcAppName, orgUrl, null, null, null, authorizationServerId, interactive, SetupService.APP_TYPE_DEVICE, client) + + ArgumentCaptor mapCapture = ArgumentCaptor.forClass(Map) + verify(propertySource).addProperties(mapCapture.capture()) + assertThat mapCapture.getValue(), is([ + "okta.oauth2.issuer": "${orgUrl}/oauth2/${authorizationServerId}".toString(), + "okta.oauth2.client-id": "test-client-id", + "okta.oauth2.client-secret": null + ]) + + // enableDeviceGrant was called + verify(setupService.authorizationServerService).enableDeviceGrant(client, authorizationServerId, rule) + } + + @Test + void createDeviceGrantAppWithComplexPolicy() { + MutablePropertySource propertySource = mock(MutablePropertySource) + String oidcAppName = "test-app-name" + String orgUrl = "https://org.example.com" + String authorizationServerId = "test-auth-id" + boolean interactive = false + + Client client = mock(Client) + + DefaultSetupService setupService = setupService() + ExtensibleResource resource = mock(ExtensibleResource) + when(resource.getString("client_id")).thenReturn("test-client-id") + when(setupService.oidcAppCreator.createDeviceCodeApp(client, oidcAppName)).thenReturn(resource) + when(setupService.authorizationServerService.getSinglePolicyRule(client, authorizationServerId)).thenReturn(Optional.empty()) + + setupService.createOidcApplication(propertySource, oidcAppName, orgUrl, null, null, null, authorizationServerId, interactive, SetupService.APP_TYPE_DEVICE, client) + + ArgumentCaptor mapCapture = ArgumentCaptor.forClass(Map) + verify(propertySource).addProperties(mapCapture.capture()) + assertThat mapCapture.getValue(), is([ + "okta.oauth2.issuer": "${orgUrl}/oauth2/${authorizationServerId}".toString(), + "okta.oauth2.client-id": "test-client-id", + "okta.oauth2.client-secret": null + ]) + + verify(setupService.authorizationServerService).getSinglePolicyRule(client, authorizationServerId) + verifyNoMoreInteractions(setupService.authorizationServerService) + } + private static DefaultSetupService setupService(OidcProperties oidcProperties = OidcProperties.oktaEnv()) { OktaOrganizationCreator organizationCreator = mock(OktaOrganizationCreator) SdkConfigurationService sdkConfigurationService = mock(SdkConfigurationService) diff --git a/integration-tests/src/test/groovy/com/okta/cli/test/AppsCreateIT.groovy b/integration-tests/src/test/groovy/com/okta/cli/test/AppsCreateIT.groovy index ad4e5274..007f5187 100644 --- a/integration-tests/src/test/groovy/com/okta/cli/test/AppsCreateIT.groovy +++ b/integration-tests/src/test/groovy/com/okta/cli/test/AppsCreateIT.groovy @@ -156,7 +156,8 @@ class AppsCreateIT implements MockWebSupport, CreateAppSupport { // PUT /api/v1/apps/test-app-id/groups/every1-id jsonRequest('{}'), //GET /api/v1/internal/apps/test-app-id/settings/clientcreds - jsonRequest('{ "client_id": "test-id" }')] + jsonRequest('{ "client_id": "test-id" }') + ] mockWebServer.with { responses.forEach { mockWebServer.enqueue(it) } @@ -188,6 +189,62 @@ class AppsCreateIT implements MockWebSupport, CreateAppSupport { } } + // This test is different from the previous one in that it runs `apps create device` + @Test + void createDeviceApp() { + MockWebServer mockWebServer = createMockServer() + List responses = [ + // GET /api/v1/authorizationServers + jsonRequest('[{ "id": "test-as", "name": "test-as-name", "issuer": "' + url(mockWebServer,"/") + '/oauth2/test-as" }]'), + // GET /api/v1/apps?q=test-project + jsonRequest('[]'), + // POST /api/v1/apps + jsonRequest('{ "id": "test-app-id", "label": "test-app-name" }'), + // GET /api/v1/groups?search=profile.name eq "everyone" + jsonRequest("[${everyoneGroup()}]"), + // PUT /api/v1/apps/test-app-id/groups/every1-id + jsonRequest('{}'), + // GET /api/v1/internal/apps/test-app-id/settings/clientcreds + jsonRequest('{ "client_id": "test-id" }'), + // GET /api/v1/authorizationServers/test-as/policies + jsonRequest('[{"id": "single-policy-id"}]'), + // GET /api/v1/authorizationServers/test-as/policies/single-policy-id/rules + jsonRequest(List.of([id: "single-rule-id", + conditions: [ + grantTypes: [ + include:[]]], + _links: [ + self: [ + href: url(mockWebServer,"/api/v1/authorizationServers/test-as/policies/single-policy-id/rules/single-rule-id")]] + ])), + // POST /api/v1/authorizationServers/test-as/policies/single-policy-id/rules/single-rule-id + jsonRequest('{}') // auth server rule update + ] + + mockWebServer.with { + responses.forEach { mockWebServer.enqueue(it) } + + List input = [ + "", // default of "test-project" + "4", // "device" type of app choice + ] + + def result = new CommandRunner() + .withSdkConfig(url(mockWebServer,"/")) + .runCommandWithInput(input,"--color=never", "apps", "create") + + assertThat result, resultMatches(0, allOf( + containsString("Okta application configuration:"), + containsString("okta.oauth2.client-id: test-id"), + containsString("okta.oauth2.issuer: ${url(mockWebServer,"/")}/oauth2/test-as"), + not(containsString("okta.oauth2.client-secret"))), + null) + + 8.times {mockWebServer.takeRequest() } + verifyDeviceGrantType(mockWebServer.takeRequest(), "/api/v1/authorizationServers/test-as/policies/single-policy-id/rules/single-rule-id") + } + } + // This test is different from the previous one in that it runs `apps create native` @Test void createAppNative() { @@ -366,7 +423,7 @@ class AppsCreateIT implements MockWebSupport, CreateAppSupport { List input = [ "", // default of "test-project" - "4", // "native" type of app choice + "5", // "native" type of app choice "", // default callback "localhost:/callback" "", // default post logout redirect ] @@ -446,4 +503,10 @@ class AppsCreateIT implements MockWebSupport, CreateAppSupport { def body = new JsonSlurper().parse(request.getBody().inputStream()) assertThat body.origin, equalTo(expectedUri) } + + private void verifyDeviceGrantType(RecordedRequest request, String url) { + verify(request, "PUT", url) + def body = new JsonSlurper().parse(request.getBody().inputStream()) + assertThat body.conditions.grantTypes.include, equalTo(["urn:ietf:params:oauth:grant-type:device_code"]) + } }