diff --git a/Directory.Build.props b/Directory.Build.props
index 40f433882..c70a855e4 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -95,7 +95,7 @@
4.6.0
4.36.0
4.57.0-preview
- 9.0.0
+ 9.1.0
8.0.5
diff --git a/NuGet.Config b/NuGet.Config
index 7604d0051..b6260ec73 100644
--- a/NuGet.Config
+++ b/NuGet.Config
@@ -3,5 +3,6 @@
+
diff --git a/docs/blog-posts/symmetric-key-support.md b/docs/blog-posts/symmetric-key-support.md
new file mode 100644
index 000000000..2f581fa4e
--- /dev/null
+++ b/docs/blog-posts/symmetric-key-support.md
@@ -0,0 +1,220 @@
+# Adding Support for Symmetric Keys in Microsoft.Identity.Web
+
+## Overview
+This proposal outlines the addition of symmetric key support for signing credentials in Microsoft.Identity.Web, allowing keys to be loaded from Key Vault or Base64 encoded strings while maintaining clean abstractions.
+
+## Requirements
+1. Support symmetric keys from:
+ - Azure Key Vault secrets
+ - Base64 encoded strings
+2. Avoid circular dependencies with Microsoft.IdentityModel
+3. Follow existing patterns in the codebase
+4. Maintain backward compatibility
+
+## Developer Experience
+The implementation provides a straightforward and type-safe approach to working with symmetric keys while maintaining clean separation of concerns:
+
+### Key Management
+When working with symmetric keys, developers can utilize two primary sources:
+
+1. **Azure Key Vault Integration**
+ ```csharp
+ var credentials = new CredentialDescription
+ {
+ SourceType = CredentialSource.SymmetricKeyFromKeyVault,
+ KeyVaultUrl = "https://your-vault.vault.azure.net",
+ KeyVaultSecretName = "your-secret-name"
+ };
+ ```
+
+2. **Direct Base64 Encoded Keys**
+ ```csharp
+ var credentials = new CredentialDescription
+ {
+ SourceType = CredentialSource.SymmetricKeyBase64Encoded,
+ Base64EncodedValue = "your-base64-encoded-key"
+ };
+ ```
+
+### Implementation Details
+- The DefaultCredentialLoader automatically selects the appropriate loader based on the SourceType
+- Key material is loaded and converted to a SymmetricSecurityKey
+- The security key is stored in the CachedValue property of CredentialDescription
+- This design maintains independence from Microsoft.IdentityModel types in the abstractions layer
+- The implementation follows the same pattern as certificate handling for consistency
+
+## Design
+
+### 1. New CredentialSource Values(Abstractions Layer)
+```csharp
+public enum CredentialSource
+{
+ // Existing values
+ Certificate = 0,
+ KeyVault = 1,
+ Base64Encoded = 2,
+ Path = 3,
+ StoreWithThumbprint = 4,
+ StoreWithDistinguishedName = 5,
+
+ // New values
+ SymmetricKeyFromKeyVault = 6,
+ SymmetricKeyBase64Encoded = 7
+}
+```
+
+### 2. SymmetricKeyDescription Class(IdWeb Layer)
+Following the same pattern as CertificateDescription:
+
+```csharp
+public class SymmetricKeyDescription : CredentialDescription
+{
+ public static SymmetricKeyDescription FromKeyVault(string keyVaultUrl, string secretName)
+ {
+ return new SymmetricKeyDescription
+ {
+ SourceType = CredentialSource.SymmetricKeyFromKeyVault,
+ KeyVaultUrl = keyVaultUrl,
+ KeyVaultSecretName = secretName
+ };
+ }
+
+ public static SymmetricKeyDescription FromBase64Encoded(string base64EncodedValue)
+ {
+ return new SymmetricKeyDescription
+ {
+ SourceType = CredentialSource.SymmetricKeyBase64Encoded,
+ Base64EncodedValue = base64EncodedValue
+ };
+ }
+}
+```
+
+### 3. New Loader Classes(IdWeb Layer)
+Internal implementation in Microsoft.Identity.Web:
+
+```csharp
+internal class KeyVaultSymmetricKeyLoader : ICredentialSourceLoader
+{
+ private readonly SecretClient _secretClient;
+
+ public KeyVaultSymmetricKeyLoader(SecretClient secretClient)
+ {
+ _secretClient = secretClient ?? throw new ArgumentNullException(nameof(secretClient));
+ }
+
+ public async Task LoadIfNeededAsync(CredentialDescription description, CredentialSourceLoaderParameters? parameters)
+ {
+ _ = Throws.IfNull(description);
+
+ if (description.CachedValue != null)
+ return;
+
+ if (string.IsNullOrEmpty(description.KeyVaultUrl))
+ throw new ArgumentException("KeyVaultUrl is required for KeyVault source");
+
+ if (string.IsNullOrEmpty(description.KeyVaultSecretName))
+ throw new ArgumentException("KeyVaultSecretName is required for KeyVault source");
+
+ // Load secret from Key Vault
+ var secret = await _secretClient.GetSecretAsync(description.KeyVaultSecretName).ConfigureAwait(false);
+ if (secret?.Value == null)
+ throw new InvalidOperationException($"Secret {description.KeyVaultSecretName} not found in Key Vault");
+
+ try
+ {
+ // Convert secret value to bytes and create SymmetricSecurityKey
+ var keyBytes = Convert.FromBase64String(secret.Value.Value);
+ description.CachedValue = new SymmetricSecurityKey(keyBytes);
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Failed to create symmetric key from Key Vault secret: {ex.Message}", ex);
+ }
+ }
+}
+
+internal class Base64EncodedSymmetricKeyLoader : ICredentialSourceLoader
+{
+ public async Task LoadIfNeededAsync(CredentialDescription description, CredentialSourceLoaderParameters? parameters)
+ {
+ _ = Throws.IfNull(description);
+
+ if (description.CachedValue != null)
+ return;
+
+ if (string.IsNullOrEmpty(description.Base64EncodedValue))
+ throw new ArgumentException("Base64EncodedValue is required for Base64Encoded source");
+
+ try
+ {
+ // Convert Base64 string to bytes and create SymmetricSecurityKey
+ var keyBytes = Convert.FromBase64String(description.Base64EncodedValue);
+ description.CachedValue = new SymmetricSecurityKey(keyBytes);
+ }
+ catch (Exception ex)
+ {
+ throw new FormatException("Invalid Base64 string for symmetric key", ex);
+ }
+
+ await Task.CompletedTask.ConfigureAwait(false);
+ }
+}
+```
+
+### 4. DefaultCredentialsLoader Changes(IdWeb Layer)
+Update the loader to handle both certificate and symmetric key scenarios:
+
+```csharp
+public partial class DefaultCredentialsLoader : ICredentialsLoader, ISigningCredentialsLoader
+{
+ public DefaultCredentialsLoader(ILogger? logger)
+ {
+ _logger = logger ?? new NullLogger();
+
+ CredentialSourceLoaders = new Dictionary
+ {
+ // Existing certificate loaders
+ { CredentialSource.KeyVault, new KeyVaultCertificateLoader() },
+ { CredentialSource.Path, new FromPathCertificateLoader() },
+ { CredentialSource.StoreWithThumbprint, new StoreWithThumbprintCertificateLoader() },
+ { CredentialSource.StoreWithDistinguishedName, new StoreWithDistinguishedNameCertificateLoader() },
+ { CredentialSource.Base64Encoded, new Base64EncodedCertificateLoader() },
+
+ // New symmetric key loaders
+ { CredentialSource.SymmetricKeyFromKeyVault, new KeyVaultSymmetricKeyLoader(_secretClient) },
+ { CredentialSource.SymmetricKeyBase64Encoded, new Base64EncodedSymmetricKeyLoader() }
+ };
+ }
+
+ public async Task LoadSigningCredentialsAsync(
+ CredentialDescription credentialDescription,
+ CredentialSourceLoaderParameters? parameters = null)
+ {
+ _ = Throws.IfNull(credentialDescription);
+
+ try
+ {
+ await LoadCredentialsIfNeededAsync(credentialDescription, parameters);
+
+ if (credentialDescription.Certificate != null)
+ {
+ return new X509SigningCredentials(
+ credentialDescription.Certificate,
+ credentialDescription.Algorithm);
+ }
+ else if (credentialDescription.CachedValue is SymmetricSecurityKey key)
+ {
+ return new SigningCredentials(key, credentialDescription.Algorithm);
+ }
+
+ return null;
+ }
+ catch (Exception ex)
+ {
+ Logger.CredentialLoadingFailure(_logger, credentialDescription, ex);
+ throw;
+ }
+ }
+}
+```
diff --git a/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs b/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs
index 7b6cade30..63391b656 100644
--- a/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs
+++ b/src/Microsoft.Identity.Web.Certificate/DefaultCredentialsLoader.cs
@@ -9,13 +9,14 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Identity.Abstractions;
+using Microsoft.IdentityModel.Tokens;
namespace Microsoft.Identity.Web
{
///
/// Default credentials loader.
///
- public partial class DefaultCredentialsLoader : ICredentialsLoader
+ public partial class DefaultCredentialsLoader : ICredentialsLoader, ISigningCredentialsLoader
{
private readonly ILogger _logger;
private readonly ConcurrentDictionary _loadingSemaphores = new ConcurrentDictionary();
@@ -48,13 +49,13 @@ public DefaultCredentialsLoader() : this(null)
}
///
- /// Dictionary of credential loaders per credential source. The application can add more to
+ /// Dictionary of credential loaders per credential source. The application can add more to
/// process additional credential sources(like dSMS).
///
public IDictionary CredentialSourceLoaders { get; }
///
- /// Load the credentials from the description, if needed.
+ /// Load the credentials from the description, if needed.
/// Important: Ignores SKIP flag, propagates exceptions.
public async Task LoadCredentialsIfNeededAsync(CredentialDescription credentialDescription, CredentialSourceLoaderParameters? parameters = null)
{
@@ -99,9 +100,9 @@ public async Task LoadCredentialsIfNeededAsync(CredentialDescription credentialD
}
///
- /// Loads first valid credential which is not marked as Skipped.
+ /// Loads first valid credential which is not marked as Skipped.
public async Task LoadFirstValidCredentialsAsync(
- IEnumerable credentialDescriptions,
+ IEnumerable credentialDescriptions,
CredentialSourceLoaderParameters? parameters = null)
{
foreach (var credentialDescription in credentialDescriptions)
@@ -116,6 +117,31 @@ public async Task LoadCredentialsIfNeededAsync(CredentialDescription credentialD
return null;
}
+ ///
+ public async Task LoadSigningCredentialsAsync(
+ CredentialDescription credentialDescription,
+ CredentialSourceLoaderParameters? parameters = null)
+ {
+ _ = Throws.IfNull(credentialDescription);
+
+ try
+ {
+ await LoadCredentialsIfNeededAsync(credentialDescription, parameters);
+
+ if (credentialDescription.Certificate != null)
+ {
+ return new X509SigningCredentials(credentialDescription.Certificate, credentialDescription.Algorithm);
+ }
+
+ return null;
+ }
+ catch (Exception ex)
+ {
+ Logger.CredentialLoadingFailure(_logger, credentialDescription, ex);
+ throw;
+ }
+ }
+
///
public void ResetCredentials(IEnumerable credentialDescriptions)
{
diff --git a/src/Microsoft.Identity.Web.Certificate/ISigningCredentialsLoader.cs b/src/Microsoft.Identity.Web.Certificate/ISigningCredentialsLoader.cs
new file mode 100644
index 000000000..2a703c618
--- /dev/null
+++ b/src/Microsoft.Identity.Web.Certificate/ISigningCredentialsLoader.cs
@@ -0,0 +1,25 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System.Threading.Tasks;
+using Microsoft.Identity.Abstractions;
+using Microsoft.IdentityModel.Tokens;
+
+namespace Microsoft.Identity.Web
+{
+ ///
+ /// Interface for loading signing credentials.
+ ///
+ public interface ISigningCredentialsLoader
+ {
+ ///
+ /// Loads SigningCredentials from the credential description.
+ ///
+ /// Credential description.
+ /// Optional parameters for loading credentials.
+ /// SigningCredentials if successful, null otherwise.
+ Task LoadSigningCredentialsAsync(
+ CredentialDescription credentialDescription,
+ CredentialSourceLoaderParameters? parameters = null);
+ }
+}
diff --git a/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt b/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt
index e69de29bb..55bdbaf7d 100644
--- a/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt
+++ b/src/Microsoft.Identity.Web.Certificate/PublicAPI.Unshipped.txt
@@ -0,0 +1,3 @@
+Microsoft.Identity.Web.DefaultCredentialsLoader.LoadSigningCredentialsAsync(Microsoft.Identity.Abstractions.CredentialDescription! credentialDescription, Microsoft.Identity.Abstractions.CredentialSourceLoaderParameters? parameters = null) -> System.Threading.Tasks.Task!
+Microsoft.Identity.Web.ISigningCredentialsLoader
+Microsoft.Identity.Web.ISigningCredentialsLoader.LoadSigningCredentialsAsync(Microsoft.Identity.Abstractions.CredentialDescription! credentialDescription, Microsoft.Identity.Abstractions.CredentialSourceLoaderParameters? parameters = null) -> System.Threading.Tasks.Task!
\ No newline at end of file
diff --git a/tests/Microsoft.Identity.Web.Test/Certificates/DefaultCredentialsLoaderSigningCredentialsTests.cs b/tests/Microsoft.Identity.Web.Test/Certificates/DefaultCredentialsLoaderSigningCredentialsTests.cs
new file mode 100644
index 000000000..bfb20fc70
--- /dev/null
+++ b/tests/Microsoft.Identity.Web.Test/Certificates/DefaultCredentialsLoaderSigningCredentialsTests.cs
@@ -0,0 +1,130 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using System;
+using System.Security.Cryptography.X509Certificates;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+using Microsoft.Identity.Abstractions;
+using Microsoft.Identity.Web.Test.Common;
+using Microsoft.IdentityModel.Tokens;
+using Xunit;
+
+namespace Microsoft.Identity.Web.Test.Certificates
+{
+ public class DefaultCredentialsLoaderSigningCredentialsTests
+ {
+ private readonly CustomMockLogger _logger;
+ private readonly DefaultCredentialsLoader _loader;
+
+ public DefaultCredentialsLoaderSigningCredentialsTests()
+ {
+ _logger = new CustomMockLogger();
+ _loader = new DefaultCredentialsLoader(_logger);
+ }
+
+ [Fact]
+ public async Task LoadSigningCredentialsAsync_NullDescription_ThrowsArgumentNullException()
+ {
+ // Act & Assert
+ await Assert.ThrowsAsync(
+ () => _loader.LoadSigningCredentialsAsync(null!, null));
+ }
+
+ [Theory]
+ [MemberData(nameof(LoadSigningCredentialsTheoryData))]
+ public async Task LoadSigningCredentialsAsync_Tests(SigningCredentialsTheoryData data)
+ {
+ // Act
+ if (data.ExpectedException != null)
+ {
+ var exception = await Assert.ThrowsAsync(
+ data.ExpectedException.GetType(),
+ () => _loader.LoadSigningCredentialsAsync(data.CredentialDescription, null));
+
+ return;
+ }
+
+ var result = await _loader.LoadSigningCredentialsAsync(data.CredentialDescription, null);
+
+ // Assert
+ if (data.ExpectNullResult)
+ {
+ Assert.Null(result);
+ }
+ else
+ {
+ Assert.NotNull(result);
+ Assert.IsType(result);
+ Assert.Equal(data.CredentialDescription.Algorithm, result.Algorithm);
+ Assert.NotNull(((X509SigningCredentials)result).Certificate);
+ }
+ }
+
+ public static TheoryData LoadSigningCredentialsTheoryData()
+ {
+ return new TheoryData
+ {
+ // Test with Base64 encoded certificate with private key
+ new SigningCredentialsTheoryData
+ {
+ CredentialDescription = CertificateDescription.FromBase64Encoded(
+ TestConstants.CertificateX5cWithPrivateKey,
+ TestConstants.CertificateX5cWithPrivateKeyPassword),
+ ExpectNullResult = false,
+ Algorithm = SecurityAlgorithms.RsaSha512
+ },
+
+ // Test with certificate from file
+ new SigningCredentialsTheoryData
+ {
+ CredentialDescription = CertificateDescription.FromPath(
+ "Certificates/SelfSignedTestCert.pfx",
+ TestConstants.CertificateX5cWithPrivateKeyPassword),
+ ExpectNullResult = false,
+ Algorithm = SecurityAlgorithms.RsaSha384
+ },
+
+ // Test with invalid certificate description
+ new SigningCredentialsTheoryData
+ {
+ CredentialDescription = new CertificateDescription(),
+ ExpectNullResult = true,
+ Algorithm = SecurityAlgorithms.RsaSha256
+ },
+
+ // Test with invalid Base64 value (should throw)
+ new SigningCredentialsTheoryData
+ {
+ CredentialDescription = CertificateDescription.FromBase64Encoded("invalid"),
+ ExpectNullResult = false,
+ ExpectedException = new FormatException(),
+ Algorithm = SecurityAlgorithms.RsaSha256
+ },
+
+ // Test with invalid file path (should throw)
+ new SigningCredentialsTheoryData
+ {
+ CredentialDescription = CertificateDescription.FromPath(
+ "nonexistent.pfx",
+ TestConstants.CertificateX5cWithPrivateKeyPassword),
+ ExpectNullResult = false,
+ ExpectedException = new System.Security.Cryptography.CryptographicException(),
+ Algorithm = SecurityAlgorithms.RsaSha256
+ }
+ };
+ }
+ }
+
+ public class SigningCredentialsTheoryData
+ {
+ public CertificateDescription CredentialDescription { get; set; } = new CertificateDescription();
+ public bool ExpectNullResult { get; set; }
+ public Exception? ExpectedException { get; set; }
+ public string Algorithm
+ {
+ get => CredentialDescription.Algorithm!;
+ set => CredentialDescription.Algorithm = value;
+ }
+ }
+}