Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
60 changes: 59 additions & 1 deletion DataverseConnection.Tests/DataverseCredentialFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,73 @@ public void Create_WrapsInteractiveCredentials_WithPersistentCache_ByDefault(
{
var options = new DataverseOptions
{
DataverseUrl = "https://org.crm4.dynamics.com",
CredentialType = credentialType
};

var credential = DataverseCredentialFactory.Create(options);
var credential = DataverseCredentialFactory.Create(options, options.DataverseUrl);

// The interactive credentials are wrapped so tokens persist across runs by default.
Assert.IsType<PersistentAuthCredential>(credential);
}

[Fact]
public void Create_UsesSamePersistentCredentialKey_ForSameEnvironmentUrl()
{
var first = Assert.IsType<PersistentAuthCredential>(
DataverseCredentialFactory.Create(
new DataverseOptions(),
"https://ORG.crm4.dynamics.com/"));
var second = Assert.IsType<PersistentAuthCredential>(
DataverseCredentialFactory.Create(
new DataverseOptions(),
"https://org.crm4.dynamics.com/some/path"));

Assert.Equal(first.PersistenceKey, second.PersistenceKey);
}

[Fact]
public void Create_UsesDifferentPersistentCredentialKeys_ForDifferentEnvironmentUrls()
{
var first = Assert.IsType<PersistentAuthCredential>(
DataverseCredentialFactory.Create(
new DataverseOptions(),
"https://first.crm4.dynamics.com"));
var second = Assert.IsType<PersistentAuthCredential>(
DataverseCredentialFactory.Create(
new DataverseOptions(),
"https://second.crm4.dynamics.com"));

Assert.NotEqual(first.PersistenceKey, second.PersistenceKey);
}

[Fact]
public void Create_UsesDifferentPersistentCredentialKeys_ForDifferentCredentialTypes()
{
const string url = "https://org.crm4.dynamics.com";
var browser = Assert.IsType<PersistentAuthCredential>(
DataverseCredentialFactory.Create(new DataverseOptions(), url));
var deviceCode = Assert.IsType<PersistentAuthCredential>(
DataverseCredentialFactory.Create(
new DataverseOptions { CredentialType = DataverseCredentialType.DeviceCodeCredential },
url));

Assert.NotEqual(browser.PersistenceKey, deviceCode.PersistenceKey);
}

[Theory]
[InlineData(DataverseCredentialType.DeviceCodeCredential)]
[InlineData(DataverseCredentialType.InteractiveBrowserCredential)]
public void Create_RequiresUrl_ForDefaultPersistentCredentials(DataverseCredentialType credentialType)
{
var options = new DataverseOptions { CredentialType = credentialType };

var exception = Assert.Throws<InvalidOperationException>(
() => DataverseCredentialFactory.Create(options));

Assert.Contains("DataverseUrl", exception.Message);
}

[Fact]
public void Create_UsesExplicitTokenCredentialBeforeSelectedType()
{
Expand Down
12 changes: 9 additions & 3 deletions DataverseConnection/Internal/DataverseCredentialFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ namespace DataverseConnection.Internal
/// </summary>
internal static class DataverseCredentialFactory
{
public static TokenCredential Create(DataverseOptions options)
public static TokenCredential Create(DataverseOptions options, string? dataverseUrl = null)
{
ArgumentNullException.ThrowIfNull(options);

Expand All @@ -27,12 +27,12 @@ options.AzureCliCredentialOptions is null
// as rarely as possible. Caller-supplied options are respected as-is.
DataverseCredentialType.DeviceCodeCredential =>
options.DeviceCodeCredentialOptions is null
? PersistentCredentialCache.CreateDeviceCode()
? PersistentCredentialCache.CreateDeviceCode(RequireDataverseUrl(dataverseUrl))
: new DeviceCodeCredential(options.DeviceCodeCredentialOptions),

DataverseCredentialType.InteractiveBrowserCredential =>
options.InteractiveBrowserCredentialOptions is null
? PersistentCredentialCache.CreateInteractiveBrowser()
? PersistentCredentialCache.CreateInteractiveBrowser(RequireDataverseUrl(dataverseUrl))
: new InteractiveBrowserCredential(options.InteractiveBrowserCredentialOptions),

_ => throw new ArgumentOutOfRangeException(
Expand All @@ -41,5 +41,11 @@ options.InteractiveBrowserCredentialOptions is null
"Unsupported Dataverse credential type.")
};
}

private static string RequireDataverseUrl(string? dataverseUrl) =>
!string.IsNullOrWhiteSpace(dataverseUrl)
? dataverseUrl
: throw new InvalidOperationException(
"DataverseUrl is required to create a persistent interactive credential.");
}
}
29 changes: 23 additions & 6 deletions DataverseConnection/Internal/PersistentCredentialCache.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Azure.Core;
Expand Down Expand Up @@ -30,13 +32,13 @@ internal static class PersistentCredentialCache
/// <summary>
/// Creates an <see cref="InteractiveBrowserCredential"/> with persistent token caching.
/// </summary>
public static TokenCredential CreateInteractiveBrowser()
public static TokenCredential CreateInteractiveBrowser(string dataverseUrl)
{
const string key = "interactive-browser";
var key = CreatePersistenceKey("interactive-browser", dataverseUrl);
var record = TryLoadRecord(key);
var credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions
{
TokenCachePersistenceOptions = new TokenCachePersistenceOptions { Name = CacheName },
TokenCachePersistenceOptions = new TokenCachePersistenceOptions { Name = CreateCacheName(key) },
AuthenticationRecord = record,
});

