From 88699e56bf93ec9493b00ec935877717168cd0cf Mon Sep 17 00:00:00 2001 From: Dan Le <8456328+dan-redcupit@users.noreply.github.com> Date: Fri, 23 Jan 2026 00:00:10 -0800 Subject: [PATCH] feat: add multi-profile support for managing multiple Okta organizations This feature allows MSPs, MSSPs, and developers to manage credentials for multiple Okta organizations using named profiles, similar to AWS CLI profiles. New features: - `okta login --profile-name ` to create/update named profiles - `okta profiles list` to list all configured profiles - `okta profiles use ` to switch the active profile - `okta profiles show [name]` to display profile details - `okta profiles delete ` to remove a profile - `okta --profile ` for one-off commands with a specific profile - `OKTA_CLI_PROFILE` environment variable support Configuration format: ```yaml okta: profiles: default: orgUrl: https://dev-123456.okta.com token: 00abc... acme-corp: orgUrl: https://acme.okta.com token: 00xyz... activeProfile: default ``` Backward compatibility: - Automatically migrates legacy single-profile format on first use - Legacy format continues to be readable without migration Co-Authored-By: Claude Opus 4.5 --- .../main/java/com/okta/cli/Environment.java | 84 +++++ cli/src/main/java/com/okta/cli/OktaCli.java | 7 + .../com/okta/cli/commands/BaseCommand.java | 3 + .../java/com/okta/cli/commands/Login.java | 75 +++- .../okta/cli/commands/profiles/Profiles.java | 39 +++ .../cli/commands/profiles/ProfilesDelete.java | 81 +++++ .../cli/commands/profiles/ProfilesList.java | 88 +++++ .../cli/commands/profiles/ProfilesShow.java | 76 +++++ .../cli/commands/profiles/ProfilesUse.java | 66 ++++ .../okta/cli/common/model/OktaProfile.java | 68 ++++ .../DefaultProfileConfigurationService.java | 323 ++++++++++++++++++ .../service/ProfileConfigurationService.java | 114 +++++++ ...aultProfileConfigurationServiceTest.groovy | 209 ++++++++++++ 13 files changed, 1218 insertions(+), 15 deletions(-) create mode 100644 cli/src/main/java/com/okta/cli/commands/profiles/Profiles.java create mode 100644 cli/src/main/java/com/okta/cli/commands/profiles/ProfilesDelete.java create mode 100644 cli/src/main/java/com/okta/cli/commands/profiles/ProfilesList.java create mode 100644 cli/src/main/java/com/okta/cli/commands/profiles/ProfilesShow.java create mode 100644 cli/src/main/java/com/okta/cli/commands/profiles/ProfilesUse.java create mode 100644 common/src/main/java/com/okta/cli/common/model/OktaProfile.java create mode 100644 common/src/main/java/com/okta/cli/common/service/DefaultProfileConfigurationService.java create mode 100644 common/src/main/java/com/okta/cli/common/service/ProfileConfigurationService.java create mode 100644 common/src/test/groovy/com/okta/cli/common/service/DefaultProfileConfigurationServiceTest.groovy 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") + } +}