Skip to content
Merged
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
252 changes: 252 additions & 0 deletions src/PPDS.Auth/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
# PPDS.Auth

Authentication profile management for Power Platform with encrypted credential storage and multi-cloud support.

## Installation

```bash
dotnet add package PPDS.Auth
```

## Quick Start

```csharp
// 1. Create and store a profile
var store = new ProfileStore();
var profile = new AuthProfile("dev")
{
AuthMethod = AuthMethod.InteractiveBrowser,
ClientId = "51f81489-12ee-4a9e-aaae-a2591f45987d" // Default Power Platform client
Comment on lines +18 to +19

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AuthProfile class does not have a ClientId property. For interactive browser and device code authentication methods, the provider uses a hardcoded Microsoft public client ID (51f81489-12ee-4a9e-aaae-a2591f45987d). Remove the ClientId property from these examples as it's not a valid property of AuthProfile.

Suggested change
AuthMethod = AuthMethod.InteractiveBrowser,
ClientId = "51f81489-12ee-4a9e-aaae-a2591f45987d" // Default Power Platform client
AuthMethod = AuthMethod.InteractiveBrowser

Copilot uses AI. Check for mistakes.
};
await store.SaveAsync(profile);
Comment on lines +16 to +21

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AuthProfile class does not have a constructor that accepts a string parameter. The correct initialization pattern should use property initializers without passing the name to the constructor. For example:

var profile = new AuthProfile
{
    Name = "dev",
    AuthMethod = AuthMethod.InteractiveBrowser,
    ClientId = "51f81489-12ee-4a9e-aaae-a2591f45987d"
};

This issue appears throughout the README in all code examples that create AuthProfile instances.

Copilot uses AI. Check for mistakes.

// 2. Authenticate and get a ServiceClient
var provider = CredentialProviderFactory.Create(profile);
var client = await ServiceClientFactory.CreateAsync(provider, profile);
Comment on lines +14 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The Quick Start example contains several errors that will prevent a user from getting started successfully. It appears to be based on an outdated or incorrect version of the API.

  1. new AuthProfile("dev") is not a valid constructor. AuthProfile should be initialized using an object initializer like new AuthProfile { Name = "dev" }.
  2. store.SaveAsync expects a ProfileCollection, but is called with a single AuthProfile.
  3. ServiceClientFactory.CreateAsync is not a valid static method. To get a ServiceClient, you should use provider.CreateServiceClientAsync(environmentUrl).
  4. The example is missing the environment URL, which is a required parameter.

This suggestion corrects the code to align with the library's API.

Suggested change
// 1. Create and store a profile
var store = new ProfileStore();
var profile = new AuthProfile("dev")
{
AuthMethod = AuthMethod.InteractiveBrowser,
ClientId = "51f81489-12ee-4a9e-aaae-a2591f45987d" // Default Power Platform client
};
await store.SaveAsync(profile);
// 2. Authenticate and get a ServiceClient
var provider = CredentialProviderFactory.Create(profile);
var client = await ServiceClientFactory.CreateAsync(provider, profile);
// 1. Create a profile
var profile = new AuthProfile
{
Name = "dev",
AuthMethod = AuthMethod.InteractiveBrowser,
ClientId = "51f81489-12ee-4a9e-aaae-a2591f45987d" // Default Power Platform client
};
// 2. Authenticate and get a ServiceClient
var provider = CredentialProviderFactory.Create(profile);
var client = await provider.CreateServiceClientAsync("https://your-org.crm.dynamics.com");

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ServiceClientFactory is not a static class and does not have a static CreateAsync method. The correct usage requires creating an instance first and then calling instance methods like CreateFromProfileAsync. For example:

var factory = new ServiceClientFactory();
var client = await factory.CreateFromProfileAsync(profile);

Or more typically, you would use the credential provider directly via the ICredentialProvider.CreateServiceClientAsync method.

Suggested change
var client = await ServiceClientFactory.CreateAsync(provider, profile);
var factory = new ServiceClientFactory();
var client = await factory.CreateFromProfileAsync(profile);

Copilot uses AI. Check for mistakes.
```

## Features

### Profile Storage

Profiles are stored with encrypted secrets (DPAPI on Windows):

```csharp
var store = new ProfileStore();

// Save a profile
await store.SaveAsync(profile);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The SaveAsync method on ProfileStore expects a ProfileCollection object, not a single AuthProfile. To save a new profile, you should first load the existing collection, add the new profile to it, and then save the collection.


// Load all profiles
var profiles = await store.LoadAsync();

// Get active profile
var active = profiles.GetActiveProfile();

// Set active profile
profiles.SetActive("dev");
await store.SaveAsync(profiles);
```

