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; + } +}