Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
31296d2
fix: add thread-safe async locking to ProfileConnectionSource
joshsmithxrm Jan 2, 2026
2c6b58f
fix: use ConcurrentBag for thread-safe provider tracking
joshsmithxrm Jan 2, 2026
22e9261
fix: properly unregister MSAL cache in InteractiveBrowserCredentialPr…
joshsmithxrm Jan 2, 2026
9b44a31
fix: properly unregister MSAL cache in GlobalDiscoveryService
joshsmithxrm Jan 2, 2026
3dc668b
fix: remove redundant CancellationTokenSource from Program.cs
joshsmithxrm Jan 2, 2026
7df3c9c
fix: use ConcurrentDictionary for entity type code cache
joshsmithxrm Jan 2, 2026
3560286
refactor: extract duplicate ParseBypassPlugins to DataCommandGroup
joshsmithxrm Jan 2, 2026
52bf3bb
chore: suppress NU1702 warning for cross-framework PPDS.Plugins refer…
joshsmithxrm Jan 2, 2026
24b0f6f
fix: remove NU1903 vulnerability warning suppression
joshsmithxrm Jan 2, 2026
e726a2d
fix: use X509CertificateLoader for .NET 9+ certificate loading
joshsmithxrm Jan 2, 2026
9feb599
fix: suppress SYSLIB0014 warning for ServicePointManager
joshsmithxrm Jan 2, 2026
8308af0
fix: use async/await in thread safety tests (xUnit1031)
joshsmithxrm Jan 2, 2026
77def06
fix: address 11 code review findings from #80
joshsmithxrm Jan 2, 2026
1be65e9
fix: add AuthenticationOutput for configurable auth messaging (#80)
joshsmithxrm Jan 2, 2026
b74e145
fix: address remaining issue 71 code review findings (8, 12)
joshsmithxrm Jan 2, 2026
88fc7da
refactor: extract phase processors from TieredImporter (#18)
joshsmithxrm Jan 2, 2026
60c9374
fix: address PR review feedback (issues 1, 3, 4)
joshsmithxrm Jan 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions src/PPDS.Auth/AuthenticationOutput.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using System;

namespace PPDS.Auth;

/// <summary>
/// Controls authentication status output for the auth library.
/// </summary>
/// <remarks>
/// By default, authentication status messages are written to the console.
/// Library consumers who don't want console output can set <see cref="Writer"/> to null
/// or provide a custom action to redirect output.
/// </remarks>
public static class AuthenticationOutput
{
private static volatile Action<string>? _writer = Console.WriteLine;

/// <summary>
/// Gets or sets the action used to write authentication status messages.
/// Set to null to suppress all output, or provide a custom action to redirect.
/// Default: Console.WriteLine
/// </summary>
/// <example>
/// <code>
/// // Suppress all auth output
/// AuthenticationOutput.Writer = null;
///
/// // Redirect to a logger
/// AuthenticationOutput.Writer = message => logger.LogInformation(message);
/// </code>
/// </example>
public static Action<string>? Writer
{
get => _writer;
set => _writer = value;
}

/// <summary>
/// Writes a status message using the configured writer.
/// Does nothing if Writer is null.
/// </summary>
/// <param name="message">The message to write.</param>
internal static void WriteLine(string message = "")
{
_writer?.Invoke(message);
}

/// <summary>
/// Resets the writer to the default (Console.WriteLine).
/// </summary>
public static void Reset()
{
_writer = Console.WriteLine;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,17 @@ private void LoadCertificate()
{
var flags = X509KeyStorageFlags.MachineKeySet | X509KeyStorageFlags.PersistKeySet;

#if NET9_0_OR_GREATER
// Use X509CertificateLoader for .NET 9+ (X509Certificate2 constructors are obsolete)
_certificate = X509CertificateLoader.LoadPkcs12FromFile(
_certificatePath,
_certificatePassword,
flags);
#else
_certificate = string.IsNullOrEmpty(_certificatePassword)
? new X509Certificate2(_certificatePath, (string?)null, flags)
: new X509Certificate2(_certificatePath, _certificatePassword, flags);
#endif

if (!_certificate.HasPrivateKey)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@ private string BuildConnectionString(string environmentUrl)
// Add store location if not default
if (_storeLocation != StoreLocation.CurrentUser)
{
builder.Append($"StoreName={_storeLocation};");
builder.Append($"StoreLocation={_storeLocation};");
}

// Add authority for non-public clouds
Expand Down
40 changes: 40 additions & 0 deletions src/PPDS.Auth/Credentials/CredentialProviderFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ public static class CredentialProviderFactory
/// <param name="profile">The auth profile.</param>
/// <param name="deviceCodeCallback">Optional callback for device code display (for DeviceCode auth in headless mode).</param>
/// <returns>A credential provider for the profile's auth method.</returns>
/// <exception cref="ArgumentNullException">If profile is null.</exception>
/// <exception cref="ArgumentException">If required profile fields are missing for the auth method.</exception>
/// <exception cref="NotSupportedException">If the auth method is not supported.</exception>
public static ICredentialProvider Create(
AuthProfile profile,
Expand All @@ -22,6 +24,9 @@ public static ICredentialProvider Create(
if (profile == null)
throw new ArgumentNullException(nameof(profile));

// Validate required fields based on auth method
ValidateRequiredFields(profile);

return profile.AuthMethod switch
{
AuthMethod.InteractiveBrowser => InteractiveBrowserCredentialProvider.FromProfile(profile),
Expand All @@ -40,6 +45,41 @@ public static ICredentialProvider Create(
};
}

/// <summary>
/// Validates that required fields are present for the profile's auth method.
/// </summary>
private static void ValidateRequiredFields(AuthProfile profile)
{
switch (profile.AuthMethod)
{
case AuthMethod.GitHubFederated:
case AuthMethod.AzureDevOpsFederated:
RequireField(profile.ApplicationId, nameof(profile.ApplicationId), profile.AuthMethod);
RequireField(profile.TenantId, nameof(profile.TenantId), profile.AuthMethod);
break;

case AuthMethod.UsernamePassword:
RequireField(profile.Username, nameof(profile.Username), profile.AuthMethod);
RequireField(profile.Password, nameof(profile.Password), profile.AuthMethod);
break;

// Other auth methods validate in their FromProfile methods
}
}

/// <summary>
/// Throws if a required field is null or empty.
/// </summary>
private static void RequireField(string? value, string fieldName, AuthMethod authMethod)
{
if (string.IsNullOrWhiteSpace(value))
{
throw new ArgumentException(
$"Profile field '{fieldName}' is required for {authMethod} authentication.",
fieldName);
}
}

/// <summary>
/// Creates the appropriate interactive provider based on environment.
/// Uses browser authentication by default, falls back to device code for headless environments.
Expand Down
32 changes: 17 additions & 15 deletions src/PPDS.Auth/Credentials/DeviceCodeCredentialProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -184,19 +184,15 @@ private async Task<string> GetTokenAsync(string environmentUrl, bool forceIntera
}
else
{
// Default console output
Console.WriteLine();
Console.WriteLine("To sign in, use a web browser to open the page:");
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($" {deviceCodeResult.VerificationUrl}");
Console.ResetColor();
Console.WriteLine();
Console.WriteLine("Enter the code:");
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" {deviceCodeResult.UserCode}");
Console.ResetColor();
Console.WriteLine();
Console.WriteLine("Waiting for authentication...");
// Default output via AuthenticationOutput (can be redirected or suppressed)
AuthenticationOutput.WriteLine();
AuthenticationOutput.WriteLine("To sign in, use a web browser to open the page:");
AuthenticationOutput.WriteLine($" {deviceCodeResult.VerificationUrl}");
AuthenticationOutput.WriteLine();
AuthenticationOutput.WriteLine("Enter the code:");
AuthenticationOutput.WriteLine($" {deviceCodeResult.UserCode}");
AuthenticationOutput.WriteLine();
AuthenticationOutput.WriteLine("Waiting for authentication...");
}
return Task.CompletedTask;
})
Expand All @@ -205,8 +201,8 @@ private async Task<string> GetTokenAsync(string environmentUrl, bool forceIntera

if (_deviceCodeCallback == null)
{
Console.WriteLine($"Authenticated as: {_cachedResult.Account.Username}");
Console.WriteLine();
AuthenticationOutput.WriteLine($"Authenticated as: {_cachedResult.Account.Username}");
AuthenticationOutput.WriteLine();
}

return _cachedResult.AccessToken;
Expand Down Expand Up @@ -262,6 +258,12 @@ public void Dispose()
if (_disposed)
return;

// Unregister cache before disposal to release file locks
if (_cacheHelper != null && _msalClient != null)
{
_cacheHelper.UnregisterCache(_msalClient.UserTokenCache);
}

_disposed = true;
}
}
Expand Down
14 changes: 10 additions & 4 deletions src/PPDS.Auth/Credentials/InteractiveBrowserCredentialProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,8 +205,8 @@ private async Task<string> GetTokenAsync(string environmentUrl, bool forceIntera
}

// Interactive browser authentication
Console.WriteLine();
Console.WriteLine("Opening browser for authentication...");
AuthenticationOutput.WriteLine();
AuthenticationOutput.WriteLine("Opening browser for authentication...");

try
{
Expand All @@ -222,8 +222,8 @@ private async Task<string> GetTokenAsync(string environmentUrl, bool forceIntera
throw new OperationCanceledException("Authentication was canceled by the user.", ex);
}

Console.WriteLine($"Authenticated as: {_cachedResult.Account.Username}");
Console.WriteLine();
AuthenticationOutput.WriteLine($"Authenticated as: {_cachedResult.Account.Username}");
AuthenticationOutput.WriteLine();

return _cachedResult.AccessToken;
}
Expand Down Expand Up @@ -278,6 +278,12 @@ public void Dispose()
if (_disposed)
return;

// Unregister cache helper to release file locks on token cache
if (_cacheHelper != null && _msalClient != null)
{
_cacheHelper.UnregisterCache(_msalClient.UserTokenCache);
}

_disposed = true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,12 @@ public void Dispose()
if (_disposed)
return;

// Unregister cache before disposal to release file locks
if (_cacheHelper != null && _msalClient != null)
{
_cacheHelper.UnregisterCache(_msalClient.UserTokenCache);
}

_disposed = true;
}
}
50 changes: 23 additions & 27 deletions src/PPDS.Auth/Discovery/GlobalDiscoveryService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,12 @@ public async Task<IReadOnlyList<DiscoveredEnvironment>> DiscoverEnvironmentsAsyn
var environments = new List<DiscoveredEnvironment>();
foreach (var org in organizations)
{
// Get the web API endpoint from the Endpoints dictionary
string apiUrl = string.Empty;
// Get the web application URL - used for both API connections and web interface
// In Dataverse, the WebApplication endpoint is the base URL (e.g., https://org.crm.dynamics.com)
string? baseUrl = null;
if (org.Endpoints.TryGetValue(Microsoft.Xrm.Sdk.Discovery.EndpointType.WebApplication, out var webAppUrl))
{
apiUrl = webAppUrl;
}

// Get the application URL
string? appUrl = null;
if (org.Endpoints.TryGetValue(Microsoft.Xrm.Sdk.Discovery.EndpointType.WebApplication, out var webUrl))
{
appUrl = webUrl;
baseUrl = webAppUrl;
}

// Parse TenantId if present (it may be a string or Guid depending on version)
Expand All @@ -122,8 +116,8 @@ public async Task<IReadOnlyList<DiscoveredEnvironment>> DiscoverEnvironmentsAsyn
FriendlyName = org.FriendlyName,
UniqueName = org.UniqueName,
UrlName = org.UrlName,
ApiUrl = apiUrl,
Url = appUrl,
ApiUrl = baseUrl ?? string.Empty,
Url = baseUrl,
State = (int)org.State,
Version = org.OrganizationVersion,
Region = org.Geo,
Expand Down Expand Up @@ -174,7 +168,7 @@ private Func<string, Task<string>> CreateTokenProviderFunction(
{
if (_deviceCodeCallback == null)
{
Console.WriteLine("Opening browser for authentication...");
AuthenticationOutput.WriteLine("Opening browser for authentication...");
}

result = await _msalClient!
Expand All @@ -198,18 +192,14 @@ private Func<string, Task<string>> CreateTokenProviderFunction(
}
else
{
Console.WriteLine();
Console.WriteLine("To sign in, use a web browser to open the page:");
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($" {deviceCodeResult.VerificationUrl}");
Console.ResetColor();
Console.WriteLine();
Console.WriteLine("Enter the code:");
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" {deviceCodeResult.UserCode}");
Console.ResetColor();
Console.WriteLine();
Console.WriteLine("Waiting for authentication...");
AuthenticationOutput.WriteLine();
AuthenticationOutput.WriteLine("To sign in, use a web browser to open the page:");
AuthenticationOutput.WriteLine($" {deviceCodeResult.VerificationUrl}");
AuthenticationOutput.WriteLine();
AuthenticationOutput.WriteLine("Enter the code:");
AuthenticationOutput.WriteLine($" {deviceCodeResult.UserCode}");
AuthenticationOutput.WriteLine();
AuthenticationOutput.WriteLine("Waiting for authentication...");
}
return Task.CompletedTask;
})
Expand All @@ -219,8 +209,8 @@ private Func<string, Task<string>> CreateTokenProviderFunction(

if (_deviceCodeCallback == null)
{
Console.WriteLine($"Authenticated as: {result.Account.Username}");
Console.WriteLine();
AuthenticationOutput.WriteLine($"Authenticated as: {result.Account.Username}");
AuthenticationOutput.WriteLine();
}

return result.AccessToken;
Expand Down Expand Up @@ -278,6 +268,12 @@ public void Dispose()
if (_disposed)
return;

// Unregister cache helper to release file locks on token cache
if (_cacheHelper != null && _msalClient != null)
{
_cacheHelper.UnregisterCache(_msalClient.UserTokenCache);
}

_disposed = true;
}
}
Loading
Loading