From d18a033b1ee6d923a72d440718c5d496b57c2ffc Mon Sep 17 00:00:00 2001
From: Steve Sanderson <SteveSandersonMS@users.noreply.github.com>
Date: Fri, 24 May 2019 15:28:37 +0100
Subject: [PATCH] Integrate AuthorizeView with actual authorization (#10487)

---
 .../src/Hosting/WebAssemblyHostBuilder.cs     |   7 +
 .../src/Services/WebAssemblyConsoleLogger.cs  |  34 +++
 .../src/Services/WebAssemblyLoggerFactory.cs  |  23 ++
 .../Microsoft.AspNetCore.Components.csproj    |   1 +
 ...etCore.Components.netstandard2.0.Manual.cs |   8 +-
 .../src/Auth/AuthorizeDataAdapter.cs          |  39 +++
 .../Components/src/Auth/AuthorizeView.razor   |  49 +++-
 .../Microsoft.AspNetCore.Components.csproj    |   1 +
 .../Components/test/Auth/AuthorizeViewTest.cs | 275 ++++++++++++++++--
 src/Components/Shared/test/TestRenderer.cs    |   2 +-
 src/Components/test/E2ETest/Tests/AuthTest.cs |  52 +++-
 .../AuthTest/AuthorizeViewCases.razor         |  34 ++-
 .../ClientSideAuthenticationStateData.cs      |   2 +-
 .../ServerAuthenticationStateProvider.cs      |   2 +-
 .../test/testassets/BasicTestApp/Startup.cs   |   6 +
 .../TestServer/Controllers/UserController.cs  |  20 +-
 .../TestServer/Pages/Authentication.cshtml    |  19 +-
 .../test/testassets/TestServer/Startup.cs     |   6 +
 18 files changed, 529 insertions(+), 51 deletions(-)
 create mode 100644 src/Components/Blazor/Blazor/src/Services/WebAssemblyConsoleLogger.cs
 create mode 100644 src/Components/Blazor/Blazor/src/Services/WebAssemblyLoggerFactory.cs
 create mode 100644 src/Components/Components/src/Auth/AuthorizeDataAdapter.cs

diff --git a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs
index 8cf720e1ca6..0b741cbd22c 100644
--- a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs
+++ b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHostBuilder.cs
@@ -8,6 +8,8 @@ using Microsoft.AspNetCore.Blazor.Services;
 using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Components.Routing;
 using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+using Microsoft.Extensions.Logging;
 using Microsoft.JSInterop;
 
 namespace Microsoft.AspNetCore.Blazor.Hosting
@@ -92,6 +94,7 @@ namespace Microsoft.AspNetCore.Blazor.Hosting
             services.AddSingleton<IComponentContext, WebAssemblyComponentContext>();
             services.AddSingleton<IUriHelper>(WebAssemblyUriHelper.Instance);
             services.AddSingleton<INavigationInterception>(WebAssemblyNavigationInterception.Instance);
+            services.AddSingleton<ILoggerFactory, WebAssemblyLoggerFactory>();
             services.AddSingleton<HttpClient>(s =>
             {
                 // Creating the URI helper needs to wait until the JS Runtime is initialized, so defer it.
@@ -102,6 +105,10 @@ namespace Microsoft.AspNetCore.Blazor.Hosting
                 };
             });
 
+            // Needed for authorization
+            services.AddOptions();
+            services.TryAdd(ServiceDescriptor.Singleton(typeof(ILogger<>), typeof(WebAssemblyConsoleLogger<>)));
+
             foreach (var configureServicesAction in _configureServicesActions)
             {
                 configureServicesAction(_BrowserHostBuilderContext, services);
diff --git a/src/Components/Blazor/Blazor/src/Services/WebAssemblyConsoleLogger.cs b/src/Components/Blazor/Blazor/src/Services/WebAssemblyConsoleLogger.cs
new file mode 100644
index 00000000000..c86c1cf30be
--- /dev/null
+++ b/src/Components/Blazor/Blazor/src/Services/WebAssemblyConsoleLogger.cs
@@ -0,0 +1,34 @@
+// 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 System;
+using Microsoft.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Blazor.Services
+{
+    internal class WebAssemblyConsoleLogger<T> : ILogger<T>, ILogger
+    {
+        public IDisposable BeginScope<TState>(TState state)
+        {
+            return NoOpDisposable.Instance;
+        }
+
+        public bool IsEnabled(LogLevel logLevel)
+        {
+            return logLevel >= LogLevel.Warning;
+        }
+
+        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
+        {
+            var formattedMessage = formatter(state, exception);
+            Console.WriteLine($"[{logLevel}] {formattedMessage}");
+        }
+
+        private class NoOpDisposable : IDisposable
+        {
+            public static NoOpDisposable Instance = new NoOpDisposable();
+
+            public void Dispose() { }
+        }
+    }
+}
diff --git a/src/Components/Blazor/Blazor/src/Services/WebAssemblyLoggerFactory.cs b/src/Components/Blazor/Blazor/src/Services/WebAssemblyLoggerFactory.cs
new file mode 100644
index 00000000000..73458387e74
--- /dev/null
+++ b/src/Components/Blazor/Blazor/src/Services/WebAssemblyLoggerFactory.cs
@@ -0,0 +1,23 @@
+// 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.Extensions.Logging;
+
+namespace Microsoft.AspNetCore.Blazor.Services
+{
+    internal class WebAssemblyLoggerFactory : ILoggerFactory
+    {
+        public void AddProvider(ILoggerProvider provider)
+        {
+            // No-op
+        }
+
+        public ILogger CreateLogger(string categoryName)
+            => new WebAssemblyConsoleLogger<object>();
+
+        public void Dispose()
+        {
+            // No-op
+        }
+    }
+}
diff --git a/src/Components/Components/ref/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/ref/Microsoft.AspNetCore.Components.csproj
index bb08a3ecfed..4e878d13e32 100644
--- a/src/Components/Components/ref/Microsoft.AspNetCore.Components.csproj
+++ b/src/Components/Components/ref/Microsoft.AspNetCore.Components.csproj
@@ -5,6 +5,7 @@
   </PropertyGroup>
   <ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
     <Compile Include="Microsoft.AspNetCore.Components.netstandard2.0.cs" />
+    <Reference Include="Microsoft.AspNetCore.Authorization"  />
     <Reference Include="Microsoft.JSInterop"  />
     <Reference Include="System.ComponentModel.Annotations"  />
   </ItemGroup>
diff --git a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.Manual.cs b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.Manual.cs
index 72db875d487..73ee830f5ce 100644
--- a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.Manual.cs
+++ b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.Manual.cs
@@ -59,7 +59,13 @@ namespace Microsoft.AspNetCore.Components
         [Microsoft.AspNetCore.Components.ParameterAttribute]
         public Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.AuthenticationState> ChildContent { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } }
         [Microsoft.AspNetCore.Components.ParameterAttribute]
