Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
<AzureSecurityKeyVaultCertificatesVersion>4.6.0</AzureSecurityKeyVaultCertificatesVersion>
<MicrosoftGraphVersion>4.36.0</MicrosoftGraphVersion>
<MicrosoftGraphBetaVersion>4.57.0-preview</MicrosoftGraphBetaVersion>
<MicrosoftIdentityAbstractionsVersion>9.0.0</MicrosoftIdentityAbstractionsVersion>
<MicrosoftIdentityAbstractionsVersion>9.1.0</MicrosoftIdentityAbstractionsVersion>
Copy link
Collaborator

Choose a reason for hiding this comment

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

It's not published, @RojaEnnam FYI

<!--CVE-2024-43485-->
<SystemTextJsonVersion>8.0.5</SystemTextJsonVersion>
<!--CVE-2023-29331-->
Expand Down
1 change: 1 addition & 0 deletions NuGet.Config
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
<packageSources>
<clear />
<add key="NuGet" value="https://api.nuget.org/v3/index.json" />
<add key="Local" value="C:\repos\Builds\abstractions" />
Copy link
Collaborator

Choose a reason for hiding this comment

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

No longer needed

</packageSources>
</configuration>
220 changes: 220 additions & 0 deletions docs/blog-posts/symmetric-key-support.md
Original file line number Diff line number Diff line change
@@ -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,
Copy link
Collaborator

Choose a reason for hiding this comment

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

It would not be 6, which is already used.
Is it a common scenario to get symmetric keys from KeyVault? or would they rather come from somewhere else? (like the metadata document). Do we have customer requests for symmetric keys?

Would it be a thing to expose the symmetric key as base64encoded?

In that case should the Algorithm be used? or is it already in the key?

Copy link
Author

Choose a reason for hiding this comment

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

I will update the right number in the implementation, this is created by CLINE and it knew only the IdWeb files.

For ServerNoncePolicy the credentials can also be Symmetric, today we have this supported in SAL. I am not aware of other places the key can come from, but today in SAL they are being expected to come from either KeyVault or base64encoded string. Algorithm is still used while creating the SigningCredentials from the key see here

SymmetricKeyBase64Encoded = 7
}
```

### 2. SymmetricKeyDescription Class(IdWeb Layer)
Following the same pattern as CertificateDescription:

```csharp
public class SymmetricKeyDescription : CredentialDescription
Copy link
Member

Choose a reason for hiding this comment

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

The CredentialDescription class already has the responsibility of managing different types of credentials - see the CredentialType property - it can handle Certificates, Signed Assertions (i.e. JWT tokens with a special audience to denote they are actually credentials), client secrets and decrypt keys.

Have you considered just extending CredentialDescription instead of creating a new class? What are the pros / cons?

{
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);
Copy link
Member

@bgavrilMS bgavrilMS May 27, 2025

Choose a reason for hiding this comment

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

The fact that CachedValue is an object is requires users to cast to the right object (string, X509Certificate2 etc.).

At least for certificates, a Certificate property also exists to avoid a cast. I suggest you do the same for symmetric keys?

Copy link
Member

Choose a reason for hiding this comment

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

(read my comments below about extending CredentialDescription first)

}
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<DefaultCredentialsLoader>? logger)
{
_logger = logger ?? new NullLogger<DefaultCredentialsLoader>();

CredentialSourceLoaders = new Dictionary<CredentialSource, ICredentialSourceLoader>
{
// 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<SigningCredentials?> LoadSigningCredentialsAsync(
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if a simpler design would be to rely on the existing object model, i.e. ICredentialsLoader and to treat the SymmetricSecurityKey as another type of credential - see CredentialType enum (alongside DecryptKeys, which is also not used by MSAL).

Apart from simpelr object model, this would avoid mixing the concept of SigningCredentials in Id.Web. I believe SigningCredentials + X509 has some perf implications (i.e. loading it often is expensive, caching needs to be done).

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;
}
}
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <summary>
/// Default credentials loader.
/// </summary>
public partial class DefaultCredentialsLoader : ICredentialsLoader
public partial class DefaultCredentialsLoader : ICredentialsLoader, ISigningCredentialsLoader
{
private readonly ILogger<DefaultCredentialsLoader> _logger;
private readonly ConcurrentDictionary<string, SemaphoreSlim> _loadingSemaphores = new ConcurrentDictionary<string, SemaphoreSlim>();
Expand Down Expand Up @@ -48,13 +49,13 @@ public DefaultCredentialsLoader() : this(null)
}