Expand All @@ -51,13 +53,13 @@ record is not null,
/// <summary>
/// Creates a <see cref="DeviceCodeCredential"/> with persistent token caching.
/// </summary>
public static TokenCredential CreateDeviceCode()
public static TokenCredential CreateDeviceCode(string dataverseUrl)
{
const string key = "device-code";
var key = CreatePersistenceKey("device-code", dataverseUrl);
var record = TryLoadRecord(key);
var credential = new DeviceCodeCredential(new DeviceCodeCredentialOptions
{
TokenCachePersistenceOptions = new TokenCachePersistenceOptions { Name = CacheName },
TokenCachePersistenceOptions = new TokenCachePersistenceOptions { Name = CreateCacheName(key) },
AuthenticationRecord = record,
});

Expand All @@ -69,6 +71,19 @@ record is not null,
() => new DeviceCodeCredential());
}

internal static string CreatePersistenceKey(string credentialType, string dataverseUrl)
{
ArgumentException.ThrowIfNullOrWhiteSpace(credentialType);
ArgumentException.ThrowIfNullOrWhiteSpace(dataverseUrl);

var uri = new Uri(dataverseUrl, UriKind.Absolute);
var environment = uri.GetLeftPart(UriPartial.Authority).ToLowerInvariant();
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(environment))).ToLowerInvariant();
return $"{credentialType}-{hash}";
}

internal static string CreateCacheName(string key) => $"{CacheName}-{key}";

