Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,17 @@ protected override async Task<ClientAssertion> GetClientAssertionAsync(Assertion

if (assertionRequestOptions != null && !string.IsNullOrEmpty(assertionRequestOptions.ClientAssertionFmiPath))
{
// Extract tenant from TokenEndpoint if available and if it's from the same cloud instance.
// This enables tenant override propagation while preserving cross-cloud scenarios.
// TokenEndpoint format: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
string? tenant = ExtractTenantFromTokenEndpointIfSameInstance(
assertionRequestOptions.TokenEndpoint,
_options.Instance);

acquireTokenOptions = new AcquireTokenOptions()
{
FmiPath = assertionRequestOptions.ClientAssertionFmiPath
FmiPath = assertionRequestOptions.ClientAssertionFmiPath,
Tenant = tenant
};
}

Expand All @@ -83,5 +91,60 @@ protected override async Task<ClientAssertion> GetClientAssertionAsync(Assertion
}
return clientAssertion;
}

/// <summary>
/// Extracts the tenant from a token endpoint URL if the endpoint is from the same cloud instance.
/// This enables tenant override propagation while preserving cross-cloud scenarios.
/// </summary>
/// <param name="tokenEndpoint">Token endpoint URL in the format https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token</param>
/// <param name="configuredInstance">The configured instance URL (e.g., https://login.microsoftonline.com/)</param>
/// <returns>The tenant ID if the endpoint is from the same instance, otherwise null.</returns>
internal static string? ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance)
{
if (string.IsNullOrEmpty(tokenEndpoint) || string.IsNullOrEmpty(configuredInstance))
{
return null;
}

try
{
var endpointUri = new Uri(tokenEndpoint!);

// Safely construct instance URI by trimming trailing slash
var normalizedInstance = configuredInstance!.TrimEnd('/');
var instanceUri = new Uri(normalizedInstance);

// Only extract tenant if the host matches (same cloud instance)
if (!string.Equals(endpointUri.Host, instanceUri.Host, StringComparison.OrdinalIgnoreCase))
{
return null;
}

// TokenEndpoint format: https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
// Validate the path follows the expected pattern before extracting tenant.
var pathSegments = endpointUri.AbsolutePath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);

// Expected pattern: [tenantId, oauth2, v2.0, token] or similar
// We need at least the tenant segment and some oauth2 path segments
if (pathSegments.Length >= 2)
{
// Verify this looks like a token endpoint (contains "oauth2" somewhere after tenant)
for (int i = 1; i < pathSegments.Length; i++)
{
if (string.Equals(pathSegments[i], "oauth2", StringComparison.OrdinalIgnoreCase))
{
// Found oauth2 segment, the first segment is likely the tenant
return pathSegments[0];
}
}
}
}
catch (UriFormatException)
{
// Invalid URI, return null
}

return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
static Microsoft.Identity.Web.OidcFic.OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(string? tokenEndpoint, string? configuredInstance) -> string?
16 changes: 10 additions & 6 deletions tests/DevApps/aspnet-mvc/OwinWebApi/Web.config
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
</system.webServer>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Identity.Client" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.79.2.0" newVersion="4.79.2.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.Identity.Web.Diagnostics" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-3.13.0.0" newVersion="3.13.0.0"/>
Expand Down Expand Up @@ -78,7 +82,7 @@
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.IdentityModel.Tokens.Jwt" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.DiagnosticSource" publicKeyToken="CC7B13FFCD2DDD51" culture="neutral"/>
Expand All @@ -94,27 +98,27 @@
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Tokens" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Protocols.WsFederation" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-5.5.0.0" newVersion="5.5.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Protocols.OpenIdConnect" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Protocols" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Logging" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Abstractions" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.12.1.0" newVersion="8.12.1.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.Identity.Client" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
Expand Down
16 changes: 10 additions & 6 deletions tests/DevApps/aspnet-mvc/OwinWebApp/Web.config
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
</system.web>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="Microsoft.Identity.Client" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.79.2.0" newVersion="4.79.2.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.Identity.Web.Diagnostics" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-3.13.0.0" newVersion="3.13.0.0"/>
Expand Down Expand Up @@ -79,7 +83,7 @@
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.IdentityModel.Tokens.Jwt" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Diagnostics.DiagnosticSource" publicKeyToken="CC7B13FFCD2DDD51" culture="neutral"/>
Expand All @@ -95,27 +99,27 @@
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Tokens" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Protocols.WsFederation" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-5.5.0.0" newVersion="5.5.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Protocols.OpenIdConnect" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Protocols" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Logging" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.14.0.0" newVersion="8.14.0.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.IdentityModel.Abstractions" publicKeyToken="31BF3856AD364E35" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-8.12.1.0" newVersion="8.12.1.0"/>
<bindingRedirect oldVersion="0.0.0.0-8.15.0.0" newVersion="8.15.0.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="Microsoft.Identity.Client" publicKeyToken="0A613F4DD989E8AE" culture="neutral"/>
Expand Down
25 changes: 16 additions & 9 deletions tests/E2E Tests/AgentApplications/AutonomousAgentTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,24 @@
using Microsoft.Graph;
using Microsoft.Identity.Abstractions;
using Microsoft.Identity.Web;
using Microsoft.Identity.Web.TokenCacheProviders.Distributed;
using Microsoft.Identity.Web.TokenCacheProviders.InMemory;
using Microsoft.IdentityModel.Tokens;