/// <summary>
/// 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).
/// </summary>
public IDictionary<CredentialSource, ICredentialSourceLoader> CredentialSourceLoaders { get; }

/// <inheritdoc/>
/// 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)
{
Expand Down Expand Up @@ -99,9 +100,9 @@ public async Task LoadCredentialsIfNeededAsync(CredentialDescription credentialD
}

/// <inheritdoc/>
/// Loads first valid credential which is not marked as Skipped.
/// Loads first valid credential which is not marked as Skipped.
public async Task<CredentialDescription?> LoadFirstValidCredentialsAsync(
IEnumerable<CredentialDescription> credentialDescriptions,
IEnumerable<CredentialDescription> credentialDescriptions,
CredentialSourceLoaderParameters? parameters = null)
{
foreach (var credentialDescription in credentialDescriptions)
Expand All @@ -116,6 +117,31 @@ public async Task LoadCredentialsIfNeededAsync(CredentialDescription credentialD
return null;
}

/// <inheritdoc/>
public async Task<SigningCredentials?> LoadSigningCredentialsAsync(
CredentialDescription credentialDescription,
Copy link
Member

Choose a reason for hiding this comment

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

nit: spacing (add a tab to the params)

CredentialSourceLoaderParameters? parameters = null)
{
_ = Throws.IfNull(credentialDescription);

try
{
await LoadCredentialsIfNeededAsync(credentialDescription, parameters);

if (credentialDescription.Certificate != null)
Copy link
Author

Choose a reason for hiding this comment

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

This shows creating the SigningCredentials only from the certificate. I've included the proposal to add support for loading SymmetricSecurityKey in this PR.

{
return new X509SigningCredentials(credentialDescription.Certificate, credentialDescription.Algorithm);
}

return null;
Copy link
Author

Choose a reason for hiding this comment

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

Returning null if Certificate is not loaded because a null certificate is a valid state. Open to suggestions.

}
catch (Exception ex)
{
Logger.CredentialLoadingFailure(_logger, credentialDescription, ex);
throw;
}
}

/// <inheritdoc/>
public void ResetCredentials(IEnumerable<CredentialDescription> credentialDescriptions)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Interface for loading signing credentials.
/// </summary>
public interface ISigningCredentialsLoader
{
/// <summary>
/// Loads SigningCredentials from the credential description.
/// </summary>
/// <param name="credentialDescription">Credential description.</param>
/// <param name="parameters">Optional parameters for loading credentials.</param>
/// <returns>SigningCredentials if successful, null otherwise.</returns>
Task<SigningCredentials?> LoadSigningCredentialsAsync(
Copy link
Member

Choose a reason for hiding this comment

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

Not sure this is needed if CredentialDescription can alraedy return an X509Certificate2 and potentially a SymmetricKey . It becomes just a convenience method.

CredentialDescription credentialDescription,
CredentialSourceLoaderParameters? parameters = null);
}
}
Original file line number Diff line number Diff line change
@@ -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.IdentityModel.Tokens.SigningCredentials?>!
Microsoft.Identity.Web.ISigningCredentialsLoader
Microsoft.Identity.Web.ISigningCredentialsLoader.LoadSigningCredentialsAsync(Microsoft.Identity.Abstractions.CredentialDescription! credentialDescription, Microsoft.Identity.Abstractions.CredentialSourceLoaderParameters? parameters = null) -> System.Threading.Tasks.Task<Microsoft.IdentityModel.Tokens.SigningCredentials?>!
Loading
Loading