internal static AuthenticationRecord? TryLoadRecord(string key)
{
try
Expand Down Expand Up @@ -121,6 +136,8 @@ internal sealed class PersistentAuthCredential : TokenCredential
private volatile bool _recordEnsured;
private TokenCredential? _fallback;

internal string PersistenceKey => _key;

public PersistentAuthCredential(
TokenCredential inner,
bool hasRecord,
Expand Down
28 changes: 18 additions & 10 deletions DataverseConnection/Internal/ServiceClientBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,16 +23,7 @@ public static ServiceClient Build(
IConfiguration? configuration,
TokenCredential credential)
{
string? dataverseUrl = options.DataverseUrl;
if (string.IsNullOrWhiteSpace(dataverseUrl))
{
dataverseUrl = configuration is null
? null
: DataverseOptionsBinder.GetDataverseUrl(configuration);
}

if (string.IsNullOrWhiteSpace(dataverseUrl))
throw new InvalidOperationException("DataverseUrl must be provided via options or configuration (DataverseUrl or DATAVERSE_URL).");
var dataverseUrl = ResolveDataverseUrl(options, configuration);

var credentialCacheIdentity = CredentialCacheIdentities.GetValue(
credential,
Expand Down Expand Up @@ -75,6 +66,23 @@ async Task<string> TokenProvider(string url)
return serviceClient;
}

internal static string ResolveDataverseUrl(
DataverseOptions options,
IConfiguration? configuration)
{
ArgumentNullException.ThrowIfNull(options);

var dataverseUrl = options.DataverseUrl;
if (string.IsNullOrWhiteSpace(dataverseUrl) && configuration is not null)
dataverseUrl = DataverseOptionsBinder.GetDataverseUrl(configuration);

if (string.IsNullOrWhiteSpace(dataverseUrl))
throw new InvalidOperationException(
"DataverseUrl must be provided via options or configuration (DataverseUrl or DATAVERSE_URL).");

return dataverseUrl;
}

private sealed record CredentialCacheIdentity(long Value);
}
}
23 changes: 19 additions & 4 deletions DataverseConnection/ServiceClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ public class ServiceClientFactory : IServiceClientFactory
{
private readonly IMemoryCache _memoryCache;
private readonly IConfiguration _configuration;
private readonly TokenCredential _defaultOptionsCredential;
private readonly DataverseOptions _defaultOptions;
private readonly object _defaultCredentialGate = new();
private TokenCredential? _defaultOptionsCredential;

public ServiceClientFactory(
IMemoryCache memoryCache,
Expand All @@ -24,15 +25,17 @@ public ServiceClientFactory(
_memoryCache = memoryCache;
_configuration = configuration;
_defaultOptions = defaultOptions ?? new DataverseOptions();
_defaultOptionsCredential = Internal.DataverseCredentialFactory.Create(_defaultOptions);
}

public ServiceClient CreateClient(DataverseOptions? options = null)
{
var effectiveOptions = options ?? _defaultOptions;
var dataverseUrl = Internal.ServiceClientBuilder.ResolveDataverseUrl(
effectiveOptions,
_configuration);
var credential = options is null
? _defaultOptionsCredential
: Internal.DataverseCredentialFactory.Create(options);
? GetOrCreateDefaultCredential(dataverseUrl)
: Internal.DataverseCredentialFactory.Create(options, dataverseUrl);

return Internal.ServiceClientBuilder.Build(
effectiveOptions,
Expand All @@ -41,5 +44,17 @@ public ServiceClient CreateClient(DataverseOptions? options = null)
credential
);
}

private TokenCredential GetOrCreateDefaultCredential(string dataverseUrl)
{
if (_defaultOptionsCredential is not null)
return _defaultOptionsCredential;

lock (_defaultCredentialGate)
{
return _defaultOptionsCredential ??=
Internal.DataverseCredentialFactory.Create(_defaultOptions, dataverseUrl);
}
}
}
}
3 changes: 2 additions & 1 deletion DataverseConnection/ServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ public static IServiceCollection AddDataverse(this IServiceCollection services,
Internal.DataverseOptionsBinder.Bind(options, configuration);
configureOptions?.Invoke(options);

var credential = Internal.DataverseCredentialFactory.Create(options);
var dataverseUrl = Internal.ServiceClientBuilder.ResolveDataverseUrl(options, configuration);
var credential = Internal.DataverseCredentialFactory.Create(options, dataverseUrl);
return Internal.ServiceClientBuilder.Build(
options,
memoryCache,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ To inject a custom credential into the factory, set `DataverseOptions.TokenCrede

## Persistent token caching

For `InteractiveBrowserCredential` and `DeviceCodeCredential`, the library enables persistent token caching by default (when you do not pass your own credential-specific options). Tokens and the signed-in account are stored under `~/.dataverseconnection`, so subsequent runs — including separate CLI invocations — acquire tokens silently instead of prompting again. `AzureCliCredential` is unaffected because the `az` CLI manages its own cache.
For `InteractiveBrowserCredential` and `DeviceCodeCredential`, the library enables persistent token caching by default (when you do not pass your own credential-specific options). The cache and signed-in account are indexed by the normalized Dataverse environment URL and stored under `~/.dataverseconnection`. Separate projects that use the same environment reuse its sign-in, while a different environment gets an independent sign-in and cannot overwrite the first one. `AzureCliCredential` is unaffected because the `az` CLI manages its own cache.

The on-disk cache is encrypted using the operating system keychain (DPAPI on Windows, Keychain on macOS, **libsecret on Linux/WSL**). If encrypted storage is unavailable — common on headless Linux or WSL without libsecret — the library falls back to a non-persistent credential that prompts on every run, rather than writing tokens to disk unencrypted.

Expand Down
Loading