diff --git a/Directory.Packages.props b/Directory.Packages.props
index a58477f84e..6d2b8f63ee 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -32,11 +32,15 @@
-
+
+
+
+
+
diff --git a/LibsAndSamples.sln b/LibsAndSamples.sln
index ce76cc9fab..f99f608d00 100644
--- a/LibsAndSamples.sln
+++ b/LibsAndSamples.sln
@@ -189,6 +189,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MacMauiAppWithBroker", "tes
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MacConsoleAppWithBroker", "tests\devapps\MacConsoleAppWithBroker\MacConsoleAppWithBroker.csproj", "{DBD18BC8-72E4-47D4-BD79-8DEBD9F2C0D0}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Identity.Client.Labs", "src\client\Microsoft.Identity.Client.Labs\Microsoft.Identity.Client.Labs.csproj", "{0175FBD0-A14C-4742-803A-CCE71B4FA566}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.Identity.Client.Labs.Tests", "tests\Microsoft.Identity.Client.Labs.Tests\Microsoft.Identity.Client.Labs.Tests.csproj", "{FD5E59AC-D6D3-18C6-B50A-DE15C654C942}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug + MobileApps|Any CPU = Debug + MobileApps|Any CPU
@@ -1907,6 +1911,90 @@ Global
{DBD18BC8-72E4-47D4-BD79-8DEBD9F2C0D0}.Release|x64.Build.0 = Release|Any CPU
{DBD18BC8-72E4-47D4-BD79-8DEBD9F2C0D0}.Release|x86.ActiveCfg = Release|Any CPU
{DBD18BC8-72E4-47D4-BD79-8DEBD9F2C0D0}.Release|x86.Build.0 = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|Any CPU.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|Any CPU.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|ARM.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|ARM.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|ARM64.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|ARM64.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|iPhone.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|iPhone.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|x64.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|x64.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|x86.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug + MobileApps|x86.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|ARM.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|iPhone.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|iPhone.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|x64.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Debug|x86.Build.0 = Debug|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|Any CPU.Build.0 = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|ARM.ActiveCfg = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|ARM.Build.0 = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|ARM64.Build.0 = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|iPhone.ActiveCfg = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|iPhone.Build.0 = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|x64.ActiveCfg = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|x64.Build.0 = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|x86.ActiveCfg = Release|Any CPU
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566}.Release|x86.Build.0 = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|Any CPU.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|Any CPU.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|ARM.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|ARM.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|ARM64.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|ARM64.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|iPhone.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|iPhone.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|x64.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|x64.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|x86.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug + MobileApps|x86.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|ARM.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|ARM.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|ARM64.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|ARM64.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|iPhone.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|iPhone.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|iPhoneSimulator.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|iPhoneSimulator.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|x64.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Debug|x86.Build.0 = Debug|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|ARM.ActiveCfg = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|ARM.Build.0 = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|ARM64.ActiveCfg = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|ARM64.Build.0 = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|iPhone.ActiveCfg = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|iPhone.Build.0 = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|iPhoneSimulator.ActiveCfg = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|iPhoneSimulator.Build.0 = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|x64.ActiveCfg = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|x64.Build.0 = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|x86.ActiveCfg = Release|Any CPU
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -1963,6 +2051,8 @@ Global
{97995B86-AA0F-3AF9-DA40-85A6263E4391} = {9B0B5396-4D95-4C15-82ED-DC22B5A3123F}
{AEF6BB00-931F-4638-955D-24D735625C34} = {34BE693E-3496-45A4-B1D2-D3A0E068EEDB}
{DBD18BC8-72E4-47D4-BD79-8DEBD9F2C0D0} = {34BE693E-3496-45A4-B1D2-D3A0E068EEDB}
+ {0175FBD0-A14C-4742-803A-CCE71B4FA566} = {1A37FD75-94E9-4D6F-953A-0DABBD7B49E9}
+ {FD5E59AC-D6D3-18C6-B50A-DE15C654C942} = {9B0B5396-4D95-4C15-82ED-DC22B5A3123F}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {020399A9-DC27-4B82-9CAA-EF488665AC27}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Abstractions/AppSecretKeys.cs b/src/client/Microsoft.Identity.Client.Labs/Abstractions/AppSecretKeys.cs
new file mode 100644
index 0000000000..5928dc8517
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Abstractions/AppSecretKeys.cs
@@ -0,0 +1,55 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+namespace Microsoft.Identity.Client.Labs
+{
+ ///
+ /// Declares the Key Vault secret names for an application's credentials.
+ /// Each property is a secret name (not the secret value).
+ ///
+ public sealed class AppSecretKeys
+ {
+ ///
+ /// Initializes a new instance of the class.
+ /// For optional secrets, pass an empty string ("") to indicate "not configured".
+ ///
+ /// Key Vault secret name that stores the application's client ID.
+ /// Optional Key Vault secret name that stores the application's client secret. Use "" if not used.
+ /// Optional Key Vault secret name that stores a Base64-encoded PFX certificate. Use "" if not used.
+ /// Optional Key Vault secret name that stores the password for the PFX. Use "" if not used.
+ public AppSecretKeys(
+ string clientIdSecret,
+ string clientSecretSecret = "",
+ string pfxSecret = "",
+ string pfxPasswordSecret = "")
+ {
+ ClientIdSecret = clientIdSecret;
+ ClientSecretSecret = clientSecretSecret ?? string.Empty;
+ PfxSecret = pfxSecret ?? string.Empty;
+ PfxPasswordSecret = pfxPasswordSecret ?? string.Empty;
+ }
+
+ ///
+ /// Gets the Key Vault secret name that stores the application's client ID.
+ ///
+ public string ClientIdSecret { get; }
+
+ ///
+ /// Gets the Key Vault secret name that stores the application's client secret.
+ /// Empty string indicates "not configured".
+ ///
+ public string ClientSecretSecret { get; }
+
+ ///
+ /// Gets the Key Vault secret name that stores a Base64-encoded PFX certificate.
+ /// Empty string indicates "not configured".
+ ///
+ public string PfxSecret { get; }
+
+ ///
+ /// Gets the Key Vault secret name that stores the password for the PFX certificate.
+ /// Empty string indicates "not configured".
+ ///
+ public string PfxPasswordSecret { get; }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Abstractions/Enums.cs b/src/client/Microsoft.Identity.Client.Labs/Abstractions/Enums.cs
new file mode 100644
index 0000000000..81bc29f272
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Abstractions/Enums.cs
@@ -0,0 +1,134 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+namespace Microsoft.Identity.Client.Labs
+{
+ ///
+ /// Represents the authentication style used by a test user.
+ ///
+ public enum AuthType
+ {
+ ///
+ /// A basic username/password account with no federation or multi-factor authentication (MFA).
+ ///
+ Basic,
+
+ ///
+ /// A federated account (for example, via ADFS, Ping, or another identity provider).
+ ///
+ Federated,
+
+ ///
+ /// An account that requires multi-factor authentication (MFA).
+ ///
+ Mfa,
+
+ ///
+ /// A business-to-business (B2B) guest account invited into the tenant.
+ ///
+ Guest
+ }
+
+ ///
+ /// Identifies the cloud or sovereign environment where identities and applications are hosted.
+ ///
+ public enum CloudType
+ {
+ ///
+ /// Azure Public (global) cloud.
+ ///
+ Public,
+
+ ///
+ /// Azure Government (GCC/GCC High) cloud.
+ ///
+ Gcc,
+
+ ///
+ /// Azure Government Department of Defense (DoD) environment.
+ ///
+ Dod,
+
+ ///
+ /// Azure China cloud (operated by 21Vianet).
+ ///
+ China,
+
+ ///
+ /// Microsoft Cloud for Germany / Azure Germany.
+ ///
+ Germany,
+
+ ///
+ /// Integration or pre‑production environment used for testing.
+ ///
+ Canary
+ }
+
+ ///
+ /// Names the functional test scenario or user pool.
+ ///
+ public enum Scenario
+ {
+ ///
+ /// Basic or smoke-test scenarios.
+ ///
+ Basic,
+
+ ///
+ /// On‑Behalf‑Of (OBO) flow scenarios.
+ ///
+ Obo,
+
+ ///
+ /// Confidential Client Application (CCA) scenarios.
+ ///
+ Cca,
+
+ ///
+ /// Device Code flow scenarios.
+ ///
+ DeviceCode,
+
+ ///
+ /// Resource Owner Password Credentials (ROPC) flow scenarios.
+ ///
+ Ropc,
+
+ ///
+ /// Daemon (application‑only) scenarios without user interaction.
+ ///
+ Daemon
+ }
+
+ ///
+ /// Specifies the type of application whose credentials should be resolved.
+ ///
+ public enum AppKind
+ {
+ ///
+ /// A public client application (desktop, mobile, or SPA) that does not use a client secret.
+ ///
+ PublicClient,
+
+ ///
+ /// A confidential client application (web app/service) that authenticates with a client secret or certificate.
+ ///
+ ConfidentialClient,
+
+ ///
+ /// A headless/background application that runs without user interaction.
+ ///
+ Daemon,
+
+ ///
+ /// A protected Web API (resource) that validates tokens issued to clients.
+ ///
+ WebApi,
+
+ ///
+ /// A web application (confidential client) that signs in users and can call downstream APIs.
+ ///
+ WebApp
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Abstractions/IAccountMapProvider.cs b/src/client/Microsoft.Identity.Client.Labs/Abstractions/IAccountMapProvider.cs
new file mode 100644
index 0000000000..db7b31abc5
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Abstractions/IAccountMapProvider.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+
+namespace Microsoft.Identity.Client.Labs
+{
+ ///
+ /// Provides a mapping between a user tuple
+ /// (, , ) and the
+ /// Key Vault secret name that stores the corresponding username.
+ ///
+ public interface IAccountMapProvider
+ {
+ ///
+ /// Gets a map of tuples to username secret names. The value for each key must be a Key Vault
+ /// secret name whose value is the username to use for the tuple.
+ ///
+ ///
+ /// A read-only dictionary keyed by (authType, cloudType, scenario) whose values are
+ /// the Key Vault secret names containing usernames.
+ ///
+ IReadOnlyDictionary<(AuthType auth, CloudType cloud, Scenario scenario), string> GetUsernameMap();
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Abstractions/IAppMapProvider.cs b/src/client/Microsoft.Identity.Client.Labs/Abstractions/IAppMapProvider.cs
new file mode 100644
index 0000000000..8eedcbd9be
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Abstractions/IAppMapProvider.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+namespace Microsoft.Identity.Client.Labs
+{
+ ///
+ /// Provides a mapping between an application tuple
+ /// (, , ) and the
+ /// Key Vault secret names that store its client ID and credentials.
+ ///
+ public interface IAppMapProvider
+ {
+ ///
+ /// Gets a map of tuples to application secret-name sets.
+ ///
+ ///
+ /// A read-only dictionary keyed by (cloudType, scenario, appKind) whose values
+ /// describe which Key Vault secret names hold each application credential.
+ ///
+ IReadOnlyDictionary<(CloudType cloud, Scenario scenario, AppKind kind), AppSecretKeys> GetAppMap();
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Abstractions/Options.cs b/src/client/Microsoft.Identity.Client.Labs/Abstractions/Options.cs
new file mode 100644
index 0000000000..5a1b608635
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Abstractions/Options.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+
+namespace Microsoft.Identity.Client.Labs
+{
+ ///
+ /// Central configuration for the Labs resolver, including Key Vault location and
+ /// the policy used to select the password secret for user accounts.
+ ///
+ public sealed class LabsOptions
+ {
+ ///
+ /// Gets or sets the URI of the Azure Key Vault that contains secrets referenced by this package.
+ ///
+ public Uri KeyVaultUri { get; set; } = default!;
+
+ ///
+ /// Gets or sets the global password secret name (representing the current "active lab" password)
+ /// to use for most user tuples, for example msidlab1_pwd.
+ /// Use an empty string to indicate that no global value is configured.
+ ///
+ public string GlobalPasswordSecret { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets per-cloud password secret overrides, allowing sovereign clouds to use a
+ /// different active-lab password secret name. Leave empty if not used.
+ ///
+ public Dictionary PasswordSecretByCloud { get; set; } = new();
+
+ ///
+ /// Gets or sets per-tuple password secret overrides. The dictionary key must be the
+ /// lowercase string "{auth}.{cloud}.{scenario}" (for example, "basic.public.obo").
+ /// Leave empty if not used.
+ ///
+ public Dictionary PasswordSecretByTuple { get; set; } = new();
+
+ ///
+ /// Gets or sets a value indicating whether secret names should be derived by convention
+ /// when a corresponding map entry is missing.
+ ///
+ public bool EnableConventionFallback { get; set; } = true;
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Abstractions/Resolvers.cs b/src/client/Microsoft.Identity.Client.Labs/Abstractions/Resolvers.cs
new file mode 100644
index 0000000000..7eb6602a1d
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Abstractions/Resolvers.cs
@@ -0,0 +1,93 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Identity.Client.Labs
+{
+ ///
+ /// Resolves user credentials (username and password) for a given tuple
+ /// (, , ).
+ ///
+ public interface IAccountResolver
+ {
+ ///
+ /// Resolves the user credentials for the specified tuple.
+ ///
+ /// The authentication style of the user.
+ /// The cloud environment.
+ /// The scenario (pool) for which the user is requested.
+ /// An optional cancellation token.
+ ///
+ /// A tuple (Username, Password) containing the credential values retrieved from Key Vault.
+ ///
+ Task<(string Username, string Password)> ResolveUserAsync(
+ AuthType auth, CloudType cloud, Scenario scenario, CancellationToken ct = default);
+ }
+
+ ///
+ /// Represents application credentials materialized from Key Vault secrets.
+ /// Optional fields use empty strings or empty arrays when not configured.
+ ///
+ public sealed class AppCredentials
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The application (client) identifier.
+ /// The optional client secret used by confidential clients. Use "" if not used.
+ /// The optional PFX certificate content. Use if not used.
+ /// The optional password used to load the PFX certificate. Use "" if not used.
+ public AppCredentials(
+ string clientId,
+ string clientSecret = "",
+ byte[]? pfxBytes = null,
+ string pfxPassword = "")
+ {
+ ClientId = clientId;
+ ClientSecret = clientSecret ?? string.Empty;
+ PfxBytes = pfxBytes ?? Array.Empty();
+ PfxPassword = pfxPassword ?? string.Empty;
+ }
+
+ ///
+ /// Gets the application (client) identifier.
+ ///
+ public string ClientId { get; }
+
+ ///
+ /// Gets the client secret. Empty string indicates "not configured".
+ ///
+ public string ClientSecret { get; }
+
+ ///
+ /// Gets the PFX certificate content. Empty array indicates "not configured".
+ ///
+ public byte[] PfxBytes { get; }
+
+ ///
+ /// Gets the password used to load the PFX certificate. Empty string indicates "not configured".
+ ///
+ public string PfxPassword { get; }
+ }
+
+ ///
+ /// Resolves application credentials for a given tuple
+ /// (, , ).
+ ///
+ public interface IAppResolver
+ {
+ ///
+ /// Resolves the application credentials for the specified tuple.
+ ///
+ /// The cloud environment.
+ /// The scenario (pool) for which the application is requested.
+ /// The type of application to resolve.
+ /// An optional cancellation token.
+ /// An instance containing the resolved values.
+ Task ResolveAppAsync(
+ CloudType cloud, Scenario scenario, AppKind kind, CancellationToken ct = default);
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/DependencyInjection/LabsServiceCollectionExtensions.cs b/src/client/Microsoft.Identity.Client.Labs/DependencyInjection/LabsServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..6eef3e6da1
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/DependencyInjection/LabsServiceCollectionExtensions.cs
@@ -0,0 +1,74 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using Microsoft.Identity.Client.Labs.Internal;
+
+namespace Microsoft.Identity.Client.Labs
+{
+ ///
+ /// Extension methods for registering Labs services in a dependency injection container.
+ ///
+ public static class LabsServiceCollectionExtensions
+ {
+ ///
+ /// Registers the Labs resolvers and configuration with the provided service collection.
+ ///
+ /// The target service collection.
+ /// A delegate used to configure .
+ /// The same service collection for chaining.
+ /// Thrown if or is null.
+ public static IServiceCollection AddLabsIdentity(
+ this IServiceCollection services,
+ Action configure)
+ {
+ if (services is null)
+ throw new ArgumentNullException(nameof(services));
+ if (configure is null)
+ throw new ArgumentNullException(nameof(configure));
+
+ services.Configure(configure);
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton(sp =>
+ {
+ var opt = sp.GetRequiredService>().Value;
+ if (opt.KeyVaultUri is null)
+ {
+ throw new InvalidOperationException("LabsOptions.KeyVaultUri must be set.");
+ }
+
+ return new KeyVaultSecretStore(opt.KeyVaultUri);
+ });
+
+ services.AddSingleton();
+ services.AddSingleton();
+
+ return services;
+ }
+
+ ///
+ /// Registers an SDK-specific account map provider with the service collection.
+ ///
+ /// The type that implements .
+ /// The target service collection.
+ /// The same service collection for chaining.
+ public static IServiceCollection AddAccountMapProvider(this IServiceCollection services)
+ where T : class, IAccountMapProvider
+ => services.AddSingleton();
+
+ ///
+ /// Registers an SDK-specific app map provider with the service collection.
+ ///
+ /// The type that implements .
+ /// The target service collection.
+ /// The same service collection for chaining.
+ public static IServiceCollection AddAppMapProvider(this IServiceCollection services)
+ where T : class, IAppMapProvider
+ => services.AddSingleton();
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Internal/AccountMapAggregator.cs b/src/client/Microsoft.Identity.Client.Labs/Internal/AccountMapAggregator.cs
new file mode 100644
index 0000000000..8191915d1d
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Internal/AccountMapAggregator.cs
@@ -0,0 +1,80 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.Options;
+
+namespace Microsoft.Identity.Client.Labs.Internal
+{
+ ///
+ /// Aggregates username maps from multiple providers and applies the password-secret selection policy.
+ ///
+ internal sealed class AccountMapAggregator
+ {
+ private readonly Dictionary<(AuthType, CloudType, Scenario), string> _usernames;
+ private readonly LabsOptions _opt;
+
+ public AccountMapAggregator(IEnumerable providers, IOptions opt)
+ {
+ _opt = opt?.Value ?? throw new ArgumentNullException(nameof(opt));
+ _usernames = new();
+
+ foreach (var p in providers)
+ {
+ var map = p.GetUsernameMap();
+ if (map is null)
+ continue;
+
+ foreach (var kv in map)
+ {
+ // last registered wins
+ _usernames[kv.Key] = kv.Value;
+ }
+ }
+ }
+
+ public string GetUsernameSecret(AuthType a, CloudType c, Scenario s)
+ {
+ if (_usernames.TryGetValue((a, c, s), out var name))
+ return name;
+
+ if (!_opt.EnableConventionFallback)
+ throw new KeyNotFoundException($"No username secret mapping for ({a},{c},{s}).");
+
+ return $"cld_{a.ToString().ToLowerInvariant()}_{c.ToString().ToLowerInvariant()}_{s.ToString().ToLowerInvariant()}_uname";
+ }
+
+ public string GetPasswordSecret(AuthType a, CloudType c, Scenario s)
+ {
+ // tuple override
+ var tupleKey = $"{a}.{c}.{s}".ToLowerInvariant();
+ if (_opt.PasswordSecretByTuple.TryGetValue(tupleKey, out var tupleSecret) &&
+ !string.IsNullOrWhiteSpace(tupleSecret))
+ {
+ return tupleSecret;
+ }
+
+ // cloud override
+ if (_opt.PasswordSecretByCloud.TryGetValue(c, out var cloudSecret) &&
+ !string.IsNullOrWhiteSpace(cloudSecret))
+ {
+ return cloudSecret;
+ }
+
+ // global
+ if (!string.IsNullOrWhiteSpace(_opt.GlobalPasswordSecret))
+ {
+ return _opt.GlobalPasswordSecret;
+ }
+
+ // convention
+ if (_opt.EnableConventionFallback)
+ {
+ return $"cld_{a.ToString().ToLowerInvariant()}_{c.ToString().ToLowerInvariant()}_{s.ToString().ToLowerInvariant()}_pwd";
+ }
+
+ throw new KeyNotFoundException($"No password secret configured for ({a},{c},{s}).");
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Internal/AccountResolver.cs b/src/client/Microsoft.Identity.Client.Labs/Internal/AccountResolver.cs
new file mode 100644
index 0000000000..76a58b75ba
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Internal/AccountResolver.cs
@@ -0,0 +1,39 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Identity.Client.Labs.Internal
+{
+ ///
+ /// Default implementation of that uses map providers and Key Vault.
+ ///
+ internal sealed class AccountResolver : IAccountResolver
+ {
+ private readonly AccountMapAggregator _agg;
+ private readonly ISecretStore _store;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public AccountResolver(AccountMapAggregator agg, ISecretStore store)
+ {
+ _agg = agg;
+ _store = store;
+ }
+
+ ///
+ public async Task<(string Username, string Password)> ResolveUserAsync(
+ AuthType auth, CloudType cloud, Scenario scenario, CancellationToken ct = default)
+ {
+ var unameSecret = _agg.GetUsernameSecret(auth, cloud, scenario);
+ var pwdSecret = _agg.GetPasswordSecret(auth, cloud, scenario);
+
+ var username = await _store.GetAsync(unameSecret, ct).ConfigureAwait(false);
+ var password = await _store.GetAsync(pwdSecret, ct).ConfigureAwait(false);
+
+ return (username, password);
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Internal/AppMapAggregator.cs b/src/client/Microsoft.Identity.Client.Labs/Internal/AppMapAggregator.cs
new file mode 100644
index 0000000000..9b1c6953ec
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Internal/AppMapAggregator.cs
@@ -0,0 +1,38 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+
+namespace Microsoft.Identity.Client.Labs.Internal
+{
+ ///
+ /// Aggregates application maps from multiple providers.
+ ///
+ internal sealed class AppMapAggregator
+ {
+ private readonly Dictionary<(CloudType, Scenario, AppKind), AppSecretKeys> _apps;
+
+ public AppMapAggregator(IEnumerable providers)
+ {
+ _apps = new();
+
+ foreach (var p in providers)
+ {
+ var map = p.GetAppMap();
+ if (map is null)
+ continue;
+
+ foreach (var kv in map)
+ {
+ // last registered wins
+ _apps[kv.Key] = kv.Value;
+ }
+ }
+ }
+
+ public AppSecretKeys ResolveKeys(CloudType c, Scenario s, AppKind k)
+ => _apps.TryGetValue((c, s, k), out var keys)
+ ? keys
+ : throw new KeyNotFoundException($"No app mapping for ({c},{s},{k}).");
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Internal/AppResolver.cs b/src/client/Microsoft.Identity.Client.Labs/Internal/AppResolver.cs
new file mode 100644
index 0000000000..0bf45c674d
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Internal/AppResolver.cs
@@ -0,0 +1,67 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Identity.Client.Labs.Internal
+{
+ ///
+ /// Default implementation of that uses map providers and Key Vault.
+ ///
+ internal sealed class AppResolver : IAppResolver
+ {
+ private readonly AppMapAggregator _agg;
+ private readonly ISecretStore _store;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public AppResolver(AppMapAggregator agg, ISecretStore store)
+ {
+ _agg = agg;
+ _store = store;
+ }
+
+ ///
+ public async Task ResolveAppAsync(CloudType cloud, Scenario scenario, AppKind kind, CancellationToken ct = default)
+ {
+ var keys = _agg.ResolveKeys(cloud, scenario, kind);
+
+ var clientId = await _store.GetAsync(keys.ClientIdSecret, ct).ConfigureAwait(false);
+
+ string clientSecret = string.Empty;
+ byte[] pfxBytes = Array.Empty();
+ string pfxPwd = string.Empty;
+
+ if (!string.IsNullOrEmpty(keys.ClientSecretSecret))
+ {
+ clientSecret = await _store.GetAsync(keys.ClientSecretSecret, ct).ConfigureAwait(false);
+ }
+
+ if (!string.IsNullOrEmpty(keys.PfxSecret))
+ {
+ var b64 = await _store.GetAsync(keys.PfxSecret, ct).ConfigureAwait(false);
+ if (!string.IsNullOrWhiteSpace(b64))
+ {
+ try
+ {
+ pfxBytes = Convert.FromBase64String(b64);
+ }
+ catch (FormatException ex)
+ {
+ throw new InvalidOperationException($"Secret '{keys.PfxSecret}' is not valid Base64.", ex);
+ }
+ }
+ }
+
+ if (!string.IsNullOrEmpty(keys.PfxPasswordSecret))
+ {
+ pfxPwd = await _store.GetAsync(keys.PfxPasswordSecret, ct).ConfigureAwait(false);
+ }
+
+ return new AppCredentials(clientId, clientSecret, pfxBytes, pfxPwd);
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Internal/ISecretStore.cs b/src/client/Microsoft.Identity.Client.Labs/Internal/ISecretStore.cs
new file mode 100644
index 0000000000..fa0ae38983
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Internal/ISecretStore.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Microsoft.Identity.Client.Labs.Internal
+{
+ ///
+ /// Abstraction of a secret store (Azure Key Vault by default).
+ ///
+ internal interface ISecretStore
+ {
+ ///
+ /// Retrieves the value of the specified secret.
+ ///
+ /// The name of the secret to read.
+ /// An optional cancellation token.
+ /// The secret value.
+ Task GetAsync(string secretName, CancellationToken ct = default);
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/Internal/KeyVaultSecretStore.cs b/src/client/Microsoft.Identity.Client.Labs/Internal/KeyVaultSecretStore.cs
new file mode 100644
index 0000000000..cc78c34f24
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Internal/KeyVaultSecretStore.cs
@@ -0,0 +1,44 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Azure.Identity;
+using Azure.Security.KeyVault.Secrets;
+
+namespace Microsoft.Identity.Client.Labs.Internal
+{
+ ///
+ /// Secret store implementation backed by Azure Key Vault.
+ ///
+ internal sealed class KeyVaultSecretStore : ISecretStore
+ {
+ private readonly SecretClient _kv;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The Key Vault URI.
+ public KeyVaultSecretStore(Uri keyVaultUri)
+ {
+ if (keyVaultUri is null)
+ throw new ArgumentNullException(nameof(keyVaultUri));
+ _kv = new SecretClient(keyVaultUri, new DefaultAzureCredential());
+ }
+
+ ///
+ public async Task GetAsync(string secretName, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(secretName))
+ throw new ArgumentException("Secret name cannot be null or empty.", nameof(secretName));
+
+ // IMPORTANT: specify the cancellation token by name to avoid the 'version' parameter
+ var response = await _kv
+ .GetSecretAsync(secretName, version: null, cancellationToken: ct)
+ .ConfigureAwait(false);
+
+ return response.Value.Value;
+ }
+ }
+}
diff --git a/src/client/Microsoft.Identity.Client.Labs/InternalsVisibleTo.cs b/src/client/Microsoft.Identity.Client.Labs/InternalsVisibleTo.cs
new file mode 100644
index 0000000000..b3cee18deb
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/InternalsVisibleTo.cs
@@ -0,0 +1,6 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Microsoft.Identity.Client.Labs.Tests")]
diff --git a/src/client/Microsoft.Identity.Client.Labs/Microsoft.Identity.Client.Labs.csproj b/src/client/Microsoft.Identity.Client.Labs/Microsoft.Identity.Client.Labs.csproj
new file mode 100644
index 0000000000..d6dc746142
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/Microsoft.Identity.Client.Labs.csproj
@@ -0,0 +1,42 @@
+
+
+
+ net8.0;netstandard2.0
+ enable
+ enable
+ true
+
+
+ true
+ Microsoft.Identity.Client.Labs
+ Microsoft
+ Microsoft
+ https://github.com/AzureAD/microsoft-authentication-library-for-dotnet
+ MIT
+ Shared test identity resolver for MSAL, IDWEB and related SDKs (Key Vault based).
+ true
+ snupkg
+
+ true
+ latest
+
+
+
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/client/Microsoft.Identity.Client.Labs/PublicApi/PublicAPI.Shipped.txt b/src/client/Microsoft.Identity.Client.Labs/PublicApi/PublicAPI.Shipped.txt
new file mode 100644
index 0000000000..7dc5c58110
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/PublicApi/PublicAPI.Shipped.txt
@@ -0,0 +1 @@
+#nullable enable
diff --git a/src/client/Microsoft.Identity.Client.Labs/PublicApi/PublicAPI.Unshipped.txt b/src/client/Microsoft.Identity.Client.Labs/PublicApi/PublicAPI.Unshipped.txt
new file mode 100644
index 0000000000..b4cfc52ffe
--- /dev/null
+++ b/src/client/Microsoft.Identity.Client.Labs/PublicApi/PublicAPI.Unshipped.txt
@@ -0,0 +1,61 @@
+Microsoft.Identity.Client.Labs.AppCredentials
+Microsoft.Identity.Client.Labs.AppCredentials.AppCredentials(string! clientId, string! clientSecret = "", byte[]? pfxBytes = null, string! pfxPassword = "") -> void
+Microsoft.Identity.Client.Labs.AppCredentials.ClientId.get -> string!
+Microsoft.Identity.Client.Labs.AppCredentials.ClientSecret.get -> string!
+Microsoft.Identity.Client.Labs.AppCredentials.PfxBytes.get -> byte[]!
+Microsoft.Identity.Client.Labs.AppCredentials.PfxPassword.get -> string!
+Microsoft.Identity.Client.Labs.AppKind
+Microsoft.Identity.Client.Labs.AppKind.ConfidentialClient = 1 -> Microsoft.Identity.Client.Labs.AppKind
+Microsoft.Identity.Client.Labs.AppKind.Daemon = 2 -> Microsoft.Identity.Client.Labs.AppKind
+Microsoft.Identity.Client.Labs.AppKind.PublicClient = 0 -> Microsoft.Identity.Client.Labs.AppKind
+Microsoft.Identity.Client.Labs.AppKind.WebApi = 3 -> Microsoft.Identity.Client.Labs.AppKind
+Microsoft.Identity.Client.Labs.AppKind.WebApp = 4 -> Microsoft.Identity.Client.Labs.AppKind
+Microsoft.Identity.Client.Labs.AppSecretKeys
+Microsoft.Identity.Client.Labs.AppSecretKeys.AppSecretKeys(string! clientIdSecret, string! clientSecretSecret = "", string! pfxSecret = "", string! pfxPasswordSecret = "") -> void
+Microsoft.Identity.Client.Labs.AppSecretKeys.ClientIdSecret.get -> string!
+Microsoft.Identity.Client.Labs.AppSecretKeys.ClientSecretSecret.get -> string!
+Microsoft.Identity.Client.Labs.AppSecretKeys.PfxPasswordSecret.get -> string!
+Microsoft.Identity.Client.Labs.AppSecretKeys.PfxSecret.get -> string!
+Microsoft.Identity.Client.Labs.AuthType
+Microsoft.Identity.Client.Labs.AuthType.Basic = 0 -> Microsoft.Identity.Client.Labs.AuthType
+Microsoft.Identity.Client.Labs.AuthType.Federated = 1 -> Microsoft.Identity.Client.Labs.AuthType
+Microsoft.Identity.Client.Labs.AuthType.Guest = 3 -> Microsoft.Identity.Client.Labs.AuthType
+Microsoft.Identity.Client.Labs.AuthType.Mfa = 2 -> Microsoft.Identity.Client.Labs.AuthType
+Microsoft.Identity.Client.Labs.CloudType
+Microsoft.Identity.Client.Labs.CloudType.Canary = 5 -> Microsoft.Identity.Client.Labs.CloudType
+Microsoft.Identity.Client.Labs.CloudType.China = 3 -> Microsoft.Identity.Client.Labs.CloudType
+Microsoft.Identity.Client.Labs.CloudType.Dod = 2 -> Microsoft.Identity.Client.Labs.CloudType
+Microsoft.Identity.Client.Labs.CloudType.Gcc = 1 -> Microsoft.Identity.Client.Labs.CloudType
+Microsoft.Identity.Client.Labs.CloudType.Germany = 4 -> Microsoft.Identity.Client.Labs.CloudType
+Microsoft.Identity.Client.Labs.CloudType.Public = 0 -> Microsoft.Identity.Client.Labs.CloudType
+Microsoft.Identity.Client.Labs.IAccountMapProvider
+Microsoft.Identity.Client.Labs.IAccountMapProvider.GetUsernameMap() -> System.Collections.Generic.IReadOnlyDictionary<(Microsoft.Identity.Client.Labs.AuthType auth, Microsoft.Identity.Client.Labs.CloudType cloud, Microsoft.Identity.Client.Labs.Scenario scenario), string!>!
+Microsoft.Identity.Client.Labs.IAccountResolver
+Microsoft.Identity.Client.Labs.IAccountResolver.ResolveUserAsync(Microsoft.Identity.Client.Labs.AuthType auth, Microsoft.Identity.Client.Labs.CloudType cloud, Microsoft.Identity.Client.Labs.Scenario scenario, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<(string! Username, string! Password)>!
+Microsoft.Identity.Client.Labs.IAppMapProvider
+Microsoft.Identity.Client.Labs.IAppMapProvider.GetAppMap() -> System.Collections.Generic.IReadOnlyDictionary<(Microsoft.Identity.Client.Labs.CloudType cloud, Microsoft.Identity.Client.Labs.Scenario scenario, Microsoft.Identity.Client.Labs.AppKind kind), Microsoft.Identity.Client.Labs.AppSecretKeys!>!
+Microsoft.Identity.Client.Labs.IAppResolver
+Microsoft.Identity.Client.Labs.IAppResolver.ResolveAppAsync(Microsoft.Identity.Client.Labs.CloudType cloud, Microsoft.Identity.Client.Labs.Scenario scenario, Microsoft.Identity.Client.Labs.AppKind kind, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task!
+Microsoft.Identity.Client.Labs.LabsOptions
+Microsoft.Identity.Client.Labs.LabsOptions.EnableConventionFallback.get -> bool
+Microsoft.Identity.Client.Labs.LabsOptions.EnableConventionFallback.set -> void
+Microsoft.Identity.Client.Labs.LabsOptions.GlobalPasswordSecret.get -> string!
+Microsoft.Identity.Client.Labs.LabsOptions.GlobalPasswordSecret.set -> void
+Microsoft.Identity.Client.Labs.LabsOptions.KeyVaultUri.get -> System.Uri!
+Microsoft.Identity.Client.Labs.LabsOptions.KeyVaultUri.set -> void
+Microsoft.Identity.Client.Labs.LabsOptions.LabsOptions() -> void
+Microsoft.Identity.Client.Labs.LabsOptions.PasswordSecretByCloud.get -> System.Collections.Generic.Dictionary!
+Microsoft.Identity.Client.Labs.LabsOptions.PasswordSecretByCloud.set -> void
+Microsoft.Identity.Client.Labs.LabsOptions.PasswordSecretByTuple.get -> System.Collections.Generic.Dictionary!
+Microsoft.Identity.Client.Labs.LabsOptions.PasswordSecretByTuple.set -> void
+Microsoft.Identity.Client.Labs.LabsServiceCollectionExtensions
+Microsoft.Identity.Client.Labs.Scenario
+Microsoft.Identity.Client.Labs.Scenario.Basic = 0 -> Microsoft.Identity.Client.Labs.Scenario
+Microsoft.Identity.Client.Labs.Scenario.Cca = 2 -> Microsoft.Identity.Client.Labs.Scenario
+Microsoft.Identity.Client.Labs.Scenario.Daemon = 5 -> Microsoft.Identity.Client.Labs.Scenario
+Microsoft.Identity.Client.Labs.Scenario.DeviceCode = 3 -> Microsoft.Identity.Client.Labs.Scenario
+Microsoft.Identity.Client.Labs.Scenario.Obo = 1 -> Microsoft.Identity.Client.Labs.Scenario
+Microsoft.Identity.Client.Labs.Scenario.Ropc = 4 -> Microsoft.Identity.Client.Labs.Scenario
+static Microsoft.Identity.Client.Labs.LabsServiceCollectionExtensions.AddAccountMapProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+static Microsoft.Identity.Client.Labs.LabsServiceCollectionExtensions.AddAppMapProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
+static Microsoft.Identity.Client.Labs.LabsServiceCollectionExtensions.AddLabsIdentity(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services, System.Action! configure) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
diff --git a/tests/Microsoft.Identity.Client.Labs.Tests/AccountMapAggregatorTests.cs b/tests/Microsoft.Identity.Client.Labs.Tests/AccountMapAggregatorTests.cs
new file mode 100644
index 0000000000..a1f20b843e
--- /dev/null
+++ b/tests/Microsoft.Identity.Client.Labs.Tests/AccountMapAggregatorTests.cs
@@ -0,0 +1,53 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.Options;
+using Microsoft.Identity.Client.Labs;
+using Microsoft.Identity.Client.Labs.Internal;
+using Microsoft.Identity.Client.Labs.Tests.TestDoubles;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.Identity.Client.Labs.Tests.Unit
+{
+ [TestClass]
+ public class AccountMapAggregatorTests
+ {
+ // Renamed to avoid hiding Microsoft.Extensions.Options.Options
+ private static IOptions CreateOpts(bool enableConvention = true) =>
+ Microsoft.Extensions.Options.Options.Create(
+ new LabsOptions { EnableConventionFallback = enableConvention });
+
+ [TestMethod]
+ public void Username_From_Provider_Is_Used()
+ {
+ var provider = new FakeAccountMapProvider(new Dictionary<(AuthType, CloudType, Scenario), string>
+ {
+ { (AuthType.Basic, CloudType.Public, Scenario.Basic), "cld_basic_public_basic_uname" }
+ });
+
+ var agg = new AccountMapAggregator(new[] { provider }, CreateOpts());
+ var name = agg.GetUsernameSecret(AuthType.Basic, CloudType.Public, Scenario.Basic);
+
+ Assert.AreEqual("cld_basic_public_basic_uname", name);
+ }
+
+ [TestMethod]
+ public void Username_Convention_When_Missing_And_Enabled()
+ {
+ var agg = new AccountMapAggregator(Array.Empty(), CreateOpts(enableConvention: true));
+ var name = agg.GetUsernameSecret(AuthType.Federated, CloudType.Public, Scenario.Obo);
+
+ Assert.AreEqual("cld_federated_public_obo_uname", name);
+ }
+
+ [TestMethod]
+ public void Username_Throws_When_Missing_And_Convention_Disabled()
+ {
+ var agg = new AccountMapAggregator(Array.Empty(), CreateOpts(enableConvention: false));
+ Assert.ThrowsException(() =>
+ agg.GetUsernameSecret(AuthType.Basic, CloudType.Public, Scenario.Obo));
+ }
+ }
+}
diff --git a/tests/Microsoft.Identity.Client.Labs.Tests/AccountResolverTests.cs b/tests/Microsoft.Identity.Client.Labs.Tests/AccountResolverTests.cs
new file mode 100644
index 0000000000..b431568df7
--- /dev/null
+++ b/tests/Microsoft.Identity.Client.Labs.Tests/AccountResolverTests.cs
@@ -0,0 +1,78 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Options;
+using Microsoft.Identity.Client.Labs;
+using Microsoft.Identity.Client.Labs.Internal;
+using Microsoft.Identity.Client.Labs.Tests.TestDoubles;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.Identity.Client.Labs.Tests.Unit
+{
+ [TestClass]
+ public class AccountResolverTests
+ {
+ [TestMethod]
+ public async Task Resolves_User_From_Store_And_Policy()
+ {
+ var acctMap = new FakeAccountMapProvider(new Dictionary<(AuthType, CloudType, Scenario), string>
+ {
+ { (AuthType.Basic, CloudType.Public, Scenario.Obo), "cld_basic_public_obo_uname" }
+ });
+
+ var opts = Options.Create(new LabsOptions { GlobalPasswordSecret = "msidlab1_pwd" });
+ var agg = new AccountMapAggregator(new[] { acctMap }, opts);
+
+ // Use a non-sensitive, generated test value (prevents CredScan false positives)
+ var expectedPassword = $"UT_{Guid.NewGuid():N}";
+
+ var store = new FakeSecretStore(new Dictionary
+ {
+ ["cld_basic_public_obo_uname"] = "ci-user@contoso.onmicrosoft.com",
+ ["msidlab1_pwd"] = expectedPassword
+ });
+
+ var resolver = new AccountResolver(agg, store);
+
+ var (u, p) = await resolver
+ .ResolveUserAsync(AuthType.Basic, CloudType.Public, Scenario.Obo)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual("ci-user@contoso.onmicrosoft.com", u);
+ Assert.AreEqual(expectedPassword, p);
+ }
+
+ [TestMethod]
+ public async Task Uses_Convention_For_Username_When_Missing()
+ {
+ var acctMap = new FakeAccountMapProvider(new Dictionary<(AuthType, CloudType, Scenario), string>());
+ var opts = Options.Create(new LabsOptions
+ {
+ GlobalPasswordSecret = "msidlab1_pwd",
+ EnableConventionFallback = true
+ });
+
+ var agg = new AccountMapAggregator(new[] { acctMap }, opts);
+
+ var expectedPassword = $"UT_{Guid.NewGuid():N}";
+
+ var store = new FakeSecretStore(new Dictionary
+ {
+ ["cld_basic_public_basic_uname"] = "ci-basic@contoso.onmicrosoft.com",
+ ["msidlab1_pwd"] = expectedPassword
+ });
+
+ var resolver = new AccountResolver(agg, store);
+
+ var (u, p) = await resolver
+ .ResolveUserAsync(AuthType.Basic, CloudType.Public, Scenario.Basic)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual("ci-basic@contoso.onmicrosoft.com", u);
+ Assert.AreEqual(expectedPassword, p);
+ }
+ }
+}
diff --git a/tests/Microsoft.Identity.Client.Labs.Tests/AppMapAggregatorTests.cs b/tests/Microsoft.Identity.Client.Labs.Tests/AppMapAggregatorTests.cs
new file mode 100644
index 0000000000..01b06223dc
--- /dev/null
+++ b/tests/Microsoft.Identity.Client.Labs.Tests/AppMapAggregatorTests.cs
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using Microsoft.Identity.Client.Labs;
+using Microsoft.Identity.Client.Labs.Internal;
+using Microsoft.Identity.Client.Labs.Tests.TestDoubles;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.Identity.Client.Labs.Tests.Unit
+{
+ [TestClass]
+ public class AppMapAggregatorTests
+ {
+ [TestMethod]
+ public void Resolves_App_Keys()
+ {
+ var map = new Dictionary<(CloudType, Scenario, AppKind), AppSecretKeys>
+ {
+ { (CloudType.Public, Scenario.Obo, AppKind.ConfidentialClient),
+ new AppSecretKeys("cid", "csec", "pfx", "pfxpwd") }
+ };
+
+ var provider = new FakeAppMapProvider(map);
+ var agg = new AppMapAggregator(new[] { provider });
+
+ var keys = agg.ResolveKeys(CloudType.Public, Scenario.Obo, AppKind.ConfidentialClient);
+
+ Assert.AreEqual("cid", keys.ClientIdSecret);
+ Assert.AreEqual("csec", keys.ClientSecretSecret);
+ Assert.AreEqual("pfx", keys.PfxSecret);
+ Assert.AreEqual("pfxpwd", keys.PfxPasswordSecret);
+ }
+
+ [TestMethod]
+ public void Throws_When_App_Key_Missing()
+ {
+ var provider = new FakeAppMapProvider(new Dictionary<(CloudType, Scenario, AppKind), AppSecretKeys>());
+ var agg = new AppMapAggregator(new[] { provider });
+
+ Assert.ThrowsException(() =>
+ agg.ResolveKeys(CloudType.Public, Scenario.Cca, AppKind.ConfidentialClient));
+ }
+ }
+}
diff --git a/tests/Microsoft.Identity.Client.Labs.Tests/AppResolverTests.cs b/tests/Microsoft.Identity.Client.Labs.Tests/AppResolverTests.cs
new file mode 100644
index 0000000000..1aae6466ff
--- /dev/null
+++ b/tests/Microsoft.Identity.Client.Labs.Tests/AppResolverTests.cs
@@ -0,0 +1,103 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Identity.Client.Labs;
+using Microsoft.Identity.Client.Labs.Internal;
+using Microsoft.Identity.Client.Labs.Tests.TestDoubles;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.Identity.Client.Labs.Tests.Unit
+{
+ [TestClass]
+ public class AppResolverTests
+ {
+ [TestMethod]
+ public async Task Resolves_App_With_Secret()
+ {
+ var appMap = new FakeAppMapProvider(new Dictionary<(CloudType, Scenario, AppKind), AppSecretKeys>
+ {
+ { (CloudType.Public, Scenario.Obo, AppKind.ConfidentialClient),
+ new AppSecretKeys("cid", "csec") }
+ });
+
+ // Generate a non-sensitive test value
+ var expectedClientSecret = $"UT_{Guid.NewGuid():N}";
+
+ var store = new FakeSecretStore(new Dictionary
+ {
+ ["cid"] = "11111111-1111-1111-1111-111111111111",
+ ["csec"] = expectedClientSecret
+ });
+
+ var agg = new AppMapAggregator(new[] { appMap });
+ var resolver = new AppResolver(agg, store);
+
+ var app = await resolver.ResolveAppAsync(CloudType.Public, Scenario.Obo, AppKind.ConfidentialClient)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual("11111111-1111-1111-1111-111111111111", app.ClientId);
+ Assert.AreEqual(expectedClientSecret, app.ClientSecret);
+ CollectionAssert.AreEqual(Array.Empty(), app.PfxBytes);
+ Assert.AreEqual(string.Empty, app.PfxPassword);
+ }
+
+ [TestMethod]
+ public async Task Resolves_App_With_Pfx()
+ {
+ var pfxBytes = new byte[] { 1, 2, 3, 4, 5 };
+ var b64 = Convert.ToBase64String(pfxBytes);
+
+ var appMap = new FakeAppMapProvider(new Dictionary<(CloudType, Scenario, AppKind), AppSecretKeys>
+ {
+ { (CloudType.Public, Scenario.Obo, AppKind.WebApi),
+ new AppSecretKeys("api_cid", "", "api_pfx", "api_pfxpwd") }
+ });
+
+ var expectedPfxPassword = $"UTPFX_{Guid.NewGuid():N}";
+
+ var store = new FakeSecretStore(new Dictionary
+ {
+ ["api_cid"] = "22222222-2222-2222-2222-222222222222",
+ ["api_pfx"] = b64,
+ ["api_pfxpwd"] = expectedPfxPassword
+ });
+
+ var agg = new AppMapAggregator(new[] { appMap });
+ var resolver = new AppResolver(agg, store);
+
+ var app = await resolver.ResolveAppAsync(CloudType.Public, Scenario.Obo, AppKind.WebApi)
+ .ConfigureAwait(false);
+
+ Assert.AreEqual("22222222-2222-2222-2222-222222222222", app.ClientId);
+ CollectionAssert.AreEqual(pfxBytes, app.PfxBytes);
+ Assert.AreEqual(expectedPfxPassword, app.PfxPassword);
+ }
+
+ [TestMethod]
+ public async Task Throws_On_Invalid_Base64_Pfx()
+ {
+ var appMap = new FakeAppMapProvider(new Dictionary<(CloudType, Scenario, AppKind), AppSecretKeys>
+ {
+ { (CloudType.Public, Scenario.Obo, AppKind.WebApi),
+ new AppSecretKeys("api_cid", "", "api_pfx", "api_pfxpwd") }
+ });
+
+ var store = new FakeSecretStore(new Dictionary
+ {
+ ["api_cid"] = "cid",
+ ["api_pfx"] = "NOT-BASE64",
+ ["api_pfxpwd"] = "UT_IGNORE_THIS_VALUE" // benign literal
+ });
+
+ var agg = new AppMapAggregator(new[] { appMap });
+ var resolver = new AppResolver(agg, store);
+
+ await Assert.ThrowsExceptionAsync(async () =>
+ await resolver.ResolveAppAsync(CloudType.Public, Scenario.Obo, AppKind.WebApi).ConfigureAwait(false))
+ .ConfigureAwait(false);
+ }
+ }
+}
diff --git a/tests/Microsoft.Identity.Client.Labs.Tests/DiRegistrationTests.cs b/tests/Microsoft.Identity.Client.Labs.Tests/DiRegistrationTests.cs
new file mode 100644
index 0000000000..c609a5241c
--- /dev/null
+++ b/tests/Microsoft.Identity.Client.Labs.Tests/DiRegistrationTests.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Collections.Generic;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Identity.Client.Labs;
+using Microsoft.Identity.Client.Labs.Internal;
+using Microsoft.Identity.Client.Labs.Tests.TestDoubles;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.Identity.Client.Labs.Tests.Unit
+{
+ [TestClass]
+ public class DiRegistrationTests
+ {
+ [TestMethod]
+ public void Registers_Services_And_Allows_Store_Override()
+ {
+ var services = new ServiceCollection();
+
+ // Neutral secret-name placeholders (avoid "secret/password/pwd" keywords)
+ const string NameUser = "UT_NAME_USER";
+ const string NameGlobal = "UT_NAME_GLOBAL";
+ const string NameClientId = "UT_NAME_CLIENTID";
+
+ services.AddLabsIdentity(o =>
+ {
+ o.KeyVaultUri = new Uri("https://example.vault.azure.net/");
+ // This is a *secret name* (key), not a secret value.
+ o.GlobalPasswordSecret = NameGlobal;
+ });
+
+ // Minimal maps so resolvers have something to resolve
+ services.AddSingleton(sp =>
+ new FakeAccountMapProvider(new Dictionary<(AuthType, CloudType, Scenario), string>
+ {
+ { (AuthType.Basic, CloudType.Public, Scenario.Basic), NameUser }
+ }));
+
+ services.AddSingleton(sp =>
+ new FakeAppMapProvider(new Dictionary<(CloudType, Scenario, AppKind), AppSecretKeys>
+ {
+ { (CloudType.Public, Scenario.Basic, AppKind.PublicClient),
+ new AppSecretKeys(NameClientId) }
+ }));
+
+ // Generate a benign placeholder for secret *values*
+ var placeholderValue = $"UT_{Guid.NewGuid():N}";
+
+ // Register a fake store: keys are secret *names*; values are placeholders
+ services.AddSingleton(sp =>
+ new FakeSecretStore(new Dictionary
+ {
+ [NameUser] = "user@example.com",
+ [NameGlobal] = placeholderValue,
+ [NameClientId] = "33333333-3333-3333-3333-333333333333"
+ }));
+
+ var sp = services.BuildServiceProvider();
+
+ var acct = sp.GetRequiredService();
+ var app = sp.GetRequiredService();
+
+ Assert.IsNotNull(acct);
+ Assert.IsNotNull(app);
+ }
+ }
+}
diff --git a/tests/Microsoft.Identity.Client.Labs.Tests/Microsoft.Identity.Client.Labs.Tests.csproj b/tests/Microsoft.Identity.Client.Labs.Tests/Microsoft.Identity.Client.Labs.Tests.csproj
new file mode 100644
index 0000000000..0b1991fbe4
--- /dev/null
+++ b/tests/Microsoft.Identity.Client.Labs.Tests/Microsoft.Identity.Client.Labs.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net8.0
+ false
+ enable
+
+
+
+ false
+ false
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/Microsoft.Identity.Client.Labs.Tests/PasswordSelectionPolicyTests.cs b/tests/Microsoft.Identity.Client.Labs.Tests/PasswordSelectionPolicyTests.cs
new file mode 100644
index 0000000000..965a8249a9
--- /dev/null
+++ b/tests/Microsoft.Identity.Client.Labs.Tests/PasswordSelectionPolicyTests.cs
@@ -0,0 +1,89 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using Microsoft.Extensions.Options;
+using Microsoft.Identity.Client.Labs;
+using Microsoft.Identity.Client.Labs.Internal;
+using Microsoft.Identity.Client.Labs.Tests.TestDoubles;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Microsoft.Identity.Client.Labs.Tests.Unit
+{
+ [TestClass]
+ public class PasswordSelectionPolicyTests
+ {
+ private static AccountMapAggregator MakeAgg(LabsOptions options)
+ {
+ var provider = new FakeAccountMapProvider(new Dictionary<(AuthType, CloudType, Scenario), string>
+ {
+ { (AuthType.Basic, CloudType.Public, Scenario.Obo), "cld_basic_public_obo_uname" }
+ });
+
+ return new AccountMapAggregator(new[] { provider }, Options.Create(options));
+ }
+
+ [TestMethod]
+ public void Tuple_Override_Wins_Over_Cloud_And_Global()
+ {
+ var options = new LabsOptions
+ {
+ GlobalPasswordSecret = "global_pwd",
+ PasswordSecretByCloud = new() { [CloudType.Public] = "cloud_pwd" },
+ PasswordSecretByTuple = new() { ["basic.public.obo"] = "tuple_pwd" }
+ };
+
+ var agg = MakeAgg(options);
+ var pwd = agg.GetPasswordSecret(AuthType.Basic, CloudType.Public, Scenario.Obo);
+
+ Assert.AreEqual("tuple_pwd", pwd);
+ }
+
+ [TestMethod]
+ public void Cloud_Override_Wins_Over_Global()
+ {
+ var options = new LabsOptions
+ {
+ GlobalPasswordSecret = "global_pwd",
+ PasswordSecretByCloud = new() { [CloudType.Public] = "cloud_pwd" }
+ };
+
+ var agg = MakeAgg(options);
+ var pwd = agg.GetPasswordSecret(AuthType.Basic, CloudType.Public, Scenario.Obo);
+
+ Assert.AreEqual("cloud_pwd", pwd);
+ }
+
+ [TestMethod]
+ public void Global_Is_Used_When_No_Overrides()
+ {
+ var options = new LabsOptions { GlobalPasswordSecret = "global_pwd" };
+
+ var agg = MakeAgg(options);
+ var pwd = agg.GetPasswordSecret(AuthType.Basic, CloudType.Public, Scenario.Obo);
+
+ Assert.AreEqual("global_pwd", pwd);
+ }
+
+ [TestMethod]
+ public void Convention_Used_When_No_Config_And_Enabled()
+ {
+ var options = new LabsOptions { EnableConventionFallback = true };
+
+ var agg = MakeAgg(options);
+ var pwd = agg.GetPasswordSecret(AuthType.Basic, CloudType.Public, Scenario.Obo);
+
+ Assert.AreEqual("cld_basic_public_obo_pwd", pwd);
+ }
+
+ [TestMethod]
+ public void Throws_When_No_Config_And_Convention_Disabled()
+ {
+ var options = new LabsOptions { EnableConventionFallback = false };
+ var agg = MakeAgg(options);
+
+ Assert.ThrowsException(() =>
+ agg.GetPasswordSecret(AuthType.Basic, CloudType.Public, Scenario.Obo));
+ }
+ }
+}
diff --git a/tests/Microsoft.Identity.Client.Labs.Tests/TestDoubles/Fakes.cs b/tests/Microsoft.Identity.Client.Labs.Tests/TestDoubles/Fakes.cs
new file mode 100644
index 0000000000..9423cf08d0
--- /dev/null
+++ b/tests/Microsoft.Identity.Client.Labs.Tests/TestDoubles/Fakes.cs
@@ -0,0 +1,50 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Identity.Client.Labs;
+using Microsoft.Identity.Client.Labs.Internal;
+
+namespace Microsoft.Identity.Client.Labs.Tests.TestDoubles
+{
+ internal sealed class FakeAccountMapProvider : IAccountMapProvider
+ {
+ private readonly IReadOnlyDictionary<(AuthType, CloudType, Scenario), string> _map;
+
+ public FakeAccountMapProvider(IReadOnlyDictionary<(AuthType, CloudType, Scenario), string> map)
+ => _map = map;
+
+ public IReadOnlyDictionary<(AuthType auth, CloudType cloud, Scenario scenario), string> GetUsernameMap() => _map;
+ }
+
+ internal sealed class FakeAppMapProvider : IAppMapProvider
+ {
+ private readonly IReadOnlyDictionary<(CloudType, Scenario, AppKind), AppSecretKeys> _map;
+
+ public FakeAppMapProvider(IReadOnlyDictionary<(CloudType, Scenario, AppKind), AppSecretKeys> map)
+ => _map = map;
+
+ public IReadOnlyDictionary<(CloudType cloud, Scenario scenario, AppKind kind), AppSecretKeys> GetAppMap() => _map;
+ }
+
+ internal sealed class FakeSecretStore : ISecretStore
+ {
+ private readonly Dictionary _secrets = new();
+
+ public FakeSecretStore(IDictionary? initial = null)
+ {
+ if (initial != null)
+ {
+ foreach (var kv in initial)
+ _secrets[kv.Key] = kv.Value;
+ }
+ }
+
+ public Task GetAsync(string secretName, CancellationToken ct = default)
+ => Task.FromResult(_secrets.TryGetValue(secretName, out var v) ? v : string.Empty);
+
+ public void Set(string name, string value) => _secrets[name] = value;
+ }
+}