Default storage location: `~/.ppds/profiles.json`

### Authentication Methods

```csharp
// Interactive Browser (opens browser for OAuth)
var profile = new AuthProfile("dev")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

AuthProfile does not have a constructor that accepts a name. This should be initialized using an object initializer, e.g., new AuthProfile { Name = "dev", ... }. This issue is repeated in all subsequent examples in this section.

{
AuthMethod = AuthMethod.InteractiveBrowser,
ClientId = "51f81489-12ee-4a9e-aaae-a2591f45987d"

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AuthProfile class does not have a ClientId property. Remove this property assignment as it's not valid. The provider uses a hardcoded Microsoft public client ID for interactive browser authentication.

Copilot uses AI. Check for mistakes.
};

// Device Code (for headless/SSH environments)
var profile = new AuthProfile("dev")
{
AuthMethod = AuthMethod.DeviceCode,
ClientId = "51f81489-12ee-4a9e-aaae-a2591f45987d"
Comment on lines +66 to +67

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The AuthProfile class does not have a ClientId property. Remove this property assignment as it's not valid. The provider uses a hardcoded Microsoft public client ID for device code authentication.

Suggested change
AuthMethod = AuthMethod.DeviceCode,
ClientId = "51f81489-12ee-4a9e-aaae-a2591f45987d"
AuthMethod = AuthMethod.DeviceCode

Copilot uses AI. Check for mistakes.
};

// Client Secret (service principal)
var profile = new AuthProfile("ci")
{
AuthMethod = AuthMethod.ClientSecret,
ApplicationId = "your-app-id",
TenantId = "your-tenant-id",
ClientSecret = "your-secret" // Stored encrypted
};

// Certificate File
var profile = new AuthProfile("prod")
{
AuthMethod = AuthMethod.CertificateFile,
ApplicationId = "your-app-id",
TenantId = "your-tenant-id",
CertificatePath = "/path/to/cert.pfx",
CertificatePassword = "password" // Stored encrypted
};

// Certificate Store (Windows)
var profile = new AuthProfile("prod")
{
AuthMethod = AuthMethod.CertificateStore,
ApplicationId = "your-app-id",
TenantId = "your-tenant-id",
CertificateThumbprint = "ABC123...",
CertificateStoreLocation = StoreLocation.CurrentUser
};

// Managed Identity (Azure-hosted)
var profile = new AuthProfile("azure")
{
AuthMethod = AuthMethod.ManagedIdentity,
ManagedIdentityClientId = "user-assigned-id" // Optional for user-assigned
};

// GitHub Actions OIDC
var profile = new AuthProfile("github")
{
AuthMethod = AuthMethod.GitHubFederated,
ApplicationId = "your-app-id",
TenantId = "your-tenant-id"
};

// Azure DevOps OIDC
var profile = new AuthProfile("azdo")
{
AuthMethod = AuthMethod.AzureDevOpsFederated,
ApplicationId = "your-app-id",
TenantId = "your-tenant-id",
AzureDevOpsServiceConnectionId = "service-connection-id"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The AzureDevOpsServiceConnectionId property does not exist on the AuthProfile class. The credential provider for Azure DevOps retrieves this information from environment variables, so this line should be removed.

};
```

### Environment Discovery

Discover environments accessible to the authenticated user:

```csharp
var provider = CredentialProviderFactory.Create(profile);
var discovery = new GlobalDiscoveryService();

var environments = await discovery.GetEnvironmentsAsync(provider.GetTokenAsync);
Comment on lines +129 to +132

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This example for environment discovery is incorrect. The GlobalDiscoveryService.GetEnvironmentsAsync method does not take any parameters as it handles authentication internally. Also, the ICredentialProvider is not needed for discovery.

Suggested change
var provider = CredentialProviderFactory.Create(profile);
var discovery = new GlobalDiscoveryService();
var environments = await discovery.GetEnvironmentsAsync(provider.GetTokenAsync);
// The discovery service can be created from a profile to use its cloud/tenant settings.
var discovery = GlobalDiscoveryService.FromProfile(profile);
var environments = await discovery.GetEnvironmentsAsync();

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GlobalDiscoveryService class does not have a GetEnvironmentsAsync method. The correct method name is DiscoverEnvironmentsAsync. Update the example to use the correct method name.

Suggested change
var environments = await discovery.GetEnvironmentsAsync(provider.GetTokenAsync);
var environments = await discovery.DiscoverEnvironmentsAsync(provider.GetTokenAsync);

Copilot uses AI. Check for mistakes.

foreach (var env in environments)
{
Console.WriteLine($"{env.FriendlyName} - {env.Url}");
}
```