-        public Microsoft.AspNetCore.Components.RenderFragment NotAuthorized { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } }
+        public Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.AuthenticationState> NotAuthorized { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } }
+        [Microsoft.AspNetCore.Components.ParameterAttribute]
+        public string Policy { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } }
+        [Microsoft.AspNetCore.Components.ParameterAttribute]
+        public string Roles { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } }
+        [Microsoft.AspNetCore.Components.ParameterAttribute]
+        public object Resource { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } private set { throw null; } }
         protected override void BuildRenderTree(Microsoft.AspNetCore.Components.RenderTree.RenderTreeBuilder builder) { }
         [System.Diagnostics.DebuggerStepThroughAttribute]
         protected override System.Threading.Tasks.Task OnParametersSetAsync() { throw null; }
diff --git a/src/Components/Components/src/Auth/AuthorizeDataAdapter.cs b/src/Components/Components/src/Auth/AuthorizeDataAdapter.cs
new file mode 100644
index 00000000000..3da3e762616
--- /dev/null
+++ b/src/Components/Components/src/Auth/AuthorizeDataAdapter.cs
@@ -0,0 +1,39 @@
+// 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 System;
+using Microsoft.AspNetCore.Authorization;
+
+namespace Microsoft.AspNetCore.Components
+{
+    // This is so the AuthorizeView can avoid implementing IAuthorizeData (even privately)
+    internal class AuthorizeDataAdapter : IAuthorizeData
+    {
+        private readonly AuthorizeView _component;
+
+        public AuthorizeDataAdapter(AuthorizeView component)
+        {
+            _component = component ?? throw new ArgumentNullException(nameof(component));
+        }
+
+        public string Policy
+        {
+            get => _component.Policy;
+            set => throw new NotSupportedException();
+        }
+
+        public string Roles
+        {
+            get => _component.Roles;
+            set => throw new NotSupportedException();
+        }
+
+        // AuthorizeView doesn't expose any such parameter, as it wouldn't be used anyway,
+        // since we already have the ClaimsPrincipal by the time AuthorizeView gets involved.
+        public string AuthenticationSchemes
+        {
+            get => null;
+            set => throw new NotSupportedException();
+        }
+    }
+}
diff --git a/src/Components/Components/src/Auth/AuthorizeView.razor b/src/Components/Components/src/Auth/AuthorizeView.razor
index ae7b1682d54..0514ba3f9c3 100644
--- a/src/Components/Components/src/Auth/AuthorizeView.razor
+++ b/src/Components/Components/src/Auth/AuthorizeView.razor
@@ -1,20 +1,26 @@
 @namespace Microsoft.AspNetCore.Components
+@using System.Security.Claims
+@using Microsoft.AspNetCore.Authorization
+@inject IAuthorizationService AuthorizationService
+@inject IAuthorizationPolicyProvider AuthorizationPolicyProvider
 
 @if (currentAuthenticationState == null)
 {
     @Authorizing
 }
-else if (IsAuthorized())
+else if (isAuthorized)
 {
     @((Authorized ?? ChildContent)?.Invoke(currentAuthenticationState))
 }
 else
 {
-    @NotAuthorized
+    @(NotAuthorized?.Invoke(currentAuthenticationState))
 }
 
 @functions {
+    private IAuthorizeData[] selfAsAuthorizeData;
     private AuthenticationState currentAuthenticationState;
+    private bool isAuthorized;
 
     [CascadingParameter] private Task<AuthenticationState> AuthenticationState { get; set; }
 
@@ -26,7 +32,7 @@ else
     /// <summary>
     /// The content that will be displayed if the user is not authorized.
     /// </summary>
-    [Parameter] public RenderFragment NotAuthorized { get; private set; }
+    [Parameter] public RenderFragment<AuthenticationState> NotAuthorized { get; private set; }
 
     /// <summary>
     /// The content that will be displayed if the user is authorized.
@@ -39,6 +45,29 @@ else
     /// </summary>
     [Parameter] public RenderFragment Authorizing { get; private set; }
 
+    /// <summary>
+    /// The policy name that determines whether the content can be displayed.
+    /// </summary>
+    [Parameter] public string Policy { get; private set; }
+
+    /// <summary>
+    /// A comma delimited list of roles that are allowed to display the content.
+    /// </summary>
+    [Parameter] public string Roles { get; private set; }
+
+    /// <summary>
+    /// The resource to which access is being controlled.
+    /// </summary>
+    [Parameter] public object Resource { get; private set; }
+
+    protected override void OnInit()
+    {
+        selfAsAuthorizeData = new[]
+        {
+            new AuthorizeDataAdapter((AuthorizeView)(object)this)
+        };
+    }
+
     protected override async Task OnParametersSetAsync()
     {
         // We allow 'ChildContent' for convenience in basic cases, and 'Authorized' for symmetry
@@ -54,15 +83,17 @@ else
         currentAuthenticationState = null;
 
         // Then render in completed state
+        // Importantly, we *don't* call StateHasChanged between the following async steps,
+        // otherwise we'd display an incorrect UI state while waiting for IsAuthorizedAsync
         currentAuthenticationState = await AuthenticationState;
+        isAuthorized = await IsAuthorizedAsync(currentAuthenticationState.User);
     }
 
-    private bool IsAuthorized()
+    private async Task<bool> IsAuthorizedAsync(ClaimsPrincipal user)
     {
-        // TODO: Support various authorization condition parameters, equivalent to those offered
-        // by the [Authorize] attribute, e.g., "Roles" and "Policy". This is on hold until we're
-        // able to reference the policy evaluator APIs from this package.
-
-        return currentAuthenticationState.User?.Identity?.IsAuthenticated == true;
+        var policy = await AuthorizationPolicy.CombineAsync(
+            AuthorizationPolicyProvider, selfAsAuthorizeData);
+        var result = await AuthorizationService.AuthorizeAsync(user, Resource, policy);
+        return result.Succeeded;
     }
 }
diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
index 46121def31b..abdf16892e5 100644
--- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
+++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
@@ -10,6 +10,7 @@
   </PropertyGroup>
 
   <ItemGroup>
+    <Reference Include="Microsoft.AspNetCore.Authorization" />
     <Reference Include="Microsoft.JSInterop" />
     <Reference Include="System.ComponentModel.Annotations" />
   </ItemGroup>
diff --git a/src/Components/Components/test/Auth/AuthorizeViewTest.cs b/src/Components/Components/test/Auth/AuthorizeViewTest.cs
index 5617c3b32d1..60c3f445d6a 100644
--- a/src/Components/Components/test/Auth/AuthorizeViewTest.cs
+++ b/src/Components/Components/test/Auth/AuthorizeViewTest.cs
@@ -2,14 +2,18 @@
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
+using System.Collections.Generic;
 using System.Diagnostics;
 using System.Linq;
 using System.Security.Claims;
 using System.Security.Principal;
 using System.Threading;
 using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Authorization.Infrastructure;
 using Microsoft.AspNetCore.Components.RenderTree;
 using Microsoft.AspNetCore.Components.Test.Helpers;
+using Microsoft.Extensions.DependencyInjection;
 using Xunit;
 
 namespace Microsoft.AspNetCore.Components
@@ -24,7 +28,8 @@ namespace Microsoft.AspNetCore.Components
         public void RendersNothingIfNotAuthorized()
         {
             // Arrange
-            var renderer = new TestRenderer();
+            var authorizationService = new TestAuthorizationService();
+            var renderer = CreateTestRenderer(authorizationService);
             var rootComponent = WrapInAuthorizeView(
                 childContent:
                     context => builder => builder.AddContent(0, "This should not be rendered"));
@@ -36,18 +41,27 @@ namespace Microsoft.AspNetCore.Components
             // Assert
             var diff = renderer.Batches.Single().GetComponentDiffs<AuthorizeView>().Single();
             Assert.Empty(diff.Edits);
+
+            // Assert: The IAuthorizationService was given expected criteria
+            Assert.Collection(authorizationService.AuthorizeCalls, call =>
+            {
+                Assert.Null(call.user.Identity);
+                Assert.Null(call.resource);
+                Assert.Collection(call.requirements,
+                    req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req));
+            });
         }
 
         [Fact]
         public void RendersNotAuthorizedContentIfNotAuthorized()
         {
             // Arrange
-            var renderer = new TestRenderer();
+            var authorizationService = new TestAuthorizationService();
+            var renderer = CreateTestRenderer(authorizationService);
             var rootComponent = WrapInAuthorizeView(
-                childContent:
-                    context => builder => builder.AddContent(0, "This should not be rendered"),
                 notAuthorizedContent:
-                    builder => builder.AddContent(0, "You are not authorized"));
+                    context => builder => builder.AddContent(0, $"You are not authorized, even though we know you are {context.User.Identity.Name}"));
+            rootComponent.AuthenticationState = CreateAuthenticationState("Nellie");
 
             // Act
             renderer.AssignRootComponentId(rootComponent);
@@ -60,7 +74,16 @@ namespace Microsoft.AspNetCore.Components
                 Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type);
                 AssertFrame.Text(
                     renderer.Batches.Single().ReferenceFrames[edit.ReferenceFrameIndex],
-                    "You are not authorized");
+                    "You are not authorized, even though we know you are Nellie");
+            });
+
+            // Assert: The IAuthorizationService was given expected criteria
+            Assert.Collection(authorizationService.AuthorizeCalls, call =>
+            {
+                Assert.Equal("Nellie", call.user.Identity.Name);
+                Assert.Null(call.resource);
+                Assert.Collection(call.requirements,
+                    req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req));
             });
         }
 
