diff --git a/src/dvx.Tests/ConfigLoaderTests.cs b/src/dvx.Tests/ConfigLoaderTests.cs index dc0479c..f6b0972 100644 --- a/src/dvx.Tests/ConfigLoaderTests.cs +++ b/src/dvx.Tests/ConfigLoaderTests.cs @@ -1,5 +1,6 @@ using System.Text.Json; using dvx.Config; +using dvx.Models; using Shouldly; using Xunit; @@ -406,6 +407,99 @@ public void ResolveEnvironmentConfig_NoEnvAndNoConfig_ThrowsClearMessage() ex.Message.ShouldContain("--env"); } + // ── auth type ────────────────────────────────────────────────────────── + + [Fact] + public void ResolveEnvironmentConfig_InteractiveFlag_WinsOverConfigAuthType() + { + var path = WriteConfig(""" + { + "environments": [ + { "name": "dev", "url": "https://dev.crm.dynamics.com", + "clientId": "c1", "clientSecret": "s1" } + ] + } + """); + var config = ConfigLoader.TryLoad(path); + + var env = ConfigLoader.ResolveEnvironmentConfig( + "dev", config, null, null, null, cliInteractiveAuth: true); + + env.AuthType.ShouldBe(DataverseAuthType.Interactive); + } + + [Fact] + public void ResolveEnvironmentConfig_Interactive_RequiresOnlyUrl() + { + var env = ConfigLoader.ResolveEnvironmentConfig( + envName: null, config: null, + cliUrl: "https://org.crm.dynamics.com", + cliClientId: null, cliClientSecret: null, + cliInteractiveAuth: true); + + env.AuthType.ShouldBe(DataverseAuthType.Interactive); + env.Url.ShouldBe("https://org.crm.dynamics.com"); + } + + [Fact] + public void ResolveEnvironmentConfig_Interactive_NoUrl_ThrowsNamingUrl() + { + var ex = Should.Throw(() => + ConfigLoader.ResolveEnvironmentConfig( + envName: null, config: null, + cliUrl: null, cliClientId: null, cliClientSecret: null, + cliInteractiveAuth: true)); + + ex.Message.ShouldContain("--url"); + } + + [Fact] + public void ResolveEnvironmentConfig_Interactive_FromConfigAuthType_NeedsNoSecret() + { + var path = WriteConfig(""" + { + "environments": [ + { "name": "dev", "url": "https://dev.crm.dynamics.com", "authType": "interactive" } + ] + } + """); + var config = ConfigLoader.TryLoad(path); + + var env = ConfigLoader.ResolveEnvironmentConfig("dev", config, null, null, null); + + env.AuthType.ShouldBe(DataverseAuthType.Interactive); + env.Url.ShouldBe("https://dev.crm.dynamics.com"); + } + + [Fact] + public void Load_AuthType_ParsesCaseInsensitively() + { + var path = WriteConfig(""" + { + "environments": [ + { "name": "dev", "url": "https://dev.crm.dynamics.com", "authType": "Interactive" } + ] + } + """); + + ConfigLoader.TryLoad(path)!.Environments[0].AuthType.ShouldBe(DataverseAuthType.Interactive); + } + + [Fact] + public void Load_AuthType_DefaultsToClientSecret_WhenOmitted() + { + var path = WriteConfig(""" + { + "environments": [ + { "name": "dev", "url": "https://dev.crm.dynamics.com", + "clientId": "c1", "clientSecret": "s1" } + ] + } + """); + + ConfigLoader.TryLoad(path)!.Environments[0].AuthType.ShouldBe(DataverseAuthType.ClientSecret); + } + // ── webResources ─────────────────────────────────────────────────────── [Fact] diff --git a/src/dvx.Tests/DataverseClientFactoryTests.cs b/src/dvx.Tests/DataverseClientFactoryTests.cs new file mode 100644 index 0000000..995e9b6 --- /dev/null +++ b/src/dvx.Tests/DataverseClientFactoryTests.cs @@ -0,0 +1,75 @@ +using dvx.Models; +using dvx.Services; +using Shouldly; +using Xunit; + +namespace dvx.Tests +{ + public class DataverseClientFactoryTests + { + [Fact] + public void ClientSecretConnectionString_ReturnsCorrectFormat() + { + var env = new EnvironmentConfig + { + Url = "https://test.crm.dynamics.com", + ClientId = "my-client-id", + ClientSecret = "my-secret" + }; + + var connStr = DataverseClientFactory.ClientSecretConnectionString(env); + + connStr.ShouldContain("AuthType=ClientSecret"); + connStr.ShouldContain("Url=https://test.crm.dynamics.com"); + connStr.ShouldContain("ClientId=my-client-id"); + connStr.ShouldContain("ClientSecret=my-secret"); + } + + [Fact] + public void InteractiveConnectionString_ReturnsCorrectFormat() + { + var url = "https://test.crm.dynamics.com"; + + var connStr = DataverseClientFactory.InteractiveConnectionString(url); + + connStr.ShouldContain("AuthType=OAuth"); + connStr.ShouldContain($"Url={url}"); + connStr.ShouldContain("AppId=51f81489-12ee-4a9e-aaae-a2591f45987d"); + connStr.ShouldContain("RedirectUri=http://localhost"); + connStr.ShouldContain("LoginPrompt=Auto"); + } + + [Fact] + public void GetConnectionString_UsesInteractive_WhenAuthTypeIsInteractive() + { + var env = new EnvironmentConfig + { + Url = "https://test.crm.dynamics.com", + AuthType = DataverseAuthType.Interactive + }; + + var connStr = DataverseClientFactory.GetConnectionString(env); + + connStr.ShouldContain("AuthType=OAuth"); + connStr.ShouldContain("Url=https://test.crm.dynamics.com"); + } + + [Fact] + public void GetConnectionString_UsesClientSecret_WhenAuthTypeIsClientSecret() + { + var env = new EnvironmentConfig + { + Url = "https://test.crm.dynamics.com", + AuthType = DataverseAuthType.ClientSecret, + ClientId = "cid", + ClientSecret = "sec" + }; + + var connStr = DataverseClientFactory.GetConnectionString(env); + + connStr.ShouldContain("AuthType=ClientSecret"); + connStr.ShouldContain("ClientId=cid"); + connStr.ShouldContain("ClientSecret=sec"); + } + } +} diff --git a/src/dvx/Commands/AdoptCommand.cs b/src/dvx/Commands/AdoptCommand.cs index f4a9f55..cfef7f6 100644 --- a/src/dvx/Commands/AdoptCommand.cs +++ b/src/dvx/Commands/AdoptCommand.cs @@ -32,9 +32,10 @@ public static Command Build(ILoggerFactory loggerFactory) "Defaults to the project's assembly name."); var dryRun = CommandOptions.DryRun(); var verbose = CommandOptions.Verbose(); + var interactiveAuth = CommandOptions.InteractiveAuth(); cmd.AddOptions(env, config, url, clientId, clientSecret, project, assemblyName, - dryRun, verbose); + dryRun, verbose, interactiveAuth); cmd.SetHandler((InvocationContext ctx) => { @@ -47,12 +48,13 @@ public static Command Build(ILoggerFactory loggerFactory) var asmName = ctx.ParseResult.GetValueForOption(assemblyName); var isDryRun = ctx.ParseResult.GetValueForOption(dryRun); var isVerbose = ctx.ParseResult.GetValueForOption(verbose); + var cliInteractive = ctx.ParseResult.GetValueForOption(interactiveAuth); try { var appConfig = ConfigLoader.TryLoad(configPath); var envConfig = ConfigLoader.ResolveEnvironmentConfig( - envName, appConfig, cliUrl, cliClientId, cliSecret); + envName, appConfig, cliUrl, cliClientId, cliSecret, cliInteractive); var resolvedProject = ConfigLoader.ResolveProject(appConfig, projectPath); using var svc = DataverseClientFactory.Create(envConfig); diff --git a/src/dvx/Commands/CreateConfigCommand.cs b/src/dvx/Commands/CreateConfigCommand.cs index f749ed5..d9488fe 100644 --- a/src/dvx/Commands/CreateConfigCommand.cs +++ b/src/dvx/Commands/CreateConfigCommand.cs @@ -15,6 +15,7 @@ public static class CreateConfigCommand { "name": "dev", "url": "https://your-dev-org.crm4.dynamics.com", + "authType": "clientSecret", "clientId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "clientSecret": "your-secret" } diff --git a/src/dvx/Commands/DeployCommand.cs b/src/dvx/Commands/DeployCommand.cs index 2d9a4e1..399db46 100644 --- a/src/dvx/Commands/DeployCommand.cs +++ b/src/dvx/Commands/DeployCommand.cs @@ -20,10 +20,11 @@ public static Command Build() var project = CommandOptions.Project(); var publisherPrefix = CommandOptions.PublisherPrefix(); var solutionUniqueName = CommandOptions.SolutionUniqueName(); + var interactiveAuth = CommandOptions.InteractiveAuth(); var verbose = CommandOptions.Verbose(); cmd.AddOptions(env, config, url, clientId, clientSecret, project, publisherPrefix, - solutionUniqueName, verbose); + solutionUniqueName, interactiveAuth, verbose); cmd.SetHandler((InvocationContext ctx) => { @@ -35,13 +36,14 @@ public static Command Build() var projectPath = ctx.ParseResult.GetValueForOption(project)!; var pubPrefix = ctx.ParseResult.GetValueForOption(publisherPrefix); var cliSolution = ctx.ParseResult.GetValueForOption(solutionUniqueName); + var cliInteractive = ctx.ParseResult.GetValueForOption(interactiveAuth); var isVerbose = ctx.ParseResult.GetValueForOption(verbose); try { var appConfig = ConfigLoader.TryLoad(configPath); var envConfig = ConfigLoader.ResolveEnvironmentConfig( - envName, appConfig, cliUrl, cliClientId, cliSecret); + envName, appConfig, cliUrl, cliClientId, cliSecret, cliInteractive); var configured = ConfigLoader.ResolveConfiguredPublisherPrefix(appConfig, pubPrefix); var solution = ConfigLoader.ResolveSolutionUniqueName(appConfig, cliSolution); var resolvedProject = ConfigLoader.ResolveProject(appConfig, projectPath); diff --git a/src/dvx/Commands/RegisterCommand.cs b/src/dvx/Commands/RegisterCommand.cs index 639270e..c06c120 100644 --- a/src/dvx/Commands/RegisterCommand.cs +++ b/src/dvx/Commands/RegisterCommand.cs @@ -25,11 +25,12 @@ public static Command Build(ILoggerFactory loggerFactory) var verbose = CommandOptions.Verbose(); var solutionUniqueName = CommandOptions.SolutionUniqueName(); var deleteOrphaned = CommandOptions.DeleteOrphanedSteps(); + var interactiveAuth = CommandOptions.InteractiveAuth(); // --project and --assembly-name are mutually exclusive; both optional — validated at runtime cmd.AddOptions(env, config, url, clientId, clientSecret, project, assemblyName, - dryRun, verbose, solutionUniqueName, deleteOrphaned); + dryRun, verbose, solutionUniqueName, deleteOrphaned, interactiveAuth); cmd.SetHandler((InvocationContext ctx) => { @@ -44,6 +45,7 @@ public static Command Build(ILoggerFactory loggerFactory) var isVerbose = ctx.ParseResult.GetValueForOption(verbose); var cliSolution = ctx.ParseResult.GetValueForOption(solutionUniqueName); var delOrphaned = ctx.ParseResult.GetValueForOption(deleteOrphaned); + var cliInteractive = ctx.ParseResult.GetValueForOption(interactiveAuth); if (!string.IsNullOrEmpty(projectPath) && !string.IsNullOrEmpty(asmName)) { @@ -57,7 +59,7 @@ public static Command Build(ILoggerFactory loggerFactory) { var appConfig = ConfigLoader.TryLoad(configPath); var envConfig = ConfigLoader.ResolveEnvironmentConfig( - envName, appConfig, cliUrl, cliClientId, cliSecret); + envName, appConfig, cliUrl, cliClientId, cliSecret, cliInteractive); var solution = ConfigLoader.ResolveSolutionUniqueName(appConfig, cliSolution); using var svc = DataverseClientFactory.Create(envConfig); diff --git a/src/dvx/Commands/Shared/CommandOptions.cs b/src/dvx/Commands/Shared/CommandOptions.cs index f45c0de..ac95ca4 100644 --- a/src/dvx/Commands/Shared/CommandOptions.cs +++ b/src/dvx/Commands/Shared/CommandOptions.cs @@ -29,6 +29,11 @@ public static class CommandOptions "Service principal client secret. " + "Overrides config. Env var: DVX_CLIENT_SECRET."); + public static Option InteractiveAuth() => new Option( + "--interactive-auth", + "Sign in interactively via the browser instead of a client secret (local dev). " + + "Token is cached securely. Equivalent to authType=interactive in config."); + public static Option Project() => new Option( "--project", "Path to the plugin .csproj file. The tool will run 'dotnet build' to produce the .nupkg and .dll. " + diff --git a/src/dvx/Commands/SyncCommand.cs b/src/dvx/Commands/SyncCommand.cs index 58bb9df..533a6f0 100644 --- a/src/dvx/Commands/SyncCommand.cs +++ b/src/dvx/Commands/SyncCommand.cs @@ -24,9 +24,10 @@ public static Command Build(ILoggerFactory loggerFactory) var verbose = CommandOptions.Verbose(); var solutionUniqueName = CommandOptions.SolutionUniqueName(); var deleteOrphaned = CommandOptions.DeleteOrphanedSteps(); + var interactiveAuth = CommandOptions.InteractiveAuth(); cmd.AddOptions(env, config, url, clientId, clientSecret, project, publisherPrefix, - dryRun, verbose, solutionUniqueName, deleteOrphaned); + dryRun, verbose, solutionUniqueName, deleteOrphaned, interactiveAuth); cmd.SetHandler((InvocationContext ctx) => { @@ -41,12 +42,13 @@ public static Command Build(ILoggerFactory loggerFactory) var isVerbose = ctx.ParseResult.GetValueForOption(verbose); var cliSolution = ctx.ParseResult.GetValueForOption(solutionUniqueName); var delOrphaned = ctx.ParseResult.GetValueForOption(deleteOrphaned); + var cliInteractive = ctx.ParseResult.GetValueForOption(interactiveAuth); try { var appConfig = ConfigLoader.TryLoad(configPath); var envConfig = ConfigLoader.ResolveEnvironmentConfig( - envName, appConfig, cliUrl, cliClientId, cliSecret); + envName, appConfig, cliUrl, cliClientId, cliSecret, cliInteractive); var configured = ConfigLoader.ResolveConfiguredPublisherPrefix(appConfig, cliPrefix); var solution = ConfigLoader.ResolveSolutionUniqueName(appConfig, cliSolution); var resolvedProject = ConfigLoader.ResolveProject(appConfig, projectPath); diff --git a/src/dvx/Commands/WebResourceSyncCommand.cs b/src/dvx/Commands/WebResourceSyncCommand.cs index 8a93bc7..8994d09 100644 --- a/src/dvx/Commands/WebResourceSyncCommand.cs +++ b/src/dvx/Commands/WebResourceSyncCommand.cs @@ -29,9 +29,10 @@ public static Command Build(ILoggerFactory loggerFactory) var deleteOrphaned = CommandOptions.DeleteOrphaned(); var dryRun = CommandOptions.DryRun(); var verbose = CommandOptions.Verbose(); + var interactiveAuth = CommandOptions.InteractiveAuth(); cmd.AddOptions(env, config, url, clientId, clientSecret, folder, manifest, publisherPrefix, - solutionUniqueName, noPublish, deleteOrphaned, dryRun, verbose); + solutionUniqueName, noPublish, deleteOrphaned, dryRun, verbose, interactiveAuth); cmd.SetHandler((InvocationContext ctx) => { @@ -49,6 +50,7 @@ public static Command Build(ILoggerFactory loggerFactory) var delOrphaned = p.GetValueForOption(deleteOrphaned); var isDryRun = p.GetValueForOption(dryRun); var isVerbose = p.GetValueForOption(verbose); + var cliInteractive = p.GetValueForOption(interactiveAuth); try { @@ -84,7 +86,7 @@ public static Command Build(ILoggerFactory loggerFactory) } var envConfig = ConfigLoader.ResolveEnvironmentConfig( - envName, appConfig, cliUrl, cliClientId, cliSecret); + envName, appConfig, cliUrl, cliClientId, cliSecret, cliInteractive); using var svc = DataverseClientFactory.Create(envConfig); var desired = new List(); diff --git a/src/dvx/Config/ConfigLoader.cs b/src/dvx/Config/ConfigLoader.cs index 20eaf6f..37c85e9 100644 --- a/src/dvx/Config/ConfigLoader.cs +++ b/src/dvx/Config/ConfigLoader.cs @@ -1,10 +1,18 @@ using System.Text.Json; +using System.Text.Json.Serialization; using dvx.Models; namespace dvx.Config { public static class ConfigLoader { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + Converters = { new JsonStringEnumConverter() }, + }; + + /// /// Returns the config file path to use: the explicit path when given, otherwise the /// nearest dvx.json found by walking up from @@ -46,8 +54,7 @@ public static class ConfigLoader return null; var json = File.ReadAllText(filePath); - var config = JsonSerializer.Deserialize(json, - new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + var config = JsonSerializer.Deserialize(json, JsonOptions); if (config is null) throw new InvalidOperationException($"Config file at '{filePath}' is empty or invalid."); @@ -74,7 +81,8 @@ public static EnvironmentConfig ResolveEnvironmentConfig( AppConfig? config, string? cliUrl, string? cliClientId, - string? cliClientSecret) + string? cliClientSecret, + bool cliInteractiveAuth = false) { // Start from config entry when an environment name is resolvable; // fall back to an empty base when all values will come from CLI/env vars. @@ -91,6 +99,7 @@ public static EnvironmentConfig ResolveEnvironmentConfig( base_ = GetEnvironment(config, resolvedName); } else if (cliUrl is null && cliClientId is null && cliClientSecret is null + && !cliInteractiveAuth && Environment.GetEnvironmentVariable("DVX_URL") is null && Environment.GetEnvironmentVariable("DVX_CLIENT_ID") is null && Environment.GetEnvironmentVariable("DVX_CLIENT_SECRET") is null) @@ -100,9 +109,12 @@ public static EnvironmentConfig ResolveEnvironmentConfig( "or provide --url / --client-id / --client-secret directly."); } + // --interactive-auth flag wins; otherwise honour the config's authType. + var authType = cliInteractiveAuth ? DataverseAuthType.Interactive : base_.AuthType; + // Layer: environment variables over base - var url = Environment.GetEnvironmentVariable("DVX_URL") ?? base_.Url; - var clientId = Environment.GetEnvironmentVariable("DVX_CLIENT_ID") ?? base_.ClientId; + var url = Environment.GetEnvironmentVariable("DVX_URL") ?? base_.Url; + var clientId = Environment.GetEnvironmentVariable("DVX_CLIENT_ID") ?? base_.ClientId; var clientSecret = Environment.GetEnvironmentVariable("DVX_CLIENT_SECRET") ?? base_.ClientSecret; // Layer: CLI args (highest priority) @@ -112,9 +124,12 @@ public static EnvironmentConfig ResolveEnvironmentConfig( // Validate var missing = new List(); - if (string.IsNullOrWhiteSpace(url)) missing.Add("--url / DVX_URL"); - if (string.IsNullOrWhiteSpace(clientId)) missing.Add("--client-id / DVX_CLIENT_ID"); - if (string.IsNullOrWhiteSpace(clientSecret)) missing.Add("--client-secret / DVX_CLIENT_SECRET"); + if (string.IsNullOrWhiteSpace(url)) missing.Add("--url / DVX_URL"); + if (authType == DataverseAuthType.ClientSecret) + { + if (string.IsNullOrWhiteSpace(clientId)) missing.Add("--client-id / DVX_CLIENT_ID"); + if (string.IsNullOrWhiteSpace(clientSecret)) missing.Add("--client-secret / DVX_CLIENT_SECRET"); + } if (missing.Count > 0) throw new InvalidOperationException( @@ -126,6 +141,7 @@ public static EnvironmentConfig ResolveEnvironmentConfig( Url = url, ClientId = clientId, ClientSecret = clientSecret, + AuthType = authType, }; } diff --git a/src/dvx/Models/EnvironmentConfig.cs b/src/dvx/Models/EnvironmentConfig.cs index dfd5b2d..adb1ab7 100644 --- a/src/dvx/Models/EnvironmentConfig.cs +++ b/src/dvx/Models/EnvironmentConfig.cs @@ -1,10 +1,13 @@ namespace dvx.Models { + public enum DataverseAuthType { ClientSecret, Interactive } + public class EnvironmentConfig { public string Name { get; set; } = string.Empty; public string Url { get; set; } = string.Empty; public string ClientId { get; set; } = string.Empty; public string ClientSecret { get; set; } = string.Empty; + public DataverseAuthType AuthType { get; set; } = DataverseAuthType.ClientSecret; } } diff --git a/src/dvx/Services/DataverseClientFactory.cs b/src/dvx/Services/DataverseClientFactory.cs index 010997f..9354b07 100644 --- a/src/dvx/Services/DataverseClientFactory.cs +++ b/src/dvx/Services/DataverseClientFactory.cs @@ -5,12 +5,16 @@ namespace dvx.Services { public static class DataverseClientFactory { + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Security", + "S6418", + Justification = "This is a public OAuth AppId, not a secret.")] + private const string OAuthAppId = "51f81489-12ee-4a9e-aaae-a2591f45987d"; + private const string OAuthRedirectUri = "http://localhost"; + public static ServiceClient Create(EnvironmentConfig env) { - var connStr = $"AuthType=ClientSecret;" + - $"Url={env.Url};" + - $"ClientId={env.ClientId};" + - $"ClientSecret={env.ClientSecret}"; + var connStr = GetConnectionString(env); var client = new ServiceClient(connStr); if (!client.IsReady) @@ -19,5 +23,26 @@ public static ServiceClient Create(EnvironmentConfig env) return client; } + + internal static string GetConnectionString(EnvironmentConfig env) => + env.AuthType == DataverseAuthType.Interactive + ? InteractiveConnectionString(env.Url) + : ClientSecretConnectionString(env); + + internal static string ClientSecretConnectionString(EnvironmentConfig env) => + $"AuthType=ClientSecret;" + + $"Url={env.Url};" + + $"ClientId={env.ClientId};" + + $"ClientSecret={env.ClientSecret}"; + + + internal static string InteractiveConnectionString(string url) + { + return $"AuthType=OAuth;" + + $"Url={url};" + + $"AppId={OAuthAppId};" + + $"RedirectUri={OAuthRedirectUri};" + + $"LoginPrompt=Auto;"; + } } } diff --git a/src/dvx/dvx.csproj b/src/dvx/dvx.csproj index 1ed1631..bebc0a1 100644 --- a/src/dvx/dvx.csproj +++ b/src/dvx/dvx.csproj @@ -7,7 +7,7 @@ true dvx dvx.cli - 1.7.1 + 1.8.0 Byron Matus CLI for deploying code-first Dataverse / Power Platform artifacts — plugin assemblies and web resources. default @@ -17,8 +17,8 @@ https://github.com/beyro/dvx-cli package-icon.png MIT - 1.7.1: -- Improve performance of sync/register by only making a AddSolutionComponent request if steps are not already part of the solution. + 1.8.0: +- Added interactive login via `authType` in config file or `--interactive-auth` flag. @@ -29,7 +29,7 @@ - +