From a9d85fb04580945815771b890d4c607d2ac6d697 Mon Sep 17 00:00:00 2001 From: Ignacio Inglese Date: Mon, 14 Sep 2026 13:02:24 +0100 Subject: [PATCH 1/2] fix: validate OWIN permission claims on 3.x Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../AppBuilderExtension.cs | 52 ++++- .../InternalAPI.Unshipped.txt | 1 + .../Microsoft.Identity.Web.OWIN.xml | 5 + .../Properties/InternalsVisibleTo.cs | 1 + .../Microsoft.Identity.Web.Test.csproj | 2 + .../OwinClaimsValidationTests.cs | 180 ++++++++++++++++++ 6 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 tests/Microsoft.Identity.Web.Test/OwinClaimsValidationTests.cs diff --git a/src/Microsoft.Identity.Web.OWIN/AppBuilderExtension.cs b/src/Microsoft.Identity.Web.OWIN/AppBuilderExtension.cs index 43612febd..39cbff8f6 100644 --- a/src/Microsoft.Identity.Web.OWIN/AppBuilderExtension.cs +++ b/src/Microsoft.Identity.Web.OWIN/AppBuilderExtension.cs @@ -3,6 +3,7 @@ using System; using System.Globalization; +using System.Linq; using System.Security.Authentication; using System.Security.Claims; using System.Threading.Tasks; @@ -38,6 +39,11 @@ public static class AppBuilderExtension /// Update the OWIN options if you want to finesse the token validation. /// Configuration section in which to read the options. /// The app builder to chain. + /// + /// By default, bearer tokens must contain a recognized scope or role claim with a non-whitespace value. Set + /// to + /// true when the application uses ACL-based authorization instead. + /// public static IAppBuilder AddMicrosoftIdentityWebApi( this IAppBuilder app, OwinTokenAcquirerFactory tokenAcquirerFactory, @@ -49,12 +55,12 @@ public static IAppBuilder AddMicrosoftIdentityWebApi( var configuration = tokenAcquirerFactory.Configuration; // Configure the Microsoft authentication options - services.Configure( - string.Empty, + Action configureOptions = configureMicrosoftIdentityApplicationOptions ?? (option => { configuration?.GetSection(configurationSection).Bind(option); - })); + }); + services.Configure(string.Empty, configureOptions); services.Configure( string.Empty, (option => @@ -85,9 +91,49 @@ public static IAppBuilder AddMicrosoftIdentityWebApi( updateOptions(options); } + MicrosoftIdentityApplicationOptions applicationOptions = new(); + configureOptions(applicationOptions); + ConfigureClaimsValidation(options, applicationOptions.AllowWebApiToBeAuthorizedByACL); + return app.UseOAuthBearerAuthentication(options); } + internal static void ConfigureClaimsValidation( + OAuthBearerAuthenticationOptions options, + bool allowWebApiToBeAuthorizedByACL) + { + if (allowWebApiToBeAuthorizedByACL) + { + return; + } + + IOAuthBearerAuthenticationProvider provider = + options.Provider ?? new OAuthBearerAuthenticationProvider(); + + options.Provider = new OAuthBearerAuthenticationProvider + { + OnRequestToken = provider.RequestToken, + OnApplyChallenge = provider.ApplyChallenge, + OnValidateIdentity = async context => + { + bool hasRequiredClaim = context.Ticket.Identity.Claims.Any(claim => + (claim.Type == ClaimConstants.Scope + || claim.Type == ClaimConstants.Scp + || claim.Type == ClaimConstants.Roles + || claim.Type == ClaimConstants.Role) + && !string.IsNullOrWhiteSpace(claim.Value)); + + if (!hasRequiredClaim) + { + context.Rejected(); + return; + } + + await provider.ValidateIdentity(context).ConfigureAwait(false); + }, + }; + } + /// /// Adds a protected web app. /// diff --git a/src/Microsoft.Identity.Web.OWIN/InternalAPI.Unshipped.txt b/src/Microsoft.Identity.Web.OWIN/InternalAPI.Unshipped.txt index 7dc5c5811..b63b1c475 100644 --- a/src/Microsoft.Identity.Web.OWIN/InternalAPI.Unshipped.txt +++ b/src/Microsoft.Identity.Web.OWIN/InternalAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +static Microsoft.Identity.Web.AppBuilderExtension.ConfigureClaimsValidation(Microsoft.Owin.Security.OAuth.OAuthBearerAuthenticationOptions! options, bool allowWebApiToBeAuthorizedByACL) -> void diff --git a/src/Microsoft.Identity.Web.OWIN/Microsoft.Identity.Web.OWIN.xml b/src/Microsoft.Identity.Web.OWIN/Microsoft.Identity.Web.OWIN.xml index 60cfe36d6..c56f21a30 100644 --- a/src/Microsoft.Identity.Web.OWIN/Microsoft.Identity.Web.OWIN.xml +++ b/src/Microsoft.Identity.Web.OWIN/Microsoft.Identity.Web.OWIN.xml @@ -43,6 +43,11 @@ Update the OWIN options if you want to finesse the token validation. Configuration section in which to read the options. The app builder to chain. + + By default, bearer tokens must contain a recognized scope or role claim with a non-whitespace value. Set + to + true when the application uses ACL-based authorization instead. + diff --git a/src/Microsoft.Identity.Web.OWIN/Properties/InternalsVisibleTo.cs b/src/Microsoft.Identity.Web.OWIN/Properties/InternalsVisibleTo.cs index 4d17c83a2..bdaa961fd 100644 --- a/src/Microsoft.Identity.Web.OWIN/Properties/InternalsVisibleTo.cs +++ b/src/Microsoft.Identity.Web.OWIN/Properties/InternalsVisibleTo.cs @@ -4,3 +4,4 @@ using System.Runtime.CompilerServices; // Allow this assembly to be serviced when run on desktop CLR +[assembly: InternalsVisibleTo("Microsoft.Identity.Web.Test, PublicKey=00240000048000009400000006020000002400005253413100040000010001002D96616729B54F6D013D71559A017F50AA4861487226C523959D1579B93F3FDF71C08B980FD3130062B03D3DE115C4B84E7AC46AEF5E192A40E7457D5F3A08F66CEAB71143807F2C3CB0DA5E23B38F0559769978406F6E5D30CEADD7985FC73A5A609A8B74A1DF0A29399074A003A226C943D480FEC96DBEC7106A87896539AD")] diff --git a/tests/Microsoft.Identity.Web.Test/Microsoft.Identity.Web.Test.csproj b/tests/Microsoft.Identity.Web.Test/Microsoft.Identity.Web.Test.csproj index 3ba22e53e..e814e6d55 100644 --- a/tests/Microsoft.Identity.Web.Test/Microsoft.Identity.Web.Test.csproj +++ b/tests/Microsoft.Identity.Web.Test/Microsoft.Identity.Web.Test.csproj @@ -9,6 +9,7 @@ + @@ -52,6 +53,7 @@ + diff --git a/tests/Microsoft.Identity.Web.Test/OwinClaimsValidationTests.cs b/tests/Microsoft.Identity.Web.Test/OwinClaimsValidationTests.cs new file mode 100644 index 000000000..04585bbb1 --- /dev/null +++ b/tests/Microsoft.Identity.Web.Test/OwinClaimsValidationTests.cs @@ -0,0 +1,180 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.IdentityModel.Tokens; +using Microsoft.Owin; +using Microsoft.Owin.Security; +using Microsoft.Owin.Security.OAuth; +using Xunit; + +namespace Microsoft.Identity.Web.Test +{ + public class OwinClaimsValidationTests + { + [Fact] + public async Task MissingScopesAndRoles_AuthenticationFailsAsync() + { + OAuthBearerAuthenticationOptions options = CreateOptions(); + AppBuilderExtension.ConfigureClaimsValidation(options, allowWebApiToBeAuthorizedByACL: false); + OAuthValidateIdentityContext context = CreateContext(options); + + await options.Provider.ValidateIdentity(context); + + Assert.False(context.IsValidated); + } + + [Theory] + [InlineData(ClaimConstants.Scope)] + [InlineData(ClaimConstants.Scp)] + [InlineData(ClaimConstants.Roles)] + [InlineData(ClaimConstants.Role)] + public async Task ScopeOrRoleClaim_AuthenticationSucceedsAsync(string claimType) + { + OAuthBearerAuthenticationOptions options = CreateOptions(); + AppBuilderExtension.ConfigureClaimsValidation(options, allowWebApiToBeAuthorizedByACL: false); + OAuthValidateIdentityContext context = CreateContext(options, claimType); + + await options.Provider.ValidateIdentity(context); + + Assert.True(context.IsValidated); + } + + [Theory] + [InlineData(ClaimConstants.Scope, "")] + [InlineData(ClaimConstants.Scope, " \t")] + [InlineData(ClaimConstants.Scp, "")] + [InlineData(ClaimConstants.Scp, " \t")] + [InlineData(ClaimConstants.Roles, "")] + [InlineData(ClaimConstants.Roles, " \t")] + [InlineData(ClaimConstants.Role, "")] + [InlineData(ClaimConstants.Role, " \t")] + public async Task EmptyScopeOrRoleClaim_AuthenticationFailsAsync(string claimType, string claimValue) + { + OAuthBearerAuthenticationOptions options = CreateOptions(); + AppBuilderExtension.ConfigureClaimsValidation(options, allowWebApiToBeAuthorizedByACL: false); + OAuthValidateIdentityContext context = CreateContext(options, claimType, claimValue); + + await options.Provider.ValidateIdentity(context); + + Assert.False(context.IsValidated); + } + + [Fact] + public async Task MissingScopesAndRoles_WithAclAuthorization_AuthenticationSucceedsAsync() + { + OAuthBearerAuthenticationOptions options = CreateOptions(); + AppBuilderExtension.ConfigureClaimsValidation(options, allowWebApiToBeAuthorizedByACL: true); + OAuthValidateIdentityContext context = CreateContext(options); + + await options.Provider.ValidateIdentity(context); + + Assert.True(context.IsValidated); + } + + [Theory] + [InlineData(null, "value")] + [InlineData(ClaimConstants.Scp, "")] + [InlineData(ClaimConstants.Roles, " \t")] + public async Task ConsumerValidation_DoesNotRunForInvalidIdentityAsync( + string? claimType, + string claimValue) + { + int callbackCount = 0; + OAuthBearerAuthenticationOptions options = new() + { + Provider = new OAuthBearerAuthenticationProvider + { + OnValidateIdentity = context => + { + callbackCount++; + context.Validated(); + return Task.CompletedTask; + }, + }, + }; + AppBuilderExtension.ConfigureClaimsValidation(options, allowWebApiToBeAuthorizedByACL: false); + OAuthValidateIdentityContext context = CreateContext(options, claimType, claimValue); + + await options.Provider.ValidateIdentity(context); + + Assert.Equal(0, callbackCount); + Assert.False(context.IsValidated); + } + + [Fact] + public async Task ConsumerRejection_RemainsRejectedAsync() + { + OAuthBearerAuthenticationOptions options = new() + { + Provider = new OAuthBearerAuthenticationProvider + { + OnValidateIdentity = context => + { + context.Rejected(); + return Task.CompletedTask; + }, + }, + }; + AppBuilderExtension.ConfigureClaimsValidation(options, allowWebApiToBeAuthorizedByACL: false); + OAuthValidateIdentityContext context = CreateContext(options, ClaimConstants.Scp); + + await options.Provider.ValidateIdentity(context); + + Assert.False(context.IsValidated); + } + + [Fact] + public async Task RequestTokenAndApplyChallenge_AreForwardedAsync() + { + int requestTokenCount = 0; + int applyChallengeCount = 0; + OAuthBearerAuthenticationOptions options = new() + { + Provider = new OAuthBearerAuthenticationProvider + { + OnRequestToken = context => + { + requestTokenCount++; + return Task.CompletedTask; + }, + OnApplyChallenge = context => + { + applyChallengeCount++; + return Task.CompletedTask; + }, + }, + }; + AppBuilderExtension.ConfigureClaimsValidation(options, allowWebApiToBeAuthorizedByACL: false); + + await options.Provider.RequestToken(new OAuthRequestTokenContext(new OwinContext(), "token")); + await options.Provider.ApplyChallenge(new OAuthChallengeContext(new OwinContext(), "Bearer")); + + Assert.Equal(1, requestTokenCount); + Assert.Equal(1, applyChallengeCount); + } + + private static OAuthBearerAuthenticationOptions CreateOptions() + { + return new OAuthBearerAuthenticationOptions + { + Provider = new OAuthBearerAuthenticationProvider(), + }; + } + + private static OAuthValidateIdentityContext CreateContext( + OAuthBearerAuthenticationOptions options, + string? claimType = null, + string claimValue = "value") + { + ClaimsIdentity identity = claimType is null + ? new CaseSensitiveClaimsIdentity(authenticationType: "Bearer") + : new CaseSensitiveClaimsIdentity(new[] { new Claim(claimType, claimValue) }, "Bearer"); + AuthenticationTicket ticket = new(identity, new AuthenticationProperties()); + OAuthValidateIdentityContext context = new(new OwinContext(), options, ticket); + context.Validated(); + return context; + } + } +} From 60ba5811068125d39a52424477ef9280c08e735b Mon Sep 17 00:00:00 2001 From: Ignacio Inglese Date: Mon, 14 Sep 2026 16:17:20 +0100 Subject: [PATCH 2/2] Compile OWIN claims tests only on net472 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Microsoft.Identity.Web.Test.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Microsoft.Identity.Web.Test/Microsoft.Identity.Web.Test.csproj b/tests/Microsoft.Identity.Web.Test/Microsoft.Identity.Web.Test.csproj index e814e6d55..6a077429e 100644 --- a/tests/Microsoft.Identity.Web.Test/Microsoft.Identity.Web.Test.csproj +++ b/tests/Microsoft.Identity.Web.Test/Microsoft.Identity.Web.Test.csproj @@ -19,6 +19,7 @@ +