From f6d4464f3e2e5244eeb33d30d0c80a4e27d5269e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20S=C3=B8rensen?= Date: Sat, 22 Aug 2026 21:32:32 +0200 Subject: [PATCH] Fix token store on linux --- .../DataverseCredentialFactoryTests.cs | 16 +++++++++++ .../DataverseConnection.csproj | 1 + .../Internal/PersistentCredentialCache.cs | 28 +++++++++++++------ DataverseWhoAmI/DataverseWhoAmI.csproj | 1 + README.md | 6 ++-- 5 files changed, 42 insertions(+), 10 deletions(-) diff --git a/DataverseConnection.Tests/DataverseCredentialFactoryTests.cs b/DataverseConnection.Tests/DataverseCredentialFactoryTests.cs index 9d70cde..d75432a 100644 --- a/DataverseConnection.Tests/DataverseCredentialFactoryTests.cs +++ b/DataverseConnection.Tests/DataverseCredentialFactoryTests.cs @@ -74,6 +74,22 @@ public void Create_WrapsInteractiveCredentials_WithPersistentCache_ByDefault( Assert.IsType(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() { diff --git a/DataverseConnection/DataverseConnection.csproj b/DataverseConnection/DataverseConnection.csproj index 91f8966..bff61f3 100644 --- a/DataverseConnection/DataverseConnection.csproj +++ b/DataverseConnection/DataverseConnection.csproj @@ -14,6 +14,7 @@ + diff --git a/DataverseConnection/Internal/PersistentCredentialCache.cs b/DataverseConnection/Internal/PersistentCredentialCache.cs index 86b3641..38cfc66 100644 --- a/DataverseConnection/Internal/PersistentCredentialCache.cs +++ b/DataverseConnection/Internal/PersistentCredentialCache.cs @@ -11,15 +11,15 @@ namespace DataverseConnection.Internal { /// /// 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 , 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 , + /// allowing later runs to acquire tokens silently. /// /// /// 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. /// internal static class PersistentCredentialCache { @@ -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, }); @@ -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, }); @@ -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 diff --git a/DataverseWhoAmI/DataverseWhoAmI.csproj b/DataverseWhoAmI/DataverseWhoAmI.csproj index 9493000..887f458 100644 --- a/DataverseWhoAmI/DataverseWhoAmI.csproj +++ b/DataverseWhoAmI/DataverseWhoAmI.csproj @@ -18,6 +18,7 @@ + diff --git a/README.md b/README.md index d6a7488..74ac20d 100644 --- a/README.md +++ b/README.md @@ -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