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; + } } }