Skip to content

Commit b496a26

Browse files
Copilotjmprieur
andauthored
Fix tenant not propagated in credential FIC acquisition (#3633)
* Initial plan * Fix tenant propagation in OidcIdpSignedAssertionProvider credential FIC acquisition Co-authored-by: jmprieur <[email protected]> * Address code review feedback: improve URI handling and validate token endpoint pattern Co-authored-by: jmprieur <[email protected]> * Add unit tests for ExtractTenantFromTokenEndpointIfSameInstance method and make it internal Co-authored-by: jmprieur <[email protected]> * Fix warnings in owin test apps * Add E2E test for tenant override with common/organizations authority Co-authored-by: jmprieur <[email protected]> * Simplify the Autonomous agent tests * Update tests/E2E Tests/AgentApplications/AutonomousAgentTests.cs * Inproving the autonomous test with tenant override --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: jmprieur <[email protected]> Co-authored-by: Jean-Marc Prieur <[email protected]>
1 parent 68c9fb9 commit b496a26

File tree

11 files changed

+222
-22
lines changed

11 files changed

+222
-22
lines changed

src/Microsoft.Identity.Web.OidcFIC/OidcIdpSignedAssertionProvider.cs

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,17 @@ protected override async Task<ClientAssertion> GetClientAssertionAsync(Assertion
5555

5656
if (assertionRequestOptions != null && !string.IsNullOrEmpty(assertionRequestOptions.ClientAssertionFmiPath))
5757
{
58+
// Extract tenant from TokenEndpoint if available and if it's from the same cloud instance.
59+
// This enables tenant override propagation while preserving cross-cloud scenarios.
60+
// TokenEndpoint format: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
61+
string? tenant = ExtractTenantFromTokenEndpointIfSameInstance(
62+
assertionRequestOptions.TokenEndpoint,
63+
_options.Instance);
64+
5865
acquireTokenOptions = new AcquireTokenOptions()
5966
{
60-
FmiPath = assertionRequestOptions.ClientAssertionFmiPath
67+
FmiPath = assertionRequestOptions.ClientAssertionFmiPath,
68+
Tenant = tenant
6169
};
6270
}
6371

@@ -83,5 +91,60 @@ protected override async Task<ClientAssertion> GetClientAssertionAsync(Assertion
8391
}
8492
return clientAssertion;
8593
}
94+
95+
/// <summary>
96+
/// Extracts the tenant from a token endpoint URL if the endpoint is from the same cloud instance.
97+
/// This enables tenant override propagation while preserving cross-cloud scenarios.
98+
/// </summary>
99+
/// <param name="tokenEndpoint">Token endpoint URL in the format https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token</param>
100+
/// <param name="configuredInstance">The configured instance URL (e.g., https://login.microsoftonline.com/)</param>
101+
/// <returns>The tenant ID if the endpoint is from the same instance, otherwise null.</returns>
102+
internal static string? ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance)
103+
{
104+
if (string.IsNullOrEmpty(tokenEndpoint) || string.IsNullOrEmpty(configuredInstance))
105+
{
106+
return null;
107+
}
108+
109+
try
110+
{
111+
var endpointUri = new Uri(tokenEndpoint!);
112+
113+
// Safely construct instance URI by trimming trailing slash
114+
var normalizedInstance = configuredInstance!.TrimEnd('/');
115+
var instanceUri = new Uri(normalizedInstance);
116+
117+
// Only extract tenant if the host matches (same cloud instance)
118+
if (!string.Equals(endpointUri.Host, instanceUri.Host, StringComparison.OrdinalIgnoreCase))
119+
{
120+
return null;
121+
}
122+
123+
// TokenEndpoint format: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
124+
// Validate the path follows the expected pattern before extracting tenant.
125+
var pathSegments = endpointUri.AbsolutePath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
126+
127+
// Expected pattern: [tenantId, oauth2, v2.0, token] or similar
128+
// We need at least the tenant segment and some oauth2 path segments
129+
if (pathSegments.Length >= 2)
130+
{
131+
// Verify this looks like a token endpoint (contains "oauth2" somewhere after tenant)
132+
for (int i = 1; i < pathSegments.Length; i++)
133+
{
134+
if (string.Equals(pathSegments[i], "oauth2", StringComparison.OrdinalIgnoreCase))
135+
{
136+
// Found oauth2 segment, the first segment is likely the tenant
137+
return pathSegments[0];
138+
}
139+
}
140+
}
141+
}
142+
catch (UriFormatException)
143+
{
144+
// Invalid URI, return null
145+
}
146+
147+
return null;
148+
}
86149
}
87150
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
#nullable enable
2+
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?

