Skip to content

Commit 024e7b4

Browse files
Merge branch 'main' into rginsburg/msiv2_feature_branch
2 parents 7bc1735 + f0a02aa commit 024e7b4

File tree

61 files changed

+2063
-285
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+2063
-285
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
4.76.0
2+
======
3+
4+
### New Features
5+
* Removal of `ExperimentalFeatures` flag on `WithMtlsProofOfPossession` API
6+
* Add Service Fabric token revocation support
7+
* Adding WithExtraBodyParameters api
8+
* Enable mTLS Proof‑of‑Possession for Client‑Assertion Delegates
9+
10+
### Bug Fixes
11+
* #5400 Fixing issue that leads to multiple active access tokens in the cache for non-tenanted oidc authority
12+
* Update NativeInterop package version to 0.19.4
13+
114
4.74.1
215
======
316

Directory.Packages.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<PropertyGroup>
33
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
44
<!-- Version of the Microsoft.Identity.Client.NativeInterop package. -->
5-
<MSALRuntimeNativeInteropVersion>0.19.2</MSALRuntimeNativeInteropVersion>
5+
<MSALRuntimeNativeInteropVersion>0.19.4</MSALRuntimeNativeInteropVersion>
66
<!-- Version of MSAL if not defined by the CI-->
77
<MsalInternalVersion>4.61.0</MsalInternalVersion>
88
</PropertyGroup>

LibsAndSamples.sln

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "all", "all", "{C44AADC0-1E9
4646
.editorconfig = .editorconfig
4747
.gitattributes = .gitattributes
4848
.gitignore = .gitignore
49+
CHANGELOG.md = CHANGELOG.md
4950
build\CodeCoverage.runsettings = build\CodeCoverage.runsettings
5051
build\credscan-exclusion.json = build\credscan-exclusion.json
5152
Directory.Build.props = Directory.Build.props

build/template-build-and-run-all-tests.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ jobs: #Build and stage projects
6868
steps:
6969
- template: template-desktop-unit-and-automation.yaml
7070

71-
- task: BuildQualityChecks@9
71+
- task: BuildQualityChecks@10
7272
condition: ne(variables['Build.SourceBranch'], 'refs/heads/main') #gate should not run on main branch
7373
inputs:
7474
checkCoverage: '$(CodeCoverageGateEnabled)'

docs/msi_v2/msi_with_credential_design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ This section outlines the necessary steps to acquire an access token using the M
120120

121121
### 1. Retrieve Platform Metadata
122122

123-
`GET /metadata/identity/getPlatformMetadata?api-version=2025-05-01&cid={CUID}&uaid={client_id}`
123+
`GET /metadata/identity/getPlatformMetadata?api-version=2025-05-01&uaid={client_id}`
124124

125125
Response supplies the UAID/client_id, tenant_id, CUID, and (for attestable VMs) the regional MAA endpoint
126126