### Environment Resolution

Resolve environments by various identifiers:

```csharp
var resolver = new EnvironmentResolver(discovery, provider.GetTokenAsync);

// By URL
var env = await resolver.ResolveAsync("https://org.crm.dynamics.com");

// By unique name
var env = await resolver.ResolveAsync("org");

// By environment ID
var env = await resolver.ResolveAsync("00000000-0000-0000-0000-000000000000");

// By partial friendly name (fuzzy match)
var env = await resolver.ResolveAsync("Production");
Comment on lines +145 to +157

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This example for environment resolution is incorrect. EnvironmentResolver is a static class and does not have a constructor or an ResolveAsync method. You should first get a list of environments and then pass it to the static EnvironmentResolver.Resolve method.

Suggested change
var resolver = new EnvironmentResolver(discovery, provider.GetTokenAsync);
// By URL
var env = await resolver.ResolveAsync("https://org.crm.dynamics.com");
// By unique name
var env = await resolver.ResolveAsync("org");
// By environment ID
var env = await resolver.ResolveAsync("00000000-0000-0000-0000-000000000000");
// By partial friendly name (fuzzy match)
var env = await resolver.ResolveAsync("Production");
// First, get the list of available environments
var discovery = new GlobalDiscoveryService();
var environments = await discovery.GetEnvironmentsAsync();
// Then, resolve a specific environment from the list
// By URL
var envByUrl = EnvironmentResolver.Resolve(environments, "https://org.crm.dynamics.com");
// By unique name
var envByUniqueName = EnvironmentResolver.Resolve(environments, "org");
// By environment ID
var envById = EnvironmentResolver.Resolve(environments, "00000000-0000-0000-0000-000000000000");
// By partial friendly name (fuzzy match)
var envByFriendlyName = EnvironmentResolver.Resolve(environments, "Production");

Comment on lines +145 to +157

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The EnvironmentResolver.Resolve method is synchronous, not asynchronous. The examples should use Resolve instead of ResolveAsync, and remove the await keyword. For example:

var env = resolver.Resolve("https://org.crm.dynamics.com");

Note that EnvironmentResolver is also a static class, so you would call it as EnvironmentResolver.Resolve(environments, identifier) without creating an instance.

Suggested change
var resolver = new EnvironmentResolver(discovery, provider.GetTokenAsync);
// By URL
var env = await resolver.ResolveAsync("https://org.crm.dynamics.com");
// By unique name
var env = await resolver.ResolveAsync("org");
// By environment ID
var env = await resolver.ResolveAsync("00000000-0000-0000-0000-000000000000");
// By partial friendly name (fuzzy match)
var env = await resolver.ResolveAsync("Production");
// `environments` is the collection of environments returned from discovery.
// By URL
var env = EnvironmentResolver.Resolve(environments, "https://org.crm.dynamics.com");
// By unique name
var env = EnvironmentResolver.Resolve(environments, "org");
// By environment ID
var env = EnvironmentResolver.Resolve(environments, "00000000-0000-0000-0000-000000000000");
// By partial friendly name (fuzzy match)
var env = EnvironmentResolver.Resolve(environments, "Production");

Copilot uses AI. Check for mistakes.
```

### Multi-Cloud Support

```csharp
var profile = new AuthProfile("gcc")
{
Cloud = CloudEnvironment.UsGov, // GCC
// ... other settings
};

// Supported clouds:
// - CloudEnvironment.Public (default)
// - CloudEnvironment.UsGov (GCC)
// - CloudEnvironment.UsGovHigh (GCC High)
// - CloudEnvironment.UsGovDoD (DoD)

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The correct enum value is CloudEnvironment.UsGovDod (with lowercase 'od'), not UsGovDoD. Update the comment to match the actual enum value.

Suggested change
// - CloudEnvironment.UsGovDoD (DoD)
// - CloudEnvironment.UsGovDod (DoD)

Copilot uses AI. Check for mistakes.
// - CloudEnvironment.China
// - CloudEnvironment.UsNat
// - CloudEnvironment.UsSec
Comment on lines +175 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The list of supported clouds includes UsNat and UsSec, which are not defined in the CloudEnvironment enum. These should be removed to reflect the actual supported clouds.

```