@@ -68,7 +91,9 @@ namespace Microsoft.AspNetCore.Components
         public void RendersNothingIfAuthorizedButNoChildContentOrAuthorizedContentProvided()
         {
             // Arrange
-            var renderer = new TestRenderer();
+            var authorizationService = new TestAuthorizationService();
+            authorizationService.NextResult = AuthorizationResult.Success();
+            var renderer = CreateTestRenderer(authorizationService);
             var rootComponent = WrapInAuthorizeView();
             rootComponent.AuthenticationState = CreateAuthenticationState("Nellie");
 
@@ -79,13 +104,24 @@ namespace Microsoft.AspNetCore.Components
             // Assert
             var diff = renderer.Batches.Single().GetComponentDiffs<AuthorizeView>().Single();
             Assert.Empty(diff.Edits);
+
+            // Assert: The IAuthorizationService was given expected criteria
+            Assert.Collection(authorizationService.AuthorizeCalls, call =>
+            {
+                Assert.Equal("Nellie", call.user.Identity.Name);
+                Assert.Null(call.resource);
+                Assert.Collection(call.requirements,
+                    req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req));
+            });
         }
 
         [Fact]
         public void RendersChildContentIfAuthorized()
         {
             // Arrange
-            var renderer = new TestRenderer();
+            var authorizationService = new TestAuthorizationService();
+            authorizationService.NextResult = AuthorizationResult.Success();
+            var renderer = CreateTestRenderer(authorizationService);
             var rootComponent = WrapInAuthorizeView(
                 childContent: context => builder =>
                     builder.AddContent(0, $"You are authenticated as {context.User.Identity.Name}"));
@@ -104,13 +140,24 @@ namespace Microsoft.AspNetCore.Components
                     renderer.Batches.Single().ReferenceFrames[edit.ReferenceFrameIndex],
                     "You are authenticated as Nellie");
             });
+
+            // Assert: The IAuthorizationService was given expected criteria
+            Assert.Collection(authorizationService.AuthorizeCalls, call =>
+            {
+                Assert.Equal("Nellie", call.user.Identity.Name);
+                Assert.Null(call.resource);
+                Assert.Collection(call.requirements,
+                    req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req));
+            });
         }
 
         [Fact]
         public void RendersAuthorizedContentIfAuthorized()
         {
             // Arrange
-            var renderer = new TestRenderer();
+            var authorizationService = new TestAuthorizationService();
+            authorizationService.NextResult = AuthorizationResult.Success();
+            var renderer = CreateTestRenderer(authorizationService);
             var rootComponent = WrapInAuthorizeView(
                 authorizedContent: context => builder =>
                     builder.AddContent(0, $"You are authenticated as {context.User.Identity.Name}"));
@@ -129,13 +176,24 @@ namespace Microsoft.AspNetCore.Components
                     renderer.Batches.Single().ReferenceFrames[edit.ReferenceFrameIndex],
                     "You are authenticated as Nellie");
             });
+
+            // Assert: The IAuthorizationService was given expected criteria
+            Assert.Collection(authorizationService.AuthorizeCalls, call =>
+            {
+                Assert.Equal("Nellie", call.user.Identity.Name);
+                Assert.Null(call.resource);
+                Assert.Collection(call.requirements,
+                    req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req));
+            });
         }
 
         [Fact]
         public void RespondsToChangeInAuthorizationState()
         {
             // Arrange
-            var renderer = new TestRenderer();
+            var authorizationService = new TestAuthorizationService();
+            authorizationService.NextResult = AuthorizationResult.Success();
+            var renderer = CreateTestRenderer(authorizationService);
             var rootComponent = WrapInAuthorizeView(
                 childContent: context => builder =>
                     builder.AddContent(0, $"You are authenticated as {context.User.Identity.Name}"));
@@ -147,6 +205,7 @@ namespace Microsoft.AspNetCore.Components
             rootComponent.TriggerRender();
             var authorizeViewComponentId = renderer.Batches.Single()
                 .GetComponentFrames<AuthorizeView>().Single().ComponentId;
+            authorizationService.AuthorizeCalls.Clear();
 
             // Act
             rootComponent.AuthenticationState = CreateAuthenticationState("Ronaldo");
@@ -164,13 +223,23 @@ namespace Microsoft.AspNetCore.Components
                     batch.ReferenceFrames[edit.ReferenceFrameIndex],
                     "You are authenticated as Ronaldo");
             });
+
+            // Assert: The IAuthorizationService was given expected criteria
+            Assert.Collection(authorizationService.AuthorizeCalls, call =>
+            {
+                Assert.Equal("Ronaldo", call.user.Identity.Name);
+                Assert.Null(call.resource);
+                Assert.Collection(call.requirements,
+                    req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req));
+            });
         }
 
         [Fact]
         public void ThrowsIfBothChildContentAndAuthorizedContentProvided()
         {
             // Arrange
-            var renderer = new TestRenderer();
+            var authorizationService = new TestAuthorizationService();
+            var renderer = CreateTestRenderer(authorizationService);
             var rootComponent = WrapInAuthorizeView(
                 authorizedContent: context => builder => { },
                 childContent: context => builder => { });
@@ -187,12 +256,12 @@ namespace Microsoft.AspNetCore.Components
         {
             // Arrange
             var @event = new ManualResetEventSlim();
-            var renderer = new TestRenderer()
-            {
-                OnUpdateDisplayComplete = () => { @event.Set(); },
-            };
+            var authorizationService = new TestAuthorizationService();
+            var renderer = CreateTestRenderer(authorizationService);
+            renderer.OnUpdateDisplayComplete = () => { @event.Set(); };
             var rootComponent = WrapInAuthorizeView(
-                notAuthorizedContent: builder => builder.AddContent(0, "You are not authorized"));
+                notAuthorizedContent:
+                    context => builder => builder.AddContent(0, "You are not authorized"));
             var authTcs = new TaskCompletionSource<AuthenticationState>();
             rootComponent.AuthenticationState = authTcs.Task;
 
@@ -228,10 +297,10 @@ namespace Microsoft.AspNetCore.Components
         {
             // Arrange
             var @event = new ManualResetEventSlim();
-            var renderer = new TestRenderer()
-            {
-                OnUpdateDisplayComplete = () => { @event.Set(); },
-            };
+            var authorizationService = new TestAuthorizationService();
+            authorizationService.NextResult = AuthorizationResult.Success();
+            var renderer = CreateTestRenderer(authorizationService);
+            renderer.OnUpdateDisplayComplete = () => { @event.Set(); };
             var rootComponent = WrapInAuthorizeView(
                 authorizingContent: builder => builder.AddContent(0, "Auth pending..."),
                 authorizedContent: context => builder => builder.AddContent(0, $"Hello, {context.User.Identity.Name}!"));
@@ -276,13 +345,96 @@ namespace Microsoft.AspNetCore.Components
                     batch2.ReferenceFrames[edit.ReferenceFrameIndex],
                     "Hello, Monsieur!");
             });