tests/DevApps/aspnet-mvc/OwinWebApi/Web.config

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
</system.webServer>
2121
<runtime>
2222
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
23+
<dependentAssembly>
24+
<assemblyIdentity name="Microsoft.Identity.Client" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
25+
<bindingRedirect oldVersion="0.0.0.0-4.79.2.0" newVersion="4.79.2.0"/>
26+
</dependentAssembly>
2327
<dependentAssembly>
2428
<assemblyIdentity name="Microsoft.Identity.Web.Diagnostics" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
2529
<bindingRedirect oldVersion="0.0.0.0-3.13.0.0" newVersion="3.13.0.0"/>
@@ -78,7 +82,7 @@
7882
</dependentAssembly>
7983
<dependentAssembly>
8084
<assemblyIdentity name="System.IdentityModel.Tokens.Jwt" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
81-
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
85+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
8286
</dependentAssembly>
8387
<dependentAssembly>
8488
<assemblyIdentity name="System.Diagnostics.DiagnosticSource" publicKeyToken="CC7B13FFCD2DDD51" culture="neutral"/>
@@ -94,27 +98,27 @@
9498
</dependentAssembly>
9599
<dependentAssembly>
96100
<assemblyIdentity name="Microsoft.IdentityModel.Tokens" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
97-
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
101+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
98102
</dependentAssembly>
99103
<dependentAssembly>
100104
<assemblyIdentity name="Microsoft.IdentityModel.Protocols.WsFederation" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
101105
<bindingRedirect oldVersion="0.0.0.0-5.5.0.0" newVersion="5.5.0.0"/>
102106
</dependentAssembly>
103107
<dependentAssembly>
104108
<assemblyIdentity name="Microsoft.IdentityModel.Protocols.OpenIdConnect" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
105-
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
109+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
106110
</dependentAssembly>
107111
<dependentAssembly>
108112
<assemblyIdentity name="Microsoft.IdentityModel.Protocols" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
109-
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
113+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
110114
</dependentAssembly>
111115
<dependentAssembly>
112116
<assemblyIdentity name="Microsoft.IdentityModel.Logging" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
113-
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
117+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
114118
</dependentAssembly>
115119
<dependentAssembly>
116120
<assemblyIdentity name="Microsoft.IdentityModel.Abstractions" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
117-
<bindingRedirect oldVersion="0.0.0.0-8.12.1.0" newVersion="8.12.1.0"/>
121+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
118122
</dependentAssembly>
119123
<dependentAssembly>
120124
<assemblyIdentity name="Microsoft.Identity.Client" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>

tests/DevApps/aspnet-mvc/OwinWebApp/Web.config

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@
2121
</system.web>
2222
<runtime>
2323
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
24+
<dependentAssembly>
25+
<assemblyIdentity name="Microsoft.Identity.Client" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
26+
<bindingRedirect oldVersion="0.0.0.0-4.79.2.0" newVersion="4.79.2.0"/>
27+
</dependentAssembly>
2428
<dependentAssembly>
2529
<assemblyIdentity name="Microsoft.Identity.Web.Diagnostics" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
2630
<bindingRedirect oldVersion="0.0.0.0-3.13.0.0" newVersion="3.13.0.0"/>
@@ -79,7 +83,7 @@
7983
</dependentAssembly>
8084
<dependentAssembly>
8185
<assemblyIdentity name="System.IdentityModel.Tokens.Jwt" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
82-
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
86+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
8387
</dependentAssembly>
8488
<dependentAssembly>
8589
<assemblyIdentity name="System.Diagnostics.DiagnosticSource" publicKeyToken="CC7B13FFCD2DDD51" culture="neutral"/>
@@ -95,27 +99,27 @@
9599
</dependentAssembly>
96100
<dependentAssembly>
97101
<assemblyIdentity name="Microsoft.IdentityModel.Tokens" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
98-
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
102+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
99103
</dependentAssembly>
100104
<dependentAssembly>
101105
<assemblyIdentity name="Microsoft.IdentityModel.Protocols.WsFederation" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
102106
<bindingRedirect oldVersion="0.0.0.0-5.5.0.0" newVersion="5.5.0.0"/>
103107
</dependentAssembly>
104108
<dependentAssembly>
105109
<assemblyIdentity name="Microsoft.IdentityModel.Protocols.OpenIdConnect" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
106-
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
110+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
107111
</dependentAssembly>
108112
<dependentAssembly>
109113
<assemblyIdentity name="Microsoft.IdentityModel.Protocols" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
110-
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
114+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
111115
</dependentAssembly>
112116
<dependentAssembly>
113117
<assemblyIdentity name="Microsoft.IdentityModel.Logging" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
114-
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
118+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
115119
</dependentAssembly>
116120
<dependentAssembly>
117121
<assemblyIdentity name="Microsoft.IdentityModel.Abstractions" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
118-
<bindingRedirect oldVersion="0.0.0.0-8.12.1.0" newVersion="8.12.1.0"/>
122+
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
119123
</dependentAssembly>
120124
<dependentAssembly>
121125
<assemblyIdentity name="Microsoft.Identity.Client" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>

