diff --git a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs index 1500126c495a..4d76e479638b 100644 --- a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs +++ b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs @@ -3,6 +3,34 @@ namespace Microsoft.AspNetCore.Components { + public partial class AuthenticationState + { + public AuthenticationState(System.Security.Claims.ClaimsPrincipal user) { } + public System.Security.Claims.ClaimsPrincipal User { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } + } + public delegate void AuthenticationStateChangedHandler(System.Threading.Tasks.Task task); + public abstract partial class AuthenticationStateProvider + { + protected AuthenticationStateProvider() { } + public event Microsoft.AspNetCore.Components.AuthenticationStateChangedHandler AuthenticationStateChanged { add { } remove { } } + public abstract System.Threading.Tasks.Task GetAuthenticationStateAsync(); + protected void NotifyAuthenticationStateChanged(System.Threading.Tasks.Task task) { } + } + public partial class AuthorizeView : Microsoft.AspNetCore.Components.ComponentBase + { + public AuthorizeView() { } + [Microsoft.AspNetCore.Components.ParameterAttribute] + public Microsoft.AspNetCore.Components.RenderFragment Authorized { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } + [Microsoft.AspNetCore.Components.ParameterAttribute] + public Microsoft.AspNetCore.Components.RenderFragment Authorizing { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } + [Microsoft.AspNetCore.Components.ParameterAttribute] + public Microsoft.AspNetCore.Components.RenderFragment ChildContent { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } + [Microsoft.AspNetCore.Components.ParameterAttribute] + public Microsoft.AspNetCore.Components.RenderFragment NotAuthorized { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.RenderTree.RenderTreeBuilder builder) { } + [System.Diagnostics.DebuggerStepThroughAttribute] + protected override System.Threading.Tasks.Task OnParametersSetAsync() { throw null; } + } [Microsoft.AspNetCore.Components.BindElementAttribute("select", null, "value", "onchange")] [Microsoft.AspNetCore.Components.BindElementAttribute("textarea", null, "value", "onchange")] [Microsoft.AspNetCore.Components.BindInputElementAttribute("checkbox", null, "checked", "onchange")] @@ -57,6 +85,15 @@ public static partial class BindMethods public static System.Action SetValueHandler(System.Action setter, string existingValue) { throw null; } public static System.Action SetValueHandler(System.Action setter, T existingValue) { throw null; } } + public partial class CascadingAuthenticationState : Microsoft.AspNetCore.Components.ComponentBase, System.IDisposable + { + public CascadingAuthenticationState() { } + [Microsoft.AspNetCore.Components.ParameterAttribute] + public Microsoft.AspNetCore.Components.RenderFragment ChildContent { [System.Runtime.CompilerServices.CompilerGeneratedAttribute]get { throw null; } } + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.RenderTree.RenderTreeBuilder builder) { } + protected override void OnInit() { } + void System.IDisposable.Dispose() { } + } [System.AttributeUsageAttribute(System.AttributeTargets.Property, AllowMultiple=false, Inherited=false)] public sealed partial class CascadingParameterAttribute : System.Attribute { diff --git a/src/Components/Components/src/Auth/AuthenticationState.cs b/src/Components/Components/src/Auth/AuthenticationState.cs new file mode 100644 index 000000000000..2a145d44f350 --- /dev/null +++ b/src/Components/Components/src/Auth/AuthenticationState.cs @@ -0,0 +1,28 @@ +// 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 System.Security.Claims; + +namespace Microsoft.AspNetCore.Components +{ + /// + /// Provides information about the currently authenticated user, if any. + /// + public class AuthenticationState + { + /// + /// Gets a that describes the current user. + /// + public ClaimsPrincipal User { get; } + + /// + /// Constructs an instance of . + /// + /// A representing the user. + public AuthenticationState(ClaimsPrincipal user) + { + User = user ?? throw new ArgumentNullException(nameof(user)); + } + } +} diff --git a/src/Components/Components/src/Auth/AuthenticationStateProvider.cs b/src/Components/Components/src/Auth/AuthenticationStateProvider.cs new file mode 100644 index 000000000000..ffd2fc252a5d --- /dev/null +++ b/src/Components/Components/src/Auth/AuthenticationStateProvider.cs @@ -0,0 +1,49 @@ +// 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 System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components +{ + /// + /// Provides information about the authentication state of the current user. + /// + public abstract class AuthenticationStateProvider + { + /// + /// Gets an instance that describes + /// the current user. + /// + /// An instance that describes the current user. + public abstract Task GetAuthenticationStateAsync(); + + /// + /// An event that provides notification when the + /// has changed. For example, this event may be raised if a user logs in or out. + /// +#pragma warning disable 0067 // "Never used" (it's only raised by subclasses) + public event AuthenticationStateChangedHandler AuthenticationStateChanged; +#pragma warning restore 0067 + + /// + /// Raises the event. + /// + /// A that supplies the updated . + protected void NotifyAuthenticationStateChanged(Task task) + { + if (task == null) + { + throw new ArgumentNullException(nameof(task)); + } + + AuthenticationStateChanged?.Invoke(task); + } + } + + /// + /// A handler for the event. + /// + /// A that supplies the updated . + public delegate void AuthenticationStateChangedHandler(Task task); +} diff --git a/src/Components/Components/src/Auth/AuthorizeView.razor b/src/Components/Components/src/Auth/AuthorizeView.razor new file mode 100644 index 000000000000..ae7b1682d544 --- /dev/null +++ b/src/Components/Components/src/Auth/AuthorizeView.razor @@ -0,0 +1,68 @@ +@namespace Microsoft.AspNetCore.Components + +@if (currentAuthenticationState == null) +{ + @Authorizing +} +else if (IsAuthorized()) +{ + @((Authorized ?? ChildContent)?.Invoke(currentAuthenticationState)) +} +else +{ + @NotAuthorized +} + +@functions { + private AuthenticationState currentAuthenticationState; + + [CascadingParameter] private Task AuthenticationState { get; set; } + + /// + /// The content that will be displayed if the user is authorized. + /// + [Parameter] public RenderFragment ChildContent { get; private set; } + + /// + /// The content that will be displayed if the user is not authorized. + /// + [Parameter] public RenderFragment NotAuthorized { get; private set; } + + /// + /// The content that will be displayed if the user is authorized. + /// If you specify a value for this parameter, do not also specify a value for . + /// + [Parameter] public RenderFragment Authorized { get; private set; } + + /// + /// The content that will be displayed while asynchronous authorization is in progress. + /// + [Parameter] public RenderFragment Authorizing { get; private set; } + + protected override async Task OnParametersSetAsync() + { + // We allow 'ChildContent' for convenience in basic cases, and 'Authorized' for symmetry + // with 'NotAuthorized' in other cases. Besides naming, they are equivalent. To avoid + // confusion, explicitly prevent the case where both are supplied. + if (ChildContent != null && Authorized != null) + { + throw new InvalidOperationException($"When using {nameof(AuthorizeView)}, do not specify both '{nameof(Authorized)}' and '{nameof(ChildContent)}'."); + } + + // First render in pending state + // If the task has already completed, this render will be skipped + currentAuthenticationState = null; + + // Then render in completed state + currentAuthenticationState = await AuthenticationState; + } + + private bool IsAuthorized() + { + // 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; + } +} diff --git a/src/Components/Components/src/Auth/CascadingAuthenticationState.razor b/src/Components/Components/src/Auth/CascadingAuthenticationState.razor new file mode 100644 index 000000000000..f3bf0bd3275c --- /dev/null +++ b/src/Components/Components/src/Auth/CascadingAuthenticationState.razor @@ -0,0 +1,36 @@ +@namespace Microsoft.AspNetCore.Components +@implements IDisposable +@inject AuthenticationStateProvider AuthenticationStateProvider + + + +@functions { + private Task _currentAuthenticationStateTask; + + /// + /// The content to which the authentication state should be provided. + /// + [Parameter] public RenderFragment ChildContent { get; private set; } + + protected override void OnInit() + { + AuthenticationStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged; + + _currentAuthenticationStateTask = AuthenticationStateProvider + .GetAuthenticationStateAsync(); + } + + private void OnAuthenticationStateChanged(Task newAuthStateTask) + { + Invoke(() => + { + _currentAuthenticationStateTask = newAuthStateTask; + StateHasChanged(); + }); + } + + void IDisposable.Dispose() + { + AuthenticationStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged; + } +} diff --git a/src/Components/Components/src/ChangeDetection.cs b/src/Components/Components/src/ChangeDetection.cs index 82ef21582ab7..9a0a1620beb4 100644 --- a/src/Components/Components/src/ChangeDetection.cs +++ b/src/Components/Components/src/ChangeDetection.cs @@ -40,6 +40,7 @@ private static bool IsKnownImmutableType(Type type) => type.IsPrimitive || type == typeof(string) || type == typeof(DateTime) + || type == typeof(Type) || type == typeof(decimal); } } diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj index 0974eb1c359a..8a5d930dd822 100644 --- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj +++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj @@ -1,4 +1,4 @@ - + netstandard2.0 @@ -6,6 +6,7 @@ true true true + 3.0 diff --git a/src/Components/Components/test/Auth/AuthorizeViewTest.cs b/src/Components/Components/test/Auth/AuthorizeViewTest.cs new file mode 100644 index 000000000000..3039cca28e32 --- /dev/null +++ b/src/Components/Components/test/Auth/AuthorizeViewTest.cs @@ -0,0 +1,308 @@ +// 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 System.Linq; +using System.Security.Claims; +using System.Security.Principal; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Xunit; + +namespace Microsoft.AspNetCore.Components +{ + public class AuthorizeViewTest + { + [Fact] + public void RendersNothingIfNotAuthorized() + { + // Arrange + var renderer = new TestRenderer(); + var rootComponent = WrapInAuthorizeView( + childContent: + context => builder => builder.AddContent(0, "This should not be rendered")); + + // Act + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + + // Assert + var diff = renderer.Batches.Single().GetComponentDiffs().Single(); + Assert.Empty(diff.Edits); + } + + [Fact] + public void RendersNotAuthorizedContentIfNotAuthorized() + { + // Arrange + var renderer = new TestRenderer(); + var rootComponent = WrapInAuthorizeView( + childContent: + context => builder => builder.AddContent(0, "This should not be rendered"), + notAuthorizedContent: + builder => builder.AddContent(0, "You are not authorized")); + + // Act + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + + // Assert + var diff = renderer.Batches.Single().GetComponentDiffs().Single(); + Assert.Collection(diff.Edits, edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + renderer.Batches.Single().ReferenceFrames[edit.ReferenceFrameIndex], + "You are not authorized"); + }); + } + + [Fact] + public void RendersNothingIfAuthorizedButNoChildContentOrAuthorizedContentProvided() + { + // Arrange + var renderer = new TestRenderer(); + var rootComponent = WrapInAuthorizeView(); + rootComponent.AuthenticationState = CreateAuthenticationState("Nellie"); + + // Act + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + + // Assert + var diff = renderer.Batches.Single().GetComponentDiffs().Single(); + Assert.Empty(diff.Edits); + } + + [Fact] + public void RendersChildContentIfAuthorized() + { + // Arrange + var renderer = new TestRenderer(); + var rootComponent = WrapInAuthorizeView( + childContent: context => builder => + builder.AddContent(0, $"You are authenticated as {context.User.Identity.Name}")); + rootComponent.AuthenticationState = CreateAuthenticationState("Nellie"); + + // Act + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + + // Assert + var diff = renderer.Batches.Single().GetComponentDiffs().Single(); + Assert.Collection(diff.Edits, edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + renderer.Batches.Single().ReferenceFrames[edit.ReferenceFrameIndex], + "You are authenticated as Nellie"); + }); + } + + [Fact] + public void RendersAuthorizedContentIfAuthorized() + { + // Arrange + var renderer = new TestRenderer(); + var rootComponent = WrapInAuthorizeView( + authorizedContent: context => builder => + builder.AddContent(0, $"You are authenticated as {context.User.Identity.Name}")); + rootComponent.AuthenticationState = CreateAuthenticationState("Nellie"); + + // Act + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + + // Assert + var diff = renderer.Batches.Single().GetComponentDiffs().Single(); + Assert.Collection(diff.Edits, edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + renderer.Batches.Single().ReferenceFrames[edit.ReferenceFrameIndex], + "You are authenticated as Nellie"); + }); + } + + [Fact] + public void RespondsToChangeInAuthorizationState() + { + // Arrange + var renderer = new TestRenderer(); + var rootComponent = WrapInAuthorizeView( + childContent: context => builder => + builder.AddContent(0, $"You are authenticated as {context.User.Identity.Name}")); + rootComponent.AuthenticationState = CreateAuthenticationState("Nellie"); + + // Render in initial state. From other tests, we know this renders + // a single batch with the correct output. + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + var authorizeViewComponentId = renderer.Batches.Single() + .GetComponentFrames().Single().ComponentId; + + // Act + rootComponent.AuthenticationState = CreateAuthenticationState("Ronaldo"); + rootComponent.TriggerRender(); + + // Assert: It's only one new diff. We skip the intermediate "await" render state + // because the task was completed synchronously. + Assert.Equal(2, renderer.Batches.Count); + var batch = renderer.Batches.Last(); + var diff = batch.DiffsByComponentId[authorizeViewComponentId].Single(); + Assert.Collection(diff.Edits, edit => + { + Assert.Equal(RenderTreeEditType.UpdateText, edit.Type); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "You are authenticated as Ronaldo"); + }); + } + + [Fact] + public void ThrowsIfBothChildContentAndAuthorizedContentProvided() + { + // Arrange + var renderer = new TestRenderer(); + var rootComponent = WrapInAuthorizeView( + authorizedContent: context => builder => { }, + childContent: context => builder => { }); + + // Act/Assert + renderer.AssignRootComponentId(rootComponent); + var ex = Assert.Throws(() => + rootComponent.TriggerRender()); + Assert.Equal("When using AuthorizeView, do not specify both 'Authorized' and 'ChildContent'.", ex.Message); + } + + [Fact] + public void RendersNothingUntilAuthorizationCompleted() + { + // Arrange + var renderer = new TestRenderer(); + var rootComponent = WrapInAuthorizeView( + notAuthorizedContent: builder => builder.AddContent(0, "You are not authorized")); + var authTcs = new TaskCompletionSource(); + rootComponent.AuthenticationState = authTcs.Task; + + // Act/Assert 1: Auth pending + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + var batch1 = renderer.Batches.Single(); + var authorizeViewComponentId = batch1.GetComponentFrames().Single().ComponentId; + var diff1 = batch1.DiffsByComponentId[authorizeViewComponentId].Single(); + Assert.Empty(diff1.Edits); + + // Act/Assert 2: Auth process completes asynchronously + authTcs.SetResult(new AuthenticationState(new ClaimsPrincipal())); + Assert.Equal(2, renderer.Batches.Count); + var batch2 = renderer.Batches[1]; + var diff2 = batch2.DiffsByComponentId[authorizeViewComponentId].Single(); + Assert.Collection(diff2.Edits, edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch2.ReferenceFrames[edit.ReferenceFrameIndex], + "You are not authorized"); + }); + } + + [Fact] + public void RendersAuthorizingContentUntilAuthorizationCompleted() + { + // Arrange + var renderer = new TestRenderer(); + var rootComponent = WrapInAuthorizeView( + authorizingContent: builder => builder.AddContent(0, "Auth pending..."), + authorizedContent: context => builder => builder.AddContent(0, $"Hello, {context.User.Identity.Name}!")); + var authTcs = new TaskCompletionSource(); + rootComponent.AuthenticationState = authTcs.Task; + + // Act/Assert 1: Auth pending + renderer.AssignRootComponentId(rootComponent); + rootComponent.TriggerRender(); + var batch1 = renderer.Batches.Single(); + var authorizeViewComponentId = batch1.GetComponentFrames().Single().ComponentId; + var diff1 = batch1.DiffsByComponentId[authorizeViewComponentId].Single(); + Assert.Collection(diff1.Edits, edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch1.ReferenceFrames[edit.ReferenceFrameIndex], + "Auth pending..."); + }); + + // Act/Assert 2: Auth process completes asynchronously + authTcs.SetResult(CreateAuthenticationState("Monsieur").Result); + Assert.Equal(2, renderer.Batches.Count); + var batch2 = renderer.Batches[1]; + var diff2 = batch2.DiffsByComponentId[authorizeViewComponentId].Single(); + Assert.Collection(diff2.Edits, + edit => + { + Assert.Equal(RenderTreeEditType.RemoveFrame, edit.Type); + Assert.Equal(0, edit.SiblingIndex); + }, + edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + Assert.Equal(0, edit.SiblingIndex); + AssertFrame.Text( + batch2.ReferenceFrames[edit.ReferenceFrameIndex], + "Hello, Monsieur!"); + }); + } + + private static TestAuthStateProviderComponent WrapInAuthorizeView( + RenderFragment childContent = null, + RenderFragment authorizedContent = null, + RenderFragment notAuthorizedContent = null, + RenderFragment authorizingContent = null) + { + return new TestAuthStateProviderComponent(builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(AuthorizeView.ChildContent), childContent); + builder.AddAttribute(2, nameof(AuthorizeView.Authorized), authorizedContent); + builder.AddAttribute(3, nameof(AuthorizeView.NotAuthorized), notAuthorizedContent); + builder.AddAttribute(4, nameof(AuthorizeView.Authorizing), authorizingContent); + builder.CloseComponent(); + }); + } + + class TestAuthStateProviderComponent : AutoRenderComponent + { + private readonly RenderFragment _childContent; + + public Task AuthenticationState { get; set; } + = Task.FromResult(new AuthenticationState(new ClaimsPrincipal())); + + public TestAuthStateProviderComponent(RenderFragment childContent) + { + _childContent = childContent; + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent>>(0); + builder.AddAttribute(1, nameof(CascadingValue>.Value), AuthenticationState); + builder.AddAttribute(2, RenderTreeBuilder.ChildContent, _childContent); + builder.CloseComponent(); + } + } + + public static Task CreateAuthenticationState(string username) + => Task.FromResult(new AuthenticationState( + new ClaimsPrincipal(new TestIdentity { Name = username }))); + + class TestIdentity : IIdentity + { + public string AuthenticationType => "Test"; + + public bool IsAuthenticated => true; + + public string Name { get; set; } + } + } +} diff --git a/src/Components/Components/test/Auth/CascadingAuthenticationStateTest.cs b/src/Components/Components/test/Auth/CascadingAuthenticationStateTest.cs new file mode 100644 index 000000000000..d21665249e9a --- /dev/null +++ b/src/Components/Components/test/Auth/CascadingAuthenticationStateTest.cs @@ -0,0 +1,221 @@ +// 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 System.Linq; +using System.Security.Claims; +using System.Security.Principal; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Microsoft.AspNetCore.Components +{ + public class CascadingAuthenticationStateTest + { + [Fact] + public void RequiresRegisteredService() + { + // Arrange + var renderer = new TestRenderer(); + var component = new AutoRenderFragmentComponent(builder => + { + builder.OpenComponent(0); + builder.CloseComponent(); + }); + + // Act/Assert + renderer.AssignRootComponentId(component); + var ex = Assert.Throws(() => component.TriggerRender()); + Assert.Contains($"There is no registered service of type '{typeof(AuthenticationStateProvider).FullName}'.", ex.Message); + } + + [Fact] + public void SuppliesSynchronouslyAvailableAuthStateToChildContent() + { + // Arrange: Service + var services = new ServiceCollection(); + var authStateProvider = new TestAuthStateProvider() + { + CurrentAuthStateTask = Task.FromResult(CreateAuthenticationState("Bert")) + }; + services.AddSingleton(authStateProvider); + + // Arrange: Renderer and component + var renderer = new TestRenderer(services.BuildServiceProvider()); + var component = new UseCascadingAuthenticationStateComponent(); + + // Act + renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Assert + var batch = renderer.Batches.Single(); + var receiveAuthStateId = batch.GetComponentFrames().Single().ComponentId; + var receiveAuthStateDiff = batch.DiffsByComponentId[receiveAuthStateId].Single(); + Assert.Collection(receiveAuthStateDiff.Edits, edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "Authenticated: True; Name: Bert; Pending: False; Renders: 1"); + }); + } + + [Fact] + public void SuppliesAsynchronouslyAvailableAuthStateToChildContent() + { + // Arrange: Service + var services = new ServiceCollection(); + var authStateTaskCompletionSource = new TaskCompletionSource(); + var authStateProvider = new TestAuthStateProvider() + { + CurrentAuthStateTask = authStateTaskCompletionSource.Task + }; + services.AddSingleton(authStateProvider); + + // Arrange: Renderer and component + var renderer = new TestRenderer(services.BuildServiceProvider()); + var component = new UseCascadingAuthenticationStateComponent(); + + // Act 1: Initial synchronous render + renderer.AssignRootComponentId(component); + component.TriggerRender(); + + // Assert 1: Empty state + var batch1 = renderer.Batches.Single(); + var receiveAuthStateFrame = batch1.GetComponentFrames().Single(); + var receiveAuthStateId = receiveAuthStateFrame.ComponentId; + var receiveAuthStateComponent = (ReceiveAuthStateComponent)receiveAuthStateFrame.Component; + var receiveAuthStateDiff1 = batch1.DiffsByComponentId[receiveAuthStateId].Single(); + Assert.Collection(receiveAuthStateDiff1.Edits, edit => + { + Assert.Equal(RenderTreeEditType.PrependFrame, edit.Type); + AssertFrame.Text( + batch1.ReferenceFrames[edit.ReferenceFrameIndex], + "Authenticated: False; Name: ; Pending: True; Renders: 1"); + }); + + // Act/Assert 2: Auth state fetch task completes in background + // No new renders yet, because the cascading parameter itself hasn't changed + authStateTaskCompletionSource.SetResult(CreateAuthenticationState("Bert")); + Assert.Single(renderer.Batches); + + // Act/Assert 3: Refresh display + receiveAuthStateComponent.TriggerRender(); + Assert.Equal(2, renderer.Batches.Count); + var batch2 = renderer.Batches.Last(); + var receiveAuthStateDiff2 = batch2.DiffsByComponentId[receiveAuthStateId].Single(); + Assert.Collection(receiveAuthStateDiff2.Edits, edit => + { + Assert.Equal(RenderTreeEditType.UpdateText, edit.Type); + AssertFrame.Text( + batch2.ReferenceFrames[edit.ReferenceFrameIndex], + "Authenticated: True; Name: Bert; Pending: False; Renders: 2"); + }); + } + + [Fact] + public void RespondsToNotificationsFromAuthenticationStateProvider() + { + // Arrange: Service + var services = new ServiceCollection(); + var authStateProvider = new TestAuthStateProvider() + { + CurrentAuthStateTask = Task.FromResult(CreateAuthenticationState(null)) + }; + services.AddSingleton(authStateProvider); + + // Arrange: Renderer and component, initially rendered + var renderer = new TestRenderer(services.BuildServiceProvider()); + var component = new UseCascadingAuthenticationStateComponent(); + renderer.AssignRootComponentId(component); + component.TriggerRender(); + var receiveAuthStateId = renderer.Batches.Single() + .GetComponentFrames().Single().ComponentId; + + // Act 2: AuthenticationStateProvider issues notification + authStateProvider.TriggerAuthenticationStateChanged( + Task.FromResult(CreateAuthenticationState("Bert"))); + + // Assert 2: Re-renders content + Assert.Equal(2, renderer.Batches.Count); + var batch = renderer.Batches.Last(); + var receiveAuthStateDiff = batch.DiffsByComponentId[receiveAuthStateId].Single(); + Assert.Collection(receiveAuthStateDiff.Edits, edit => + { + Assert.Equal(RenderTreeEditType.UpdateText, edit.Type); + AssertFrame.Text( + batch.ReferenceFrames[edit.ReferenceFrameIndex], + "Authenticated: True; Name: Bert; Pending: False; Renders: 2"); + }); + } + + class ReceiveAuthStateComponent : AutoRenderComponent + { + int numRenders; + + [CascadingParameter] Task AuthStateTask { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + numRenders++; + + if (AuthStateTask.IsCompleted) + { + var identity = AuthStateTask.Result.User.Identity; + builder.AddContent(0, $"Authenticated: {identity.IsAuthenticated}; Name: {identity.Name}; Pending: False; Renders: {numRenders}"); + } + else + { + builder.AddContent(0, $"Authenticated: False; Name: ; Pending: True; Renders: {numRenders}"); + } + } + } + + class UseCascadingAuthenticationStateComponent : AutoRenderComponent + { + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenComponent(0); + builder.AddAttribute(1, RenderTreeBuilder.ChildContent, new RenderFragment(childBuilder => + { + childBuilder.OpenComponent(0); + childBuilder.CloseComponent(); + })); + builder.CloseComponent(); + } + } + + class TestAuthStateProvider : AuthenticationStateProvider + { + public Task CurrentAuthStateTask { get; set; } + + public override Task GetAuthenticationStateAsync() + { + return CurrentAuthStateTask; + } + + internal void TriggerAuthenticationStateChanged(Task authState) + { + NotifyAuthenticationStateChanged(authState); + } + } + + public static AuthenticationState CreateAuthenticationState(string username) + => new AuthenticationState(new ClaimsPrincipal(username == null + ? new ClaimsIdentity() + : (IIdentity)new TestIdentity { Name = username })); + + class TestIdentity : IIdentity + { + public string AuthenticationType => "Test"; + + public bool IsAuthenticated => true; + + public string Name { get; set; } + } + } +} diff --git a/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs b/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs index 8b68083281ae..5527e8243515 100644 --- a/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs +++ b/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs @@ -44,6 +44,10 @@ public override CircuitHost CreateCircuitHost( jsRuntime.Initialize(client); componentContext.Initialize(client); + // You can replace the AuthenticationStateProvider with a custom one, but in that case initialization is up to you + var authenticationStateProvider = scope.ServiceProvider.GetService(); + (authenticationStateProvider as FixedAuthenticationStateProvider)?.Initialize(httpContext.User); + var uriHelper = (RemoteUriHelper)scope.ServiceProvider.GetRequiredService(); if (client.Connected) { diff --git a/src/Components/Server/src/Circuits/FixedAuthenticationStateProvider.cs b/src/Components/Server/src/Circuits/FixedAuthenticationStateProvider.cs new file mode 100644 index 000000000000..41c0942cd86b --- /dev/null +++ b/src/Components/Server/src/Circuits/FixedAuthenticationStateProvider.cs @@ -0,0 +1,33 @@ +// 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 System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Components.Server.Circuits +{ + /// + /// An intended for use in server-side + /// Blazor. The circuit factory will supply a from + /// the current , which will stay fixed for the + /// lifetime of the circuit since cannot change. + /// + /// This can therefore only be used with redirect-style authentication flows, + /// since it requires a new HTTP request in order to become a different user. + /// + internal class FixedAuthenticationStateProvider : AuthenticationStateProvider + { + private Task _authenticationStateTask; + + public void Initialize(ClaimsPrincipal user) + { + _authenticationStateTask = Task.FromResult(new AuthenticationState(user)); + } + + public override Task GetAuthenticationStateAsync() + => _authenticationStateTask + ?? throw new InvalidOperationException($"{nameof(GetAuthenticationStateAsync)} was called before {nameof(Initialize)}."); + } +} diff --git a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs index cc45d4e3b912..f68a57b1cdf4 100644 --- a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs @@ -67,6 +67,7 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return builder; } diff --git a/src/Components/Server/test/Circuits/FixedAuthenticationStateProviderTest.cs b/src/Components/Server/test/Circuits/FixedAuthenticationStateProviderTest.cs new file mode 100644 index 000000000000..e1a538c9ab85 --- /dev/null +++ b/src/Components/Server/test/Circuits/FixedAuthenticationStateProviderTest.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 System.Security.Claims; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Server.Circuits; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace Microsoft.AspNetCore.Components.Server.Tests.Circuits +{ + public class FixedAuthenticationStateProviderTest + { + [Fact] + public async Task CannotProvideAuthenticationStateBeforeInitialization() + { + await Assert.ThrowsAsync(() => + new FixedAuthenticationStateProvider() + .GetAuthenticationStateAsync()); + } + + [Fact] + public async Task SuppliesAuthenticationStateWithFixedUser() + { + // Arrange + var user = new ClaimsPrincipal(); + var provider = new FixedAuthenticationStateProvider(); + provider.Initialize(user); + + // Act + var authenticationState = await provider.GetAuthenticationStateAsync(); + + // Assert + Assert.NotNull(authenticationState); + Assert.Same(user, authenticationState.User); + } + } +} diff --git a/src/Components/Shared/test/AutoRenderFragmentComponent.cs b/src/Components/Shared/test/AutoRenderFragmentComponent.cs new file mode 100644 index 000000000000..272c33a61b80 --- /dev/null +++ b/src/Components/Shared/test/AutoRenderFragmentComponent.cs @@ -0,0 +1,20 @@ +// 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.Components.RenderTree; + +namespace Microsoft.AspNetCore.Components.Test.Helpers +{ + public class AutoRenderFragmentComponent : AutoRenderComponent + { + private readonly RenderFragment _renderFragment; + + public AutoRenderFragmentComponent(RenderFragment renderFragment) + { + _renderFragment = renderFragment; + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + => _renderFragment(builder); + } +} diff --git a/src/Components/Shared/test/CapturedBatch.cs b/src/Components/Shared/test/CapturedBatch.cs index e416c32bb160..5fec0db53692 100644 --- a/src/Components/Shared/test/CapturedBatch.cs +++ b/src/Components/Shared/test/CapturedBatch.cs @@ -1,8 +1,9 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// 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 System.Collections.Generic; +using System.Linq; using Microsoft.AspNetCore.Components.RenderTree; namespace Microsoft.AspNetCore.Components.Test.Helpers @@ -18,6 +19,12 @@ public class CapturedBatch public IList DisposedComponentIDs { get; set; } public RenderTreeFrame[] ReferenceFrames { get; set; } + public IEnumerable GetComponentFrames() where T : IComponent + => ReferenceFrames.Where(f => f.FrameType == RenderTreeFrameType.Component && f.Component is T); + + public IEnumerable GetComponentDiffs() where T : IComponent + => GetComponentFrames().SelectMany(f => DiffsByComponentId[f.ComponentId]); + internal void AddDiff(RenderTreeDiff diff) { var componentId = diff.ComponentId;