+
+            // Assert: The IAuthorizationService was given expected criteria
+            Assert.Collection(authorizationService.AuthorizeCalls, call =>
+            {
+                Assert.Equal("Monsieur", call.user.Identity.Name);
+                Assert.Null(call.resource);
+                Assert.Collection(call.requirements,
+                    req => Assert.IsType<DenyAnonymousAuthorizationRequirement>(req));
+            });
+        }
+
+        [Fact]
+        public void IncludesPolicyInAuthorizeCall()
+        {
+            // Arrange
+            var authorizationService = new TestAuthorizationService();
+            var renderer = CreateTestRenderer(authorizationService);
+            var rootComponent = WrapInAuthorizeView(policy: "MyTestPolicy");
+            rootComponent.AuthenticationState = CreateAuthenticationState("Nellie");
+
+            // Act
+            renderer.AssignRootComponentId(rootComponent);
+            rootComponent.TriggerRender();
+
+            // Assert
+            Assert.Collection(authorizationService.AuthorizeCalls, call =>
+            {
+                Assert.Equal("Nellie", call.user.Identity.Name);
+                Assert.Null(call.resource);
+                Assert.Collection(call.requirements,
+                    req => Assert.Equal("MyTestPolicy", ((TestPolicyRequirement)req).PolicyName));
+            });
+        }
+
+        [Fact]
+        public void IncludesRolesInAuthorizeCall()
+        {
+            // Arrange
+            var authorizationService = new TestAuthorizationService();
+            var renderer = CreateTestRenderer(authorizationService);
+            var rootComponent = WrapInAuthorizeView(roles: "SuperTestRole1, SuperTestRole2");
+            rootComponent.AuthenticationState = CreateAuthenticationState("Nellie");
+
+            // Act
+            renderer.AssignRootComponentId(rootComponent);
+            rootComponent.TriggerRender();
+
+            // Assert
+            Assert.Collection(authorizationService.AuthorizeCalls, call =>
+            {
+                Assert.Equal("Nellie", call.user.Identity.Name);
+                Assert.Null(call.resource);
+                Assert.Collection(call.requirements, req => Assert.Equal(
+                    new[] { "SuperTestRole1", "SuperTestRole2" },
+                    ((RolesAuthorizationRequirement)req).AllowedRoles));
+            });
+        }
+
+        [Fact]
+        public void IncludesResourceInAuthorizeCall()
+        {
+            // Arrange
+            var authorizationService = new TestAuthorizationService();
+            var renderer = CreateTestRenderer(authorizationService);
+            var resource = new object();
+            var rootComponent = WrapInAuthorizeView(resource: resource);
+            rootComponent.AuthenticationState = CreateAuthenticationState("Nellie");
+
+            // Act
+            renderer.AssignRootComponentId(rootComponent);
+            rootComponent.TriggerRender();
+
+            // Assert
+            Assert.Collection(authorizationService.AuthorizeCalls, call =>
+            {
+                Assert.Equal("Nellie", call.user.Identity.Name);
+                Assert.Same(resource, call.resource);
+                Assert.Collection(call.requirements, req =>
+                    Assert.IsType<DenyAnonymousAuthorizationRequirement>(req));
+            });
         }
 
         private static TestAuthStateProviderComponent WrapInAuthorizeView(
             RenderFragment<AuthenticationState> childContent = null,
             RenderFragment<AuthenticationState> authorizedContent = null,
-            RenderFragment notAuthorizedContent = null,
-            RenderFragment authorizingContent = null)
+            RenderFragment<AuthenticationState> notAuthorizedContent = null,
+            RenderFragment authorizingContent = null,
+            string policy = null,
+            string roles = null,
+            object resource = null)
         {
             return new TestAuthStateProviderComponent(builder =>
             {
@@ -291,6 +443,9 @@ namespace Microsoft.AspNetCore.Components
                 builder.AddAttribute(2, nameof(AuthorizeView.Authorized), authorizedContent);
                 builder.AddAttribute(3, nameof(AuthorizeView.NotAuthorized), notAuthorizedContent);
                 builder.AddAttribute(4, nameof(AuthorizeView.Authorizing), authorizingContent);
+                builder.AddAttribute(5, nameof(AuthorizeView.Policy), policy);
+                builder.AddAttribute(6, nameof(AuthorizeView.Roles), roles);
+                builder.AddAttribute(7, nameof(AuthorizeView.Resource), resource);
                 builder.CloseComponent();
             });
         }
@@ -311,11 +466,31 @@ namespace Microsoft.AspNetCore.Components
             {
                 builder.OpenComponent<CascadingValue<Task<AuthenticationState>>>(0);
                 builder.AddAttribute(1, nameof(CascadingValue<Task<AuthenticationState>>.Value), AuthenticationState);
-                builder.AddAttribute(2, RenderTreeBuilder.ChildContent, _childContent);
+                builder.AddAttribute(2, RenderTreeBuilder.ChildContent, (RenderFragment)(builder =>
+                {
+                    builder.OpenComponent<NeverReRenderComponent>(0);
+                    builder.AddAttribute(1, RenderTreeBuilder.ChildContent, _childContent);
+                    builder.CloseComponent();
+                }));
                 builder.CloseComponent();
             }
         }
 