docs/msiv1_token_revocation.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,82 @@ The `xms_cc` parameter can hold **multiple** client capabilities, formatted as:
125125
> [!NOTE]
126126
> RPs or MITS should not bypass cache if a bad token is not passed by MSAL.
127127
128+
#### Cluster-wide cache-bypass optimization (hash-based)
129+
130+
```mermaid
131+
sequenceDiagram
132+
%% cluster-wide cache-bypass (hash-based)
133+
autonumber
134+
participant NodeA as "Node A (stale token T0)"
135+
participant NodeB as "Node B (fresh token T1)"
136+
participant MI as "Managed-Identity endpoint / cache"
137+
participant AAD as "Azure AD"
138+
139+
Note over NodeA,AAD: Conditional-Access change → claims="{…}"
140+
141+
NodeA->>MI: GET /token? claims + hash(T0)
142+
MI->>MI: cache lookup hash(T0) (hit - stale)
143+
MI->>AAD: request fresh token T1
144+
AAD-->>MI: token T1
145+
MI->>MI: store hash(T1)\ninvalidate hash(T0)
146+
MI-->>NodeA: token T1 (fresh)
147+
148+
Note over NodeA,NodeB: shortly after …
149+
150+
NodeB->>MI: GET /token? claims + hash(T1)
151+
MI->>MI: cache lookup hash(T1) (hit - fresh)
152+
MI-->>NodeB: token T1 (from cache)
153+
154+
Note over NodeA,NodeB: **Only one** AAD call for this revoked token
155+
```
156+
157+
#### Step-by-step flow
158+
159+
1. **Claims challenge arrives** (e.g., CAE / Conditional Access).
160+
`AcquireToken*` receives `claims="{...}"`.
161+
162+
---
163+
164+
##### **Node A** (first node that still holds the stale token)
165+
166+
| Step | Action |
167+
|------|--------|
168+
| A-1 | Finds **`access_token_A`** in its local MSAL cache. |
169+
| A-2 | Computes **`hash_A = sha256(access_token_A)`**. |
170+
| A-3 | Calls the Managed-Identity (MI) endpoint with<br/>`token_sha256_to_refresh = hash_A`. |
171+
| A-4 | MI detects `hash_A` in its cache ⇒ marks token revoked, requests a new token **`access_token_B`** from AAD. |
172+
| A-5 | MI cache now stores `hash_B = sha256(access_token_B) → access_token_B`. |
173+
174+
---
175+
176+
##### **Node B** (arrives moments later)
177+
178+
| Step | Action |
179+
|------|--------|
180+
| B-1 | Already has **`access_token_B`** via cache propagation/read-through. |
181+
| B-2 | Computes **`hash_B = sha256(access_token_B)`**. |
182+
| B-3 | Sends `token_sha256_to_refresh = hash_B`. |
183+
| B-4 | MI cache looks up `hash_B`**hit** (token already fresh). |
184+
| B-5 | MI returns **HTTP 200** + **`access_token_B`**_no extra AAD round-trip_. |
185+
186+
---
187+
188+
##### Cluster settles
189+
190+
* **Only one** outbound call to AAD per unique revoked token, no matter how many nodes receive the claims challenge.
191+
* Dramatically reduces pressure on the MI proxy and on ESTS in large Service Fabric (or AKS) deployments.
192+
193+
---
194+
195+
##### Why a simple `bypass_cache=true` flag isn’t enough
196+
197+
* `bypass_cache=true` forces **every** node to refresh → scales **O(N)** with cluster size.
198+
Large clusters could issue thousands of token requests within seconds, triggering throttling (`429`) or high latency.
199+
200+
* The **hash check** turns the problem into **O(1)**:
201+
The first node refreshes; the hash acts as an idempotency key so all other nodes immediately reuse the fresh token already in the MI cache.
202+
203+
128204
#### Motivation
129205

130206
The *internal protocol* between the client and the RP (i.e. calling the MITS endpoint in case of Service Fabric), is a simplified version of CAE. This is because CAE is claims driven and involves JSON operations such as JSON doc merges. The RP doesn't need the actual claims to perform revocation, it just needs a signal to bypass the cache. As such, it was decided to not use the full claims value internally.

