From f49270d9d62bb8940b197a6141113e147756c3bb Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson Date: Thu, 22 Mar 2018 22:49:34 -0700 Subject: [PATCH] Add Azure AD package + tests --- AADIntegration.sln | 14 + NuGetPackageVerifier.json | 7 + .../AzureAD/Controllers/AccountController.cs | 45 +++ .../AzureAD/Pages/Account/AccessDenied.cshtml | 10 + .../Pages/Account/AccessDenied.cshtml.cs | 24 ++ .../Areas/AzureAD/Pages/Account/Error.cshtml | 23 ++ .../AzureAD/Pages/Account/Error.cshtml.cs | 40 +++ .../AzureAD/Pages/Account/SignedOut.cshtml | 10 + .../AzureAD/Pages/Account/SignedOut.cshtml.cs | 31 ++ .../AzureAD/Pages/Account/_viewImports.cshtml | 2 + .../Areas/AzureAD/Pages/_ViewStart.cshtml | 3 + ...AzureADAccountControllerFeatureProvider.cs | 22 ++ .../AzureADAuthenticationBuilderExtensions.cs | 223 ++++++++++++++ .../AzureADDefaults.cs | 45 +++ .../AzureADOptions.cs | 67 +++++ .../AzureADOptionsConfiguration.cs | 38 +++ .../AzureADSchemeOptions.cs | 25 ++ .../CookieOptionsConfiguration.cs | 51 ++++ .../JwtBearerOptionsConfiguration.cs | 54 ++++ ...spNetCore.Authentication.AzureAD.UI.csproj | 40 +++ .../OpenIdConnectOptionsConfiguration.cs | 55 ++++ .../Properties/AssemblyInfo.cs | 6 + ...eADAuthenticationBuilderExtensionsTests.cs | 243 +++++++++++++++ .../Controllers/AccountControllerTests.cs | 281 ++++++++++++++++++ ...Core.Authentication.AzureAD.UI.Test.csproj | 18 ++ .../xunit.runner.json | 3 + 26 files changed, 1380 insertions(+) create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Controllers/AccountController.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/AccessDenied.cshtml create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/AccessDenied.cshtml.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/Error.cshtml create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/Error.cshtml.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/SignedOut.cshtml create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/SignedOut.cshtml.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/_viewImports.cshtml create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/_ViewStart.cshtml create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADAccountControllerFeatureProvider.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADAuthenticationBuilderExtensions.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADDefaults.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADOptions.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADOptionsConfiguration.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADSchemeOptions.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/CookieOptionsConfiguration.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/JwtBearerOptionsConfiguration.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Microsoft.AspNetCore.Authentication.AzureAD.UI.csproj create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/OpenIdConnectOptionsConfiguration.cs create mode 100644 src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Properties/AssemblyInfo.cs create mode 100644 test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/AzureADAuthenticationBuilderExtensionsTests.cs create mode 100644 test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/Controllers/AccountControllerTests.cs create mode 100644 test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test.csproj create mode 100644 test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/xunit.runner.json diff --git a/AADIntegration.sln b/AADIntegration.sln index c981c9bf90..9289706e3b 100644 --- a/AADIntegration.sln +++ b/AADIntegration.sln @@ -15,6 +15,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Authen EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Authentication.AzureADB2C.UI.Test", "test\Microsoft.AspNetCore.Authentication.AzureADB2C.UI.Test\Microsoft.AspNetCore.Authentication.AzureADB2C.UI.Test.csproj", "{454089F9-ED16-4A11-9C52-2BA74DCF5D35}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Authentication.AzureAD.UI", "src\Microsoft.AspNetCore.Authentication.AzureAD.UI\Microsoft.AspNetCore.Authentication.AzureAD.UI.csproj", "{1762840C-A14A-4498-9883-CC671956F0F2}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Authentication.AzureAD.UI.Test", "test\Microsoft.AspNetCore.Authentication.AzureAD.UI.Test\Microsoft.AspNetCore.Authentication.AzureAD.UI.Test.csproj", "{3D0CF896-3A9D-4A8F-A343-A2E1A131C861}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +37,14 @@ Global {454089F9-ED16-4A11-9C52-2BA74DCF5D35}.Debug|Any CPU.Build.0 = Debug|Any CPU {454089F9-ED16-4A11-9C52-2BA74DCF5D35}.Release|Any CPU.ActiveCfg = Release|Any CPU {454089F9-ED16-4A11-9C52-2BA74DCF5D35}.Release|Any CPU.Build.0 = Release|Any CPU + {1762840C-A14A-4498-9883-CC671956F0F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1762840C-A14A-4498-9883-CC671956F0F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1762840C-A14A-4498-9883-CC671956F0F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1762840C-A14A-4498-9883-CC671956F0F2}.Release|Any CPU.Build.0 = Release|Any CPU + {3D0CF896-3A9D-4A8F-A343-A2E1A131C861}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3D0CF896-3A9D-4A8F-A343-A2E1A131C861}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3D0CF896-3A9D-4A8F-A343-A2E1A131C861}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3D0CF896-3A9D-4A8F-A343-A2E1A131C861}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -41,6 +53,8 @@ Global {5D2378D4-9DDA-468F-82C6-AE4DAA54E96C} = {8CF63E1D-F9F7-4CB4-AD90-88C4077F7BFF} {16912327-C9B3-49FC-87E0-49FEDED0A065} = {75A812B0-D98C-45F3-B2A9-357BBDF7331A} {454089F9-ED16-4A11-9C52-2BA74DCF5D35} = {57F46508-E53D-4F6B-B77C-2EFE95925AEF} + {1762840C-A14A-4498-9883-CC671956F0F2} = {75A812B0-D98C-45F3-B2A9-357BBDF7331A} + {3D0CF896-3A9D-4A8F-A343-A2E1A131C861} = {57F46508-E53D-4F6B-B77C-2EFE95925AEF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C6DBF56C-E862-46EA-A4E0-993D2950D78D} diff --git a/NuGetPackageVerifier.json b/NuGetPackageVerifier.json index b1d0afdbf6..adc8b62fce 100644 --- a/NuGetPackageVerifier.json +++ b/NuGetPackageVerifier.json @@ -10,6 +10,13 @@ "lib/netstandard2.0/Microsoft.AspNetCore.Authentication.AzureADB2C.UI.Views.dll": "This library contains precompiled views." } } + }, + "Microsoft.AspNetCore.Authentication.AzureAD.UI": { + "Exclusions": { + "DOC_MISSING": { + "lib/netstandard2.0/Microsoft.AspNetCore.Authentication.AzureAD.UI.Views.dll": "This library contains precompiled views." + } + } } } } diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Controllers/AccountController.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Controllers/AccountController.cs new file mode 100644 index 0000000000..95d1d045d2 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Controllers/AccountController.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI.AzureAD.Controllers.Internal +{ + [NonController] + [AllowAnonymous] + [Area("AzureAD")] + [Route("[area]/[controller]/[action]")] + internal class AccountController : Controller + { + public AccountController(IOptionsMonitor options) + { + Options = options; + } + + public IOptionsMonitor Options { get; } + + [HttpGet("{scheme?}")] + public IActionResult SignIn([FromRoute] string scheme) + { + scheme = scheme ?? AzureADDefaults.AuthenticationScheme; + var redirectUrl = Url.Content("~/"); + return Challenge( + new AuthenticationProperties { RedirectUri = redirectUrl }, + scheme); + } + + [HttpGet("{scheme?}")] + public IActionResult SignOut([FromRoute] string scheme) + { + scheme = scheme ?? AzureADDefaults.AuthenticationScheme; + var options = Options.Get(scheme); + var callbackUrl = Url.Page("/Account/SignedOut", pageHandler: null, values: null, protocol: Request.Scheme); + return SignOut( + new AuthenticationProperties { RedirectUri = callbackUrl }, + options.CookieSchemeName, + options.OpenIdConnectSchemeName); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/AccessDenied.cshtml b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/AccessDenied.cshtml new file mode 100644 index 0000000000..cc15816741 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/AccessDenied.cshtml @@ -0,0 +1,10 @@ +@page +@model AccessDeniedModel +@{ + ViewData["Title"] = "Access denied"; +} + +
+

@ViewData["Title"]

+

You do not have access to this resource.

+
\ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/AccessDenied.cshtml.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/AccessDenied.cshtml.cs new file mode 100644 index 0000000000..94db862a83 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/AccessDenied.cshtml.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI.Internal +{ + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code.This API may change or be removed in future releases + /// + [AllowAnonymous] + public class AccessDeniedModel : PageModel + { + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code.This API may change or be removed in future releases + /// + public void OnGet() + { + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/Error.cshtml b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/Error.cshtml new file mode 100644 index 0000000000..b1f4622758 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/Error.cshtml @@ -0,0 +1,23 @@ +@page +@model ErrorModel +@{ + ViewData["Title"] = "Error"; +} + +

Error.

+

An error occurred while processing your request.

+ +@if (Model.ShowRequestId) +{ +

+ Request ID: @Model.RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ Development environment should not be enabled in deployed applications, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the ASPNETCORE_ENVIRONMENT environment variable to Development, and restarting the application. +

\ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/Error.cshtml.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/Error.cshtml.cs new file mode 100644 index 0000000000..2a56285057 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/Error.cshtml.cs @@ -0,0 +1,40 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using System.Diagnostics; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI.Internal +{ + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code.This API may change or be removed in future releases + /// + [AllowAnonymous] + public class ErrorModel : PageModel + { + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code.This API may change or be removed in future releases + /// + public string RequestId { get; set; } + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code.This API may change or be removed in future releases + /// + public bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code.This API may change or be removed in future releases + /// + [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] + public void OnGet() + { + RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/SignedOut.cshtml b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/SignedOut.cshtml new file mode 100644 index 0000000000..41fcf9554a --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/SignedOut.cshtml @@ -0,0 +1,10 @@ +@page +@model SignedOutModel +@{ + ViewData["Title"] = "Signed out"; +} + +

@ViewData["Title"]

+

+ You have successfully signed out. +

\ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/SignedOut.cshtml.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/SignedOut.cshtml.cs new file mode 100644 index 0000000000..48eac9f5d0 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/SignedOut.cshtml.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI.Internal +{ + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code.This API may change or be removed in future releases + /// + [AllowAnonymous] + public class SignedOutModel : PageModel + { + /// + /// This API supports infrastructure and is not intended to be used + /// directly from your code.This API may change or be removed in future releases + /// + public IActionResult OnGet() + { + if (User.Identity.IsAuthenticated) + { + return LocalRedirect("~/"); + } + + return Page(); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/_viewImports.cshtml b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/_viewImports.cshtml new file mode 100644 index 0000000000..8f74cb9c22 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/Account/_viewImports.cshtml @@ -0,0 +1,2 @@ +@using Microsoft.AspNetCore.Authentication.AzureAD.UI.Internal +@namespace Microsoft.AspNetCore.Authentication.AzureAD.UI.Pages.Internal \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/_ViewStart.cshtml b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/_ViewStart.cshtml new file mode 100644 index 0000000000..a5f10045db --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Areas/AzureAD/Pages/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "_Layout"; +} diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADAccountControllerFeatureProvider.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADAccountControllerFeatureProvider.cs new file mode 100644 index 0000000000..ecfc3dab9b --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADAccountControllerFeatureProvider.cs @@ -0,0 +1,22 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using System.Collections.Generic; +using System.Reflection; +using Microsoft.AspNetCore.Authentication.AzureAD.UI.AzureAD.Controllers.Internal; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.AspNetCore.Mvc.Controllers; + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI +{ + internal class AzureADAccountControllerFeatureProvider : IApplicationFeatureProvider, IApplicationFeatureProvider + { + public void PopulateFeature(IEnumerable parts, ControllerFeature feature) + { + if (!feature.Controllers.Contains(typeof(AccountController).GetTypeInfo())) + { + feature.Controllers.Add(typeof(AccountController).GetTypeInfo()); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADAuthenticationBuilderExtensions.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADAuthenticationBuilderExtensions.cs new file mode 100644 index 0000000000..9abdc3c687 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADAuthenticationBuilderExtensions.cs @@ -0,0 +1,223 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Authentication.AzureAD.UI; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Mvc.ApplicationParts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication +{ + /// + /// Extension methods to add Azure Active Directory Authentication to your application. + /// + public static class AzureADAuthenticationBuilderExtensions + { + /// + /// Adds JWT Bearer authentication to your app for Azure Active Directory Applications. + /// + /// The . + /// The to configure the + /// . + /// + /// The . + public static AuthenticationBuilder AddAzureADBearer(this AuthenticationBuilder builder, Action configureOptions) => + builder.AddAzureADBearer( + AzureADDefaults.BearerAuthenticationScheme, + AzureADDefaults.JwtBearerAuthenticationScheme, + configureOptions); + + /// + /// Adds JWT Bearer authentication to your app for Azure Active Directory Applications. + /// + /// The . + /// The identifier for the virtual scheme. + /// The identifier for the underlying JWT Bearer scheme. + /// The to configure the + /// . + /// + /// The . + public static AuthenticationBuilder AddAzureADBearer( + this AuthenticationBuilder builder, + string scheme, + string jwtBearerScheme, + Action configureOptions) + { + + builder.AddPolicyScheme(scheme, displayName: null, configureOptions: o => + { + o.ForwardDefault = jwtBearerScheme; + }); + + builder.Services.Configure(TryAddJwtBearerSchemeMapping(scheme, jwtBearerScheme)); + + builder.Services.TryAddSingleton, AzureADOptionsConfiguration>(); + + builder.Services.TryAddSingleton, JwtBearerOptionsConfiguration>(); + + builder.Services.Configure(scheme, configureOptions); + builder.AddJwtBearer(); + + return builder; + } + + /// + /// Adds Azure Active Directory Authentication to your application. + /// + /// The . + /// The to configure the + /// + /// + /// The . + public static AuthenticationBuilder AddAzureAD(this AuthenticationBuilder builder, Action configureOptions) => + builder.AddAzureAD( + AzureADDefaults.AuthenticationScheme, + AzureADDefaults.OpenIdScheme, + AzureADDefaults.CookieScheme, + AzureADDefaults.DisplayName, + configureOptions); + + /// + /// Adds Azure Active Directory Authentication to your application. + /// + /// The . + /// The identifier for the virtual scheme. + /// The identifier for the underlying Open ID Connect scheme. + /// The identifier for the underlying cookie scheme. + /// The display name for the scheme. + /// The to configure the + /// + /// + /// The . + public static AuthenticationBuilder AddAzureAD( + this AuthenticationBuilder builder, + string scheme, + string openIdConnectScheme, + string cookieScheme, + string displayName, + Action configureOptions) + { + AddAdditionalMvcApplicationParts(builder.Services); + builder.AddPolicyScheme(scheme, displayName, o => + { + o.ForwardDefault = cookieScheme; + o.ForwardChallenge = openIdConnectScheme; + }); + + builder.Services.Configure(TryAddOpenIDCookieSchemeMappings(scheme, openIdConnectScheme, cookieScheme)); + + builder.Services.TryAddSingleton, AzureADOptionsConfiguration>(); + + builder.Services.TryAddSingleton, OpenIdConnectOptionsConfiguration>(); + + builder.Services.TryAddSingleton, CookieOptionsConfiguration>(); + + builder.Services.Configure(scheme, configureOptions); + + builder.AddOpenIdConnect(openIdConnectScheme, null, o => { }); + builder.AddCookie(cookieScheme, null, o => { }); + + return builder; + } + + private static Action TryAddJwtBearerSchemeMapping(string scheme, string jwtBearerScheme) + { + return TryAddMapping; + + void TryAddMapping(AzureADSchemeOptions o) + { + if (o.JwtBearerMappings.ContainsKey(scheme)) + { + throw new InvalidOperationException($"A scheme with the name '{scheme}' was already added."); + } + foreach (var mapping in o.JwtBearerMappings) + { + if (mapping.Value.JwtBearerScheme == jwtBearerScheme) + { + throw new InvalidOperationException( + $"The JSON Web Token Bearer scheme '{jwtBearerScheme}' can't be associated with the Azure Active Directory scheme '{scheme}'. " + + $"The JSON Web Token Bearer scheme '{jwtBearerScheme}' is already mapped to the Azure Active Directory scheme '{mapping.Key}'"); + } + } + o.JwtBearerMappings.Add(scheme, new AzureADSchemeOptions.JwtBearerSchemeMapping + { + JwtBearerScheme = jwtBearerScheme + }); + }; + } + + private static Action TryAddOpenIDCookieSchemeMappings(string scheme, string openIdConnectScheme, string cookieScheme) + { + return TryAddMapping; + + void TryAddMapping(AzureADSchemeOptions o) + { + if (o.OpenIDMappings.ContainsKey(scheme)) + { + throw new InvalidOperationException($"A scheme with the name '{scheme}' was already added."); + } + foreach (var mapping in o.OpenIDMappings) + { + if (mapping.Value.CookieScheme == cookieScheme) + { + throw new InvalidOperationException( + $"The cookie scheme '{cookieScheme}' can't be associated with the Azure Active Directory scheme '{scheme}'. " + + $"The cookie scheme '{cookieScheme}' is already mapped to the Azure Active Directory scheme '{mapping.Key}'"); + } + + if (mapping.Value.OpenIdConnectScheme == openIdConnectScheme) + { + throw new InvalidOperationException( + $"The Open ID Connect scheme '{openIdConnectScheme}' can't be associated with the Azure Active Directory scheme '{scheme}'. " + + $"The Open ID Connect scheme '{openIdConnectScheme}' is already mapped to the Azure Active Directory scheme '{mapping.Key}'"); + } + } + o.OpenIDMappings.Add(scheme, new AzureADSchemeOptions.AzureADOpenIDSchemeMapping + { + OpenIdConnectScheme = openIdConnectScheme, + CookieScheme = cookieScheme + }); + }; + } + + private static void AddAdditionalMvcApplicationParts(IServiceCollection services) + { + var additionalParts = GetAdditionalParts(); + var mvcBuilder = services + .AddMvc() + .AddRazorPagesOptions(o => o.AllowAreas = true) + .ConfigureApplicationPartManager(apm => + { + foreach (var part in additionalParts) + { + if (!apm.ApplicationParts.Any(ap => HasSameName(ap.Name, part.Name))) + { + apm.ApplicationParts.Add(part); + } + } + + apm.FeatureProviders.Add(new AzureADAccountControllerFeatureProvider()); + }); + + bool HasSameName(string left, string right) => string.Equals(left, right, StringComparison.Ordinal); + } + + private static IEnumerable GetAdditionalParts() + { + var thisAssembly = typeof(AzureADAuthenticationBuilderExtensions).Assembly; + var relatedAssemblies = RelatedAssemblyAttribute.GetRelatedAssemblies(thisAssembly, throwOnError: true); + + foreach (var reference in relatedAssemblies) + { + yield return new CompiledRazorAssemblyPart(reference); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADDefaults.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADDefaults.cs new file mode 100644 index 0000000000..eb0d5f26fa --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADDefaults.cs @@ -0,0 +1,45 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI +{ + /// + /// Constants for different Azure Active Directory authentication components. + /// + public static class AzureADDefaults + { + /// + /// The scheme name for Open ID Connect when using + /// . + /// + public static readonly string OpenIdScheme = "AzureADOpenID"; + + /// + /// The scheme name for cookies when using + /// . + /// + public static readonly string CookieScheme = "AzureADCookie"; + + /// + /// The default scheme for Azure Active Directory Bearer. + /// + public static readonly string BearerAuthenticationScheme = "AzureADBearer"; + + /// + /// The scheme name for JWT Bearer when using + /// . + /// + public static readonly string JwtBearerAuthenticationScheme = "AzureADJwtBearer"; + + /// + /// The default scheme for Azure Active Directory. + /// + public static readonly string AuthenticationScheme = "AzureAD"; + + /// + /// The display name for Azure Active Directory. + /// + public static readonly string DisplayName = "Azure Active Directory"; + } +} diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADOptions.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADOptions.cs new file mode 100644 index 0000000000..a1b9fa3f5c --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADOptions.cs @@ -0,0 +1,67 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI +{ + /// + /// Options for configuring authentication using Azure Active Directory. + /// + public class AzureADOptions + { + /// + /// Gets or sets the OpenID Connect authentication scheme to use for authentication with this instance + /// of Azure Active Directory authentication. + /// + public string OpenIdConnectSchemeName { get; set; } = OpenIdConnectDefaults.AuthenticationScheme; + + /// + /// Gets or sets the Cookie authentication scheme to use for sign in with this instance of + /// Azure Active Directory authentication. + /// + public string CookieSchemeName { get; set; } = CookieAuthenticationDefaults.AuthenticationScheme; + + /// + /// Gets or sets the Jwt bearer authentication scheme to use for validating access tokens for this + /// instance of Azure Active Directory Bearer authentication. + /// + public string JwtBearerSchemeName { get; internal set; } + + /// + /// Gets or sets the client Id. + /// + public string ClientId { get; set; } + + /// + /// Gets or sets the tenant Id. + /// + public string TenantId { get; set; } + + /// + /// Gets or sets the Azure Active Directory instance. + /// + public string Instance { get; set; } + + /// + /// Gets or sets the domain of the Azure Active Directory tennant. + /// + public string Domain { get; set; } + + /// + /// Gets or sets the sign in callback path. + /// + public string CallbackPath { get; set; } + + /// + /// Gets or sets the sign out callback path. + /// + public string SignedOutCallbackPath { get; set; } + + /// + /// Gets all the underlying authentication schemes. + /// + public string[] AllSchemes => new[] { CookieSchemeName, OpenIdConnectSchemeName }; + } +} diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADOptionsConfiguration.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADOptionsConfiguration.cs new file mode 100644 index 0000000000..9eceb5f3cb --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADOptionsConfiguration.cs @@ -0,0 +1,38 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI +{ + internal class AzureADOptionsConfiguration : IConfigureNamedOptions + { + private readonly IOptions _schemeOptions; + + public AzureADOptionsConfiguration(IOptions schemeOptions) + { + _schemeOptions = schemeOptions; + } + + public void Configure(string name, AzureADOptions options) + { + // This can be called because of someone configuring JWT or someone configuring + // Open ID + Cookie. + if (_schemeOptions.Value.OpenIDMappings.TryGetValue(name, out var webMapping)) + { + options.OpenIdConnectSchemeName = webMapping.OpenIdConnectScheme; + options.CookieSchemeName = webMapping.CookieScheme; + return; + } + if (_schemeOptions.Value.JwtBearerMappings.TryGetValue(name, out var mapping)) + { + options.JwtBearerSchemeName = mapping.JwtBearerScheme; + return; + } + } + + public void Configure(AzureADOptions options) + { + } + } +} diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADSchemeOptions.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADSchemeOptions.cs new file mode 100644 index 0000000000..79ae769a45 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/AzureADSchemeOptions.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI +{ + internal class AzureADSchemeOptions + { + public IDictionary OpenIDMappings { get; set; } = new Dictionary(); + + public IDictionary JwtBearerMappings { get; set; } = new Dictionary(); + + public class AzureADOpenIDSchemeMapping + { + public string OpenIdConnectScheme { get; set; } + public string CookieScheme { get; set; } + } + + public class JwtBearerSchemeMapping + { + public string JwtBearerScheme { get; set; } + } + } +} diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/CookieOptionsConfiguration.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/CookieOptionsConfiguration.cs new file mode 100644 index 0000000000..9f1726ca88 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/CookieOptionsConfiguration.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI +{ + internal class CookieOptionsConfiguration : IConfigureNamedOptions + { + private readonly IOptions _schemeOptions; + private readonly IOptionsMonitor _AzureADOptions; + + public CookieOptionsConfiguration(IOptions schemeOptions, IOptionsMonitor AzureADOptions) + { + _schemeOptions = schemeOptions; + _AzureADOptions = AzureADOptions; + } + + public void Configure(string name, CookieAuthenticationOptions options) + { + var AzureADScheme = GetAzureADScheme(name); + var AzureADOptions = _AzureADOptions.Get(AzureADScheme); + if (name != AzureADOptions.CookieSchemeName) + { + return; + } + + options.LoginPath = $"/AzureAD/Account/SignIn/{AzureADScheme}"; + options.LogoutPath = $"/AzureAD/Account/SignOut/{AzureADScheme}"; + options.AccessDeniedPath = "/AzureAD/Account/AccessDenied"; + } + + public void Configure(CookieAuthenticationOptions options) + { + } + + private string GetAzureADScheme(string name) + { + foreach (var mapping in _schemeOptions.Value.OpenIDMappings) + { + if (mapping.Value.OpenIdConnectScheme == name) + { + return mapping.Key; + } + } + + return null; + } + } +} diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/JwtBearerOptionsConfiguration.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/JwtBearerOptionsConfiguration.cs new file mode 100644 index 0000000000..5754ee3798 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/JwtBearerOptionsConfiguration.cs @@ -0,0 +1,54 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using System; +using Microsoft.AspNetCore.Authentication.AzureAD.UI; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication +{ + internal class JwtBearerOptionsConfiguration : IConfigureNamedOptions + { + private readonly IOptions _schemeOptions; + private readonly IOptionsMonitor _azureADOptions; + + public JwtBearerOptionsConfiguration( + IOptions schemeOptions, + IOptionsMonitor azureADOptions) + { + _schemeOptions = schemeOptions; + _azureADOptions = azureADOptions; + } + + public void Configure(string name, JwtBearerOptions options) + { + var azureADScheme = GetAzureADScheme(name); + var azureADOptions = _azureADOptions.Get(azureADScheme); + if (name != azureADOptions.JwtBearerSchemeName) + { + return; + } + + options.Audience = azureADOptions.ClientId; + options.Authority = new Uri(new Uri(azureADOptions.Instance), azureADOptions.TenantId).ToString(); + } + + public void Configure(JwtBearerOptions options) + { + } + + private string GetAzureADScheme(string name) + { + foreach (var mapping in _schemeOptions.Value.JwtBearerMappings) + { + if (mapping.Value.JwtBearerScheme == name) + { + return mapping.Key; + } + } + + return null; + } + } +} diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Microsoft.AspNetCore.Authentication.AzureAD.UI.csproj b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Microsoft.AspNetCore.Authentication.AzureAD.UI.csproj new file mode 100644 index 0000000000..25cd19424b --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Microsoft.AspNetCore.Authentication.AzureAD.UI.csproj @@ -0,0 +1,40 @@ + + + + ASP.NET Core Azure Active Directory Integration provides components for easily integrating Azure Active Directory authentication within your ASP.NET Core application. + Precompiled views assembly for the ASP.NET Core Azure Active Directory Integration package. + netstandard2.0 + aspnetcore;authentication;AzureAD + true + $(RazorTargetName).dll + false + true + false + + + + + + + + + + + + <_Parameter1>$(ViewAssemblyDescription) + + + <_Parameter1>BuildNumber + <_Parameter2>$(BuildNumber) + + + <_Parameter1>$(NeutralLanguage) + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/OpenIdConnectOptionsConfiguration.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/OpenIdConnectOptionsConfiguration.cs new file mode 100644 index 0000000000..f2332a2231 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/OpenIdConnectOptionsConfiguration.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using System; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI +{ + internal class OpenIdConnectOptionsConfiguration : IConfigureNamedOptions + { + private readonly IOptions _schemeOptions; + private readonly IOptionsMonitor _azureADOptions; + + public OpenIdConnectOptionsConfiguration(IOptions schemeOptions, IOptionsMonitor azureADOptions) + { + _schemeOptions = schemeOptions; + _azureADOptions = azureADOptions; + } + + public void Configure(string name, OpenIdConnectOptions options) + { + var azureADScheme = GetAzureADScheme(name); + var azureADOptions = _azureADOptions.Get(azureADScheme); + if (name != azureADOptions.OpenIdConnectSchemeName) + { + return; + } + + options.ClientId = azureADOptions.ClientId; + options.Authority = new Uri(new Uri(azureADOptions.Instance), azureADOptions.TenantId).ToString(); + options.CallbackPath = azureADOptions.CallbackPath ?? options.CallbackPath; + options.SignedOutCallbackPath = azureADOptions.SignedOutCallbackPath ?? options.SignedOutCallbackPath; + options.SignInScheme = azureADOptions.CookieSchemeName; + options.UseTokenLifetime = true; + } + + private string GetAzureADScheme(string name) + { + foreach (var mapping in _schemeOptions.Value.OpenIDMappings) + { + if (mapping.Value.OpenIdConnectScheme == name) + { + return mapping.Key; + } + } + + return null; + } + + public void Configure(OpenIdConnectOptions options) + { + } + } +} diff --git a/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..aaf76791a3 --- /dev/null +++ b/src/Microsoft.AspNetCore.Authentication.AzureAD.UI/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Authentication.AzureAD.UI.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/AzureADAuthenticationBuilderExtensionsTests.cs b/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/AzureADAuthenticationBuilderExtensionsTests.cs new file mode 100644 index 0000000000..50250c8f35 --- /dev/null +++ b/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/AzureADAuthenticationBuilderExtensionsTests.cs @@ -0,0 +1,243 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using System; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Authentication.AzureAD.UI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication +{ + public class AzureADAuthenticationBuilderExtensionsTests + { + [Fact] + public void AddAzureAD_AddsAllAuthenticationHandlers() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new NullLoggerFactory()); + + // Act + services.AddAuthentication() + .AddAzureAD(o => { }); + var provider = services.BuildServiceProvider(); + + // Assert + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + } + + [Fact] + public void AddAzureAD_ConfiguresAllOptions() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new NullLoggerFactory()); + + // Act + services.AddAuthentication() + .AddAzureAD(o => + { + o.Instance = "https://login.microsoftonline.com"; + o.ClientId = "ClientId"; + o.CallbackPath = "/signin-oidc"; + o.Domain = "domain.onmicrosoft.com"; + o.TenantId = "Common"; + }); + var provider = services.BuildServiceProvider(); + + // Assert + var azureADOptionsMonitor = provider.GetService>(); + Assert.NotNull(azureADOptionsMonitor); + var azureADOptions = azureADOptionsMonitor.Get(AzureADDefaults.AuthenticationScheme); + Assert.Equal(AzureADDefaults.OpenIdScheme, azureADOptions.OpenIdConnectSchemeName); + Assert.Equal(AzureADDefaults.CookieScheme, azureADOptions.CookieSchemeName); + Assert.Equal("https://login.microsoftonline.com", azureADOptions.Instance); + Assert.Equal("ClientId", azureADOptions.ClientId); + Assert.Equal("/signin-oidc", azureADOptions.CallbackPath); + Assert.Equal("domain.onmicrosoft.com", azureADOptions.Domain); + + var openIdOptionsMonitor = provider.GetService>(); + Assert.NotNull(openIdOptionsMonitor); + var openIdOptions = openIdOptionsMonitor.Get(AzureADDefaults.OpenIdScheme); + Assert.Equal("ClientId", openIdOptions.ClientId); + Assert.Equal($"https://login.microsoftonline.com/Common", openIdOptions.Authority); + Assert.True(openIdOptions.UseTokenLifetime); + Assert.Equal("/signin-oidc", openIdOptions.CallbackPath); + Assert.Equal(AzureADDefaults.CookieScheme, openIdOptions.SignInScheme); + } + + [Fact] + public void AddAzureAD_ThrowsForDuplicatedSchemes() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new NullLoggerFactory()); + + services.AddAuthentication() + .AddAzureAD(o => { }) + .AddAzureAD(o => { }); + + var provider = services.BuildServiceProvider(); + var azureADOptionsMonitor = provider.GetService>(); + + // Act & Assert + var exception = Assert.Throws( + () => azureADOptionsMonitor.Get(AzureADDefaults.AuthenticationScheme)); + + Assert.Equal("A scheme with the name 'AzureAD' was already added.", exception.Message); + } + + [Fact] + public void AddAzureAD_ThrowsWhenOpenIdSchemeIsAlreadyInUse() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new NullLoggerFactory()); + + services.AddAuthentication() + .AddAzureAD(o => { }) + .AddAzureAD("Custom", AzureADDefaults.OpenIdScheme, "Cookie", null, o => { }); + + var provider = services.BuildServiceProvider(); + var azureADOptionsMonitor = provider.GetService>(); + + var expectedMessage = $"The Open ID Connect scheme 'AzureADOpenID' can't be associated with the Azure Active Directory scheme 'Custom'. " + + "The Open ID Connect scheme 'AzureADOpenID' is already mapped to the Azure Active Directory scheme 'AzureAD'"; + + // Act & Assert + var exception = Assert.Throws( + () => azureADOptionsMonitor.Get(AzureADDefaults.AuthenticationScheme)); + + Assert.Equal(expectedMessage, exception.Message); + } + + [Fact] + public void AddAzureAD_ThrowsWhenCookieSchemeIsAlreadyInUse() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new NullLoggerFactory()); + + services.AddAuthentication() + .AddAzureAD(o => { }) + .AddAzureAD("Custom", "OpenID", AzureADDefaults.CookieScheme, null, o => { }); + + var provider = services.BuildServiceProvider(); + var azureADOptionsMonitor = provider.GetService>(); + + var expectedMessage = $"The cookie scheme 'AzureADCookie' can't be associated with the Azure Active Directory scheme 'Custom'. " + + "The cookie scheme 'AzureADCookie' is already mapped to the Azure Active Directory scheme 'AzureAD'"; + + // Act & Assert + var exception = Assert.Throws( + () => azureADOptionsMonitor.Get(AzureADDefaults.AuthenticationScheme)); + + Assert.Equal(expectedMessage, exception.Message); + } + + [Fact] + public void AddAzureADBearer_AddsAllAuthenticationHandlers() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new NullLoggerFactory()); + + // Act + services.AddAuthentication() + .AddAzureADBearer(o => { }); + var provider = services.BuildServiceProvider(); + + // Assert + Assert.NotNull(provider.GetService()); + Assert.NotNull(provider.GetService()); + } + + [Fact] + public void AddAzureADBearer_ConfiguresAllOptions() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new NullLoggerFactory()); + + // Act + services.AddAuthentication() + .AddAzureADBearer(o => + { + o.Instance = "https://login.microsoftonline.com/"; + o.ClientId = "ClientId"; + o.CallbackPath = "/signin-oidc"; + o.Domain = "domain.onmicrosoft.com"; + o.TenantId = "TenantId"; + }); + var provider = services.BuildServiceProvider(); + + // Assert + var azureADOptionsMonitor = provider.GetService>(); + Assert.NotNull(azureADOptionsMonitor); + var options = azureADOptionsMonitor.Get(AzureADDefaults.BearerAuthenticationScheme); + Assert.Equal(AzureADDefaults.JwtBearerAuthenticationScheme, options.JwtBearerSchemeName); + Assert.Equal("https://login.microsoftonline.com/", options.Instance); + Assert.Equal("ClientId", options.ClientId); + Assert.Equal("domain.onmicrosoft.com", options.Domain); + + var bearerOptionsMonitor = provider.GetService>(); + Assert.NotNull(bearerOptionsMonitor); + var bearerOptions = bearerOptionsMonitor.Get(AzureADDefaults.JwtBearerAuthenticationScheme); + Assert.Equal("ClientId", bearerOptions.Audience); + Assert.Equal($"https://login.microsoftonline.com/TenantId", bearerOptions.Authority); + } + + [Fact] + public void AddAzureADBearer_ThrowsForDuplicatedSchemes() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new NullLoggerFactory()); + + services.AddAuthentication() + .AddAzureADBearer(o => { }) + .AddAzureADBearer(o => { }); + + var provider = services.BuildServiceProvider(); + var azureADOptionsMonitor = provider.GetService>(); + + // Act & Assert + var exception = Assert.Throws( + () => azureADOptionsMonitor.Get(AzureADDefaults.AuthenticationScheme)); + + Assert.Equal("A scheme with the name 'AzureADBearer' was already added.", exception.Message); + } + + [Fact] + public void AddAzureADBearer_ThrowsWhenBearerSchemeIsAlreadyInUse() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(new NullLoggerFactory()); + + services.AddAuthentication() + .AddAzureADBearer(o => { }) + .AddAzureADBearer("Custom", AzureADDefaults.JwtBearerAuthenticationScheme, o => { }); + + var provider = services.BuildServiceProvider(); + var azureADOptionsMonitor = provider.GetService>(); + + var expectedMessage = $"The JSON Web Token Bearer scheme 'AzureADJwtBearer' can't be associated with the Azure Active Directory scheme 'Custom'. " + + "The JSON Web Token Bearer scheme 'AzureADJwtBearer' is already mapped to the Azure Active Directory scheme 'AzureADBearer'"; + + // Act & Assert + var exception = Assert.Throws( + () => azureADOptionsMonitor.Get(AzureADDefaults.AuthenticationScheme)); + + Assert.Equal(expectedMessage, exception.Message); + } + } +} diff --git a/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/Controllers/AccountControllerTests.cs b/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/Controllers/AccountControllerTests.cs new file mode 100644 index 0000000000..8d7ab6b3da --- /dev/null +++ b/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/Controllers/AccountControllerTests.cs @@ -0,0 +1,281 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using Microsoft.AspNetCore.Authorization; + +using System; +using System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.AzureAD.UI.AzureAD.Controllers.Internal +{ + public class AccountControllerTests + { + [Fact] + public void SignInNoScheme_ChallengesAADAzureADDefaultScheme() + { + // Arrange + var controller = new AccountController( + new OptionsMonitor(AzureADDefaults.AuthenticationScheme, new AzureADOptions() + { + OpenIdConnectSchemeName = AzureADDefaults.OpenIdScheme, + CookieSchemeName = AzureADDefaults.CookieScheme + })) + { + Url = new TestUrlHelper("~/", "https://localhost/") + }; + + // Act + var result = controller.SignIn(null); + + // Assert + var challenge = Assert.IsAssignableFrom(result); + var challengedScheme = Assert.Single(challenge.AuthenticationSchemes); + Assert.Equal(AzureADDefaults.AuthenticationScheme, challengedScheme); + Assert.NotNull(challenge.Properties.RedirectUri); + Assert.Equal("https://localhost/", challenge.Properties.RedirectUri); + } + + [Fact] + public void SignInProvidedScheme_ChallengesCustomScheme() + { + // Arrange + var controller = new AccountController(new OptionsMonitor("Custom", new AzureADOptions())); + controller.Url = new TestUrlHelper("~/", "https://localhost/"); + + // Act + var result = controller.SignIn("Custom"); + + // Assert + var challenge = Assert.IsAssignableFrom(result); + var challengedScheme = Assert.Single(challenge.AuthenticationSchemes); + Assert.Equal("Custom", challengedScheme); + } + + private ClaimsPrincipal CreateAuthenticatedPrincipal(string scheme) => + new ClaimsPrincipal(new ClaimsIdentity(scheme)); + + private static ControllerContext CreateControllerContext(ClaimsPrincipal principal = null) + { + principal = principal ?? new ClaimsPrincipal(new ClaimsIdentity()); + var mock = new Mock(); + mock.Setup(authS => authS.AuthenticateAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync( + (ctx, scheme) => + { + if (principal.Identity.IsAuthenticated) + { + return AuthenticateResult.Success(new AuthenticationTicket(principal, scheme)); + } + else + { + return AuthenticateResult.NoResult(); + } + }); + return new ControllerContext() + { + HttpContext = new DefaultHttpContext() + { + RequestServices = new ServiceCollection() + .AddSingleton(mock.Object) + .BuildServiceProvider() + } + }; + } + + [Fact] + public void SignOutNoScheme_SignsOutDefaultCookiesAndDefaultOpenIDConnectAADAzureADSchemesAsync() + { + // Arrange + var options = new AzureADOptions() + { + CookieSchemeName = AzureADDefaults.CookieScheme, + OpenIdConnectSchemeName = AzureADDefaults.OpenIdScheme + }; + + var controllerContext = CreateControllerContext( + CreateAuthenticatedPrincipal(AzureADDefaults.AuthenticationScheme)); + + var descriptor = new PageActionDescriptor() + { + AttributeRouteInfo = new AttributeRouteInfo() + { + Template = "/Account/SignedOut" + } + }; + var controller = new AccountController(new OptionsMonitor(AzureADDefaults.AuthenticationScheme, options)) + { + Url = new TestUrlHelper( + controllerContext.HttpContext, + new RouteData(), + descriptor, + "/Account/SignedOut", + "https://localhost/Account/SignedOut"), + ControllerContext = new ControllerContext() + { + HttpContext = controllerContext.HttpContext + } + }; + controller.Request.Scheme = "https"; + + // Act + var result = controller.SignOut(null); + + // Assert + var signOut = Assert.IsAssignableFrom(result); + Assert.Equal(new[] { AzureADDefaults.CookieScheme, AzureADDefaults.OpenIdScheme }, signOut.AuthenticationSchemes); + Assert.NotNull(signOut.Properties.RedirectUri); + Assert.Equal("https://localhost/Account/SignedOut", signOut.Properties.RedirectUri); + } + + [Fact] + public void SignOutProvidedScheme_SignsOutCustomCookiesAndCustomOpenIDConnectAADAzureADSchemesAsync() + { + // Arrange + var options = new AzureADOptions() + { + CookieSchemeName = "Cookie", + OpenIdConnectSchemeName = "OpenID" + }; + + var controllerContext = CreateControllerContext( + CreateAuthenticatedPrincipal(AzureADDefaults.AuthenticationScheme)); + var descriptor = new PageActionDescriptor() + { + AttributeRouteInfo = new AttributeRouteInfo() + { + Template = "/Account/SignedOut" + } + }; + + var controller = new AccountController(new OptionsMonitor("Custom", options)) + { + Url = new TestUrlHelper( + controllerContext.HttpContext, + new RouteData(), + descriptor, + "/Account/SignedOut", + "https://localhost/Account/SignedOut"), + ControllerContext = new ControllerContext() + { + HttpContext = controllerContext.HttpContext + } + }; + controller.Request.Scheme = "https"; + + // Act + var result = controller.SignOut("Custom"); + + // Assert + var signOut = Assert.IsAssignableFrom(result); + Assert.Equal(new[] { "Cookie", "OpenID" }, signOut.AuthenticationSchemes); + } + + private class OptionsMonitor : IOptionsMonitor + { + public OptionsMonitor(string scheme, AzureADOptions options) + { + Scheme = scheme; + Options = options; + } + + public AzureADOptions CurrentValue => throw new NotImplementedException(); + + public string Scheme { get; } + public AzureADOptions Options { get; } + + public AzureADOptions Get(string name) + { + if (name == Scheme) + { + return Options; + } + + return null; + } + + public IDisposable OnChange(Action listener) + { + throw new NotImplementedException(); + } + } + + private class TestUrlHelper : IUrlHelper + { + public TestUrlHelper(string contentPath, string url) + { + ContentPath = contentPath; + Url = url; + } + + public TestUrlHelper( + HttpContext context, + RouteData routeData, + ActionDescriptor descriptor, + string contentPath, + string url) + { + HttpContext = context; + RouteData = routeData; + ActionDescriptor = descriptor; + ContentPath = contentPath; + Url = url; + } + + public ActionContext ActionContext => + new ActionContext(HttpContext, RouteData, ActionDescriptor); + + public string ContentPath { get; } + public string Url { get; } + public HttpContext HttpContext { get; } + public RouteData RouteData { get; } + public ActionDescriptor ActionDescriptor { get; } + + public string Action(UrlActionContext actionContext) + { + throw new NotImplementedException(); + } + + public string Content(string contentPath) + { + if (ContentPath == contentPath) + { + return Url; + } + return ""; + } + + public bool IsLocalUrl(string url) + { + throw new NotImplementedException(); + } + + public string Link(string routeName, object values) + { + throw new NotImplementedException(); + } + + public string RouteUrl(UrlRouteContext routeContext) + { + if (routeContext.Values is RouteValueDictionary dicionary && + dicionary.TryGetValue("page", out var page) && + page is string pagePath && + ContentPath == pagePath) + { + return Url; + } + + return null; + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test.csproj b/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test.csproj new file mode 100644 index 0000000000..4daafb39ac --- /dev/null +++ b/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test.csproj @@ -0,0 +1,18 @@ + + + + $(StandardTestTfms) + Microsoft.AspNetCore.Authentication.AzureAD.UI + + + + + PreserveNewest + + + + + + + + diff --git a/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/xunit.runner.json b/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/xunit.runner.json new file mode 100644 index 0000000000..42db7ef95e --- /dev/null +++ b/test/Microsoft.AspNetCore.Authentication.AzureAD.UI.Test/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "shadowCopy": false +}