+        // This is useful to show that the reason why a CascadingValue refreshes is because the
+        // value itself changed, not just that we're re-rendering the entire tree and have to
+        // recurse into all descendants because we're passing ChildContent
+        class NeverReRenderComponent : ComponentBase
+        {
+            [Parameter] RenderFragment ChildContent { get; set; }
+
+            protected override bool ShouldRender() => false;
+
+            protected override void BuildRenderTree(RenderTreeBuilder builder)
+            {
+                builder.AddContent(0, ChildContent);
+            }
+        }
+
         public static Task<AuthenticationState> CreateAuthenticationState(string username)
             => Task.FromResult(new AuthenticationState(
                 new ClaimsPrincipal(new TestIdentity { Name = username })));
@@ -328,5 +503,59 @@ namespace Microsoft.AspNetCore.Components
 
             public string Name { get; set; }
         }
+
+        public TestRenderer CreateTestRenderer(IAuthorizationService authorizationService)
+        {
+            var serviceCollection = new ServiceCollection();
+            serviceCollection.AddSingleton(authorizationService);
+            serviceCollection.AddSingleton<IAuthorizationPolicyProvider>(new TestAuthorizationPolicyProvider());
+            return new TestRenderer(serviceCollection.BuildServiceProvider());
+        }
+
+        private class TestAuthorizationService : IAuthorizationService
+        {
+            public AuthorizationResult NextResult { get; set; }
+                = AuthorizationResult.Failed();
+
+            public List<(ClaimsPrincipal user, object resource, IEnumerable<IAuthorizationRequirement> requirements)> AuthorizeCalls { get; }
+                = new List<(ClaimsPrincipal user, object resource, IEnumerable<IAuthorizationRequirement> requirements)>();
+
+            public Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object resource, IEnumerable<IAuthorizationRequirement> requirements)
+            {
+                AuthorizeCalls.Add((user, resource, requirements));
+
+                // The TestAuthorizationService doesn't actually apply any authorization requirements
+                // It just returns the specified NextResult, since we're not trying to test the logic
+                // in DefaultAuthorizationService or similar here. So it's up to tests to set a desired
+                // NextResult and assert that the expected criteria were passed by inspecting AuthorizeCalls.
+                return Task.FromResult(NextResult);
+            }
+
+            public Task<AuthorizationResult> AuthorizeAsync(ClaimsPrincipal user, object resource, string policyName)
+                => throw new NotImplementedException();
+        }
+
+        private class TestAuthorizationPolicyProvider : IAuthorizationPolicyProvider
+        {
+            private readonly AuthorizationOptions options = new AuthorizationOptions();
+
+            public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
+                => Task.FromResult(options.DefaultPolicy);
+
+            public Task<AuthorizationPolicy> GetFallbackPolicyAsync()
+                => Task.FromResult(options.FallbackPolicy);
+
+            public Task<AuthorizationPolicy> GetPolicyAsync(string policyName) => Task.FromResult(
+                new AuthorizationPolicy(new[]
+                {
+                    new TestPolicyRequirement { PolicyName = policyName }
+                },
+                new[] { $"TestScheme:{policyName}" }));
+        }
+
+        public class TestPolicyRequirement : IAuthorizationRequirement
+        {
+            public string PolicyName { get; set; }
+        }
     }
 }
diff --git a/src/Components/Shared/test/TestRenderer.cs b/src/Components/Shared/test/TestRenderer.cs
index f2a99ccdd45..d838ea007b7 100644
--- a/src/Components/Shared/test/TestRenderer.cs
+++ b/src/Components/Shared/test/TestRenderer.cs
@@ -81,7 +81,7 @@ namespace Microsoft.AspNetCore.Components.Test.Helpers
         {
             if (!ShouldHandleExceptions)
             {
-                throw exception;
+                ExceptionDispatchInfo.Capture(exception).Throw();
             }
 
             HandledExceptions.Add(exception);
diff --git a/src/Components/test/E2ETest/Tests/AuthTest.cs b/src/Components/test/E2ETest/Tests/AuthTest.cs
index cc3139b6abd..3e9e6ef478d 100644
--- a/src/Components/test/E2ETest/Tests/AuthTest.cs
+++ b/src/Components/test/E2ETest/Tests/AuthTest.cs
@@ -32,7 +32,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void CascadingAuthenticationState_Unauthenticated()
         {
-            SignInAs(null);
+            SignInAs(null, null);
 
             var appElement = MountAndNavigateToAuthTest(CascadingAuthenticationStateLink);
 
@@ -44,7 +44,7 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void CascadingAuthenticationState_Authenticated()
         {
-            SignInAs("someone cool");
+            SignInAs("someone cool", null);
 
             var appElement = MountAndNavigateToAuthTest(CascadingAuthenticationStateLink);
 
@@ -56,20 +56,58 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
         [Fact]
         public void AuthorizeViewCases_NoAuthorizationRule_Unauthenticated()
         {
-            SignInAs(null);
-            MountAndNavigateToAuthTest(AuthorizeViewCases);
+            SignInAs(null, null);
+            var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases);
             WaitUntilExists(By.CssSelector("#no-authorization-rule .not-authorized"));
+            Browser.Equal("You're not authorized, anonymous", () =>
+                appElement.FindElement(By.CssSelector("#no-authorization-rule .not-authorized")).Text);
         }
 
         [Fact]
         public void AuthorizeViewCases_NoAuthorizationRule_Authenticated()
         {
-            SignInAs("Some User");
+            SignInAs("Some User", null);
             var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases);
             Browser.Equal("Welcome, Some User!", () =>
                 appElement.FindElement(By.CssSelector("#no-authorization-rule .authorized")).Text);
         }
 
+        [Fact]
+        public void AuthorizeViewCases_RequireRole_Authenticated()
+        {
+            SignInAs("Some User", "IrrelevantRole,TestRole");
+            var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases);
+            Browser.Equal("Welcome, Some User!", () =>
+                appElement.FindElement(By.CssSelector("#authorize-role .authorized")).Text);
+        }
+
+        [Fact]
+        public void AuthorizeViewCases_RequireRole_Unauthenticated()
+        {
+            SignInAs("Some User", "IrrelevantRole");
+            var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases);
+            Browser.Equal("You're not authorized, Some User", () =>
+                appElement.FindElement(By.CssSelector("#authorize-role .not-authorized")).Text);
+        }
+
+        [Fact]
+        public void AuthorizeViewCases_RequirePolicy_Authenticated()
+        {
+            SignInAs("Bert", null);
+            var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases);
+            Browser.Equal("Welcome, Bert!", () =>
+                appElement.FindElement(By.CssSelector("#authorize-policy .authorized")).Text);
+        }
+
+        [Fact]
+        public void AuthorizeViewCases_RequirePolicy_Unauthenticated()
+        {
+            SignInAs("Mallory", null);
+            var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases);
+            Browser.Equal("You're not authorized, Mallory", () =>
+                appElement.FindElement(By.CssSelector("#authorize-policy .not-authorized")).Text);
+        }
+
         IWebElement MountAndNavigateToAuthTest(string authLinkText)
         {
             Navigate(ServerPathBase);
@@ -79,12 +117,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests
             return appElement;
         }
 