Comment on lines +175 to +178

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CloudEnvironment enum does not include UsNat and UsSec values. The actual enum only contains: Public, UsGov, UsGovHigh, UsGovDod, and China. Remove the references to CloudEnvironment.UsNat and CloudEnvironment.UsSec.

Suggested change
// - CloudEnvironment.UsNat
// - CloudEnvironment.UsSec
```

Copilot uses AI. Check for mistakes.
### Integration with PPDS.Dataverse

Use profiles as a connection source for the connection pool:

```csharp
var store = new ProfileStore();
var profiles = await store.LoadAsync();
var profile = profiles.GetActiveProfile();

var provider = CredentialProviderFactory.Create(profile);
var source = new ProfileConnectionSource(profile, provider);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The constructor for ProfileConnectionSource is incorrect. It does not accept an ICredentialProvider. You should use the static factory method ProfileConnectionSource.FromProfile(profile), which requires the profile to have an environment selected.

Suggested change
var source = new ProfileConnectionSource(profile, provider);
var source = ProfileConnectionSource.FromProfile(profile);

Comment on lines +188 to +189

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ProfileConnectionSource constructor signature is incorrect in this example. The constructor requires (AuthProfile profile, string environmentUrl, int maxPoolSize = 52, Action? deviceCodeCallback = null, string? environmentDisplayName = null). The provider parameter is not part of the constructor signature. Update the example to pass the required environmentUrl parameter instead of the provider.

Suggested change
var provider = CredentialProviderFactory.Create(profile);
var source = new ProfileConnectionSource(profile, provider);
var source = new ProfileConnectionSource(profile, "https://yourorg.crm.dynamics.com");

Copilot uses AI. Check for mistakes.