eng/Move-PublicAPI.ps1

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
param(
2+
[Parameter(Mandatory = $false)]
3+
[string]$Root,
4+
5+
[switch]$DryRun
6+
)
7+
8+
<#!
9+
.SYNOPSIS
10+
Moves all APIs from PublicAPI.Unshipped.txt files into the corresponding PublicAPI.Shipped.txt files.
11+
12+
.DESCRIPTION
13+
This script scans the repository for files named "PublicAPI.Unshipped.txt" and, for each one found,
14+
appends its content to the sibling "PublicAPI.Shipped.txt" file and then clears the unshipped file.
15+
16+
Run this script every time a release is made to move unshipped API entries to the shipped list.
17+
18+
.PARAMETER Root
19+
Optional. The root directory to scan. Defaults to the repository root (one level above the script's folder).
20+
21+
.PARAMETER DryRun
22+
Optional. If specified, the script will only report what it would do without making any changes.
23+
24+
.EXAMPLE
25+
./Move-PublicAPI.ps1
26+
27+
.EXAMPLE
28+
./Move-PublicAPI.ps1 -Root C:\g\msal
29+
30+
.EXAMPLE
31+
./Move-PublicAPI.ps1 -DryRun
32+
#>
33+
34+
Set-StrictMode -Version Latest
35+
$ErrorActionPreference = 'Stop'
36+
37+
if (-not $PSBoundParameters.ContainsKey('Root') -or [string]::IsNullOrWhiteSpace($Root)) {
38+
# Default Root to the repo root (one level above script directory)
39+
$Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
40+
}
41+
42+
Write-Host "Scanning for PublicAPI.Unshipped.txt under: $Root"
43+
44+
# Find all PublicAPI.Unshipped.txt files
45+
$unshippedFiles = Get-ChildItem -LiteralPath $Root -Recurse -File -Filter 'PublicAPI.Unshipped.txt' -ErrorAction Stop
46+
47+
if (-not $unshippedFiles -or $unshippedFiles.Count -eq 0) {
48+
Write-Host 'No PublicAPI.Unshipped.txt files found. Nothing to do.'
49+
return
50+
}
51+
52+
$updatedCount = 0
53+
54+
foreach ($unshipped in $unshippedFiles) {
55+
$unshippedPath = $unshipped.FullName
56+
$shippedPath = Join-Path $unshipped.DirectoryName 'PublicAPI.Shipped.txt'
57+
58+
# Read unshipped content
59+
$unshippedContent = Get-Content -LiteralPath $unshippedPath -Raw -ErrorAction Stop
60+
61+
if ([string]::IsNullOrWhiteSpace($unshippedContent)) {
62+
Write-Host "Skipping (empty): $unshippedPath"
63+
continue
64+
}
65+
66+
# Ensure shipped file exists
67+
if (-not (Test-Path -LiteralPath $shippedPath)) {
68+
if ($DryRun) {
69+
Write-Host "Would create missing shipped file: $shippedPath"
70+
}
71+
else {
72+
New-Item -ItemType File -Path $shippedPath -Force | Out-Null
73+
}
74+
}
75+
76+
# Read existing shipped content if any
77+
$existingShipped = ''
78+
if (Test-Path -LiteralPath $shippedPath) {
79+
$existingShipped = Get-Content -LiteralPath $shippedPath -Raw -ErrorAction Stop
80+
}
81+
82+
# Prepare content to append with reasonable separation
83+
$toAppend = $unshippedContent.TrimEnd("`r", "`n")
84+
85+
if (-not [string]::IsNullOrEmpty($existingShipped)) {
86+
# Ensure at least one blank line separation between existing shipped content and new additions
87+
$needsTrailingNewLine = -not $existingShipped.EndsWith("`n")
88+
$separator = if ($needsTrailingNewLine) { "`r`n`r`n" } else { "`r`n" }
89+
$toAppend = $separator + $toAppend + "`r`n"
90+
}
91+
else {
92+
# If shipped is empty, just ensure the appended content ends with a newline
93+
$toAppend = $toAppend + "`r`n"
94+
}
95+
96+
if ($DryRun) {
97+
$movedLines = ($unshippedContent -split "(`r`n|`n|`r)").Where({ $_ -ne '' }).Count
98+
Write-Host "Would move $movedLines line(s) from: $unshippedPath"`n" to: $shippedPath"
99+
continue
100+
}
101+
102+
# Append to shipped and clear unshipped
103+
Add-Content -LiteralPath $shippedPath -Value $toAppend -Encoding UTF8
104+
105+
# Clear the unshipped file (leave file present but empty)
106+
Set-Content -LiteralPath $unshippedPath -Value '' -NoNewline -Encoding UTF8
107+
108+
$updatedCount++
109+
Write-Host "Moved content from: $unshippedPath"`n" to: $shippedPath"
110+
}
111+
112+
Write-Host "Done. Files updated: $updatedCount"

src/client/Microsoft.Identity.Client/ApiConfig/AcquireTokenForClientParameterBuilder.cs

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) Microsoft Corporation. All rights reserved.
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

44
using System;
@@ -15,6 +15,9 @@
1515
using Microsoft.Identity.Client.Utils;
1616
using Microsoft.Identity.Client.Extensibility;
1717
using Microsoft.Identity.Client.OAuth2;
18+
using System.Security.Cryptography.X509Certificates;
19+
using System.Security.Cryptography;
20+
using System.Text;
1821

1922
namespace Microsoft.Identity.Client
2023
{
@@ -44,9 +47,9 @@ internal static AcquireTokenForClientParameterBuilder Create(
4447

4548
if (!string.IsNullOrEmpty(confidentialClientApplicationExecutor.ServiceBundle.Config.CertificateIdToAssociateWithToken))
4649
{
47-
builder.WithAdditionalCacheKeyComponents(new SortedList<string, string>
50+
builder.WithAdditionalCacheKeyComponents(new SortedList<string, Func<CancellationToken, Task<string>>>
4851
{
49-
{ Constants.CertSerialNumber, confidentialClientApplicationExecutor.ServiceBundle.Config.CertificateIdToAssociateWithToken }
52+
{ Constants.CertSerialNumber, (CancellationToken ct) => { return Task.FromResult(confidentialClientApplicationExecutor.ServiceBundle.Config.CertificateIdToAssociateWithToken); } }
5053
});
5154
}
5255

@@ -96,18 +99,20 @@ public AcquireTokenForClientParameterBuilder WithSendX5C(bool withSendX5C)
9699
/// <returns>The current instance of <see cref="AcquireTokenForClientParameterBuilder"/> to enable method chaining.</returns>
97100
public AcquireTokenForClientParameterBuilder WithMtlsProofOfPossession()
98101
{
99-
if (ServiceBundle.Config.ClientCredential is not CertificateClientCredential certificateCredential)
102+
if (ServiceBundle.Config.ClientCredential is CertificateClientCredential certificateCredential)
100103
{
101-
throw new MsalClientException(
104+
if (certificateCredential.Certificate == null)
105+
{
106+
throw new MsalClientException(
102107
MsalError.MtlsCertificateNotProvided,
103108
MsalErrorMessage.MtlsCertificateNotProvidedMessage);
104-
}
105-
else
106-
{
109+
}
110+
107111
CommonParameters.AuthenticationOperation = new MtlsPopAuthenticationOperation(certificateCredential.Certificate);
108-
CommonParameters.MtlsCertificate = certificateCredential.Certificate;
112+
CommonParameters.MtlsCertificate = certificateCredential.Certificate;
109113
}
110114

115+
CommonParameters.IsMtlsPopRequested = true;
111116
return this;
112117
}
113118

@@ -141,9 +146,9 @@ public AcquireTokenForClientParameterBuilder WithFmiPath(string pathSuffix)
141146
throw new ArgumentNullException(nameof(pathSuffix));
142147
}
143148

144-
var cacheKey = new SortedList<string, string>
149+
var cacheKey = new SortedList<string, Func<CancellationToken, Task<string>>>
145150
{
146-
{ OAuth2Parameter.FmiPath, pathSuffix }
151+
{ OAuth2Parameter.FmiPath, (CancellationToken ct) => {return Task.FromResult(pathSuffix);} }
147152
};
148153

149154
this.WithAdditionalCacheKeyComponents(cacheKey);

src/client/Microsoft.Identity.Client/ApiConfig/Executors/ClientApplicationBaseExecutor.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ public async Task<AuthenticationResult> ExecuteAsync(
3333
var requestParameters = await _clientApplicationBase.CreateRequestParametersAsync(
3434
commonParameters,
3535
requestContext,
36-
_clientApplicationBase.UserTokenCacheInternal).ConfigureAwait(false);
36+
_clientApplicationBase.UserTokenCacheInternal,
37+
cancellationToken).ConfigureAwait(false);
3738

3839
requestParameters.SendX5C = silentParameters.SendX5C ?? false;
3940

@@ -59,7 +60,8 @@ public async Task<AuthenticationResult> ExecuteAsync(
5960
var requestParameters = await _clientApplicationBase.CreateRequestParametersAsync(
6061
commonParameters,
6162
requestContext,
62-
_clientApplicationBase.UserTokenCacheInternal).ConfigureAwait(false);
63+
_clientApplicationBase.UserTokenCacheInternal,
64+
cancellationToken).ConfigureAwait(false);
6365

6466
requestContext.Logger.Info(() => LogMessages.UsingXScopesForRefreshTokenRequest(commonParameters.Scopes.Count()));
6567

0 commit comments

Comments
 (0)