-        void SignInAs(string usernameOrNull)
+        void SignInAs(string usernameOrNull, string rolesOrNull)
         {
             const string authenticationPageUrl = "/Authentication";
             var baseRelativeUri = usernameOrNull == null
                 ? $"{authenticationPageUrl}?signout=true"
-                : $"{authenticationPageUrl}?username={usernameOrNull}";
+                : $"{authenticationPageUrl}?username={usernameOrNull}&roles={rolesOrNull}";
             Navigate(baseRelativeUri);
             WaitUntilExists(By.CssSelector("h1#authentication"));
         }
diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthorizeViewCases.razor b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthorizeViewCases.razor
index d78c70f72a0..00b39652f26 100644
--- a/src/Components/test/testassets/BasicTestApp/AuthTest/AuthorizeViewCases.razor
+++ b/src/Components/test/testassets/BasicTestApp/AuthTest/AuthorizeViewCases.razor
@@ -11,7 +11,39 @@
             <p class="authorized">Welcome, @context.User.Identity.Name!</p>
         </Authorized>
         <NotAuthorized>
-            <p class="not-authorized">You're not logged in.</p>
+            <p class="not-authorized">You're not authorized, @(context.User.Identity.Name ?? "anonymous")</p>
+        </NotAuthorized>
+    </AuthorizeView>
+</div>
+
+<div id="authorize-role">
+    <h3>Scenario: Require role</h3>
+
+    <AuthorizeView Roles="TestRole">
+        <Authorizing>
+            <p class="authorizing">Authorizing...</p>
+        </Authorizing>
+        <Authorized>
+            <p class="authorized">Welcome, @context.User.Identity.Name!</p>
+        </Authorized>
+        <NotAuthorized>
+            <p class="not-authorized">You're not authorized, @(context.User.Identity.Name ?? "anonymous")</p>
+        </NotAuthorized>
+    </AuthorizeView>
+</div>
+
+<div id="authorize-policy">
+    <h3>Scenario: Require policy</h3>
+
+    <AuthorizeView Policy="NameMustStartWithB">
+        <Authorizing>
+            <p class="authorizing">Authorizing...</p>
+        </Authorizing>
+        <Authorized>
+            <p class="authorized">Welcome, @context.User.Identity.Name!</p>
+        </Authorized>
+        <NotAuthorized>
+            <p class="not-authorized">You're not authorized, @(context.User.Identity.Name ?? "anonymous")</p>
         </NotAuthorized>
     </AuthorizeView>
 </div>
diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/ClientSideAuthenticationStateData.cs b/src/Components/test/testassets/BasicTestApp/AuthTest/ClientSideAuthenticationStateData.cs
index 15178b5d820..f4845803acc 100644
--- a/src/Components/test/testassets/BasicTestApp/AuthTest/ClientSideAuthenticationStateData.cs
+++ b/src/Components/test/testassets/BasicTestApp/AuthTest/ClientSideAuthenticationStateData.cs
@@ -12,6 +12,6 @@ namespace BasicTestApp.AuthTest
 
         public string UserName { get; set; }
 
-        public Dictionary<string, string> ExposedClaims { get; set; }
+        public List<(string Type, string Value)> ExposedClaims { get; set; }
     }
 }
diff --git a/src/Components/test/testassets/BasicTestApp/AuthTest/ServerAuthenticationStateProvider.cs b/src/Components/test/testassets/BasicTestApp/AuthTest/ServerAuthenticationStateProvider.cs
index 08639f9254f..40750c9c9db 100644
--- a/src/Components/test/testassets/BasicTestApp/AuthTest/ServerAuthenticationStateProvider.cs
+++ b/src/Components/test/testassets/BasicTestApp/AuthTest/ServerAuthenticationStateProvider.cs
@@ -29,7 +29,7 @@ namespace BasicTestApp.AuthTest
             if (data.IsAuthenticated)
             {
                 var claims = new[] { new Claim(ClaimTypes.Name, data.UserName) }
-                    .Concat(data.ExposedClaims.Select(c => new Claim(c.Key, c.Value)));
+                    .Concat(data.ExposedClaims.Select(c => new Claim(c.Type, c.Value)));
                 identity = new ClaimsIdentity(claims, "Server authentication");
             }
             else
