diff --git a/.gitignore b/.gitignore
index 658c8ec6..12dbc22a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -114,11 +114,7 @@
/4-WebApp-your-API/Client/obj
/2-WebApp-graph-user/2-4-Sovereign-Call-MSGraph/bin
/2-WebApp-graph-user/2-4-Sovereign-Call-MSGraph/obj
-/Microsoft.Identity.Web.Test/bin/Release/netcoreapp2.2
/Microsoft.Identity.Web.Test/obj
/4-WebApp-your-API/4-2-B2C/.vs
/4-WebApp-your-API/4-2-B2C/Client/obj
/4-WebApp-your-API/4-2-B2C/TodoListService/obj
-/2-WebApp-graph-user/2-3-Multi-Tenant/.vs/WebApp-OpenIDConnect-DotNet
-/2-WebApp-graph-user/2-3-Multi-Tenant/bin/Debug/netcoreapp2.2
-/2-WebApp-graph-user/2-3-Multi-Tenant/obj
diff --git a/2-WebApp-graph-user/2-1-Call-MSGraph/AspnetCoreWebApp-calls-Microsoft-Graph.sln b/2-WebApp-graph-user/2-1-Call-MSGraph/AspnetCoreWebApp-calls-Microsoft-Graph.sln
index 7024cab3..83026426 100644
--- a/2-WebApp-graph-user/2-1-Call-MSGraph/AspnetCoreWebApp-calls-Microsoft-Graph.sln
+++ b/2-WebApp-graph-user/2-1-Call-MSGraph/AspnetCoreWebApp-calls-Microsoft-Graph.sln
@@ -7,6 +7,13 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WebApp-OpenIDConnect-DotNet
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Identity.Web", "..\..\Microsoft.Identity.Web\Microsoft.Identity.Web.csproj", "{E0CEF26A-6CE6-4505-851B-6580D5564752}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{CD0BB564-6C1E-4FCE-B9AB-7C637FDEE569}"
+ ProjectSection(SolutionItems) = preProject
+ .editorconfig = .editorconfig
+ EndProjectSection
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.Identity.Web.Test", "..\..\Microsoft.Identity.Web.Test\Microsoft.Identity.Web.Test.csproj", "{8CCEAE2A-BDF6-470C-B6DE-7FC81A74DBD7}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -21,6 +28,10 @@ Global
{E0CEF26A-6CE6-4505-851B-6580D5564752}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E0CEF26A-6CE6-4505-851B-6580D5564752}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E0CEF26A-6CE6-4505-851B-6580D5564752}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8CCEAE2A-BDF6-470C-B6DE-7FC81A74DBD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8CCEAE2A-BDF6-470C-B6DE-7FC81A74DBD7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8CCEAE2A-BDF6-470C-B6DE-7FC81A74DBD7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8CCEAE2A-BDF6-470C-B6DE-7FC81A74DBD7}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/2-WebApp-graph-user/2-1-Call-MSGraph/WebApp-OpenIDConnect-DotNet.csproj b/2-WebApp-graph-user/2-1-Call-MSGraph/WebApp-OpenIDConnect-DotNet.csproj
index d0d1fea1..f20d8f43 100644
--- a/2-WebApp-graph-user/2-1-Call-MSGraph/WebApp-OpenIDConnect-DotNet.csproj
+++ b/2-WebApp-graph-user/2-1-Call-MSGraph/WebApp-OpenIDConnect-DotNet.csproj
@@ -18,7 +18,7 @@
-
+
diff --git a/Microsoft.Identity.Web.Test/WebApiServiceCollectionExtensionsTests.cs b/Microsoft.Identity.Web.Test/WebApiServiceCollectionExtensionsTests.cs
new file mode 100644
index 00000000..fba6c594
--- /dev/null
+++ b/Microsoft.Identity.Web.Test/WebApiServiceCollectionExtensionsTests.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using System;
+using System.Collections.Generic;
+using System.Text;
+using Xunit;
+using System.Linq;
+
+namespace Microsoft.Identity.Web.Test
+{
+ public class WebApiServiceCollectionExtensionsTests
+ {
+ [Fact]
+ public void TestAuthority()
+ {
+ // Arrange
+ JwtBearerOptions options = new JwtBearerOptions();
+
+ // Act and Assert
+ options.Authority = "https://login.microsoftonline.com/common";
+ WebApiServiceCollectionExtensions.EnsureAuthorityIsV2_0(options);
+ Assert.Equal("https://login.microsoftonline.com/common/v2.0", options.Authority);
+
+ options.Authority = "https://login.microsoftonline.us/organizations";
+ WebApiServiceCollectionExtensions.EnsureAuthorityIsV2_0(options);
+ Assert.Equal("https://login.microsoftonline.us/organizations/v2.0", options.Authority);
+
+ options.Authority = "https://login.microsoftonline.com/common/";
+ WebApiServiceCollectionExtensions.EnsureAuthorityIsV2_0(options);
+ Assert.Equal("https://login.microsoftonline.com/common/v2.0", options.Authority);
+
+ options.Authority = "https://login.microsoftonline.com/common/v2.0";
+ WebApiServiceCollectionExtensions.EnsureAuthorityIsV2_0(options);
+ Assert.Equal("https://login.microsoftonline.com/common/v2.0", options.Authority);
+
+
+ options.Authority = "https://login.microsoftonline.com/common/v2.0";
+ WebApiServiceCollectionExtensions.EnsureAuthorityIsV2_0(options);
+ Assert.Equal("https://login.microsoftonline.com/common/v2.0", options.Authority);
+
+ }
+
+ [Fact]
+ public void TestAudience()
+ {
+ JwtBearerOptions options = new JwtBearerOptions();
+
+ // Act and Assert
+ options.Audience = "https://localhost";
+ WebApiServiceCollectionExtensions.EnsureValidAudiencesContainsApiGuidIfGuidProvided(options);
+ Assert.True(options.TokenValidationParameters.ValidAudiences.Count() == 1);
+ Assert.True(options.TokenValidationParameters.ValidAudiences.First() == "https://localhost");
+
+ options.Audience = "api://1EE5A092-0DFD-42B6-88E5-C517C0141321";
+ WebApiServiceCollectionExtensions.EnsureValidAudiencesContainsApiGuidIfGuidProvided(options);
+ Assert.True(options.TokenValidationParameters.ValidAudiences.Count() == 1);
+ Assert.True(options.TokenValidationParameters.ValidAudiences.First() == "api://1EE5A092-0DFD-42B6-88E5-C517C0141321");
+
+ options.Audience = "1EE5A092-0DFD-42B6-88E5-C517C0141321";
+ WebApiServiceCollectionExtensions.EnsureValidAudiencesContainsApiGuidIfGuidProvided(options);
+ Assert.True(options.TokenValidationParameters.ValidAudiences.Count() == 2);
+ Assert.Contains("api://1EE5A092-0DFD-42B6-88E5-C517C0141321", options.TokenValidationParameters.ValidAudiences);
+ Assert.Contains("1EE5A092-0DFD-42B6-88E5-C517C0141321", options.TokenValidationParameters.ValidAudiences);
+
+ }
+ }
+}
diff --git a/Microsoft.Identity.Web/WebApiServiceCollectionExtensions.cs b/Microsoft.Identity.Web/WebApiServiceCollectionExtensions.cs
index 3fa38b82..928d31e1 100644
--- a/Microsoft.Identity.Web/WebApiServiceCollectionExtensions.cs
+++ b/Microsoft.Identity.Web/WebApiServiceCollectionExtensions.cs
@@ -53,17 +53,11 @@ public static IServiceCollection AddProtectedWebApi(
configuration.Bind(configSectionName, options);
// This is an Microsoft identity platform Web API
- var authority = options.Authority.Trim().TrimEnd('/');
- if (!authority.EndsWith("v2.0"))
- authority += "/v2.0";
- options.Authority = authority;
+ EnsureAuthorityIsV2_0(options);
- // The valid audience could be given as Client Id or as Uri. If it does not start with 'api://', this variant is added to the list of valid audiences.
- var validAudiences = new List { options.Audience };
- if (!options.Audience.StartsWith("api://", StringComparison.OrdinalIgnoreCase))
- validAudiences.Add($"api://{options.Audience}");
-
- options.TokenValidationParameters.ValidAudiences = validAudiences;
+ // The valid audience could be given as Client Id or as Uri.
+ // If it does not start with 'api://', this variant is added to the list of valid audiences.
+ EnsureValidAudiencesContainsApiGuidIfGuidProvided(options);
// Instead of using the default validation (validating against a single tenant, as we do in line of business apps),
// we inject our own multi-tenant validation logic (which even accepts both v1.0 and v2.0 tokens)
@@ -80,17 +74,17 @@ public static IServiceCollection AddProtectedWebApi(
options.Events = new JwtBearerEvents();
options.Events.OnTokenValidated = async context =>
- {
- // This check is required to ensure that the Web API only accepts tokens from tenants where it has been consented and provisioned.
- if (!context.Principal.Claims.Any(x => x.Type == ClaimConstants.Scope)
- && !context.Principal.Claims.Any(y => y.Type == ClaimConstants.Scp)
- && !context.Principal.Claims.Any(y => y.Type == ClaimConstants.Roles))
- {
- throw new UnauthorizedAccessException("Neither scope or roles claim was found in the bearer token.");
- }
-
- await Task.FromResult(0);
- };
+ {
+ // This check is required to ensure that the Web API only accepts tokens from tenants where it has been consented and provisioned.
+ if (!context.Principal.Claims.Any(x => x.Type == ClaimConstants.Scope)
+ && !context.Principal.Claims.Any(y => y.Type == ClaimConstants.Scp)
+ && !context.Principal.Claims.Any(y => y.Type == ClaimConstants.Roles))
+ {
+ throw new UnauthorizedAccessException("Neither scope or roles claim was found in the bearer token.");
+ }
+
+ await Task.FromResult(0);
+ };
if (subscribeToJwtBearerMiddlewareDiagnosticsEvents)
{
@@ -130,5 +124,37 @@ public static IServiceCollection AddProtectedApiCallsWebApis(
return services;
}
+
+ ///
+ /// Ensures that the authority is a v2.0 authority
+ ///
+ /// Jwt bearer options read from the config file
+ /// or set by the developper, for which we want to ensure the authority
+ /// is a v2.0 authority
+ internal static void EnsureAuthorityIsV2_0(JwtBearerOptions options)
+ {
+ var authority = options.Authority.Trim().TrimEnd('/');
+ if (!authority.EndsWith("v2.0"))
+ authority += "/v2.0";
+ options.Authority = authority;
+ }
+
+
+ ///
+ /// Ensure that if the audience is a GUID, api://{audience} is also added
+ /// as a valid audience (this is the default App ID URL in the app registration
+ /// portal)
+ ///
+ /// Jwt bearer options for which to ensure that
+ /// api://GUID is a valid audience
+ internal static void EnsureValidAudiencesContainsApiGuidIfGuidProvided(JwtBearerOptions options)
+ {
+ var validAudiences = new List { options.Audience };
+ if (!options.Audience.StartsWith("api://", StringComparison.OrdinalIgnoreCase)
+ && Guid.TryParse(options.Audience, out _))
+ validAudiences.Add($"api://{options.Audience}");
+
+ options.TokenValidationParameters.ValidAudiences = validAudiences;
+ }
}
}