From 2306c71501d730f62344484df61fbe8f9f193695 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20S=C3=B8rensen?= Date: Fri, 14 Aug 2026 15:12:37 +0200 Subject: [PATCH] Index keys by url --- .../DataverseCredentialFactoryTests.cs | 60 ++++++++++++++++++- .../Internal/DataverseCredentialFactory.cs | 12 +++- .../Internal/PersistentCredentialCache.cs | 29 +++++++-- .../Internal/ServiceClientBuilder.cs | 28 +++++---- DataverseConnection/ServiceClientFactory.cs | 23 +++++-- .../ServiceCollectionExtensions.cs | 3 +- README.md | 2 +- 7 files changed, 131 insertions(+), 26 deletions(-) diff --git a/DataverseConnection.Tests/DataverseCredentialFactoryTests.cs b/DataverseConnection.Tests/DataverseCredentialFactoryTests.cs index b3336c9..9d70cde 100644 --- a/DataverseConnection.Tests/DataverseCredentialFactoryTests.cs +++ b/DataverseConnection.Tests/DataverseCredentialFactoryTests.cs @@ -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(credential); } + [Fact] + public void Create_UsesSamePersistentCredentialKey_ForSameEnvironmentUrl() + { + var first = Assert.IsType( + DataverseCredentialFactory.Create( + new DataverseOptions(), + "https://ORG.crm4.dynamics.com/")); + var second = Assert.IsType( + 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( + DataverseCredentialFactory.Create( + new DataverseOptions(), + "https://first.crm4.dynamics.com")); + var second = Assert.IsType( + 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( + DataverseCredentialFactory.Create(new DataverseOptions(), url)); + var deviceCode = Assert.IsType( + 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( + () => DataverseCredentialFactory.Create(options)); + + Assert.Contains("DataverseUrl", exception.Message); + } + [Fact] public void Create_UsesExplicitTokenCredentialBeforeSelectedType() { diff --git a/DataverseConnection/Internal/DataverseCredentialFactory.cs b/DataverseConnection/Internal/DataverseCredentialFactory.cs index 7b08bc3..cdbf986 100644 --- a/DataverseConnection/Internal/DataverseCredentialFactory.cs +++ b/DataverseConnection/Internal/DataverseCredentialFactory.cs @@ -9,7 +9,7 @@ namespace DataverseConnection.Internal /// internal static class DataverseCredentialFactory { - public static TokenCredential Create(DataverseOptions options) + public static TokenCredential Create(DataverseOptions options, string? dataverseUrl = null) { ArgumentNullException.ThrowIfNull(options); @@ -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( @@ -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."); } } diff --git a/DataverseConnection/Internal/PersistentCredentialCache.cs b/DataverseConnection/Internal/PersistentCredentialCache.cs index e342ec7..86b3641 100644 --- a/DataverseConnection/Internal/PersistentCredentialCache.cs +++ b/DataverseConnection/Internal/PersistentCredentialCache.cs @@ -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; @@ -30,13 +32,13 @@ internal static class PersistentCredentialCache /// /// Creates an with persistent token caching. /// - 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, }); @@ -51,13 +53,13 @@ record is not null, /// /// Creates a with persistent token caching. /// - 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, }); @@ -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 @@ -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, diff --git a/DataverseConnection/Internal/ServiceClientBuilder.cs b/DataverseConnection/Internal/ServiceClientBuilder.cs index 3a32628..d4599df 100644 --- a/DataverseConnection/Internal/ServiceClientBuilder.cs +++ b/DataverseConnection/Internal/ServiceClientBuilder.cs @@ -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, @@ -75,6 +66,23 @@ async Task 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); } } diff --git a/DataverseConnection/ServiceClientFactory.cs b/DataverseConnection/ServiceClientFactory.cs index 28ca1f0..75412e9 100644 --- a/DataverseConnection/ServiceClientFactory.cs +++ b/DataverseConnection/ServiceClientFactory.cs @@ -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, @@ -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, @@ -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); + } + } } } diff --git a/DataverseConnection/ServiceCollectionExtensions.cs b/DataverseConnection/ServiceCollectionExtensions.cs index 4f902b0..a661e68 100644 --- a/DataverseConnection/ServiceCollectionExtensions.cs +++ b/DataverseConnection/ServiceCollectionExtensions.cs @@ -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, diff --git a/README.md b/README.md index a70a6d0..d6a7488 100644 --- a/README.md +++ b/README.md @@ -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.