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
52 changes: 49 additions & 3 deletions src/Microsoft.Identity.Web.OWIN/AppBuilderExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Globalization;
using System.Linq;
using System.Security.Authentication;
using System.Security.Claims;
using System.Threading.Tasks;
Expand Down Expand Up @@ -38,6 +39,11 @@ public static class AppBuilderExtension
/// <param name="updateOptions">Update the OWIN options if you want to finesse the token validation.</param>
/// <param name="configurationSection">Configuration section in which to read the options.</param>
/// <returns>The app builder to chain.</returns>
/// <remarks>
/// By default, bearer tokens must contain a recognized scope or role claim with a non-whitespace value. Set
/// <see cref="Microsoft.Identity.Abstractions.IdentityApplicationOptions.AllowWebApiToBeAuthorizedByACL"/> to
/// <c>true</c> when the application uses ACL-based authorization instead.
/// </remarks>
public static IAppBuilder AddMicrosoftIdentityWebApi(
this IAppBuilder app,
OwinTokenAcquirerFactory tokenAcquirerFactory,
Expand All @@ -49,12 +55,12 @@ public static IAppBuilder AddMicrosoftIdentityWebApi(
var configuration = tokenAcquirerFactory.Configuration;

// Configure the Microsoft authentication options
services.Configure(
string.Empty,
Action<MicrosoftIdentityApplicationOptions> configureOptions =
configureMicrosoftIdentityApplicationOptions ?? (option =>
{
configuration?.GetSection(configurationSection).Bind(option);
}));
});
services.Configure(string.Empty, configureOptions);
services.Configure<ConfidentialClientApplicationOptions>(
string.Empty,
(option =>
Expand Down Expand Up @@ -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);
},
};
}

/// <summary>
/// Adds a protected web app.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
#nullable enable
static Microsoft.Identity.Web.AppBuilderExtension.ConfigureClaimsValidation(Microsoft.Owin.Security.OAuth.OAuthBearerAuthenticationOptions! options, bool allowWebApiToBeAuthorizedByACL) -> void

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Identity.Web.DownstreamApi\Microsoft.Identity.Web.DownstreamApi.csproj" />
<ProjectReference Condition="'$(TargetFramework)' == 'net472'" Include="..\..\src\Microsoft.Identity.Web.OWIN\Microsoft.Identity.Web.OWIN.csproj" />
<ProjectReference Condition="'$(TargetFramework)' == 'net6.0' Or '$(TargetFramework)' == 'net7.0' Or '$(TargetFramework)' == 'net8.0' Or '$(TargetFramework)' == 'net9.0'" Include="..\..\src\Microsoft.Identity.Web.MicrosoftGraph\Microsoft.Identity.Web.MicrosoftGraph.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Identity.Web\Microsoft.Identity.Web.csproj" />
<ProjectReference Include="..\..\src\Microsoft.Identity.Web.Azure\Microsoft.Identity.Web.Azure.csproj" />
Expand All @@ -18,6 +19,7 @@

<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" />
<Compile Remove="OwinClaimsValidationTests.cs" Condition="'$(TargetFramework)' != 'net472'" />
</ItemGroup>

<ItemGroup>
Expand Down Expand Up @@ -52,6 +54,7 @@
<Compile Include="CacheExtensionsTests.cs" />
<Compile Include="CacheEncryptionTests.cs" />
<Compile Include="TestDistributedCache.cs" />
<Compile Include="OwinClaimsValidationTests.cs" Condition="'$(TargetFramework)' == 'net472'" />
</ItemGroup>

<ItemGroup>
Expand Down
180 changes: 180 additions & 0 deletions tests/Microsoft.Identity.Web.Test/OwinClaimsValidationTests.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}