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
16 changes: 16 additions & 0 deletions DataverseConnection.Tests/DataverseCredentialFactoryTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ public void Create_WrapsInteractiveCredentials_WithPersistentCache_ByDefault(
Assert.IsType<PersistentAuthCredential>(credential);
}

[Fact]
public void PersistentCache_AllowsFileFallback_OnLinux()
{
var options = PersistentCredentialCache.CreateTokenCachePersistenceOptions("test", isLinux: true);

Assert.True(options.UnsafeAllowUnencryptedStorage);
}

[Fact]
public void PersistentCache_RequiresEncryptedStorage_OnOtherOperatingSystems()
{
var options = PersistentCredentialCache.CreateTokenCachePersistenceOptions("test", isLinux: false);

Assert.False(options.UnsafeAllowUnencryptedStorage);
}

[Fact]
public void Create_UsesSamePersistentCredentialKey_ForSameEnvironmentUrl()
{
Expand Down
1 change: 1 addition & 0 deletions DataverseConnection/DataverseConnection.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.13.2" />
<PackageReference Include="Microsoft.PowerPlatform.Dataverse.Client" Version="1.2.7" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.11" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
</ItemGroup>
Expand Down
28 changes: 20 additions & 8 deletions DataverseConnection/Internal/PersistentCredentialCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ namespace DataverseConnection.Internal
{
/// <summary>
/// Builds the interactive (MSAL-based) credentials with healthy caching defaults so a user logs
/// in as rarely as possible. Tokens are persisted to an OS-encrypted store and the account is
/// remembered via a serialized <see cref="AuthenticationRecord"/>, allowing later runs to acquire
/// tokens silently.
/// in as rarely as possible. Tokens are persisted to the operating system credential store where
/// available and the account is remembered via a serialized <see cref="AuthenticationRecord"/>,
/// allowing later runs to acquire tokens silently.
/// </summary>
/// <remarks>
/// Persistent encryption relies on the OS keychain (DPAPI on Windows, Keychain on macOS,
/// libsecret on Linux). If it is unavailable — a common case on headless Linux/WSL without
/// libsecret — the credential falls back to a non-persistent one that prompts on every run,
/// rather than storing tokens unencrypted on disk.
/// libsecret on Linux). Headless Linux environments commonly have no keychain, so Linux alone
/// permits Azure Identity's unencrypted file fallback. The cache must therefore be treated as a
/// secret and made available only to the user or container that owns it.
/// </remarks>
internal static class PersistentCredentialCache
{
Expand All @@ -38,7 +38,7 @@ public static TokenCredential CreateInteractiveBrowser(string dataverseUrl)
var record = TryLoadRecord(key);
var credential = new InteractiveBrowserCredential(new InteractiveBrowserCredentialOptions
{
TokenCachePersistenceOptions = new TokenCachePersistenceOptions { Name = CreateCacheName(key) },
TokenCachePersistenceOptions = CreateTokenCachePersistenceOptions(key),
AuthenticationRecord = record,
});

Expand All @@ -59,7 +59,7 @@ public static TokenCredential CreateDeviceCode(string dataverseUrl)
var record = TryLoadRecord(key);
var credential = new DeviceCodeCredential(new DeviceCodeCredentialOptions
{
TokenCachePersistenceOptions = new TokenCachePersistenceOptions { Name = CreateCacheName(key) },
TokenCachePersistenceOptions = CreateTokenCachePersistenceOptions(key),
AuthenticationRecord = record,
});

Expand All @@ -84,6 +84,18 @@ internal static string CreatePersistenceKey(string credentialType, string datave

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

internal static TokenCachePersistenceOptions CreateTokenCachePersistenceOptions(
string key,
bool? isLinux = null) => new()
{
Name = CreateCacheName(key),

// Containers and other headless Linux hosts generally do not provide libsecret. Azure
// Identity still prefers libsecret when it is available; this only permits its
// file-based fallback so browser and device-code sessions survive process restarts.
UnsafeAllowUnencryptedStorage = isLinux ?? OperatingSystem.IsLinux(),
};

internal static AuthenticationRecord? TryLoadRecord(string key)
{
try
Expand Down
1 change: 1 addition & 0 deletions DataverseWhoAmI/DataverseWhoAmI.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<ItemGroup>
<PackageReference Include="Azure.Identity" Version="1.13.2" />
<PackageReference Include="Microsoft.PowerPlatform.Dataverse.Client" Version="1.2.7" />
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.11" />
<PackageReference Include="DataverseConnection" Version="*" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,11 @@ 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). 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.
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. 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.
The cache uses the operating system keychain when one is available (DPAPI on Windows, Keychain on macOS, and **libsecret on Linux/WSL**). Because containers and other headless Linux environments commonly have no `libsecret`, Linux also permits Azure Identity's unencrypted file-based fallback. Windows and macOS continue to require encrypted storage.

> **Linux/container security:** The Linux fallback contains reusable authentication tokens and must be treated as a secret. Run the container as a dedicated non-root user, do not share its home directory, and restrict any mounted cache volume to that user. To keep the login across container replacements, persist the user's home-directory cache data (including `~/.IdentityService` and `~/.dataverseconnection`) in a private volume.

## Configuration

Expand Down
Loading