diff --git a/cli/src/main/java/com/okta/cli/Environment.java b/cli/src/main/java/com/okta/cli/Environment.java index 61b3784e..458084e4 100644 --- a/cli/src/main/java/com/okta/cli/Environment.java +++ b/cli/src/main/java/com/okta/cli/Environment.java @@ -15,12 +15,17 @@ */ package com.okta.cli; +import com.okta.cli.common.model.OktaProfile; +import com.okta.cli.common.service.DefaultProfileConfigurationService; +import com.okta.cli.common.service.ProfileConfigurationService; import com.okta.cli.console.ConsoleOutput; import com.okta.cli.console.DefaultPrompter; import com.okta.cli.console.DisabledPrompter; import com.okta.cli.console.Prompter; import java.io.File; +import java.io.IOException; +import java.util.Optional; public class Environment { @@ -38,6 +43,10 @@ public class Environment { private boolean consoleColors = true; + private String profile = null; + + private boolean profileActivated = false; + public boolean isInteractive() { return interactive; } @@ -68,6 +77,81 @@ public File getOktaPropsFile() { return oktaPropsFile; } + /** + * Gets the currently selected profile name. + * Returns the profile set via --profile flag, or the active profile from config, + * or "default" if no profile is configured. + * + * @return the profile name + */ + public String getProfile() { + if (profile != null) { + return profile; + } + + // Check environment variable + String envProfile = System.getenv("OKTA_CLI_PROFILE"); + if (envProfile != null && !envProfile.isEmpty()) { + return envProfile; + } + + // Get active profile from config file + try { + ProfileConfigurationService profileService = new DefaultProfileConfigurationService(); + return profileService.getActiveProfileName(oktaPropsFile); + } catch (IOException e) { + return ProfileConfigurationService.DEFAULT_PROFILE_NAME; + } + } + + /** + * Sets the profile to use for this session. + * This overrides the active profile from configuration. + * + * @param profile the profile name + * @return this Environment for chaining + */ + public Environment setProfile(String profile) { + this.profile = profile; + this.profileActivated = false; // Reset so profile will be activated on next SDK call + return this; + } + + /** + * Activates the current profile by setting system properties for the Okta SDK. + * This method is idempotent - calling it multiple times has no additional effect. + * + * @throws IllegalStateException if the profile does not exist or cannot be loaded + */ + public void activateProfile() { + if (profileActivated) { + return; + } + + ProfileConfigurationService profileService = new DefaultProfileConfigurationService(); + + try { + // Migrate legacy format if needed + if (((DefaultProfileConfigurationService) profileService).isLegacyFormat(oktaPropsFile)) { + ((DefaultProfileConfigurationService) profileService).migrateFromLegacyFormat(oktaPropsFile); + } + + String profileName = getProfile(); + Optional profileOpt = profileService.getProfile(oktaPropsFile, profileName); + + if (profileOpt.isPresent()) { + profileService.activateProfileForSdk(profileOpt.get()); + profileActivated = true; + } + // If profile doesn't exist, let the SDK handle the error (might be using env vars) + } catch (IOException e) { + // If we can't read the config, let the SDK try its default behavior + if (verbose) { + System.err.println("Warning: Could not load profile configuration: " + e.getMessage()); + } + } + } + public boolean isDemo() { return Boolean.parseBoolean(System.getenv("OKTA_CLI_DEMO")); } diff --git a/cli/src/main/java/com/okta/cli/OktaCli.java b/cli/src/main/java/com/okta/cli/OktaCli.java index c5efd648..df36a3dd 100644 --- a/cli/src/main/java/com/okta/cli/OktaCli.java +++ b/cli/src/main/java/com/okta/cli/OktaCli.java @@ -21,6 +21,7 @@ import com.okta.cli.commands.Register; import com.okta.cli.commands.Start; import com.okta.cli.commands.apps.Apps; +import com.okta.cli.commands.profiles.Profiles; import com.okta.commons.lang.ApplicationInfo; import com.okta.sdk.resource.ResourceException; import picocli.AutoComplete; @@ -43,6 +44,7 @@ Register.class, Login.class, Apps.class, + Profiles.class, Start.class, Logs.class, DumpCommand.class, @@ -166,6 +168,11 @@ public void setSystemProperties(List props) { } } + @Option(names = {"-p", "--profile"}, description = "Use a specific Okta profile. Overrides the active profile from configuration.") + public void setProfile(String profile) { + environment.setProfile(profile); + } + public Environment getEnvironment() { return environment; } diff --git a/cli/src/main/java/com/okta/cli/commands/BaseCommand.java b/cli/src/main/java/com/okta/cli/commands/BaseCommand.java index 242cb8ff..33f44433 100644 --- a/cli/src/main/java/com/okta/cli/commands/BaseCommand.java +++ b/cli/src/main/java/com/okta/cli/commands/BaseCommand.java @@ -38,6 +38,9 @@ public BaseCommand(OktaCli.StandardOptions standardOptions) { @Override public Integer call() throws Exception { + // Activate the selected profile before running the command + // This sets system properties so the Okta SDK uses the correct credentials + getEnvironment().activateProfile(); return runCommand(); } diff --git a/cli/src/main/java/com/okta/cli/commands/Login.java b/cli/src/main/java/com/okta/cli/commands/Login.java index 3f3f4b1b..52746af0 100644 --- a/cli/src/main/java/com/okta/cli/commands/Login.java +++ b/cli/src/main/java/com/okta/cli/commands/Login.java @@ -15,42 +15,87 @@ */ package com.okta.cli.commands; -import com.okta.cli.common.service.DefaultSdkConfigurationService; -import com.okta.cli.common.service.SdkConfigurationService; +import com.okta.cli.common.model.OktaProfile; +import com.okta.cli.common.service.DefaultProfileConfigurationService; +import com.okta.cli.common.service.ProfileConfigurationService; import com.okta.cli.console.ConsoleOutput; import com.okta.commons.configcheck.ConfigurationValidator; -import com.okta.commons.lang.Strings; -import com.okta.sdk.impl.config.ClientConfiguration; import picocli.CommandLine; +import java.io.File; +import java.util.Optional; + @CommandLine.Command(name = "login", description = "Authorizes the Okta CLI tool") public class Login extends BaseCommand { + @CommandLine.Option(names = {"--profile-name"}, description = "Name for this profile (e.g., 'acme-corp', 'dev-tenant')") + private String profileName; + @Override public int runCommand() throws Exception { - // check if okta client config exists? - SdkConfigurationService sdkConfigurationService = new DefaultSdkConfigurationService(); - ClientConfiguration clientConfiguration = sdkConfigurationService.loadUnvalidatedConfiguration(); - String orgUrl = clientConfiguration.getBaseUrl(); + ProfileConfigurationService profileService = new DefaultProfileConfigurationService(); + File configFile = getEnvironment().getOktaPropsFile(); + + // Migrate legacy format if needed + if (((DefaultProfileConfigurationService) profileService).isLegacyFormat(configFile)) { + ((DefaultProfileConfigurationService) profileService).migrateFromLegacyFormat(configFile); + } + + // Determine profile name: --profile-name flag > --profile flag > prompt + String targetProfile = profileName; + if (targetProfile == null) { + String envProfile = getEnvironment().getProfile(); + if (!ProfileConfigurationService.DEFAULT_PROFILE_NAME.equals(envProfile)) { + targetProfile = envProfile; + } + } try (ConsoleOutput out = getConsoleOutput()) { - // prompt user to overwrite config file - if (Strings.hasText(orgUrl) - && !configQuestions().isOverwriteExistingConfig(orgUrl, getEnvironment().getOktaPropsFile().getAbsolutePath())) { - return 0; + // Prompt for profile name if not provided + if (targetProfile == null) { + targetProfile = getPrompter().promptUntilIfEmpty(null, "Profile name", ProfileConfigurationService.DEFAULT_PROFILE_NAME); } - // prompt for Base URL - orgUrl = getPrompter().promptUntilValue("Okta Org URL"); + // Check if profile already exists + Optional existingProfile = profileService.getProfile(configFile, targetProfile); + if (existingProfile.isPresent()) { + out.writeLine("Profile '" + targetProfile + "' already exists with org: " + existingProfile.get().getOrgUrl()); + if (!getPrompter().promptYesNo("Overwrite this profile?")) { + return 0; + } + } + + // Prompt for Okta Org URL + String orgUrl = getPrompter().promptUntilValue("Okta Org URL"); ConfigurationValidator.assertOrgUrl(orgUrl); + // Prompt for API token out.writeLine("Enter your Okta API token, for more information see: https://bit.ly/get-okta-api-token"); String apiToken = getPrompter().promptUntilValue(null, "Okta API token"); ConfigurationValidator.assertApiToken(apiToken); - sdkConfigurationService.writeOktaYaml(orgUrl, apiToken, getEnvironment().getOktaPropsFile()); + // Determine if this should be the active profile + boolean setAsActive = true; + String currentActive = profileService.getActiveProfileName(configFile); + if (!currentActive.equals(targetProfile) && profileService.profileExists(configFile, currentActive)) { + setAsActive = getPrompter().promptYesNo("Set '" + targetProfile + "' as the active profile?", true); + } + + // Save the profile + OktaProfile newProfile = new OktaProfile(targetProfile, orgUrl, apiToken); + profileService.saveProfile(configFile, newProfile, setAsActive); + + out.writeLine(""); + out.bold("Profile '" + targetProfile + "' saved successfully!"); + out.writeLine(""); + out.writeLine("Org URL: " + orgUrl); + if (setAsActive) { + out.writeLine("This profile is now active."); + } else { + out.writeLine("Use 'okta --profile " + targetProfile + " ' or 'okta profiles use " + targetProfile + "' to switch."); + } } return 0; diff --git a/cli/src/main/java/com/okta/cli/commands/profiles/Profiles.java b/cli/src/main/java/com/okta/cli/commands/profiles/Profiles.java new file mode 100644 index 00000000..82ba9c03 --- /dev/null +++ b/cli/src/main/java/com/okta/cli/commands/profiles/Profiles.java @@ -0,0 +1,39 @@ +/* + * 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.commands.profiles; + +import picocli.CommandLine; + +@CommandLine.Command(name = "profiles", + description = "Manage Okta CLI profiles for multiple organizations", + subcommands = { + ProfilesList.class, + ProfilesUse.class, + ProfilesShow.class, + ProfilesDelete.class, + CommandLine.HelpCommand.class + }) +public class Profiles implements Runnable { + + @CommandLine.Spec + private CommandLine.Model.CommandSpec spec; + + @Override + public void run() { + // Default action: list profiles + spec.commandLine().execute("list"); + } +} diff --git a/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesDelete.java b/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesDelete.java new file mode 100644 index 00000000..0f049667 --- /dev/null +++ b/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesDelete.java @@ -0,0 +1,81 @@ +/* + * 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.commands.profiles; + +import com.okta.cli.commands.BaseCommand; +import com.okta.cli.common.model.OktaProfile; +import com.okta.cli.common.service.DefaultProfileConfigurationService; +import com.okta.cli.common.service.ProfileConfigurationService; +import com.okta.cli.console.ConsoleOutput; +import picocli.CommandLine; + +import java.io.File; +import java.util.Optional; + +@CommandLine.Command(name = "delete", + description = "Delete a profile") +public class ProfilesDelete extends BaseCommand { + + @CommandLine.Parameters(index = "0", description = "The profile name to delete") + private String profileName; + + @CommandLine.Option(names = {"-f", "--force"}, description = "Skip confirmation prompt") + private boolean force; + + @Override + public int runCommand() throws Exception { + ProfileConfigurationService profileService = new DefaultProfileConfigurationService(); + File configFile = getEnvironment().getOktaPropsFile(); + + try (ConsoleOutput out = getConsoleOutput()) { + // Check if profile exists + Optional profile = profileService.getProfile(configFile, profileName); + if (profile.isEmpty()) { + out.writeError("Profile '" + profileName + "' not found."); + return 1; + } + + // Check if it's the active profile + String activeProfile = profileService.getActiveProfileName(configFile); + if (profileName.equals(activeProfile)) { + out.writeError("Cannot delete the active profile '" + profileName + "'."); + out.writeLine("Switch to another profile first using 'okta profiles use '."); + return 1; + } + + // Confirm deletion + if (!force) { + out.writeLine("Profile: " + profileName); + out.writeLine("Org URL: " + profile.get().getOrgUrl()); + if (!getPrompter().promptYesNo("Delete this profile?", false)) { + out.writeLine("Cancelled."); + return 0; + } + } + + // Delete the profile + boolean deleted = profileService.deleteProfile(configFile, profileName); + if (deleted) { + out.writeLine("Profile '" + profileName + "' deleted."); + } else { + out.writeError("Failed to delete profile '" + profileName + "'."); + return 1; + } + } + + return 0; + } +} diff --git a/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesList.java b/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesList.java new file mode 100644 index 00000000..ef1731d9 --- /dev/null +++ b/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesList.java @@ -0,0 +1,88 @@ +/* + * 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.commands.profiles; + +import com.okta.cli.commands.BaseCommand; +import com.okta.cli.common.model.OktaProfile; +import com.okta.cli.common.service.DefaultProfileConfigurationService; +import com.okta.cli.common.service.ProfileConfigurationService; +import com.okta.cli.console.ConsoleOutput; +import picocli.CommandLine; + +import java.io.File; +import java.util.List; + +@CommandLine.Command(name = "list", + description = "List all configured profiles") +public class ProfilesList extends BaseCommand { + + @Override + public int runCommand() throws Exception { + ProfileConfigurationService profileService = new DefaultProfileConfigurationService(); + File configFile = getEnvironment().getOktaPropsFile(); + + // Migrate legacy format if needed + if (((DefaultProfileConfigurationService) profileService).isLegacyFormat(configFile)) { + ((DefaultProfileConfigurationService) profileService).migrateFromLegacyFormat(configFile); + } + + List profiles = profileService.listProfiles(configFile); + String activeProfile = profileService.getActiveProfileName(configFile); + + try (ConsoleOutput out = getConsoleOutput()) { + if (profiles.isEmpty()) { + out.writeLine("No profiles configured. Run 'okta login' to create one."); + return 0; + } + + out.bold("Configured Okta Profiles:"); + out.writeLine(""); + out.writeLine(""); + + // Calculate column widths + int maxNameLen = profiles.stream().mapToInt(p -> p.getName().length()).max().orElse(10); + maxNameLen = Math.max(maxNameLen, 10); + + // Header + out.write(String.format(" %-" + maxNameLen + "s %-40s %s%n", "NAME", "ORG URL", "STATUS")); + out.write(String.format(" %-" + maxNameLen + "s %-40s %s%n", + "-".repeat(maxNameLen), "-".repeat(40), "------")); + + for (OktaProfile profile : profiles) { + boolean isActive = profile.getName().equals(activeProfile); + String status = isActive ? "* active" : ""; + String marker = isActive ? "* " : " "; + + if (isActive) { + out.bold(marker); + out.bold(String.format("%-" + maxNameLen + "s", profile.getName())); + out.write(" "); + out.writeLine(String.format("%-40s %s", profile.getOrgUrl(), status)); + } else { + out.write(marker); + out.writeLine(String.format("%-" + maxNameLen + "s %-40s %s", + profile.getName(), profile.getOrgUrl(), status)); + } + } + + out.writeLine(""); + out.writeLine("Use 'okta profiles use ' to switch profiles."); + out.writeLine("Use 'okta --profile ' for one-off commands."); + } + + return 0; + } +} diff --git a/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesShow.java b/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesShow.java new file mode 100644 index 00000000..8b4d0f3e --- /dev/null +++ b/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesShow.java @@ -0,0 +1,76 @@ +/* + * 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.commands.profiles; + +import com.okta.cli.commands.BaseCommand; +import com.okta.cli.common.model.OktaProfile; +import com.okta.cli.common.service.DefaultProfileConfigurationService; +import com.okta.cli.common.service.ProfileConfigurationService; +import com.okta.cli.console.ConsoleOutput; +import picocli.CommandLine; + +import java.io.File; +import java.util.Optional; + +@CommandLine.Command(name = "show", + description = "Show details of a profile") +public class ProfilesShow extends BaseCommand { + + @CommandLine.Parameters(index = "0", description = "The profile name to show (defaults to active profile)", + defaultValue = "") + private String profileName; + + @Override + public int runCommand() throws Exception { + ProfileConfigurationService profileService = new DefaultProfileConfigurationService(); + File configFile = getEnvironment().getOktaPropsFile(); + + // Use active profile if not specified + String targetProfile = profileName.isEmpty() + ? profileService.getActiveProfileName(configFile) + : profileName; + + try (ConsoleOutput out = getConsoleOutput()) { + Optional profile = profileService.getProfile(configFile, targetProfile); + if (profile.isEmpty()) { + out.writeError("Profile '" + targetProfile + "' not found."); + return 1; + } + + OktaProfile p = profile.get(); + String activeProfile = profileService.getActiveProfileName(configFile); + boolean isActive = p.getName().equals(activeProfile); + + out.bold("Profile: "); + out.writeLine(p.getName() + (isActive ? " (active)" : "")); + out.bold("Org URL: "); + out.writeLine(p.getOrgUrl()); + out.bold("Token: "); + // Mask the token for security + String maskedToken = maskToken(p.getApiToken()); + out.writeLine(maskedToken); + } + + return 0; + } + + private String maskToken(String token) { + if (token == null || token.length() < 8) { + return "****"; + } + return token.substring(0, 4) + "****" + token.substring(token.length() - 4); + } +} diff --git a/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesUse.java b/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesUse.java new file mode 100644 index 00000000..c3cce830 --- /dev/null +++ b/cli/src/main/java/com/okta/cli/commands/profiles/ProfilesUse.java @@ -0,0 +1,66 @@ +/* + * 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.commands.profiles; + +import com.okta.cli.commands.BaseCommand; +import com.okta.cli.common.model.OktaProfile; +import com.okta.cli.common.service.DefaultProfileConfigurationService; +import com.okta.cli.common.service.ProfileConfigurationService; +import com.okta.cli.console.ConsoleOutput; +import picocli.CommandLine; + +import java.io.File; +import java.util.Optional; + +@CommandLine.Command(name = "use", + description = "Switch to a different profile") +public class ProfilesUse extends BaseCommand { + + @CommandLine.Parameters(index = "0", description = "The profile name to switch to") + private String profileName; + + @Override + public int runCommand() throws Exception { + ProfileConfigurationService profileService = new DefaultProfileConfigurationService(); + File configFile = getEnvironment().getOktaPropsFile(); + + try (ConsoleOutput out = getConsoleOutput()) { + // Check if profile exists + Optional profile = profileService.getProfile(configFile, profileName); + if (profile.isEmpty()) { + out.writeError("Profile '" + profileName + "' not found."); + out.writeLine(""); + out.writeLine("Available profiles:"); + + for (OktaProfile p : profileService.listProfiles(configFile)) { + out.writeLine(" - " + p.getName()); + } + + out.writeLine(""); + out.writeLine("Use 'okta login --profile-name " + profileName + "' to create this profile."); + return 1; + } + + // Set as active + profileService.setActiveProfile(configFile, profileName); + + out.writeLine("Switched to profile '" + profileName + "'"); + out.writeLine("Org URL: " + profile.get().getOrgUrl()); + } + + return 0; + } +} diff --git a/common/src/main/java/com/okta/cli/common/model/OktaProfile.java b/common/src/main/java/com/okta/cli/common/model/OktaProfile.java new file mode 100644 index 00000000..b8403229 --- /dev/null +++ b/common/src/main/java/com/okta/cli/common/model/OktaProfile.java @@ -0,0 +1,68 @@ +/* + * 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 java.util.Objects; + +/** + * Represents an Okta profile configuration containing org URL and API token. + * Used for multi-profile support to manage multiple Okta organizations. + */ +public class OktaProfile { + + private final String name; + private final String orgUrl; + private final String apiToken; + + public OktaProfile(String name, String orgUrl, String apiToken) { + this.name = Objects.requireNonNull(name, "Profile name cannot be null"); + this.orgUrl = Objects.requireNonNull(orgUrl, "Org URL cannot be null"); + this.apiToken = Objects.requireNonNull(apiToken, "API token cannot be null"); + } + + public String getName() { + return name; + } + + public String getOrgUrl() { + return orgUrl; + } + + public String getApiToken() { + return apiToken; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OktaProfile that = (OktaProfile) o; + return Objects.equals(name, that.name); + } + + @Override + public int hashCode() { + return Objects.hash(name); + } + + @Override + public String toString() { + return "OktaProfile{" + + "name='" + name + '\'' + + ", orgUrl='" + orgUrl + '\'' + + '}'; + } +} diff --git a/common/src/main/java/com/okta/cli/common/service/DefaultProfileConfigurationService.java b/common/src/main/java/com/okta/cli/common/service/DefaultProfileConfigurationService.java new file mode 100644 index 00000000..330bf8e2 --- /dev/null +++ b/common/src/main/java/com/okta/cli/common/service/DefaultProfileConfigurationService.java @@ -0,0 +1,323 @@ +/* + * 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.service; + +import com.okta.cli.common.model.OktaProfile; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * Default implementation of ProfileConfigurationService. + * + * Configuration file format: + *
+ * okta:
+ *   profiles:
+ *     default:
+ *       orgUrl: https://dev-123456.okta.com
+ *       token: 00abc...
+ *     acme-corp:
+ *       orgUrl: https://acme.okta.com
+ *       token: 00xyz...
+ *   activeProfile: default
+ *   # Legacy format support (read-only, migrated on first write):
+ *   client:
+ *     orgUrl: https://dev-123456.okta.com
+ *     token: 00abc...
+ * 
+ */ +public class DefaultProfileConfigurationService implements ProfileConfigurationService { + + private static final String OKTA_KEY = "okta"; + private static final String PROFILES_KEY = "profiles"; + private static final String ACTIVE_PROFILE_KEY = "activeProfile"; + private static final String ORG_URL_KEY = "orgUrl"; + private static final String TOKEN_KEY = "token"; + private static final String LEGACY_CLIENT_KEY = "client"; + + @Override + public List listProfiles(File configFile) throws IOException { + Map config = loadConfig(configFile); + Map> profiles = getProfilesMap(config); + + List result = new ArrayList<>(); + for (Map.Entry> entry : profiles.entrySet()) { + String name = entry.getKey(); + Map profileData = entry.getValue(); + String orgUrl = profileData.get(ORG_URL_KEY); + String token = profileData.get(TOKEN_KEY); + if (orgUrl != null && token != null) { + result.add(new OktaProfile(name, orgUrl, token)); + } + } + return result; + } + + @Override + public Optional getProfile(File configFile, String profileName) throws IOException { + Map config = loadConfig(configFile); + Map> profiles = getProfilesMap(config); + + Map profileData = profiles.get(profileName); + if (profileData == null) { + return Optional.empty(); + } + + String orgUrl = profileData.get(ORG_URL_KEY); + String token = profileData.get(TOKEN_KEY); + if (orgUrl == null || token == null) { + return Optional.empty(); + } + + return Optional.of(new OktaProfile(profileName, orgUrl, token)); + } + + @Override + public String getActiveProfileName(File configFile) throws IOException { + Map config = loadConfig(configFile); + Map oktaConfig = getOktaConfig(config); + + Object activeProfile = oktaConfig.get(ACTIVE_PROFILE_KEY); + if (activeProfile instanceof String && !((String) activeProfile).isEmpty()) { + return (String) activeProfile; + } + return DEFAULT_PROFILE_NAME; + } + + @Override + public void saveProfile(File configFile, OktaProfile profile, boolean setAsActive) throws IOException { + Map config = loadConfig(configFile); + Map oktaConfig = getOrCreateOktaConfig(config); + Map> profiles = getOrCreateProfilesMap(oktaConfig); + + // Create profile data + Map profileData = new LinkedHashMap<>(); + profileData.put(ORG_URL_KEY, profile.getOrgUrl()); + profileData.put(TOKEN_KEY, profile.getApiToken()); + profiles.put(profile.getName(), profileData); + + // Set as active if requested or if it's the first profile + if (setAsActive || profiles.size() == 1) { + oktaConfig.put(ACTIVE_PROFILE_KEY, profile.getName()); + } + + saveConfig(configFile, config); + } + + @Override + public void setActiveProfile(File configFile, String profileName) throws IOException { + Map config = loadConfig(configFile); + Map> profiles = getProfilesMap(config); + + if (!profiles.containsKey(profileName)) { + throw new IllegalArgumentException("Profile '" + profileName + "' does not exist"); + } + + Map oktaConfig = getOrCreateOktaConfig(config); + oktaConfig.put(ACTIVE_PROFILE_KEY, profileName); + + saveConfig(configFile, config); + } + + @Override + public boolean deleteProfile(File configFile, String profileName) throws IOException { + Map config = loadConfig(configFile); + Map oktaConfig = getOktaConfig(config); + Map> profiles = getProfilesMap(config); + + // Check if trying to delete active profile + String activeProfile = getActiveProfileName(configFile); + if (profileName.equals(activeProfile)) { + throw new IllegalArgumentException("Cannot delete the active profile '" + profileName + "'. Switch to another profile first."); + } + + if (!profiles.containsKey(profileName)) { + return false; + } + + profiles.remove(profileName); + saveConfig(configFile, config); + return true; + } + + @Override + public void activateProfileForSdk(OktaProfile profile) { + // Set system properties that the Okta SDK will pick up + System.setProperty("okta.client.orgUrl", profile.getOrgUrl()); + System.setProperty("okta.client.token", profile.getApiToken()); + } + + /** + * Migrates legacy configuration format to the new profiles format. + * The legacy format stored credentials directly under okta.client. + * + * @param configFile the configuration file to migrate + * @throws IOException if migration fails + */ + public void migrateFromLegacyFormat(File configFile) throws IOException { + Map config = loadConfig(configFile); + Map oktaConfig = getOktaConfig(config); + + // Check if already migrated + if (oktaConfig.containsKey(PROFILES_KEY)) { + return; + } + + // Check for legacy format + @SuppressWarnings("unchecked") + Map legacyClient = (Map) oktaConfig.get(LEGACY_CLIENT_KEY); + if (legacyClient == null) { + return; + } + + String orgUrl = legacyClient.get(ORG_URL_KEY); + String token = legacyClient.get(TOKEN_KEY); + if (orgUrl == null || token == null) { + return; + } + + // Create default profile from legacy data + OktaProfile defaultProfile = new OktaProfile(DEFAULT_PROFILE_NAME, orgUrl, token); + saveProfile(configFile, defaultProfile, true); + } + + /** + * Checks if the configuration file uses the legacy format (single profile under okta.client). + * + * @param configFile the configuration file to check + * @return true if using legacy format + * @throws IOException if the file cannot be read + */ + public boolean isLegacyFormat(File configFile) throws IOException { + if (!configFile.exists()) { + return false; + } + + Map config = loadConfig(configFile); + Map oktaConfig = getOktaConfig(config); + + // Legacy format has 'client' but no 'profiles' + return oktaConfig.containsKey(LEGACY_CLIENT_KEY) && !oktaConfig.containsKey(PROFILES_KEY); + } + + @SuppressWarnings("unchecked") + private Map loadConfig(File configFile) throws IOException { + if (!configFile.exists()) { + return new LinkedHashMap<>(); + } + + Yaml yaml = new Yaml(); + try (InputStream inputStream = new FileInputStream(configFile)) { + Object loaded = yaml.load(inputStream); + if (loaded instanceof Map) { + return (Map) loaded; + } + return new LinkedHashMap<>(); + } + } + + @SuppressWarnings("unchecked") + private Map getOktaConfig(Map config) { + Object okta = config.get(OKTA_KEY); + if (okta instanceof Map) { + return (Map) okta; + } + return Collections.emptyMap(); + } + + @SuppressWarnings("unchecked") + private Map getOrCreateOktaConfig(Map config) { + return (Map) config.computeIfAbsent(OKTA_KEY, k -> new LinkedHashMap<>()); + } + + @SuppressWarnings("unchecked") + private Map> getProfilesMap(Map config) { + Map oktaConfig = getOktaConfig(config); + Object profiles = oktaConfig.get(PROFILES_KEY); + if (profiles instanceof Map) { + return (Map>) profiles; + } + + // Check for legacy format and return it as a single "default" profile + Object legacyClient = oktaConfig.get(LEGACY_CLIENT_KEY); + if (legacyClient instanceof Map) { + Map> legacyProfiles = new LinkedHashMap<>(); + legacyProfiles.put(DEFAULT_PROFILE_NAME, (Map) legacyClient); + return legacyProfiles; + } + + return Collections.emptyMap(); + } + + @SuppressWarnings("unchecked") + private Map> getOrCreateProfilesMap(Map oktaConfig) { + return (Map>) oktaConfig.computeIfAbsent(PROFILES_KEY, k -> new LinkedHashMap<>()); + } + + private void saveConfig(File configFile, Map config) throws IOException { + File parentDir = configFile.getParentFile(); + + // Create parent directory if needed + if (parentDir != null && !(parentDir.exists() || parentDir.mkdirs())) { + throw new IOException("Unable to create directory: " + parentDir.getAbsolutePath()); + } + + // Configure YAML dumper for readable output + DumperOptions options = new DumperOptions(); + options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + options.setPrettyFlow(true); + options.setIndent(2); + + Yaml yaml = new Yaml(options); + try (Writer writer = new OutputStreamWriter(new FileOutputStream(configFile), StandardCharsets.UTF_8)) { + yaml.dump(config, writer); + } + + // Set secure file permissions on POSIX systems + Set supportedViews = FileSystems.getDefault().supportedFileAttributeViews(); + if (supportedViews.contains("posix")) { + if (parentDir != null) { + Files.setPosixFilePermissions(parentDir.toPath(), Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE, + PosixFilePermission.OWNER_EXECUTE)); + } + + Files.setPosixFilePermissions(configFile.toPath(), Set.of( + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE)); + } + } +} diff --git a/common/src/main/java/com/okta/cli/common/service/ProfileConfigurationService.java b/common/src/main/java/com/okta/cli/common/service/ProfileConfigurationService.java new file mode 100644 index 00000000..ab762733 --- /dev/null +++ b/common/src/main/java/com/okta/cli/common/service/ProfileConfigurationService.java @@ -0,0 +1,114 @@ +/* + * 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.service; + +import com.okta.cli.common.model.OktaProfile; + +import java.io.File; +import java.io.IOException; +import java.util.List; +import java.util.Optional; + +/** + * Service for managing multiple Okta CLI profiles. + * Profiles allow users to switch between different Okta organizations easily. + */ +public interface ProfileConfigurationService { + + /** + * Default profile name used when no profile is specified. + */ + String DEFAULT_PROFILE_NAME = "default"; + + /** + * Gets all configured profiles. + * + * @param configFile the configuration file to read from + * @return list of all configured profiles + * @throws IOException if the file cannot be read + */ + List listProfiles(File configFile) throws IOException; + + /** + * Gets a specific profile by name. + * + * @param configFile the configuration file to read from + * @param profileName the name of the profile to retrieve + * @return the profile if found, empty otherwise + * @throws IOException if the file cannot be read + */ + Optional getProfile(File configFile, String profileName) throws IOException; + + /** + * Gets the currently active profile name. + * + * @param configFile the configuration file to read from + * @return the active profile name, defaults to "default" if not set + * @throws IOException if the file cannot be read + */ + String getActiveProfileName(File configFile) throws IOException; + + /** + * Saves or updates a profile configuration. + * + * @param configFile the configuration file to write to + * @param profile the profile to save + * @param setAsActive whether to set this profile as the active profile + * @throws IOException if the file cannot be written + */ + void saveProfile(File configFile, OktaProfile profile, boolean setAsActive) throws IOException; + + /** + * Sets the active profile. + * + * @param configFile the configuration file to write to + * @param profileName the name of the profile to make active + * @throws IOException if the file cannot be written + * @throws IllegalArgumentException if the profile does not exist + */ + void setActiveProfile(File configFile, String profileName) throws IOException; + + /** + * Deletes a profile. + * + * @param configFile the configuration file to modify + * @param profileName the name of the profile to delete + * @return true if the profile was deleted, false if it didn't exist + * @throws IOException if the file cannot be written + * @throws IllegalArgumentException if attempting to delete the active profile + */ + boolean deleteProfile(File configFile, String profileName) throws IOException; + + /** + * Activates a profile by setting the appropriate system properties. + * This allows the Okta SDK to pick up the profile's credentials. + * + * @param profile the profile to activate + */ + void activateProfileForSdk(OktaProfile profile); + + /** + * Checks if a profile exists. + * + * @param configFile the configuration file to read from + * @param profileName the name of the profile to check + * @return true if the profile exists + * @throws IOException if the file cannot be read + */ + default boolean profileExists(File configFile, String profileName) throws IOException { + return getProfile(configFile, profileName).isPresent(); + } +} diff --git a/common/src/test/groovy/com/okta/cli/common/service/DefaultProfileConfigurationServiceTest.groovy b/common/src/test/groovy/com/okta/cli/common/service/DefaultProfileConfigurationServiceTest.groovy new file mode 100644 index 00000000..1eea3d61 --- /dev/null +++ b/common/src/test/groovy/com/okta/cli/common/service/DefaultProfileConfigurationServiceTest.groovy @@ -0,0 +1,209 @@ +/* + * 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.service + +import com.okta.cli.common.TestUtil +import com.okta.cli.common.model.OktaProfile +import org.testng.annotations.Test + +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.attribute.PosixFilePermission + +import static org.hamcrest.MatcherAssert.assertThat +import static org.hamcrest.Matchers.* + +class DefaultProfileConfigurationServiceTest { + + @Test + void saveAndLoadProfile() { + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + File configFile = new File(File.createTempDir("profile-test-", "-dir"), "okta.yaml") + + OktaProfile profile = new OktaProfile("default", "https://dev-123456.okta.com", "test-token-123") + service.saveProfile(configFile, profile, true) + + // Verify profile was saved + Optional loaded = service.getProfile(configFile, "default") + assertThat loaded.isPresent(), is(true) + assertThat loaded.get().getName(), is("default") + assertThat loaded.get().getOrgUrl(), is("https://dev-123456.okta.com") + assertThat loaded.get().getApiToken(), is("test-token-123") + } + + @Test + void listProfiles() { + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + File configFile = new File(File.createTempDir("profile-test-", "-dir"), "okta.yaml") + + service.saveProfile(configFile, new OktaProfile("default", "https://default.okta.com", "token1"), true) + service.saveProfile(configFile, new OktaProfile("acme-corp", "https://acme.okta.com", "token2"), false) + service.saveProfile(configFile, new OktaProfile("bigco-prod", "https://bigco.okta.com", "token3"), false) + + List profiles = service.listProfiles(configFile) + assertThat profiles.size(), is(3) + assertThat profiles*.name, containsInAnyOrder("default", "acme-corp", "bigco-prod") + } + + @Test + void setActiveProfile() { + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + File configFile = new File(File.createTempDir("profile-test-", "-dir"), "okta.yaml") + + service.saveProfile(configFile, new OktaProfile("profile1", "https://p1.okta.com", "token1"), true) + service.saveProfile(configFile, new OktaProfile("profile2", "https://p2.okta.com", "token2"), false) + + assertThat service.getActiveProfileName(configFile), is("profile1") + + service.setActiveProfile(configFile, "profile2") + assertThat service.getActiveProfileName(configFile), is("profile2") + } + + @Test + void deleteProfile() { + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + File configFile = new File(File.createTempDir("profile-test-", "-dir"), "okta.yaml") + + service.saveProfile(configFile, new OktaProfile("active", "https://active.okta.com", "token1"), true) + service.saveProfile(configFile, new OktaProfile("to-delete", "https://delete.okta.com", "token2"), false) + + assertThat service.profileExists(configFile, "to-delete"), is(true) + + boolean deleted = service.deleteProfile(configFile, "to-delete") + assertThat deleted, is(true) + assertThat service.profileExists(configFile, "to-delete"), is(false) + } + + @Test(expectedExceptions = IllegalArgumentException.class) + void cannotDeleteActiveProfile() { + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + File configFile = new File(File.createTempDir("profile-test-", "-dir"), "okta.yaml") + + service.saveProfile(configFile, new OktaProfile("active", "https://active.okta.com", "token1"), true) + service.deleteProfile(configFile, "active") + } + + @Test + void migrateFromLegacyFormat() { + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + File configFile = new File(File.createTempDir("profile-test-", "-dir"), "okta.yaml") + + // Write legacy format + configFile.text = """ +okta: + client: + orgUrl: https://legacy.okta.com + token: legacy-token +""" + + assertThat service.isLegacyFormat(configFile), is(true) + + // Migrate + service.migrateFromLegacyFormat(configFile) + + // Verify migration + assertThat service.isLegacyFormat(configFile), is(false) + Optional profile = service.getProfile(configFile, "default") + assertThat profile.isPresent(), is(true) + assertThat profile.get().getOrgUrl(), is("https://legacy.okta.com") + assertThat profile.get().getApiToken(), is("legacy-token") + } + + @Test + void legacyFormatReadAsDefault() { + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + File configFile = new File(File.createTempDir("profile-test-", "-dir"), "okta.yaml") + + // Write legacy format + configFile.text = """ +okta: + client: + orgUrl: https://legacy.okta.com + token: legacy-token +""" + + // Can read legacy format as "default" profile without migration + List profiles = service.listProfiles(configFile) + assertThat profiles.size(), is(1) + assertThat profiles[0].name, is("default") + assertThat profiles[0].orgUrl, is("https://legacy.okta.com") + } + + @Test + void activateProfileForSdk() { + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + OktaProfile profile = new OktaProfile("test", "https://test.okta.com", "sdk-token") + + // Clear any existing properties + System.clearProperty("okta.client.orgUrl") + System.clearProperty("okta.client.token") + + service.activateProfileForSdk(profile) + + assertThat System.getProperty("okta.client.orgUrl"), is("https://test.okta.com") + assertThat System.getProperty("okta.client.token"), is("sdk-token") + + // Cleanup + System.clearProperty("okta.client.orgUrl") + System.clearProperty("okta.client.token") + } + + @Test + void emptyConfigFile() { + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + File configFile = new File(File.createTempDir("profile-test-", "-dir"), "okta.yaml") + // File doesn't exist yet + + List profiles = service.listProfiles(configFile) + assertThat profiles.size(), is(0) + assertThat service.getActiveProfileName(configFile), is("default") + } + + @Test + void filePermissions() { + if (!FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) { + return // Skip on non-POSIX systems + } + + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + File configFile = new File(File.createTempDir("profile-test-", "-dir"), "okta.yaml") + + service.saveProfile(configFile, new OktaProfile("test", "https://test.okta.com", "token"), true) + + assertThat Files.getPosixFilePermissions(configFile.toPath()), is([ + PosixFilePermission.OWNER_READ, + PosixFilePermission.OWNER_WRITE] as Set) + } + + @Test + void profileNotFound() { + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + File configFile = new File(File.createTempDir("profile-test-", "-dir"), "okta.yaml") + service.saveProfile(configFile, new OktaProfile("default", "https://test.okta.com", "token"), true) + + Optional profile = service.getProfile(configFile, "nonexistent") + assertThat profile.isPresent(), is(false) + } + + @Test(expectedExceptions = IllegalArgumentException.class) + void setActiveProfileNotFound() { + DefaultProfileConfigurationService service = new DefaultProfileConfigurationService() + File configFile = new File(File.createTempDir("profile-test-", "-dir"), "okta.yaml") + service.saveProfile(configFile, new OktaProfile("default", "https://test.okta.com", "token"), true) + + service.setActiveProfile(configFile, "nonexistent") + } +}