diff --git a/schemas/plugin-registration.schema.json b/schemas/plugin-registration.schema.json
new file mode 100644
index 000000000..ca1de8cc1
--- /dev/null
+++ b/schemas/plugin-registration.schema.json
@@ -0,0 +1,200 @@
+{
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "$id": "https://raw.githubusercontent.com/joshsmithxrm/ppds-sdk/main/schemas/plugin-registration.schema.json",
+ "title": "PPDS Plugin Registration Configuration",
+ "description": "Schema for plugin registration configuration files used by the PPDS CLI.",
+ "type": "object",
+ "properties": {
+ "$schema": {
+ "type": "string",
+ "description": "JSON schema reference for validation."
+ },
+ "version": {
+ "type": "string",
+ "description": "Schema version for forward compatibility.",
+ "default": "1.0"
+ },
+ "generatedAt": {
+ "type": "string",
+ "format": "date-time",
+ "description": "Timestamp when the configuration was generated (Zulu time format)."
+ },
+ "assemblies": {
+ "type": "array",
+ "description": "List of plugin assemblies in this configuration.",
+ "items": {
+ "$ref": "#/$defs/assembly"
+ }
+ }
+ },
+ "required": ["assemblies"],
+ "additionalProperties": true,
+ "$defs": {
+ "assembly": {
+ "type": "object",
+ "description": "Configuration for a single plugin assembly.",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Assembly name (without extension)."
+ },
+ "type": {
+ "type": "string",
+ "enum": ["Assembly", "Nuget"],
+ "description": "Assembly type: 'Assembly' for classic DLL, 'Nuget' for NuGet package.",
+ "default": "Assembly"
+ },
+ "path": {
+ "type": "string",
+ "description": "Relative path to the assembly DLL (from the config file location). Use forward slashes for cross-platform compatibility."
+ },
+ "packagePath": {
+ "type": "string",
+ "description": "Relative path to the NuGet package (for Nuget type only)."
+ },
+ "solution": {
+ "type": "string",
+ "description": "Solution unique name to add components to."
+ },
+ "allTypeNames": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "All plugin type names in this assembly (for orphan detection)."
+ },
+ "types": {
+ "type": "array",
+ "description": "Plugin types with their step registrations.",
+ "items": {
+ "$ref": "#/$defs/pluginType"
+ }
+ }
+ },
+ "required": ["name", "types"],
+ "additionalProperties": true
+ },
+ "pluginType": {
+ "type": "object",
+ "description": "Configuration for a single plugin type (class).",
+ "properties": {
+ "typeName": {
+ "type": "string",
+ "description": "Fully qualified type name (namespace.classname)."
+ },
+ "steps": {
+ "type": "array",
+ "description": "Step registrations for this plugin type.",
+ "items": {
+ "$ref": "#/$defs/step"
+ }
+ }
+ },
+ "required": ["typeName", "steps"],
+ "additionalProperties": true
+ },
+ "step": {
+ "type": "object",
+ "description": "Configuration for a single plugin step registration.",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Display name for the step. Auto-generated if not specified: '{TypeName}: {Message} of {Entity}'."
+ },
+ "message": {
+ "type": "string",
+ "description": "SDK message name (Create, Update, Delete, Retrieve, etc.)."
+ },
+ "entity": {
+ "type": "string",
+ "description": "Primary entity logical name. Use 'none' for global messages."
+ },
+ "secondaryEntity": {
+ "type": "string",
+ "description": "Secondary entity logical name for relationship messages (Associate, etc.)."
+ },
+ "stage": {
+ "type": "string",
+ "enum": ["PreValidation", "PreOperation", "MainOperation", "PostOperation"],
+ "description": "Pipeline stage. MainOperation is for Custom API plugins."
+ },
+ "mode": {
+ "type": "string",
+ "enum": ["Synchronous", "Asynchronous"],
+ "description": "Execution mode.",
+ "default": "Synchronous"
+ },
+ "executionOrder": {
+ "type": "integer",
+ "description": "Execution order when multiple plugins handle the same event.",
+ "default": 1
+ },
+ "filteringAttributes": {
+ "type": "string",
+ "description": "Comma-separated list of attributes that trigger this step (Update message only)."
+ },
+ "unsecureConfiguration": {
+ "type": "string",
+ "description": "Unsecure configuration string passed to plugin constructor. Safe for source control."
+ },
+ "deployment": {
+ "type": "string",
+ "enum": ["ServerOnly", "Offline", "Both"],
+ "description": "Deployment target.",
+ "default": "ServerOnly"
+ },
+ "runAsUser": {
+ "type": "string",
+ "description": "User context to run the plugin as. Use 'CallingUser' (default), 'System', or a systemuser GUID."
+ },
+ "description": {
+ "type": "string",
+ "description": "Description of what this step does."
+ },
+ "asyncAutoDelete": {
+ "type": "boolean",
+ "description": "For async steps, whether to delete the async job on successful completion.",
+ "default": false
+ },
+ "stepId": {
+ "type": "string",
+ "description": "Step identifier for associating images with specific steps on multi-step plugins."
+ },
+ "images": {
+ "type": "array",
+ "description": "Entity images registered for this step.",
+ "items": {
+ "$ref": "#/$defs/image"
+ }
+ }
+ },
+ "required": ["message", "entity", "stage"],
+ "additionalProperties": true
+ },
+ "image": {
+ "type": "object",
+ "description": "Configuration for a plugin step image (pre-image or post-image).",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name used to access the image in plugin context."
+ },
+ "entityAlias": {
+ "type": "string",
+ "description": "Entity alias for the image. Defaults to 'name' if not specified."
+ },
+ "imageType": {
+ "type": "string",
+ "enum": ["PreImage", "PostImage", "Both"],
+ "description": "Image type."
+ },
+ "attributes": {
+ "type": "string",
+ "description": "Comma-separated list of attributes to include. Null means all attributes (not recommended for performance)."
+ }
+ },
+ "required": ["name", "imageType"],
+ "additionalProperties": true
+ }
+ }
+}
diff --git a/src/PPDS.Auth/CHANGELOG.md b/src/PPDS.Auth/CHANGELOG.md
index 78b31235c..fcabdf5d3 100644
--- a/src/PPDS.Auth/CHANGELOG.md
+++ b/src/PPDS.Auth/CHANGELOG.md
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.0.0-beta.2] - 2026-01-01
+
+### Fixed
+
+- **Cross-tenant token cache issue** - Fixed bug where `ppds env list` would return environments from wrong tenant when user had profiles for multiple tenants. Root cause was MSAL account lookup using `FirstOrDefault()` instead of filtering by tenant. Now uses `HomeAccountId` for precise account lookup with tenant filtering fallback. ([#59](https://github.com/joshsmithxrm/ppds-sdk/issues/59))
+
+### Added
+
+- `HomeAccountId` property on `AuthProfile` and `ICredentialProvider` to track MSAL account identity across sessions
+
## [1.0.0-beta.1] - 2025-12-29
### Added
@@ -31,5 +41,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- JWT claims parsing for identity information
- Targets: `net8.0`, `net10.0`
-[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Auth-v1.0.0-beta.1...HEAD
+[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Auth-v1.0.0-beta.2...HEAD
+[1.0.0-beta.2]: https://github.com/joshsmithxrm/ppds-sdk/compare/Auth-v1.0.0-beta.1...Auth-v1.0.0-beta.2
[1.0.0-beta.1]: https://github.com/joshsmithxrm/ppds-sdk/releases/tag/Auth-v1.0.0-beta.1
diff --git a/src/PPDS.Auth/Credentials/AzureDevOpsFederatedCredentialProvider.cs b/src/PPDS.Auth/Credentials/AzureDevOpsFederatedCredentialProvider.cs
index ba848b00d..5bf0a241a 100644
--- a/src/PPDS.Auth/Credentials/AzureDevOpsFederatedCredentialProvider.cs
+++ b/src/PPDS.Auth/Credentials/AzureDevOpsFederatedCredentialProvider.cs
@@ -38,6 +38,9 @@ public sealed class AzureDevOpsFederatedCredentialProvider : ICredentialProvider
///
public string? ObjectId => null; // Not available for federated auth without additional calls
+ ///
+ public string? HomeAccountId => null; // Federated auth doesn't use MSAL user cache
+
///
public string? AccessToken => _cachedToken?.Token;
diff --git a/src/PPDS.Auth/Credentials/CertificateFileCredentialProvider.cs b/src/PPDS.Auth/Credentials/CertificateFileCredentialProvider.cs
index aca2d44d0..249495fb3 100644
--- a/src/PPDS.Auth/Credentials/CertificateFileCredentialProvider.cs
+++ b/src/PPDS.Auth/Credentials/CertificateFileCredentialProvider.cs
@@ -39,6 +39,9 @@ public sealed class CertificateFileCredentialProvider : ICredentialProvider
///
public string? ObjectId => null; // Service principals don't have a user OID
+ ///
+ public string? HomeAccountId => null; // Service principals don't use MSAL user cache
+
///
public string? AccessToken => null; // Connection string auth doesn't expose the token
diff --git a/src/PPDS.Auth/Credentials/CertificateStoreCredentialProvider.cs b/src/PPDS.Auth/Credentials/CertificateStoreCredentialProvider.cs
index 56e1296e7..fafa682be 100644
--- a/src/PPDS.Auth/Credentials/CertificateStoreCredentialProvider.cs
+++ b/src/PPDS.Auth/Credentials/CertificateStoreCredentialProvider.cs
@@ -43,6 +43,9 @@ public sealed class CertificateStoreCredentialProvider : ICredentialProvider
///
public string? ObjectId => null; // Service principals don't have a user OID
+ ///
+ public string? HomeAccountId => null; // Service principals don't use MSAL user cache
+
///
public string? AccessToken => null; // Connection string auth doesn't expose the token
diff --git a/src/PPDS.Auth/Credentials/ClientSecretCredentialProvider.cs b/src/PPDS.Auth/Credentials/ClientSecretCredentialProvider.cs
index a3ca178fb..fec41e553 100644
--- a/src/PPDS.Auth/Credentials/ClientSecretCredentialProvider.cs
+++ b/src/PPDS.Auth/Credentials/ClientSecretCredentialProvider.cs
@@ -35,6 +35,9 @@ public sealed class ClientSecretCredentialProvider : ICredentialProvider
///
public string? ObjectId => null; // Service principals don't have a user OID
+ ///
+ public string? HomeAccountId => null; // Service principals don't use MSAL user cache
+
///
public string? AccessToken => null; // Connection string auth doesn't expose the token
diff --git a/src/PPDS.Auth/Credentials/DeviceCodeCredentialProvider.cs b/src/PPDS.Auth/Credentials/DeviceCodeCredentialProvider.cs
index 6b5374eab..892a43a90 100644
--- a/src/PPDS.Auth/Credentials/DeviceCodeCredentialProvider.cs
+++ b/src/PPDS.Auth/Credentials/DeviceCodeCredentialProvider.cs
@@ -26,6 +26,7 @@ public sealed class DeviceCodeCredentialProvider : ICredentialProvider
private readonly CloudEnvironment _cloud;
private readonly string? _tenantId;
private readonly string? _username;
+ private readonly string? _homeAccountId;
private readonly Action? _deviceCodeCallback;
private IPublicClientApplication? _msalClient;
@@ -48,6 +49,9 @@ public sealed class DeviceCodeCredentialProvider : ICredentialProvider
///
public string? ObjectId => _cachedResult?.UniqueId;
+ ///
+ public string? HomeAccountId => _cachedResult?.Account?.HomeAccountId?.Identifier;
+
///
public string? AccessToken => _cachedResult?.AccessToken;
@@ -60,16 +64,19 @@ public sealed class DeviceCodeCredentialProvider : ICredentialProvider
/// The cloud environment.
/// Optional tenant ID (defaults to "organizations" for multi-tenant).
/// Optional username for silent auth lookup.
+ /// Optional MSAL home account identifier for precise account lookup.
/// Optional callback for displaying device code (defaults to console output).
public DeviceCodeCredentialProvider(
CloudEnvironment cloud = CloudEnvironment.Public,
string? tenantId = null,
string? username = null,
+ string? homeAccountId = null,
Action? deviceCodeCallback = null)
{
_cloud = cloud;
_tenantId = tenantId;
_username = username;
+ _homeAccountId = homeAccountId;
_deviceCodeCallback = deviceCodeCallback;
}
@@ -87,6 +94,7 @@ public static DeviceCodeCredentialProvider FromProfile(
profile.Cloud,
profile.TenantId,
profile.Username,
+ profile.HomeAccountId,
deviceCodeCallback);
}
@@ -143,19 +151,14 @@ private async Task GetTokenAsync(string environmentUrl, bool forceIntera
return _cachedResult.AccessToken;
}
- // Try silent acquisition from MSAL cache
- var accounts = await _msalClient!.GetAccountsAsync().ConfigureAwait(false);
-
- // Look up by username if we have one, otherwise fall back to first account
- var account = !string.IsNullOrEmpty(_username)
- ? accounts.FirstOrDefault(a => string.Equals(a.Username, _username, StringComparison.OrdinalIgnoreCase))
- : accounts.FirstOrDefault();
+ // Try to find the correct account for silent acquisition
+ var account = await FindAccountAsync().ConfigureAwait(false);
if (account != null)
{
try
{
- _cachedResult = await _msalClient
+ _cachedResult = await _msalClient!
.AcquireTokenSilent(scopes, account)
.ExecuteAsync(cancellationToken)
.ConfigureAwait(false);
@@ -209,6 +212,12 @@ private async Task GetTokenAsync(string environmentUrl, bool forceIntera
return _cachedResult.AccessToken;
}
+ ///
+ /// Finds the correct cached account for this profile.
+ ///
+ private Task FindAccountAsync()
+ => MsalAccountHelper.FindAccountAsync(_msalClient!, _homeAccountId, _tenantId, _username);
+
///
/// Ensures the MSAL client is initialized with token cache.
///
diff --git a/src/PPDS.Auth/Credentials/GitHubFederatedCredentialProvider.cs b/src/PPDS.Auth/Credentials/GitHubFederatedCredentialProvider.cs
index 6c2b5db6d..2247c51ec 100644
--- a/src/PPDS.Auth/Credentials/GitHubFederatedCredentialProvider.cs
+++ b/src/PPDS.Auth/Credentials/GitHubFederatedCredentialProvider.cs
@@ -38,6 +38,9 @@ public sealed class GitHubFederatedCredentialProvider : ICredentialProvider
///
public string? ObjectId => null; // Not available for federated auth without additional calls
+ ///
+ public string? HomeAccountId => null; // Federated auth doesn't use MSAL user cache
+
///
public string? AccessToken => _cachedToken?.Token;
diff --git a/src/PPDS.Auth/Credentials/ICredentialProvider.cs b/src/PPDS.Auth/Credentials/ICredentialProvider.cs
index 2fbb87bf4..3cabb0061 100644
--- a/src/PPDS.Auth/Credentials/ICredentialProvider.cs
+++ b/src/PPDS.Auth/Credentials/ICredentialProvider.cs
@@ -53,6 +53,13 @@ Task CreateServiceClientAsync(
///
string? ObjectId { get; }
+ ///
+ /// Gets the MSAL home account identifier.
+ /// Format: {objectId}.{tenantId} - uniquely identifies the account+tenant for token cache lookup.
+ /// Available after successful authentication.
+ ///
+ string? HomeAccountId { get; }
+
///
/// Gets the access token from the last authentication.
/// Available after successful authentication. Used for extracting JWT claims.
diff --git a/src/PPDS.Auth/Credentials/InteractiveBrowserCredentialProvider.cs b/src/PPDS.Auth/Credentials/InteractiveBrowserCredentialProvider.cs
index 747d4bae5..627b13c1f 100644
--- a/src/PPDS.Auth/Credentials/InteractiveBrowserCredentialProvider.cs
+++ b/src/PPDS.Auth/Credentials/InteractiveBrowserCredentialProvider.cs
@@ -28,6 +28,7 @@ public sealed class InteractiveBrowserCredentialProvider : ICredentialProvider
private readonly CloudEnvironment _cloud;
private readonly string? _tenantId;
private readonly string? _username;
+ private readonly string? _homeAccountId;
private IPublicClientApplication? _msalClient;
private MsalCacheHelper? _cacheHelper;
@@ -49,6 +50,9 @@ public sealed class InteractiveBrowserCredentialProvider : ICredentialProvider
///
public string? ObjectId => _cachedResult?.UniqueId;
+ ///
+ public string? HomeAccountId => _cachedResult?.Account?.HomeAccountId?.Identifier;
+
///
public string? AccessToken => _cachedResult?.AccessToken;
@@ -61,14 +65,17 @@ public sealed class InteractiveBrowserCredentialProvider : ICredentialProvider
/// The cloud environment.
/// Optional tenant ID (defaults to "organizations" for multi-tenant).
/// Optional username for silent auth lookup.
+ /// Optional MSAL home account identifier for precise account lookup.
public InteractiveBrowserCredentialProvider(
CloudEnvironment cloud = CloudEnvironment.Public,
string? tenantId = null,
- string? username = null)
+ string? username = null,
+ string? homeAccountId = null)
{
_cloud = cloud;
_tenantId = tenantId;
_username = username;
+ _homeAccountId = homeAccountId;
}
///
@@ -81,7 +88,8 @@ public static InteractiveBrowserCredentialProvider FromProfile(AuthProfile profi
return new InteractiveBrowserCredentialProvider(
profile.Cloud,
profile.TenantId,
- profile.Username);
+ profile.Username,
+ profile.HomeAccountId);
}
///
@@ -176,19 +184,14 @@ private async Task GetTokenAsync(string environmentUrl, bool forceIntera
return _cachedResult.AccessToken;
}
- // Try silent acquisition from MSAL cache
- var accounts = await _msalClient!.GetAccountsAsync().ConfigureAwait(false);
-
- // Look up by username if we have one, otherwise fall back to first account
- var account = !string.IsNullOrEmpty(_username)
- ? accounts.FirstOrDefault(a => string.Equals(a.Username, _username, StringComparison.OrdinalIgnoreCase))
- : accounts.FirstOrDefault();
+ // Try to find the correct account for silent acquisition
+ var account = await FindAccountAsync().ConfigureAwait(false);
if (account != null)
{
try
{
- _cachedResult = await _msalClient
+ _cachedResult = await _msalClient!
.AcquireTokenSilent(scopes, account)
.ExecuteAsync(cancellationToken)
.ConfigureAwait(false);
@@ -225,6 +228,12 @@ private async Task GetTokenAsync(string environmentUrl, bool forceIntera
return _cachedResult.AccessToken;
}
+ ///
+ /// Finds the correct cached account for this profile.
+ ///
+ private Task FindAccountAsync()
+ => MsalAccountHelper.FindAccountAsync(_msalClient!, _homeAccountId, _tenantId, _username);
+
///
/// Ensures the MSAL client is initialized with token cache.
///
diff --git a/src/PPDS.Auth/Credentials/ManagedIdentityCredentialProvider.cs b/src/PPDS.Auth/Credentials/ManagedIdentityCredentialProvider.cs
index 2cbef3a26..fb66b7368 100644
--- a/src/PPDS.Auth/Credentials/ManagedIdentityCredentialProvider.cs
+++ b/src/PPDS.Auth/Credentials/ManagedIdentityCredentialProvider.cs
@@ -40,6 +40,9 @@ public sealed class ManagedIdentityCredentialProvider : ICredentialProvider
///
public string? ObjectId => null; // Managed identity doesn't expose OID
+ ///
+ public string? HomeAccountId => null; // Managed identity doesn't use MSAL user cache
+
///
public string? AccessToken => _cachedToken?.Token;
diff --git a/src/PPDS.Auth/Credentials/MsalAccountHelper.cs b/src/PPDS.Auth/Credentials/MsalAccountHelper.cs
new file mode 100644
index 000000000..f7683f187
--- /dev/null
+++ b/src/PPDS.Auth/Credentials/MsalAccountHelper.cs
@@ -0,0 +1,66 @@
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Identity.Client;
+
+namespace PPDS.Auth.Credentials;
+
+///
+/// Helper class for MSAL account lookup operations.
+/// Provides consistent account selection logic across credential providers.
+///
+internal static class MsalAccountHelper
+{
+ ///
+ /// Finds the correct cached account for a profile.
+ /// Uses HomeAccountId for precise lookup, falls back to tenant filtering, then username.
+ ///
+ /// The MSAL public client application.
+ /// Optional MSAL home account identifier for precise lookup.
+ /// Optional tenant ID for filtering.
+ /// Optional username for filtering.
+ /// The matching account, or null if no match found (forces re-authentication).
+ internal static async Task FindAccountAsync(
+ IPublicClientApplication msalClient,
+ string? homeAccountId,
+ string? tenantId,
+ string? username = null)
+ {
+ // Best case: we have the exact account identifier stored
+ if (!string.IsNullOrEmpty(homeAccountId))
+ {
+ var account = await msalClient.GetAccountAsync(homeAccountId).ConfigureAwait(false);
+ if (account != null)
+ return account;
+ }
+
+ // Fall back to filtering accounts
+ var accounts = await msalClient.GetAccountsAsync().ConfigureAwait(false);
+ var accountList = accounts.ToList();
+
+ if (accountList.Count == 0)
+ return null;
+
+ // If we have a tenant ID, filter by it to avoid cross-tenant token usage
+ if (!string.IsNullOrEmpty(tenantId))
+ {
+ var tenantAccount = accountList.FirstOrDefault(a =>
+ string.Equals(a.HomeAccountId?.TenantId, tenantId, StringComparison.OrdinalIgnoreCase));
+ if (tenantAccount != null)
+ return tenantAccount;
+ }
+
+ // Fall back to username match
+ if (!string.IsNullOrEmpty(username))
+ {
+ var usernameAccount = accountList.FirstOrDefault(a =>
+ string.Equals(a.Username, username, StringComparison.OrdinalIgnoreCase));
+ if (usernameAccount != null)
+ return usernameAccount;
+ }
+
+ // If we can't find the right account, return null to force re-authentication.
+ // Never silently use a random cached account - that causes cross-tenant issues.
+ return null;
+ }
+}
diff --git a/src/PPDS.Auth/Credentials/UsernamePasswordCredentialProvider.cs b/src/PPDS.Auth/Credentials/UsernamePasswordCredentialProvider.cs
index b7606da9b..fd7e01ec6 100644
--- a/src/PPDS.Auth/Credentials/UsernamePasswordCredentialProvider.cs
+++ b/src/PPDS.Auth/Credentials/UsernamePasswordCredentialProvider.cs
@@ -44,6 +44,9 @@ public sealed class UsernamePasswordCredentialProvider : ICredentialProvider
///
public string? ObjectId => _cachedResult?.UniqueId;
+ ///
+ public string? HomeAccountId => _cachedResult?.Account?.HomeAccountId?.Identifier;
+
///
public string? AccessToken => _cachedResult?.AccessToken;
diff --git a/src/PPDS.Auth/Discovery/GlobalDiscoveryService.cs b/src/PPDS.Auth/Discovery/GlobalDiscoveryService.cs
index 01851eca2..9a78be382 100644
--- a/src/PPDS.Auth/Discovery/GlobalDiscoveryService.cs
+++ b/src/PPDS.Auth/Discovery/GlobalDiscoveryService.cs
@@ -24,6 +24,8 @@ public sealed class GlobalDiscoveryService : IGlobalDiscoveryService, IDisposabl
private readonly CloudEnvironment _cloud;
private readonly string? _tenantId;
+ private readonly string? _homeAccountId;
+ private readonly AuthMethod? _preferredAuthMethod;
private readonly Action? _deviceCodeCallback;
private IPublicClientApplication? _msalClient;
@@ -35,14 +37,20 @@ public sealed class GlobalDiscoveryService : IGlobalDiscoveryService, IDisposabl
///
/// The cloud environment to use.
/// Optional tenant ID.
+ /// Optional MSAL home account identifier for precise account lookup.
+ /// Optional preferred auth method from profile.
/// Optional callback for device code display.
public GlobalDiscoveryService(
CloudEnvironment cloud = CloudEnvironment.Public,
string? tenantId = null,
+ string? homeAccountId = null,
+ AuthMethod? preferredAuthMethod = null,
Action? deviceCodeCallback = null)
{
_cloud = cloud;
_tenantId = tenantId;
+ _homeAccountId = homeAccountId;
+ _preferredAuthMethod = preferredAuthMethod;
_deviceCodeCallback = deviceCodeCallback;
}
@@ -59,6 +67,8 @@ public static GlobalDiscoveryService FromProfile(
return new GlobalDiscoveryService(
profile.Cloud,
profile.TenantId,
+ profile.HomeAccountId,
+ profile.AuthMethod,
deviceCodeCallback);
}
@@ -136,15 +146,14 @@ private Func> CreateTokenProviderFunction(
{
var scopes = new[] { $"{discoveryUri.GetLeftPart(UriPartial.Authority)}/.default" };
- // Try silent acquisition first
- var accounts = await _msalClient!.GetAccountsAsync().ConfigureAwait(false);
- var account = accounts.FirstOrDefault();
+ // Try to find the correct account for silent acquisition
+ var account = await FindAccountAsync().ConfigureAwait(false);
if (account != null)
{
try
{
- var silentResult = await _msalClient
+ var silentResult = await _msalClient!
.AcquireTokenSilent(scopes, account)
.ExecuteAsync(cancellationToken)
.ConfigureAwait(false);
@@ -156,36 +165,57 @@ private Func> CreateTokenProviderFunction(
}
}
- // Fall back to device code flow
- var result = await _msalClient
- .AcquireTokenWithDeviceCode(scopes, deviceCodeResult =>
+ // Fall back to interactive or device code based on profile's auth method
+ AuthenticationResult result;
+
+ // Honor the profile's preferred auth method if it's interactive and available
+ if (_preferredAuthMethod == AuthMethod.InteractiveBrowser &&
+ InteractiveBrowserCredentialProvider.IsAvailable())
+ {
+ if (_deviceCodeCallback == null)
{
- if (_deviceCodeCallback != null)
- {
- _deviceCodeCallback(new DeviceCodeInfo(
- deviceCodeResult.UserCode,
- deviceCodeResult.VerificationUrl,
- deviceCodeResult.Message));
- }
- else
+ Console.WriteLine("Opening browser for authentication...");
+ }
+
+ result = await _msalClient!
+ .AcquireTokenInteractive(scopes)
+ .WithUseEmbeddedWebView(false)
+ .ExecuteAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ // Fall back to device code flow
+ result = await _msalClient!
+ .AcquireTokenWithDeviceCode(scopes, deviceCodeResult =>
{
- Console.WriteLine();
- Console.WriteLine("To sign in, use a web browser to open the page:");
- Console.ForegroundColor = ConsoleColor.Cyan;
- Console.WriteLine($" {deviceCodeResult.VerificationUrl}");
- Console.ResetColor();
- Console.WriteLine();
- Console.WriteLine("Enter the code:");
- Console.ForegroundColor = ConsoleColor.Yellow;
- Console.WriteLine($" {deviceCodeResult.UserCode}");
- Console.ResetColor();
- Console.WriteLine();
- Console.WriteLine("Waiting for authentication...");
- }
- return Task.CompletedTask;
- })
- .ExecuteAsync(cancellationToken)
- .ConfigureAwait(false);
+ if (_deviceCodeCallback != null)
+ {
+ _deviceCodeCallback(new DeviceCodeInfo(
+ deviceCodeResult.UserCode,
+ deviceCodeResult.VerificationUrl,
+ deviceCodeResult.Message));
+ }
+ else
+ {
+ Console.WriteLine();
+ Console.WriteLine("To sign in, use a web browser to open the page:");
+ Console.ForegroundColor = ConsoleColor.Cyan;
+ Console.WriteLine($" {deviceCodeResult.VerificationUrl}");
+ Console.ResetColor();
+ Console.WriteLine();
+ Console.WriteLine("Enter the code:");
+ Console.ForegroundColor = ConsoleColor.Yellow;
+ Console.WriteLine($" {deviceCodeResult.UserCode}");
+ Console.ResetColor();
+ Console.WriteLine();
+ Console.WriteLine("Waiting for authentication...");
+ }
+ return Task.CompletedTask;
+ })
+ .ExecuteAsync(cancellationToken)
+ .ConfigureAwait(false);
+ }
if (_deviceCodeCallback == null)
{
@@ -197,6 +227,12 @@ private Func> CreateTokenProviderFunction(
};
}
+ ///
+ /// Finds the correct cached account for this profile.
+ ///
+ private Task FindAccountAsync()
+ => MsalAccountHelper.FindAccountAsync(_msalClient!, _homeAccountId, _tenantId);
+
///
/// Ensures the MSAL client is initialized with token cache.
///
@@ -206,11 +242,13 @@ private async Task EnsureMsalClientInitializedAsync()
return;
var cloudInstance = CloudEndpoints.GetAzureCloudInstance(_cloud);
- var tenant = string.IsNullOrWhiteSpace(_tenantId) ? "organizations" : _tenantId;
+ // Always use "organizations" (multi-tenant) authority for discovery.
+ // This ensures tokens cached during profile creation (also using "organizations")
+ // can be reused. Tenant-specific authority is only needed for environment connections.
_msalClient = PublicClientApplicationBuilder
.Create(MicrosoftPublicClientId)
- .WithAuthority(cloudInstance, tenant)
+ .WithAuthority(cloudInstance, "organizations")
.WithDefaultRedirectUri()
.Build();
diff --git a/src/PPDS.Auth/Profiles/AuthProfile.cs b/src/PPDS.Auth/Profiles/AuthProfile.cs
index 53dad2aaf..c8ce3511a 100644
--- a/src/PPDS.Auth/Profiles/AuthProfile.cs
+++ b/src/PPDS.Auth/Profiles/AuthProfile.cs
@@ -165,6 +165,13 @@ public sealed class AuthProfile
[JsonPropertyName("puid")]
public string? Puid { get; set; }
+ ///
+ /// Gets or sets the MSAL home account identifier.
+ /// Format: {objectId}.{tenantId} - uniquely identifies the account+tenant for token cache lookup.
+ ///
+ [JsonPropertyName("homeAccountId")]
+ public string? HomeAccountId { get; set; }
+
#endregion
///
diff --git a/src/PPDS.Cli/CHANGELOG.md b/src/PPDS.Cli/CHANGELOG.md
index c9fc755a4..f8e0758b2 100644
--- a/src/PPDS.Cli/CHANGELOG.md
+++ b/src/PPDS.Cli/CHANGELOG.md
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+## [1.0.0-beta.3] - 2026-01-01
+
+### Added
+
+- `entityAlias` field in plugin image configuration (defaults to `name` if not specified)
+- `unsecureConfiguration` field for plugin step configuration (renamed from `configuration` for clarity)
+- `MainOperation` stage (30) support for Custom API plugin steps
+- JSON schema for `plugin-registration.json` at `schemas/plugin-registration.schema.json`
+- Zulu time format (`Z` suffix) for `generatedAt` timestamps in extracted config
+
+### Fixed
+
+- Solution component addition now works on UPDATE path (was only adding on CREATE) ([#59](https://github.com/joshsmithxrm/ppds-sdk/issues/59))
+- Cross-platform path normalization uses forward slashes consistently
+- Runtime ETC lookup for `pluginpackage` entity type code
+
## [1.0.0-beta.2] - 2025-12-30
### Added
@@ -64,6 +80,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Packaged as .NET global tool (`ppds`)
- Targets: `net10.0`
-[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Cli-v1.0.0-beta.2...HEAD
+[Unreleased]: https://github.com/joshsmithxrm/ppds-sdk/compare/Cli-v1.0.0-beta.3...HEAD
+[1.0.0-beta.3]: https://github.com/joshsmithxrm/ppds-sdk/compare/Cli-v1.0.0-beta.2...Cli-v1.0.0-beta.3
[1.0.0-beta.2]: https://github.com/joshsmithxrm/ppds-sdk/compare/Cli-v1.0.0-beta.1...Cli-v1.0.0-beta.2
[1.0.0-beta.1]: https://github.com/joshsmithxrm/ppds-sdk/releases/tag/Cli-v1.0.0-beta.1
diff --git a/src/PPDS.Cli/Commands/Auth/AuthCommandGroup.cs b/src/PPDS.Cli/Commands/Auth/AuthCommandGroup.cs
index 8adda0126..1003d255b 100644
--- a/src/PPDS.Cli/Commands/Auth/AuthCommandGroup.cs
+++ b/src/PPDS.Cli/Commands/Auth/AuthCommandGroup.cs
@@ -286,6 +286,7 @@ private static async Task ExecuteCreateAsync(CreateOptions options, Cancell
profile.Username = provider.Identity;
profile.ObjectId = provider.ObjectId;
profile.TokenExpiresOn = provider.TokenExpiresAt;
+ profile.HomeAccountId = provider.HomeAccountId;
// Store tenant ID from auth result if not already set
if (string.IsNullOrEmpty(profile.TenantId) && !string.IsNullOrEmpty(provider.TenantId))
diff --git a/src/PPDS.Cli/Commands/Plugins/ExtractCommand.cs b/src/PPDS.Cli/Commands/Plugins/ExtractCommand.cs
index 13f5e3bb1..c2c4fd861 100644
--- a/src/PPDS.Cli/Commands/Plugins/ExtractCommand.cs
+++ b/src/PPDS.Cli/Commands/Plugins/ExtractCommand.cs
@@ -104,7 +104,8 @@ private static Task ExecuteAsync(
var outputDir = Path.GetDirectoryName(outputPath) ?? ".";
// Calculate relative path from output to input
- var relativePath = Path.GetRelativePath(outputDir, input.FullName);
+ // Normalize to forward slashes for cross-platform compatibility
+ var relativePath = Path.GetRelativePath(outputDir, input.FullName).Replace('\\', '/');
if (assemblyConfig.Type == "Assembly")
{
assemblyConfig.Path = relativePath;
diff --git a/src/PPDS.Cli/Commands/Plugins/ListCommand.cs b/src/PPDS.Cli/Commands/Plugins/ListCommand.cs
index c5617ca9b..ebf5bb6f4 100644
--- a/src/PPDS.Cli/Commands/Plugins/ListCommand.cs
+++ b/src/PPDS.Cli/Commands/Plugins/ListCommand.cs
@@ -250,6 +250,7 @@ private static async Task PopulateTypesAsync(
Deployment = step.Deployment,
RunAsUser = step.ImpersonatingUserName,
AsyncAutoDelete = step.AsyncAutoDelete,
+ UnsecureConfiguration = step.Configuration,
Images = []
};
@@ -260,6 +261,7 @@ private static async Task PopulateTypesAsync(
stepOutput.Images.Add(new ImageOutput
{
Name = image.Name,
+ EntityAlias = image.EntityAlias ?? image.Name,
ImageType = image.ImageType,
Attributes = image.Attributes
});
@@ -422,6 +424,9 @@ private sealed class StepOutput
[JsonPropertyName("asyncAutoDelete")]
public bool AsyncAutoDelete { get; set; }
+ [JsonPropertyName("unsecureConfiguration")]
+ public string? UnsecureConfiguration { get; set; }
+
[JsonPropertyName("images")]
public List Images { get; set; } = [];
}
@@ -431,6 +436,9 @@ private sealed class ImageOutput
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
+ [JsonPropertyName("entityAlias")]
+ public string EntityAlias { get; set; } = string.Empty;
+
[JsonPropertyName("imageType")]
public string ImageType { get; set; } = string.Empty;
diff --git a/src/PPDS.Cli/Plugins/Extraction/AssemblyExtractor.cs b/src/PPDS.Cli/Plugins/Extraction/AssemblyExtractor.cs
index d6828943c..35f0efee7 100644
--- a/src/PPDS.Cli/Plugins/Extraction/AssemblyExtractor.cs
+++ b/src/PPDS.Cli/Plugins/Extraction/AssemblyExtractor.cs
@@ -179,7 +179,7 @@ private static PluginStepConfig MapStepAttribute(CustomAttributeData attr, Type
step.Name = value?.ToString();
break;
case "UnsecureConfiguration":
- step.Configuration = value?.ToString();
+ step.UnsecureConfiguration = value?.ToString();
break;
case "Description":
step.Description = value?.ToString();
@@ -249,6 +249,9 @@ private static PluginImageConfig MapImageAttribute(CustomAttributeData attr)
}
}
+ // Default entityAlias to name if not explicitly specified
+ image.EntityAlias ??= image.Name;
+
return image;
}
@@ -280,6 +283,7 @@ private static string MapStageValue(object? value)
{
10 => "PreValidation",
20 => "PreOperation",
+ 30 => "MainOperation",
40 => "PostOperation",
_ => intValue.ToString()
};
diff --git a/src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs b/src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs
index 9ac542ee0..64a3f0c3f 100644
--- a/src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs
+++ b/src/PPDS.Cli/Plugins/Models/PluginRegistrationConfig.cs
@@ -1,8 +1,33 @@
+using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PPDS.Cli.Plugins.Models;
+///
+/// Converts DateTimeOffset to/from Zulu time format (yyyy-MM-ddTHH:mm:ssZ).
+///
+public sealed class ZuluTimeConverter : JsonConverter
+{
+ public override DateTimeOffset? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ var value = reader.GetString();
+ return string.IsNullOrEmpty(value) ? null : DateTimeOffset.Parse(value, CultureInfo.InvariantCulture);
+ }
+
+ public override void Write(Utf8JsonWriter writer, DateTimeOffset? value, JsonSerializerOptions options)
+ {
+ if (value.HasValue)
+ {
+ writer.WriteStringValue(value.Value.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ssZ"));
+ }
+ else
+ {
+ writer.WriteNullValue();
+ }
+ }
+}
+
///
/// Root configuration for plugin registrations.
/// Serialized to/from registrations.json.
@@ -25,6 +50,7 @@ public sealed class PluginRegistrationConfig
/// Timestamp when the configuration was generated.
///
[JsonPropertyName("generatedAt")]
+ [JsonConverter(typeof(ZuluTimeConverter))]
public DateTimeOffset? GeneratedAt { get; set; }
///
@@ -175,9 +201,10 @@ public sealed class PluginStepConfig
///
/// Unsecure configuration string passed to plugin constructor.
+ /// Safe for source control (never contains secrets).
///
- [JsonPropertyName("configuration")]
- public string? Configuration { get; set; }
+ [JsonPropertyName("unsecureConfiguration")]
+ public string? UnsecureConfiguration { get; set; }
///
/// Deployment target: ServerOnly (default), Offline, or Both.
diff --git a/src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs b/src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs
index 3d7b709c7..05703ab49 100644
--- a/src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs
+++ b/src/PPDS.Cli/Plugins/Registration/PluginRegistrationService.cs
@@ -1,6 +1,8 @@
using Microsoft.Crm.Sdk.Messages;
using Microsoft.PowerPlatform.Dataverse.Client;
using Microsoft.Xrm.Sdk;
+using Microsoft.Xrm.Sdk.Messages;
+using Microsoft.Xrm.Sdk.Metadata;
using Microsoft.Xrm.Sdk.Query;
using PPDS.Cli.Plugins.Models;
@@ -14,6 +16,16 @@ public sealed class PluginRegistrationService
private readonly IOrganizationService _service;
private readonly IOrganizationServiceAsync2? _asyncService;
+ // Cache for entity type codes (ETCs) - some like pluginpackage vary by environment
+ private readonly Dictionary _entityTypeCodeCache = new();
+
+ // Well-known component type codes that are consistent across all environments
+ private static readonly Dictionary WellKnownComponentTypes = new()
+ {
+ ["pluginassembly"] = 91,
+ ["sdkmessageprocessingstep"] = 92
+ };
+
public PluginRegistrationService(IOrganizationService service)
{
_service = service;
@@ -357,6 +369,13 @@ public async Task UpsertAssemblyAsync(string name, byte[] content, string?
{
entity.Id = existing.Id;
await UpdateAsync(entity);
+
+ // Add to solution even on update (handles case where component exists but isn't in solution)
+ if (!string.IsNullOrEmpty(solutionName))
+ {
+ await AddToSolutionAsync(existing.Id, 91, solutionName); // 91 = Plugin Assembly
+ }
+
return existing.Id;
}
else
@@ -452,9 +471,9 @@ public async Task UpsertStepAsync(
entity["filteringattributes"] = stepConfig.FilteringAttributes;
}
- if (!string.IsNullOrEmpty(stepConfig.Configuration))
+ if (!string.IsNullOrEmpty(stepConfig.UnsecureConfiguration))
{
- entity["configuration"] = stepConfig.Configuration;
+ entity["configuration"] = stepConfig.UnsecureConfiguration;
}
if (!string.IsNullOrEmpty(stepConfig.Description))
@@ -482,6 +501,13 @@ public async Task UpsertStepAsync(
{
entity.Id = existing.Id;
await UpdateAsync(entity);
+
+ // Add to solution even on update (handles case where component exists but isn't in solution)
+ if (!string.IsNullOrEmpty(solutionName))
+ {
+ await AddToSolutionAsync(existing.Id, 92, solutionName); // 92 = SDK Message Processing Step
+ }
+
return existing.Id;
}
else
@@ -604,18 +630,52 @@ public async Task AddToSolutionAsync(Guid componentId, int componentType, string
#region Private Helpers
+ ///
+ /// Gets the solution component type code for an entity.
+ /// Uses well-known values for system entities, queries metadata for custom entities like pluginpackage.
+ ///
+ private async Task GetComponentTypeAsync(string entityLogicalName)
+ {
+ // Check well-known types first (no API call needed)
+ if (WellKnownComponentTypes.TryGetValue(entityLogicalName, out var wellKnownType))
+ {
+ return wellKnownType;
+ }
+
+ // Check cache
+ if (_entityTypeCodeCache.TryGetValue(entityLogicalName, out var cachedType))
+ {
+ return cachedType;
+ }
+
+ // Query entity metadata to get ObjectTypeCode
+ var request = new RetrieveEntityRequest
+ {
+ LogicalName = entityLogicalName,
+ EntityFilters = EntityFilters.Entity
+ };
+
+ try
+ {
+ var response = (RetrieveEntityResponse)await ExecuteAsync(request);
+ var objectTypeCode = response.EntityMetadata.ObjectTypeCode ?? 0;
+ _entityTypeCodeCache[entityLogicalName] = objectTypeCode;
+ return objectTypeCode;
+ }
+ catch
+ {
+ // Entity doesn't exist or can't be queried - return 0 to skip solution addition
+ return 0;
+ }
+ }
+
private async Task CreateWithSolutionAsync(Entity entity, string? solutionName)
{
var id = await CreateAsync(entity);
if (!string.IsNullOrEmpty(solutionName))
{
- var componentType = entity.LogicalName switch
- {
- "pluginassembly" => 91,
- "sdkmessageprocessingstep" => 92,
- _ => 0
- };
+ var componentType = await GetComponentTypeAsync(entity.LogicalName);
if (componentType > 0)
{
@@ -630,6 +690,7 @@ private async Task CreateWithSolutionAsync(Entity entity, string? solution
{
10 => "PreValidation",
20 => "PreOperation",
+ 30 => "MainOperation",
40 => "PostOperation",
_ => value.ToString()
};
@@ -653,6 +714,7 @@ private async Task CreateWithSolutionAsync(Entity entity, string? solution
{
"PreValidation" => 10,
"PreOperation" => 20,
+ "MainOperation" => 30,
"PostOperation" => 40,
_ => int.TryParse(stage, out var v) ? v : 40
};
diff --git a/tests/PPDS.Cli.Tests/Plugins/Models/PluginRegistrationConfigTests.cs b/tests/PPDS.Cli.Tests/Plugins/Models/PluginRegistrationConfigTests.cs
index 7fce6f1ce..3ac01633d 100644
--- a/tests/PPDS.Cli.Tests/Plugins/Models/PluginRegistrationConfigTests.cs
+++ b/tests/PPDS.Cli.Tests/Plugins/Models/PluginRegistrationConfigTests.cs
@@ -262,7 +262,7 @@ public void RoundTrip_PreservesAllProperties()
Mode = "Synchronous",
ExecutionOrder = 10,
FilteringAttributes = null,
- Configuration = "some config",
+ UnsecureConfiguration = "some config",
StepId = "step1",
Images = []
}
@@ -293,7 +293,7 @@ public void RoundTrip_PreservesAllProperties()
Assert.Equal(origStep.Name, deserStep.Name);
Assert.Equal(origStep.Message, deserStep.Message);
Assert.Equal(origStep.ExecutionOrder, deserStep.ExecutionOrder);
- Assert.Equal(origStep.Configuration, deserStep.Configuration);
+ Assert.Equal(origStep.UnsecureConfiguration, deserStep.UnsecureConfiguration);
Assert.Equal(origStep.StepId, deserStep.StepId);
}