services.AddDataverseConnectionPool(options =>
{
options.ConnectionSources.Add(source);
});
```

## Custom Credential Providers

Implement `ICredentialProvider` for custom authentication:

```csharp
public class CustomCredentialProvider : ICredentialProvider
{
public string Name => "Custom";
public bool RequiresInteraction => false;

public Task<string> GetTokenAsync(string resource, CancellationToken ct)
{
// Your custom token acquisition logic
return Task.FromResult("your-token");
}

public Task<TokenInfo> GetTokenInfoAsync(CancellationToken ct)
{
return Task.FromResult(new TokenInfo
{
UserPrincipalName = "user@example.com",
TenantId = "..."
});
Comment on lines +207 to +219

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ICredentialProvider interface does not have GetTokenAsync or GetTokenInfoAsync methods. The actual interface requires implementing CreateServiceClientAsync method and properties like Identity, TokenExpiresAt, TenantId, ObjectId, AccessToken, and IdTokenClaims. Update this example to reflect the actual interface definition.

Suggested change
public Task<string> GetTokenAsync(string resource, CancellationToken ct)
{
// Your custom token acquisition logic
return Task.FromResult("your-token");
}
public Task<TokenInfo> GetTokenInfoAsync(CancellationToken ct)
{
return Task.FromResult(new TokenInfo
{
UserPrincipalName = "user@example.com",
TenantId = "..."
});
// Required properties
public string Identity { get; private set; } = "user@example.com";
public DateTimeOffset TokenExpiresAt { get; private set; } = DateTimeOffset.UtcNow.AddHours(1);
public string TenantId { get; private set; } = "00000000-0000-0000-0000-000000000000";
public string ObjectId { get; private set; } = "11111111-1111-1111-1111-111111111111";
public string AccessToken { get; private set; } = "your-access-token";
public IDictionary<string, object> IdTokenClaims { get; private set; } =
new Dictionary<string, object>
{
["name"] = "Example User",
["preferred_username"] = "user@example.com"
};
// Required method
public Task<object> CreateServiceClientAsync(CancellationToken cancellationToken = default)
{
// TODO: Replace 'object' with your actual service client type and
// implement the logic to create and return an authenticated client.
//
// For example:
// var client = new MyServiceClient(new Uri("https://example.service/"), AccessToken);
// return Task.FromResult<object>(client);
return Task.FromResult<object>(null!);

Copilot uses AI. Check for mistakes.
}
}
```
Comment on lines +201 to +222

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

The example for implementing ICredentialProvider is incorrect and does not match the interface definition. The ICredentialProvider interface requires implementing properties like AuthMethod and the method CreateServiceClientAsync, not GetTokenAsync or GetTokenInfoAsync.

A correct, minimal implementation would look something like this:

public class CustomCredentialProvider : ICredentialProvider
{
    public AuthMethod AuthMethod => AuthMethod.InteractiveBrowser; // Or a custom value if you extend the enum
    public string? Identity { get; private set; }
    public DateTimeOffset? TokenExpiresAt { get; private set; }
    public string? TenantId { get; private set; }
    public string? ObjectId { get; private set; }
    public string? AccessToken { get; private set; }
    public System.Security.Claims.ClaimsPrincipal? IdTokenClaims { get; private set; }

    public async Task<ServiceClient> CreateServiceClientAsync(
        string environmentUrl,
        CancellationToken cancellationToken = default,
        bool forceInteractive = false)
    {
        // Your custom token acquisition logic to get a token string
        var token = "your-token";
        this.AccessToken = token;
        this.Identity = "user@example.com";

        var client = new ServiceClient(
            new Uri(environmentUrl),
            _ => Task.FromResult(token),
            useUniqueInstance: true);

        if (!client.IsReady)
        {
            throw new Exception("Failed to connect");
        }
        return client;
    }

    public void Dispose() { /* Cleanup */ }
}


## Security

### Credential Encryption

Secrets (client secrets, passwords, etc.) are encrypted at rest:
- **Windows**: DPAPI (user-scoped encryption)
- **Linux/macOS**: Base64 encoding (use OS-level file permissions)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Describing the non-Windows encryption as 'Base64 encoding' is misleading. ProfileEncryption.cs shows it uses XOR obfuscation with a machine-specific key, and then Base64 encodes the result. It would be more accurate to describe this as 'simple obfuscation' to avoid giving the impression that secrets are stored in plain Base64.

Suggested change
- **Linux/macOS**: Base64 encoding (use OS-level file permissions)
- **Linux/macOS**: Simple obfuscation (use OS-level file permissions)

Comment on lines +226 to +230

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The statement that secrets are "encrypted at rest" on Linux/macOS using Base64 is misleading given the current implementation: ProfileEncryption only applies simple XOR-based obfuscation with a machine-derived key and Base64-encodes the result, which is not cryptographically secure. An attacker who can obtain the profile file (e.g., via backup leakage or local file read) can deterministically recover client secrets and passwords, enabling service principal compromise. Consider using real encryption backed by platform keychains (e.g., libsecret/Keychain) or a strong key management scheme, and update the documentation to accurately describe the protection guarantees until this is in place.

Suggested change
### Credential Encryption
Secrets (client secrets, passwords, etc.) are encrypted at rest:
- **Windows**: DPAPI (user-scoped encryption)
- **Linux/macOS**: Base64 encoding (use OS-level file permissions)
### Credential Storage & Protection
Secrets (client secrets, passwords, etc.) are stored at rest as follows:
- **Windows**: DPAPI (user-scoped encryption)
- **Linux/macOS**: Lightweight obfuscation (XOR with a machine-derived key and Base64). This is **not** cryptographically secure; anyone who can read the profile file can recover the secrets. Rely on strict OS-level file permissions and treat the profile file as sensitive.

Copilot uses AI. Check for mistakes.

### Token Caching

MSAL token caching is used for interactive and device code flows:

```csharp
var profile = new AuthProfile("dev")
{
TokenCacheType = TokenCacheType.Persistent // Default
// TokenCacheType = TokenCacheType.InMemory // Per-process only
Comment on lines +239 to +240

Copilot AI Dec 29, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TokenCacheType enum values shown in the example are incorrect. The actual enum values are: OperatingSystem, File, and Memory (not Persistent and InMemory). Update the example to use the correct enum values.

Suggested change
TokenCacheType = TokenCacheType.Persistent // Default
// TokenCacheType = TokenCacheType.InMemory // Per-process only
TokenCacheType = TokenCacheType.OperatingSystem // Default
// TokenCacheType = TokenCacheType.Memory // Per-process only

Copilot uses AI. Check for mistakes.
};
Comment on lines +237 to +241

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This example is incorrect. The AuthProfile class does not have a TokenCacheType property. Additionally, the TokenCacheType enum does not contain a Persistent value. Token caching is handled internally by the credential providers and is persistent by default where possible. This section should probably be removed or corrected to explain the automatic caching behavior.

```

## Target Frameworks

- `net8.0`
- `net9.0`
- `net10.0`

## License

MIT License
Loading