From 07cd5f9b170db51da7b6a8b5442ce1be6b7f311d Mon Sep 17 00:00:00 2001
From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com>
Date: Thu, 30 Apr 2026 08:50:27 -0700
Subject: [PATCH 1/2] Revise mTLS-bound token API documentation
Updated the API documentation to reflect changes in the mTLS-bound token handling, including new options and migration paths for IdWeb and direct MSAL consumers.
---
docs/msi_v2/msal-bound-token-api-spec.md | 488 +++++++++++++++++++++++
1 file changed, 488 insertions(+)
create mode 100644 docs/msi_v2/msal-bound-token-api-spec.md
diff --git a/docs/msi_v2/msal-bound-token-api-spec.md b/docs/msi_v2/msal-bound-token-api-spec.md
new file mode 100644
index 0000000000..87b4e48c90
--- /dev/null
+++ b/docs/msi_v2/msal-bound-token-api-spec.md
@@ -0,0 +1,488 @@
+# MSAL .NET API Spec: `WithMtlsPopFallback()` + `MtlsPopOptions` — Bound Token Acquisition with Fallback
+
+**Status:** Draft
+**Date:** April 30, 2026
+**Applies to:** `Microsoft.Identity.Client` (MSAL .NET)
+**Related PR:** [AzureAD/microsoft-identity-web#3773](https://github.com/AzureAD/microsoft-identity-web/pull/3773)
+
+---
+
+## 1. Problem Statement
+
+### Current State (PR #3773)
+
+IdWeb currently calls MSAL's low-level APIs explicitly:
+
+```csharp
+// Pure MSI path (TokenAcquisition.cs)
+miBuilder.WithMtlsProofOfPossession()
+ .WithAttestationSupport();
+
+// FIC path (ManagedIdentityClientAssertion.cs)
+miBuilder.WithMtlsProofOfPossession()
+ .WithAttestationSupport();
+```
+
+This requires IdWeb (a higher-level SDK) to:
+1. **Know about attestation** as a binding mechanism — a low-level implementation detail.
+2. **Take a package dependency** on `Microsoft.Identity.Client.KeyAttestation`.
+3. **Hard-code the binding strategy** — no fallback if attestation fails.
+
+### Current MSAL Behavior When Things Go Wrong
+
+| Scenario | Current Behavior | Desired Behavior |
+|----------|-----------------|------------------|
+| KeyGuard key + attestation succeeds | ✅ Works | ✅ Same |
+| KeyGuard key + attestation provider not configured | ✅ Non-attested flow | ✅ Same |
+| KeyGuard key + attestation **fails** (exception) | ❌ **Throws `attestation_failed`** | 🔄 Fall back to non-attested flow |
+| Non-KeyGuard key (Hardware/InMemory) | ❌ **Throws `mtls_pop_requires_keyguard`** | 🔄 Proceed with non-attested mTLS PoP |
+| mTLS PoP not supported (IMDSv1 host) | ❌ Throws | ❌ Throws (correct — no fallback to bearer) |
+
+### Design Principle
+
+> **IdWeb needs a bound token. What MSAL does internally to get it should not be visible to IdWeb.**
+>
+> MSAL should try attested flow first, and if that fails, fall back to non-attested flow. The fallback is transparent to the caller.
+
+---
+
+## 2. Proposed API
+
+Three-level API surface — from simplest to most configurable:
+
+| API | Use Case | Fallback? |
+|-----|----------|-----------|
+| `WithMtlsPopFallback()` | **IdWeb / higher-level SDKs** — recommended | ✅ Yes |
+| `WithMtlsProofOfPossession(MtlsPopOptions)` | Advanced callers needing fine-grained control | Configurable via options |
+| `WithMtlsProofOfPossession()` | Existing strict API — no fallback | ❌ No |
+
+### 2.1 New Convenience Method: `WithMtlsPopFallback()`
+
+**Package:** `Microsoft.Identity.Client` (core package)
+**Target class:** `AcquireTokenForManagedIdentityParameterBuilder`
+
+```csharp
+namespace Microsoft.Identity.Client
+{
+ public static class ManagedIdentityPopExtensions
+ {
+ ///
+ /// Requests an mTLS-bound (Proof-of-Possession) token with automatic fallback.
+ /// MSAL will first attempt the attested binding flow (if WithAttestationSupport()
+ /// was called). If attestation fails, MSAL silently falls back to the
+ /// non-attested mTLS PoP flow instead of throwing.
+ ///
+ /// This is the recommended API for higher-level SDKs (e.g., Microsoft.Identity.Web)
+ /// that need a bound token without coupling to specific binding mechanisms.
+ ///
+ /// Equivalent to:
+ /// WithMtlsProofOfPossession(new MtlsPopOptions { EnableFallback = true })
+ ///
+ /// The AcquireTokenForManagedIdentityParameterBuilder instance.
+ /// The builder to chain .With methods.
+ public static AcquireTokenForManagedIdentityParameterBuilder WithMtlsPopFallback(
+ this AcquireTokenForManagedIdentityParameterBuilder builder)
+ {
+ return builder.WithMtlsProofOfPossession(
+ new MtlsPopOptions { EnableFallback = true });
+ }
+ }
+}
+```
+
+### 2.2 New Overload: `WithMtlsProofOfPossession(MtlsPopOptions)`
+
+```csharp
+namespace Microsoft.Identity.Client
+{
+ public static class ManagedIdentityPopExtensions
+ {
+ ///
+ /// Enables mTLS Proof-of-Possession with configurable behavior.
+ /// Use to control fallback and other settings.
+ ///
+ /// The AcquireTokenForManagedIdentityParameterBuilder instance.
+ /// Options controlling mTLS PoP behavior.
+ /// The builder to chain .With methods.
+ public static AcquireTokenForManagedIdentityParameterBuilder WithMtlsProofOfPossession(
+ this AcquireTokenForManagedIdentityParameterBuilder builder,
+ MtlsPopOptions options)
+ {
+ if (!DesktopOsHelper.IsWindows())
+ {
+ throw new MsalClientException(
+ MsalError.MtlsNotSupportedForManagedIdentity,
+ MsalErrorMessage.MtlsNotSupportedForNonWindowsMessage);
+ }
+
+ builder.CommonParameters.IsMtlsPopRequested = true;
+ builder.CommonParameters.IsBoundTokenFallbackEnabled = options.EnableFallback;
+ return builder;
+ }
+
+ // Existing API — unchanged, strict, no fallback.
+ public static AcquireTokenForManagedIdentityParameterBuilder WithMtlsProofOfPossession(
+ this AcquireTokenForManagedIdentityParameterBuilder builder) { /* unchanged */ }
+ }
+}
+```
+
+### 2.3 New Options Class: `MtlsPopOptions`
+
+```csharp
+namespace Microsoft.Identity.Client
+{
+ ///
+ /// Options for configuring mTLS Proof-of-Possession token acquisition behavior.
+ ///
+ public class MtlsPopOptions
+ {
+ ///
+ /// When true, MSAL will attempt attested binding first, and if attestation
+ /// fails (provider not configured, attestation exception, or KeyGuard unavailable),
+ /// silently fall back to non-attested mTLS PoP binding.
+ ///
+ /// When false (default), MSAL uses strict mode: attestation failures
+ /// and missing KeyGuard keys result in exceptions.
+ ///
+ /// Default: false
+ ///
+ public bool EnableFallback { get; set; }
+ }
+}
+```
+
+### 2.4 `WithAttestationSupport()` — Unchanged
+
+`WithAttestationSupport()` stays in `Microsoft.Identity.Client.KeyAttestation` package. It **still needs to be called by IdWeb** because:
+1. It brings in the **native Credential Guard DLL** via the KeyAttestation package.
+2. It **registers** the attestation token provider delegate on the builder.
+3. Without it, the fallback chain simply skips attestation and proceeds to non-attested binding.
+
+```csharp
+// KeyAttestation package — NO changes to this API
+namespace Microsoft.Identity.Client.KeyAttestation
+{
+ public static class ManagedIdentityAttestationExtensions
+ {
+ ///
+ /// Registers the Credential Guard attestation provider.
+ /// Call after WithMtlsPopFallback() or WithMtlsProofOfPossession().
+ ///
+ /// When used with WithMtlsPopFallback():
+ /// - Attestation is attempted first
+ /// - On failure, MSAL falls back to non-attested flow
+ ///
+ /// When used with WithMtlsProofOfPossession() (no options):
+ /// - Attestation failure throws an exception (strict mode)
+ ///
+ public static AcquireTokenForManagedIdentityParameterBuilder WithAttestationSupport(
+ this AcquireTokenForManagedIdentityParameterBuilder builder)
+ {
+ // Unchanged — sets AttestationTokenProvider delegate
+ }
+ }
+}
+```
+
+---
+
+## 3. New Internal Property
+
+Add to `AcquireTokenCommonParameters`:
+
+```csharp
+internal class AcquireTokenCommonParameters
+{
+ // Existing
+ public bool IsMtlsPopRequested { get; set; }
+ public Func<...> AttestationTokenProvider { get; set; }
+
+ // NEW — enables the fallback chain when set via WithMtlsPopFallback()
+ // or WithMtlsProofOfPossession(new MtlsPopOptions { EnableFallback = true })
+ public bool IsBoundTokenFallbackEnabled { get; set; }
+}
+```
+
+Propagate to `AcquireTokenForManagedIdentityParameters`:
+
+```csharp
+internal class AcquireTokenForManagedIdentityParameters
+{
+ public bool IsMtlsPopRequested { get; set; }
+ public bool IsBoundTokenFallbackEnabled { get; set; } // NEW
+ public Func<...> AttestationTokenProvider { get; set; }
+}
+```
+
+---
+
+## 4. Fallback Chain Logic in `ImdsV2ManagedIdentitySource`
+
+### 4.1 Key Type Validation (Replace Throw with Fallback)
+
+**Current code** (`CreateRequestAsync`, line ~350):
+```csharp
+if (keyInfo.Type != ManagedIdentityKeyType.KeyGuard)
+{
+ throw new MsalClientException(
+ "mtls_pop_requires_keyguard",
+ $"mTLS PoP requires KeyGuard keys. Current key type: {keyInfo.Type}");
+}
+```
+
+**Proposed change:**
+```csharp
+if (keyInfo.Type != ManagedIdentityKeyType.KeyGuard)
+{
+ if (!parameters.IsBoundTokenFallbackEnabled)
+ {
+ // Explicit WithMtlsProofOfPossession() — strict mode, throw as before
+ throw new MsalClientException(
+ "mtls_pop_requires_keyguard",
+ $"mTLS PoP requires KeyGuard keys. Current key type: {keyInfo.Type}");
+ }
+
+ // WithMtlsPopFallback() — proceed with whatever key type is available
+ _requestContext.Logger.Info(
+ $"[ImdsV2] KeyGuard not available (key type: {keyInfo.Type}). " +
+ "Proceeding with non-attested mTLS PoP binding.");
+}
+```
+
+### 4.2 Attestation Failure (Replace Throw with Fallback)
+
+**Current code** (`GetAttestationJwtAsync`, line ~498):
+```csharp
+catch (Exception ex)
+{
+ throw new MsalClientException(
+ "attestation_failed",
+ $"[ImdsV2] Attestation token provider failed: {ex.Message}",
+ ex);
+}
+```
+
+**Proposed change:**
+```csharp
+catch (Exception ex)
+{
+ if (!_isBoundTokenFallbackEnabled)
+ {
+ // Explicit WithMtlsProofOfPossession() + WithAttestationSupport()
+ // — strict mode, throw as before
+ throw new MsalClientException(
+ "attestation_failed",
+ $"[ImdsV2] Attestation token provider failed: {ex.Message}",
+ ex);
+ }
+
+ // WithMtlsPopFallback() — swallow attestation failure, proceed without attestation
+ _requestContext.Logger.Warning(
+ $"[ImdsV2] Attestation failed ({ex.Message}). " +
+ "Falling back to non-attested mTLS PoP flow.");
+ return null; // null attestation JWT → non-attested flow
+}
+```
+
+### 4.3 Full Fallback Chain (Summary)
+
+When `WithMtlsPopFallback()` (or `WithMtlsProofOfPossession(new MtlsPopOptions { EnableFallback = true })`) is used, MSAL executes the following chain:
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ WithMtlsPopFallback() + WithAttestationSupport() │
+│ │
+│ 1. Get/create key from key provider │
+│ ├── KeyGuard available? │
+│ │ ├── YES → Try attested flow │
+│ │ │ ├── Attestation succeeds → Use attested JWT │
+│ │ │ └── Attestation fails → Log warning, │
+│ │ │ proceed non-attested │
+│ │ └── NO → Log info, proceed with available key type │
+│ │ │
+│ 2. Generate CSR with available key │
+│ 3. Request certificate from IMDS V2 │
+│ 4. Acquire token with mTLS binding │
+│ │
+│ Result: Best available bound token │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ WithMtlsPopFallback() WITHOUT WithAttestationSupport() │
+│ │
+│ 1. Get/create key from key provider │
+│ 2. Skip attestation (no provider registered) │
+│ 3. Generate CSR with available key │
+│ 4. Request certificate from IMDS V2 │
+│ 5. Acquire token with non-attested mTLS binding │
+│ │
+│ Result: Non-attested bound token │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+---
+
+## 5. Impact on IdWeb (PR #3773)
+
+### Before (Current PR)
+```csharp
+// TokenAcquisition.cs — Pure MSI
+if (isTokenBinding)
+{
+ miBuilder.WithMtlsProofOfPossession()
+ .WithAttestationSupport(); // IdWeb knows about attestation
+}
+
+// ManagedIdentityClientAssertion.cs — FIC
+if (IsTokenBinding)
+{
+ miBuilder.WithMtlsProofOfPossession()
+ .WithAttestationSupport(); // IdWeb knows about attestation
+}
+```
+
+### After — Option 1: `WithMtlsPopFallback()` (Recommended)
+```csharp
+// TokenAcquisition.cs — Pure MSI
+if (isTokenBinding)
+{
+ miBuilder.WithMtlsPopFallback() // "try attested, fall back to non-attested"
+ .WithAttestationSupport(); // Still needed: brings in native DLL
+}
+
+// ManagedIdentityClientAssertion.cs — FIC
+if (IsTokenBinding)
+{
+ miBuilder.WithMtlsPopFallback() // "try attested, fall back to non-attested"
+ .WithAttestationSupport(); // Still needed: brings in native DLL
+}
+```
+
+### After — Option 2: `WithMtlsProofOfPossession(MtlsPopOptions)` (Equivalent, explicit)
+```csharp
+// TokenAcquisition.cs — Pure MSI
+if (isTokenBinding)
+{
+ miBuilder.WithMtlsProofOfPossession(new MtlsPopOptions { EnableFallback = true })
+ .WithAttestationSupport(); // Still needed: brings in native DLL
+}
+
+// ManagedIdentityClientAssertion.cs — FIC
+if (IsTokenBinding)
+{
+ miBuilder.WithMtlsProofOfPossession(new MtlsPopOptions { EnableFallback = true })
+ .WithAttestationSupport(); // Still needed: brings in native DLL
+}
+```
+
+> Both options produce identical behavior. Option 1 is syntactic sugar for Option 2.
+
+### Key Difference
+- **Before:** IdWeb says _"use mTLS PoP **with** attestation"_ (prescriptive — knows the mechanism, no fallback)
+- **After:** IdWeb says _"get me a bound token with fallback"_ + _"I have attestation capability available"_ (declarative)
+- `WithAttestationSupport()` is now purely a **capability registration** ("I have the native DLL"), not a strategy directive
+- If attestation fails at runtime, MSAL falls back transparently — IdWeb never sees the failure
+- `WithMtlsProofOfPossession()` (no args) remains available as the strict, no-fallback API
+
+---
+
+## 6. Telemetry & Logging
+
+### New Log Messages
+
+| Level | Message | When |
+|-------|---------|------|
+| Info | `[ImdsV2] WithMtlsPopFallback: KeyGuard not available (key type: {type}). Using non-attested binding.` | Key provider returns non-KeyGuard key |
+| Warning | `[ImdsV2] WithMtlsPopFallback: Attestation failed ({message}). Falling back to non-attested binding.` | Attestation delegate throws |
+| Info | `[ImdsV2] WithMtlsPopFallback: Attestation not configured. Using non-attested binding.` | No `WithAttestationSupport()` called |
+| Info | `[ImdsV2] WithMtlsPopFallback: Attested binding succeeded.` | Full attested flow completed |
+
+### Telemetry Events
+
+Add to `ApiEvent` or equivalent:
+
+```csharp
+public enum MtlsBindingOutcome
+{
+ Attested, // Full attested KeyGuard flow
+ NonAttestedKeyGuard, // KeyGuard key, attestation skipped/failed
+ NonAttestedOther, // Hardware or InMemory key
+ NotRequested // No mTLS binding requested
+}
+```
+
+---
+
+## 7. Cache Key Partitioning
+
+The current cache key includes an attestation tag (`#att=0` / `#att=1`). With fallback, a request may start as attested and fall back to non-attested.
+
+**Strategy:** Partition by **actual outcome**, not intent. After the binding is resolved:
+
+```csharp
+// Use the actual attestation outcome for the cache key
+string attestationTag = (attestationJwt != null) ? AttestationTagEnabled : AttestationTagDisabled;
+return baseKey + attestationTag;
+```
+
+This is already the effective behavior (attestation JWT is resolved before caching), so **no cache key changes needed**.
+
+---
+
+## 8. API Comparison Matrix
+
+| Aspect | `WithMtlsProofOfPossession()` | `WithMtlsProofOfPossession(MtlsPopOptions)` | `WithMtlsPopFallback()` |
+|--------|-------------------------------|----------------------------------------------|-------------------------|
+| Package | `Microsoft.Identity.Client` | `Microsoft.Identity.Client` | `Microsoft.Identity.Client` |
+| Sets `IsMtlsPopRequested` | ✅ | ✅ | ✅ |
+| Sets `IsBoundTokenFallbackEnabled` | ❌ | Configurable | ✅ (`true`) |
+| Requires KeyGuard | ✅ throws if not | Depends on `EnableFallback` | ❌ falls back |
+| On attestation failure | ❌ throws | Depends on `EnableFallback` | 🔄 falls back to non-attested |
+| Intended caller | Advanced / low-level | Fine-grained control | **IdWeb / higher-level SDKs** |
+| Breaking change | None | None (new overload) | None (new method) |
+
+---
+
+## 9. Breaking Changes
+
+**None.** This is purely additive:
+- `WithMtlsProofOfPossession()` behavior is unchanged (strict, no fallback).
+- `WithAttestationSupport()` behavior is unchanged.
+- `WithMtlsProofOfPossession(MtlsPopOptions)` is a new overload.
+- `WithMtlsPopFallback()` is a new convenience method.
+
+---
+
+## 10. Migration Guide
+
+### For IdWeb — Option 1 (recommended)
+```diff
+- miBuilder.WithMtlsProofOfPossession()
+- .WithAttestationSupport();
++ miBuilder.WithMtlsPopFallback()
++ .WithAttestationSupport();
+```
+
+### For IdWeb — Option 2 (explicit via options)
+```diff
+- miBuilder.WithMtlsProofOfPossession()
+- .WithAttestationSupport();
++ miBuilder.WithMtlsProofOfPossession(new MtlsPopOptions { EnableFallback = true })
++ .WithAttestationSupport();
+```
+
+### For direct MSAL consumers who want strict behavior (no fallback)
+No change needed. Continue using `WithMtlsProofOfPossession()` (no args).
+
+---
+
+## 11. Open Questions
+
+1. **Should `WithMtlsPopFallback()` without `WithAttestationSupport()` log a warning?** Currently it would silently use non-attested flow. Should MSAL log an informational message that attestation capability is not registered?
+
+2. **Should the fallback also cover IMDSv1 hosts?** Currently, if the host only supports IMDSv1 (404 from CSR metadata endpoint), the request throws. Should `WithMtlsPopFallback()` fall back to a bearer token on IMDSv1 hosts? *(Likely no — bound token is the requirement, not optional.)*
+
+4. **Future options:** `MtlsPopOptions` is extensible. Future properties could include things like preferred key type, attestation timeout, or custom fallback policies.
From 0028514322eb445008ac82a399d0e276b5d6bc24 Mon Sep 17 00:00:00 2001
From: Gladwin Johnson <90415114+gladjohn@users.noreply.github.com>
Date: Thu, 30 Apr 2026 09:15:40 -0700
Subject: [PATCH 2/2] Fix all review findings: cache key, plumbing, NET462, CNG
fallback, docs accuracy
- Fix Section 7: Cache key partitioning was wrong - both token and cert
caches use provider presence not actual outcome. Documented the bug
and proposed concrete fix for fallback mode.
- Fix Section 3: Full end-to-end plumbing for IsBoundTokenFallbackEnabled
through ApplyMtlsPopAndAttestation() and AuthenticateAsync() capture.
- Fix Section 4: Consistent use of _isBoundTokenFallbackEnabled instance
field. Added Section 4.3 for credential_guard_requires_cng fallback.
- Fix Section 2.2: Added ArgumentNullException for null options and
NET462 guard matching existing WithMtlsProofOfPossession().
- Fix Section 2.3: MtlsPopOptions docs corrected - provider not
configured is existing behavior not a fallback scenario.
- Fix Section 1: Reframed value proposition - KeyAttestation dependency
stays, fallback orchestration moves to MSAL.
- Fix Section 5: Added FIC scope clarification.
- Fix Section 6: Log messages now match Section 4 code exactly.
- Fix WithAttestationSupport docs: call ordering is not required.
- Fix behavior table: added CNG and NET462 rows.
- Fix naming: MSAL .NET -> MSAL.NET per repo conventions.
- Fix open questions numbering (2->4 skip).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
docs/msi_v2/msal-bound-token-api-spec.md | 234 ++++++++++++++++++-----
1 file changed, 190 insertions(+), 44 deletions(-)
diff --git a/docs/msi_v2/msal-bound-token-api-spec.md b/docs/msi_v2/msal-bound-token-api-spec.md
index 87b4e48c90..4e96dbfdb1 100644
--- a/docs/msi_v2/msal-bound-token-api-spec.md
+++ b/docs/msi_v2/msal-bound-token-api-spec.md
@@ -1,8 +1,8 @@
-# MSAL .NET API Spec: `WithMtlsPopFallback()` + `MtlsPopOptions` — Bound Token Acquisition with Fallback
+# MSAL.NET API Spec: `WithMtlsPopFallback()` + `MtlsPopOptions` — Bound Token Acquisition with Fallback
**Status:** Draft
**Date:** April 30, 2026
-**Applies to:** `Microsoft.Identity.Client` (MSAL .NET)
+**Applies to:** `Microsoft.Identity.Client` (MSAL.NET)
**Related PR:** [AzureAD/microsoft-identity-web#3773](https://github.com/AzureAD/microsoft-identity-web/pull/3773)
---
@@ -24,19 +24,23 @@ miBuilder.WithMtlsProofOfPossession()
```
This requires IdWeb (a higher-level SDK) to:
-1. **Know about attestation** as a binding mechanism — a low-level implementation detail.
-2. **Take a package dependency** on `Microsoft.Identity.Client.KeyAttestation`.
-3. **Hard-code the binding strategy** — no fallback if attestation fails.
+1. **Orchestrate fallback policy** — IdWeb hard-codes the binding approach with no fallback if attestation fails at runtime.
+2. **Take a package dependency** on `Microsoft.Identity.Client.KeyAttestation` (this dependency remains, but the fallback orchestration moves to MSAL).
+3. **Couple to low-level mechanism details** — IdWeb explicitly chains `WithMtlsProofOfPossession().WithAttestationSupport()`, prescribing the exact binding strategy rather than declaring intent.
+
+> **Note:** The `Microsoft.Identity.Client.KeyAttestation` package dependency and the `WithAttestationSupport()` call remain in IdWeb because that call brings in the native Credential Guard DLL. This proposal moves the **fallback orchestration** into MSAL, not the package dependency itself.
### Current MSAL Behavior When Things Go Wrong
| Scenario | Current Behavior | Desired Behavior |
|----------|-----------------|------------------|
| KeyGuard key + attestation succeeds | ✅ Works | ✅ Same |
-| KeyGuard key + attestation provider not configured | ✅ Non-attested flow | ✅ Same |
-| KeyGuard key + attestation **fails** (exception) | ❌ **Throws `attestation_failed`** | 🔄 Fall back to non-attested flow |
+| KeyGuard key + attestation provider not configured | ✅ Non-attested flow (returns null) | ✅ Same (not a fallback scenario) |
+| KeyGuard key + attestation **fails** (provider throws) | ❌ **Throws `attestation_failed`** | 🔄 Fall back to non-attested flow |
+| KeyGuard key + key is not RSACng | ❌ **Throws `credential_guard_requires_cng`** | 🔄 Fall back to non-attested flow |
| Non-KeyGuard key (Hardware/InMemory) | ❌ **Throws `mtls_pop_requires_keyguard`** | 🔄 Proceed with non-attested mTLS PoP |
| mTLS PoP not supported (IMDSv1 host) | ❌ Throws | ❌ Throws (correct — no fallback to bearer) |
+| Non-Windows or NET462 | ❌ Throws | ❌ Throws (platform unsupported) |
### Design Principle
@@ -104,10 +108,23 @@ namespace Microsoft.Identity.Client
/// The AcquireTokenForManagedIdentityParameterBuilder instance.
/// Options controlling mTLS PoP behavior.
/// The builder to chain .With methods.
+ ///
+ /// Thrown when is .
+ ///
public static AcquireTokenForManagedIdentityParameterBuilder WithMtlsProofOfPossession(
this AcquireTokenForManagedIdentityParameterBuilder builder,
MtlsPopOptions options)
{
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+#if NET462
+ throw new MsalClientException(
+ MsalError.MtlsNotSupportedForManagedIdentity,
+ MsalErrorMessage.MtlsNotSupportedForManagedIdentityMessage);
+#else
if (!DesktopOsHelper.IsWindows())
{
throw new MsalClientException(
@@ -118,9 +135,11 @@ namespace Microsoft.Identity.Client
builder.CommonParameters.IsMtlsPopRequested = true;
builder.CommonParameters.IsBoundTokenFallbackEnabled = options.EnableFallback;
return builder;
+#endif
}
// Existing API — unchanged, strict, no fallback.
+ // The new overload mirrors the same NET462/non-Windows constraints.
public static AcquireTokenForManagedIdentityParameterBuilder WithMtlsProofOfPossession(
this AcquireTokenForManagedIdentityParameterBuilder builder) { /* unchanged */ }
}
@@ -139,11 +158,17 @@ namespace Microsoft.Identity.Client
{
///
/// When true, MSAL will attempt attested binding first, and if attestation
- /// fails (provider not configured, attestation exception, or KeyGuard unavailable),
- /// silently fall back to non-attested mTLS PoP binding.
+ /// fails at runtime (attestation provider throws, or key is not RSACng for Credential Guard),
+ /// silently fall back to non-attested mTLS PoP binding instead of throwing.
+ /// Also allows non-KeyGuard key types (Hardware, InMemory) to proceed with
+ /// non-attested binding instead of throwing `mtls_pop_requires_keyguard`.
+ ///
+ /// Note: When the attestation provider is not configured (WithAttestationSupport()
+ /// not called), MSAL already proceeds with non-attested flow in both strict
+ /// and fallback modes — this is existing behavior, not a fallback scenario.
///
- /// When false (default), MSAL uses strict mode: attestation failures
- /// and missing KeyGuard keys result in exceptions.
+ /// When false (default), MSAL uses strict mode: attestation provider
+ /// exceptions and non-KeyGuard keys result in exceptions.
///
/// Default: false
///
@@ -167,7 +192,9 @@ namespace Microsoft.Identity.Client.KeyAttestation
{
///
/// Registers the Credential Guard attestation provider.
- /// Call after WithMtlsPopFallback() or WithMtlsProofOfPossession().
+ /// Used with WithMtlsPopFallback() or WithMtlsProofOfPossession() to enable
+ /// attested mTLS PoP flows. Order of calls does not matter — both set
+ /// independent builder state.
///
/// When used with WithMtlsPopFallback():
/// - Attestation is attempted first
@@ -187,7 +214,9 @@ namespace Microsoft.Identity.Client.KeyAttestation
---
-## 3. New Internal Property
+## 3. Internal Plumbing
+
+### 3.1 New Property on Parameter Classes
Add to `AcquireTokenCommonParameters`:
@@ -196,32 +225,79 @@ internal class AcquireTokenCommonParameters
{
// Existing
public bool IsMtlsPopRequested { get; set; }
- public Func<...> AttestationTokenProvider { get; set; }
+ public Func>
+ AttestationTokenProvider { get; set; }
- // NEW — enables the fallback chain when set via WithMtlsPopFallback()
- // or WithMtlsProofOfPossession(new MtlsPopOptions { EnableFallback = true })
+ // NEW
public bool IsBoundTokenFallbackEnabled { get; set; }
}
```
-Propagate to `AcquireTokenForManagedIdentityParameters`:
+Add to `AcquireTokenForManagedIdentityParameters`:
```csharp
internal class AcquireTokenForManagedIdentityParameters
{
public bool IsMtlsPopRequested { get; set; }
public bool IsBoundTokenFallbackEnabled { get; set; } // NEW
- public Func<...> AttestationTokenProvider { get; set; }
+ public Func>
+ AttestationTokenProvider { get; set; }
+}
+```
+
+### 3.2 Propagation in `ApplyMtlsPopAndAttestation()`
+
+The existing `ApplyMtlsPopAndAttestation()` in `AcquireTokenForManagedIdentityParameterBuilder` copies `IsMtlsPopRequested` and `AttestationTokenProvider` from common params to MI params. It must also copy the new flag:
+
+```csharp
+private static void ApplyMtlsPopAndAttestation(
+ AcquireTokenCommonParameters acquireTokenCommonParameters,
+ AcquireTokenForManagedIdentityParameters acquireTokenForManagedIdentityParameters)
+{
+ acquireTokenForManagedIdentityParameters.IsMtlsPopRequested =
+ acquireTokenCommonParameters.IsMtlsPopRequested;
+ acquireTokenForManagedIdentityParameters.AttestationTokenProvider =
+ acquireTokenCommonParameters.AttestationTokenProvider;
+
+ // NEW — propagate fallback flag
+ acquireTokenForManagedIdentityParameters.IsBoundTokenFallbackEnabled =
+ acquireTokenCommonParameters.IsBoundTokenFallbackEnabled;
+
+ // existing cache key partitioning...
+}
+```
+
+### 3.3 Capture in `ImdsV2ManagedIdentitySource`
+
+The `ImdsV2ManagedIdentitySource` already captures `_attestationTokenProvider` from `parameters.AttestationTokenProvider` in its `AuthenticateAsync()` method. The new flag must be captured the same way:
+
+```csharp
+internal class ImdsV2ManagedIdentitySource : AbstractManagedIdentity
+{
+ private Func<...> _attestationTokenProvider;
+ private bool _isBoundTokenFallbackEnabled; // NEW
+
+ public override async Task AuthenticateAsync(
+ AcquireTokenForManagedIdentityParameters parameters,
+ CancellationToken cancellationToken)
+ {
+ _attestationTokenProvider = parameters.AttestationTokenProvider;
+ _isBoundTokenFallbackEnabled = parameters.IsBoundTokenFallbackEnabled; // NEW
+
+ // ... existing logic
+ }
}
```
+This ensures both `_attestationTokenProvider` and `_isBoundTokenFallbackEnabled` are available as instance fields throughout `CreateRequestAsync()`, `GetAttestationJwtAsync()`, and other methods.
+
---
## 4. Fallback Chain Logic in `ImdsV2ManagedIdentitySource`
### 4.1 Key Type Validation (Replace Throw with Fallback)
-**Current code** (`CreateRequestAsync`, line ~350):
+**Current code** (`CreateRequestAsync`):
```csharp
if (keyInfo.Type != ManagedIdentityKeyType.KeyGuard)
{
@@ -235,15 +311,16 @@ if (keyInfo.Type != ManagedIdentityKeyType.KeyGuard)
```csharp
if (keyInfo.Type != ManagedIdentityKeyType.KeyGuard)
{
- if (!parameters.IsBoundTokenFallbackEnabled)
+ if (!_isBoundTokenFallbackEnabled)
{
- // Explicit WithMtlsProofOfPossession() — strict mode, throw as before
+ // Strict mode — throw as before
throw new MsalClientException(
"mtls_pop_requires_keyguard",
$"mTLS PoP requires KeyGuard keys. Current key type: {keyInfo.Type}");
}
- // WithMtlsPopFallback() — proceed with whatever key type is available
+ // Fallback mode — remove the early gate, let the existing non-attested
+ // path in ExecuteCertificateRequestAsync() handle non-KeyGuard keys
_requestContext.Logger.Info(
$"[ImdsV2] KeyGuard not available (key type: {keyInfo.Type}). " +
"Proceeding with non-attested mTLS PoP binding.");
@@ -252,7 +329,7 @@ if (keyInfo.Type != ManagedIdentityKeyType.KeyGuard)
### 4.2 Attestation Failure (Replace Throw with Fallback)
-**Current code** (`GetAttestationJwtAsync`, line ~498):
+**Current code** (`GetAttestationJwtAsync`):
```csharp
catch (Exception ex)
{
@@ -269,15 +346,14 @@ catch (Exception ex)
{
if (!_isBoundTokenFallbackEnabled)
{
- // Explicit WithMtlsProofOfPossession() + WithAttestationSupport()
- // — strict mode, throw as before
+ // Strict mode — throw as before
throw new MsalClientException(
"attestation_failed",
$"[ImdsV2] Attestation token provider failed: {ex.Message}",
ex);
}
- // WithMtlsPopFallback() — swallow attestation failure, proceed without attestation
+ // Fallback mode — swallow attestation failure, proceed without attestation
_requestContext.Logger.Warning(
$"[ImdsV2] Attestation failed ({ex.Message}). " +
"Falling back to non-attested mTLS PoP flow.");
@@ -285,6 +361,38 @@ catch (Exception ex)
}
```
+### 4.3 CNG Key Validation (Replace Throw with Fallback)
+
+**Current code** (`GetAttestationJwtAsync`):
+```csharp
+if (keyInfo.Key is not System.Security.Cryptography.RSACng rsaCng)
+{
+ throw new MsalClientException(
+ "credential_guard_requires_cng",
+ "[ImdsV2] Credential Guard attestation currently supports only RSA CNG keys on Windows.");
+}
+```
+
+**Proposed change:**
+```csharp
+if (keyInfo.Key is not System.Security.Cryptography.RSACng rsaCng)
+{
+ if (!_isBoundTokenFallbackEnabled)
+ {
+ throw new MsalClientException(
+ "credential_guard_requires_cng",
+ "[ImdsV2] Credential Guard attestation currently supports only RSA CNG keys.");
+ }
+
+ _requestContext.Logger.Warning(
+ "[ImdsV2] Key is not RSACng, cannot perform Credential Guard attestation. " +
+ "Falling back to non-attested mTLS PoP flow.");
+ return null;
+}
+```
+
+> **Design decision:** Fallback mode absorbs **all attestation-stage failures** — provider exceptions, CNG key type mismatch, and non-KeyGuard keys. The only failures NOT absorbed are platform-level (non-Windows, NET462) and infrastructure-level (IMDSv1 host, IMDS unreachable).
+
### 4.3 Full Fallback Chain (Summary)
When `WithMtlsPopFallback()` (or `WithMtlsProofOfPossession(new MtlsPopOptions { EnableFallback = true })`) is used, MSAL executes the following chain:
@@ -295,10 +403,12 @@ When `WithMtlsPopFallback()` (or `WithMtlsProofOfPossession(new MtlsPopOptions {
│ │
│ 1. Get/create key from key provider │
│ ├── KeyGuard available? │
-│ │ ├── YES → Try attested flow │
-│ │ │ ├── Attestation succeeds → Use attested JWT │
-│ │ │ └── Attestation fails → Log warning, │
-│ │ │ proceed non-attested │
+│ │ ├── YES → Key is RSACng? │
+│ │ │ ├── YES → Try attested flow │
+│ │ │ │ ├── Succeeds → Use attested JWT │
+│ │ │ │ └── Fails → Log warning, │
+│ │ │ │ proceed non-attested │
+│ │ │ └── NO → Log warning, proceed non-attested │
│ │ └── NO → Log info, proceed with available key type │
│ │ │
│ 2. Generate CSR with available key │
@@ -327,6 +437,8 @@ When `WithMtlsPopFallback()` (or `WithMtlsProofOfPossession(new MtlsPopOptions {
## 5. Impact on IdWeb (PR #3773)
+> **Scope note:** The FIC (Federated Identity Credential) path in IdWeb acquires a managed identity token as a client assertion. This reaches MSAL through `AcquireTokenForManagedIdentity` — the same managed identity builder. No confidential client (`AcquireTokenForClient`) API surface is changed by this proposal.
+
### Before (Current PR)
```csharp
// TokenAcquisition.cs — Pure MSI
@@ -381,9 +493,9 @@ if (IsTokenBinding)
> Both options produce identical behavior. Option 1 is syntactic sugar for Option 2.
### Key Difference
-- **Before:** IdWeb says _"use mTLS PoP **with** attestation"_ (prescriptive — knows the mechanism, no fallback)
-- **After:** IdWeb says _"get me a bound token with fallback"_ + _"I have attestation capability available"_ (declarative)
-- `WithAttestationSupport()` is now purely a **capability registration** ("I have the native DLL"), not a strategy directive
+- **Before:** IdWeb says _"use mTLS PoP **with** attestation"_ (prescriptive — no fallback if attestation fails)
+- **After:** IdWeb says _"get me a bound token with fallback"_ + _"I have attestation capability available"_ (MSAL handles fallback)
+- `WithAttestationSupport()` is now purely a **capability registration** — IdWeb still calls it (and keeps the KeyAttestation package dependency) because it brings in the native DLL, but MSAL decides the fallback policy
- If attestation fails at runtime, MSAL falls back transparently — IdWeb never sees the failure
- `WithMtlsProofOfPossession()` (no args) remains available as the strict, no-fallback API
@@ -393,12 +505,14 @@ if (IsTokenBinding)
### New Log Messages
+These messages should match the exact strings used in the fallback code paths (Section 4):
+
| Level | Message | When |
|-------|---------|------|
-| Info | `[ImdsV2] WithMtlsPopFallback: KeyGuard not available (key type: {type}). Using non-attested binding.` | Key provider returns non-KeyGuard key |
-| Warning | `[ImdsV2] WithMtlsPopFallback: Attestation failed ({message}). Falling back to non-attested binding.` | Attestation delegate throws |
-| Info | `[ImdsV2] WithMtlsPopFallback: Attestation not configured. Using non-attested binding.` | No `WithAttestationSupport()` called |
-| Info | `[ImdsV2] WithMtlsPopFallback: Attested binding succeeded.` | Full attested flow completed |
+| Info | `[ImdsV2] KeyGuard not available (key type: {type}). Proceeding with non-attested mTLS PoP binding.` | Key provider returns non-KeyGuard key (Section 4.1) |
+| Warning | `[ImdsV2] Attestation failed ({message}). Falling back to non-attested mTLS PoP flow.` | Attestation provider delegate throws (Section 4.2) |
+| Warning | `[ImdsV2] Key is not RSACng, cannot perform Credential Guard attestation. Falling back to non-attested mTLS PoP flow.` | KeyGuard key but not RSACng (Section 4.3) |
+| Info | `[ImdsV2] Attestation token provider not configured. Proceeding with non-attested flow.` | No `WithAttestationSupport()` called (existing behavior, unchanged) |
### Telemetry Events
@@ -414,21 +528,53 @@ public enum MtlsBindingOutcome
}
```
+Log `IsBoundTokenFallbackEnabled` in `AcquireTokenForManagedIdentityParameters.LogParameters()` for debugging rollout issues.
+
---
## 7. Cache Key Partitioning
-The current cache key includes an attestation tag (`#att=0` / `#att=1`). With fallback, a request may start as attested and fall back to non-attested.
+Two caches are affected by the attestation tag:
+
+### 7.1 Token Cache (in `AcquireTokenForManagedIdentityParameterBuilder.ApplyMtlsPopAndAttestation()`)
+
+Current code partitions by whether `AttestationTokenProvider` is configured:
+```csharp
+acquireTokenCommonParameters.CacheKeyComponents[MiAttCacheKeyComponent] =
+ _ => acquireTokenCommonParameters.AttestationTokenProvider != null ? s_att1 : s_att0;
+```
+
+### 7.2 Cert Cache (in `ImdsV2ManagedIdentitySource.GetMtlsCertCacheKey()`)
+
+Current code also partitions by provider presence:
+```csharp
+return baseKey + (_attestationTokenProvider != null ? AttestationTagEnabled : AttestationTagDisabled);
+```
+
+### 7.3 Problem with Fallback
+
+With fallback enabled, the attestation provider **is** configured (so cache key = `#att=1`), but attestation may **fail** at runtime, producing a non-attested binding. This means:
+- A non-attested cert/token gets cached under the `#att=1` partition
+- If attestation later succeeds (transient failure), the cached non-attested artifact is returned instead of the attested one
+
+### 7.4 Required Change
-**Strategy:** Partition by **actual outcome**, not intent. After the binding is resolved:
+Both cache keys must be based on the **actual outcome**, not provider presence. However, the cert cache key is computed **before** attestation runs (it's used to look up cached certs). This means:
+
+**Option A (Recommended):** When fallback is enabled, always use `#att=0` for the cache key. Attested and non-attested certs produce functionally equivalent bound tokens — the attestation only affects the trust level of the CSR, not the resulting cert's mTLS binding capability. This avoids the stale-cache problem entirely.
```csharp
-// Use the actual attestation outcome for the cache key
-string attestationTag = (attestationJwt != null) ? AttestationTagEnabled : AttestationTagDisabled;
-return baseKey + attestationTag;
+// In GetMtlsCertCacheKey():
+if (_isBoundTokenFallbackEnabled)
+{
+ return baseKey + AttestationTagDisabled; // fallback mode: single partition
+}
+return baseKey + (_attestationTokenProvider != null ? AttestationTagEnabled : AttestationTagDisabled);
```
-This is already the effective behavior (attestation JWT is resolved before caching), so **no cache key changes needed**.
+**Option B:** Split the cert provisioning into lookup → attestation → cache-store, making the cache key depend on outcome. This is more complex and may not be worth it if Option A is acceptable.
+
+The same approach applies to the token cache key component in `ApplyMtlsPopAndAttestation()`.
---
@@ -485,4 +631,4 @@ No change needed. Continue using `WithMtlsProofOfPossession()` (no args).
2. **Should the fallback also cover IMDSv1 hosts?** Currently, if the host only supports IMDSv1 (404 from CSR metadata endpoint), the request throws. Should `WithMtlsPopFallback()` fall back to a bearer token on IMDSv1 hosts? *(Likely no — bound token is the requirement, not optional.)*
-4. **Future options:** `MtlsPopOptions` is extensible. Future properties could include things like preferred key type, attestation timeout, or custom fallback policies.
+3. **Future options:** `MtlsPopOptions` is extensible. Future properties could include things like preferred key type, attestation timeout, or custom fallback policies.