namespace AgentApplicationsTests
{
public class AutonomousAgentTests
{
[Fact]
public async Task AutonomousAgentGetsAppTokenForAgentIdentityToCallGraphAsync()
const string overriddenTenantId = "31a58c3b-ae9c-4448-9e8f-e9e143e800df";
[Theory]
[InlineData("organizations")]
[InlineData("31a58c3b-ae9c-4448-9e8f-e9e143e800df")]
public async Task AutonomousAgentGetsAppTokenForAgentIdentityToCallGraphAsync(string configuredTenantId)
{
IServiceCollection services = new ServiceCollection();
IConfiguration configuration = new ConfigurationBuilder().AddInMemoryCollection().Build();

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

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

// Verify the token does not represent an agent user identity using the extension method
Assert.False(claimsIdentity.IsAgentUserIdentity());

// Verify we can retrieve the parent agent blueprint if present
string? parentBlueprint = claimsIdentity.GetParentAgentBlueprint();
string agentApplication = configuration["AzureAd:ClientId"]!;
Expand All @@ -65,10 +71,11 @@ public async Task AutonomousAgentGetsAppTokenForAgentIdentityToCallGraphAsync()
//// If you want to call Microsoft Graph, just inject and use the Microsoft Graph SDK with the agent identity.
GraphServiceClient graphServiceClient = serviceProvider.GetRequiredService<GraphServiceClient>();
var apps = await graphServiceClient.Applications.GetAsync(r => r.Options.WithAuthenticationOptions(options =>
{
options.WithAgentIdentity(agentIdentity);
options.RequestAppToken = true;
}));
{
options.WithAgentIdentity(agentIdentity);
options.RequestAppToken = true;
options.AcquireTokenOptions.Tenant = configuredTenantId == "organizations" ? overriddenTenantId : null;
}));
Assert.NotNull(apps);

//// If you want to call downstream APIs letting IdWeb handle authentication.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

using Microsoft.Identity.Web.OidcFic;
using Xunit;

namespace Microsoft.Identity.Web.Test
{
public class OidcIdpSignedAssertionProviderTests
{
[Theory]
[InlineData("https://login.microsoftonline.com/my-tenant-id/oauth2/v2.0/token", "https://login.microsoftonline.com/", "my-tenant-id")]
[InlineData("https://login.microsoftonline.com/contoso.onmicrosoft.com/oauth2/v2.0/token", "https://login.microsoftonline.com", "contoso.onmicrosoft.com")]
[InlineData("https://login.microsoftonline.com/12345678-1234-1234-1234-123456789abc/oauth2/v2.0/token", "https://login.microsoftonline.com/", "12345678-1234-1234-1234-123456789abc")]
public void ExtractTenantFromTokenEndpointIfSameInstance_SameInstance_ReturnsTenant(
string tokenEndpoint,
string configuredInstance,
string expectedTenant)
{
// Act
var result = OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(
tokenEndpoint,
configuredInstance);

// Assert
Assert.Equal(expectedTenant, result);
}

[Theory]
[InlineData("https://login.microsoftonline.us/my-tenant-id/oauth2/v2.0/token", "https://login.microsoftonline.com/", null)]
[InlineData("https://login.microsoftonline.com/my-tenant-id/oauth2/v2.0/token", "https://login.microsoftonline.us/", null)]
[InlineData("https://login.chinacloudapi.cn/my-tenant-id/oauth2/v2.0/token", "https://login.microsoftonline.com/", null)]
public void ExtractTenantFromTokenEndpointIfSameInstance_DifferentInstance_ReturnsNull(
string tokenEndpoint,
string configuredInstance,
string? expectedResult)
{
// Act
var result = OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(
tokenEndpoint,
configuredInstance);

// Assert
Assert.Equal(expectedResult, result);
}

[Theory]
[InlineData(null, "https://login.microsoftonline.com/")]
[InlineData("", "https://login.microsoftonline.com/")]
[InlineData("https://login.microsoftonline.com/tenant/oauth2/v2.0/token", null)]
[InlineData("https://login.microsoftonline.com/tenant/oauth2/v2.0/token", "")]
[InlineData(null, null)]
[InlineData("", "")]
public void ExtractTenantFromTokenEndpointIfSameInstance_NullOrEmptyInputs_ReturnsNull(
string? tokenEndpoint,
string? configuredInstance)
{
// Act
var result = OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(
tokenEndpoint,
configuredInstance);

// Assert
Assert.Null(result);
}

[Theory]
[InlineData("not-a-valid-uri", "https://login.microsoftonline.com/")]
[InlineData("https://login.microsoftonline.com/tenant/oauth2/v2.0/token", "not-a-valid-uri")]
public void ExtractTenantFromTokenEndpointIfSameInstance_InvalidUri_ReturnsNull(
string tokenEndpoint,
string configuredInstance)
{
// Act
var result = OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(
tokenEndpoint,
configuredInstance);

// Assert
Assert.Null(result);
}

[Theory]
[InlineData("https://login.microsoftonline.com/oauth2/v2.0/token", "https://login.microsoftonline.com/")]
[InlineData("https://login.microsoftonline.com/", "https://login.microsoftonline.com/")]
public void ExtractTenantFromTokenEndpointIfSameInstance_NoTenantInPath_ReturnsNull(
string tokenEndpoint,
string configuredInstance)
{
// Act
var result = OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(
tokenEndpoint,
configuredInstance);

// Assert
Assert.Null(result);
}

[Fact]
public void ExtractTenantFromTokenEndpointIfSameInstance_ValidatesOAuth2Pattern()
{
// Arrange
// This endpoint has a tenant but no oauth2 segment - should return null
var tokenEndpoint = "https://login.microsoftonline.com/my-tenant/some-other-path";
var configuredInstance = "https://login.microsoftonline.com/";

// Act
var result = OidcIdpSignedAssertionProvider.ExtractTenantFromTokenEndpointIfSameInstance(
tokenEndpoint,
configuredInstance);

// Assert
Assert.Null(result);
}
}
}
Loading