tests/E2E Tests/AgentApplications/AutonomousAgentTests.cs

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,24 @@
88
using Microsoft.Graph;
99
using Microsoft.Identity.Abstractions;
1010
using Microsoft.Identity.Web;
11-
using Microsoft.Identity.Web.TokenCacheProviders.Distributed;
1211
using Microsoft.Identity.Web.TokenCacheProviders.InMemory;
1312
using Microsoft.IdentityModel.Tokens;
1413

1514
namespace AgentApplicationsTests
1615
{
1716
public class AutonomousAgentTests
1817
{
19-
[Fact]
20-
public async Task AutonomousAgentGetsAppTokenForAgentIdentityToCallGraphAsync()
18+
const string overriddenTenantId = "31a58c3b-ae9c-4448-9e8f-e9e143e800df";
19+
[Theory]
20+
[InlineData("organizations")]
21+
[InlineData("31a58c3b-ae9c-4448-9e8f-e9e143e800df")]
22+
public async Task AutonomousAgentGetsAppTokenForAgentIdentityToCallGraphAsync(string configuredTenantId)
2123
{
2224
IServiceCollection services = new ServiceCollection();
2325
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection().Build();
2426

2527
configuration["AzureAd:Instance"] = "https://login.microsoftonline.com/";
26-
configuration["AzureAd:TenantId"] = "31a58c3b-ae9c-4448-9e8f-e9e143e800df";
28+
configuration["AzureAd:TenantId"] = configuredTenantId; // Set to the GUID or organizations
2729
configuration["AzureAd:ClientId"] = "d05619c9-dbf2-4e60-95fd-cc75dd0db451"; // Agent application.
2830
configuration["AzureAd:ClientCredentials:0:SourceType"] = "StoreWithDistinguishedName";
2931
configuration["AzureAd:ClientCredentials:0:CertificateStorePath"] = "LocalMachine/My";
@@ -44,6 +46,10 @@ public async Task AutonomousAgentGetsAppTokenForAgentIdentityToCallGraphAsync()
4446
//// Get an authorization header and handle the call to the downstream API yoursel
4547
IAuthorizationHeaderProvider authorizationHeaderProvider = serviceProvider.GetService<IAuthorizationHeaderProvider>()!;
4648
AuthorizationHeaderProviderOptions options = new AuthorizationHeaderProviderOptions().WithAgentIdentity(agentIdentity);
49+
if (configuredTenantId == "organizations")
50+
{
51+
options.AcquireTokenOptions.Tenant = overriddenTenantId;
52+
}
4753

4854
//// Request user tokens in autonomous agents.
4955
string authorizationHeaderWithAppToken = await authorizationHeaderProvider.CreateAuthorizationHeaderForAppAsync("https://graph.microsoft.com/.default", options);
@@ -56,7 +62,7 @@ public async Task AutonomousAgentGetsAppTokenForAgentIdentityToCallGraphAsync()
5662

5763
// Verify the token does not represent an agent user identity using the extension method
5864
Assert.False(claimsIdentity.IsAgentUserIdentity());
59-
65+
6066
// Verify we can retrieve the parent agent blueprint if present
6167
string? parentBlueprint = claimsIdentity.GetParentAgentBlueprint();
6268
string agentApplication = configuration["AzureAd:ClientId"]!;
@@ -65,10 +71,11 @@ public async Task AutonomousAgentGetsAppTokenForAgentIdentityToCallGraphAsync()
6571
//// If you want to call Microsoft Graph, just inject and use the Microsoft Graph SDK with the agent identity.
6672
GraphServiceClient graphServiceClient = serviceProvider.GetRequiredService<GraphServiceClient>();
6773
var apps = await graphServiceClient.Applications.GetAsync(r => r.Options.WithAuthenticationOptions(options =>
68-
{
69-
options.WithAgentIdentity(agentIdentity);
70-
options.RequestAppToken = true;
71-
}));
74+
{
75+
options.WithAgentIdentity(agentIdentity);
76+
options.RequestAppToken = true;
77+
options.AcquireTokenOptions.Tenant = configuredTenantId == "organizations" ? overriddenTenantId : null;
78+
}));
7279
Assert.NotNull(apps);
7380

7481
//// If you want to call downstream APIs letting IdWeb handle authentication.

0 commit comments

Comments
 (0)