diff --git a/src/Components/test/testassets/BasicTestApp/Startup.cs b/src/Components/test/testassets/BasicTestApp/Startup.cs
index 73084e0260a..a97b966f696 100644
--- a/src/Components/test/testassets/BasicTestApp/Startup.cs
+++ b/src/Components/test/testassets/BasicTestApp/Startup.cs
@@ -15,6 +15,12 @@ namespace BasicTestApp
         public void ConfigureServices(IServiceCollection services)
         {
             services.AddSingleton<AuthenticationStateProvider, ServerAuthenticationStateProvider>();
+
+            services.AddAuthorizationCore(options =>
+            {
+                options.AddPolicy("NameMustStartWithB", policy =>
+                    policy.RequireAssertion(ctx => ctx.User.Identity.Name?.StartsWith("B") ?? false));
+            });
         }
 
         public void Configure(IComponentsApplicationBuilder app)
diff --git a/src/Components/test/testassets/TestServer/Controllers/UserController.cs b/src/Components/test/testassets/TestServer/Controllers/UserController.cs
index 16764e4080a..da6a4244190 100644
--- a/src/Components/test/testassets/TestServer/Controllers/UserController.cs
+++ b/src/Components/test/testassets/TestServer/Controllers/UserController.cs
@@ -1,4 +1,6 @@
+using System;
 using System.Linq;
+using System.Security.Claims;
 using BasicTestApp.AuthTest;
 using Microsoft.AspNetCore.Mvc;
 
@@ -7,22 +9,28 @@ namespace Components.TestServer.Controllers
     [Route("api/[controller]")]
     public class UserController : Controller
     {
+        // Servers are not expected to expose everything from the server-side ClaimsPrincipal
+        // to the client. It's up to the developer to choose what kind of authentication state
+        // data is needed on the client so it can display suitable options in the UI.
+        // In this class, we inform the client only about certain roles and certain other claims.
+        static string[] ExposedRoles = new[] { "IrrelevantRole", "TestRole" };
+
         // GET api/user
         [HttpGet]
         public ClientSideAuthenticationStateData Get()
         {
-            // Servers are not expected to expose everything from the server-side ClaimsPrincipal
-            // to the client. It's up to the developer to choose what kind of authentication state
-            // data is needed on the client so it can display suitable options in the UI.
-
             return new ClientSideAuthenticationStateData
             {
                 IsAuthenticated = User.Identity.IsAuthenticated,
                 UserName = User.Identity.Name,
                 ExposedClaims = User.Claims
-                    .Where(c => c.Type == "test-claim")
-                    .ToDictionary(c => c.Type, c => c.Value)
+                    .Where(c => c.Type == "test-claim" || IsExposedRole(c))
+                    .Select(c => (c.Type, c.Value)).ToList()
             };
         }
+
+        private bool IsExposedRole(Claim claim)
+            => claim.Type == ClaimTypes.Role
+            && ExposedRoles.Contains(claim.Value);
     }
 }
diff --git a/src/Components/test/testassets/TestServer/Pages/Authentication.cshtml b/src/Components/test/testassets/TestServer/Pages/Authentication.cshtml
index 3f6cbdefd58..165618a019a 100644
--- a/src/Components/test/testassets/TestServer/Pages/Authentication.cshtml
+++ b/src/Components/test/testassets/TestServer/Pages/Authentication.cshtml
@@ -26,6 +26,10 @@
                 User name:
                 <input name="username" />
             </p>
+            <p>
+                Roles:
+                <input name="roles" />
+            </p>
             <p>
                 <button type="submit">Submit</button>
             </p>
@@ -37,7 +41,11 @@
         <p>
             Authenticated: <strong>@User.Identity.IsAuthenticated</strong>
             Username: <strong>@User.Identity.Name</strong>
-        </p>
+            Roles:
+            <strong>
+                @string.Join(", ", User.Claims.Where(c => c.Type == ClaimTypes.Role).Select(c => c.Value).ToArray())
+            </strong>
+        </p>foreach
         <a href="Authentication?signout=true">Sign out</a>
     </fieldset>
 </body>
@@ -61,6 +69,15 @@
                 new Claim("test-claim", "Test claim value"),
             };
 
+            var roles = Request.Query["roles"];
+            if (!string.IsNullOrEmpty(roles))
+            {
+                foreach (var role in roles.ToString().Split(','))
+                {
+                    claims.Add(new Claim(ClaimTypes.Role, role));
+                }
+            }
+
             await HttpContext.SignInAsync(
                 new ClaimsPrincipal(new ClaimsIdentity(claims, "FakeAuthenticationType")));
 
diff --git a/src/Components/test/testassets/TestServer/Startup.cs b/src/Components/test/testassets/TestServer/Startup.cs
index 26da1af8e05..0075a8dfac7 100644
--- a/src/Components/test/testassets/TestServer/Startup.cs
+++ b/src/Components/test/testassets/TestServer/Startup.cs
@@ -30,6 +30,12 @@ namespace TestServer
             });
             services.AddServerSideBlazor();
             services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
+
+            services.AddAuthorization(options =>
+            {
+                options.AddPolicy("NameMustStartWithB", policy =>
+                    policy.RequireAssertion(ctx => ctx.User.Identity.Name?.StartsWith("B") ?? false));
+            });
         }
 
         // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
-- 
GitLab