Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,11 @@ public static ComponentEndpointConventionBuilder MapBlazorHub(
ArgumentNullException.ThrowIfNull(path);
ArgumentNullException.ThrowIfNull(configureOptions);

var hubEndpoint = endpoints.MapHub<ComponentHub>(path, configureOptions);
var hubEndpoint = endpoints.MapHub<ComponentHub>(path, options =>
{
options.EnableAuthenticationRefresh = true;
configureOptions(options);
});

var disconnectEndpoint = endpoints.Map(
(path.EndsWith('/') ? path : path + "/") + "disconnect/",
Expand Down
10 changes: 10 additions & 0 deletions src/Components/Server/src/ComponentHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.Connections.Features;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
Expand Down Expand Up @@ -72,6 +73,15 @@ public ComponentHub(
/// </summary>
public static PathString DefaultPath { get; } = "/_blazor";

public override Task OnConnectedAsync()
{
// ComponentHub owns authentication state at the circuit layer and does not use SignalR
// groups or user routing, so it can accept an identity change without rekeying those.
Context.Features.Get<IConnectionUserRefreshFeature>()?.OnUserRefreshing = static _ => true;

return Task.CompletedTask;
}

public override Task OnDisconnectedAsync(Exception exception)
{
// If the CircuitHost is gone now this isn't an error. This could happen if the disconnect
Expand Down
23 changes: 22 additions & 1 deletion src/Components/Server/test/Circuits/ComponentHubTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server.Circuits;
using Microsoft.AspNetCore.Connections.Features;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Connections.Features;
Expand Down Expand Up @@ -335,6 +336,21 @@ public async Task ResumeCircuitFailsWithUnresolvedCircuitHandlerDependency_Notif
mockClientProxy.Verify(m => m.SendCoreAsync("JS.Error", new[] { errorMessage }, It.IsAny<CancellationToken>()), Times.Once());
}

[Fact]
public async Task OnConnectedAsyncReplacesSignalRUserRefreshPolicy()
{
var userRefreshFeature = new Mock<IConnectionUserRefreshFeature>();
userRefreshFeature.SetupAllProperties();
userRefreshFeature.Object.OnUserRefreshing = static _ => false;
var (_, hub) = InitializeComponentHub(userRefreshFeature: userRefreshFeature.Object);

await hub.OnConnectedAsync();

var callback = userRefreshFeature.Object.OnUserRefreshing;
Assert.NotNull(callback);
Assert.True(callback(new ClaimsPrincipal()));
}

[Fact]
public async Task OnAuthenticationRefreshedAsyncUpdatesCircuitUser()
{
Expand Down Expand Up @@ -372,7 +388,8 @@ private static (Mock<ISingleClientProxy>, ComponentHub) InitializeComponentHub(
ICircuitHandleRegistry handleRegistry = null,
ICircuitPersistenceProvider provider = null,
ICircuitFactory circuitFactory = null,
ClaimsPrincipal user = null)
ClaimsPrincipal user = null,
IConnectionUserRefreshFeature userRefreshFeature = null)
{
deserializer ??= new TestServerComponentDeserializer();
var ephemeralDataProtectionProvider = new EphemeralDataProtectionProvider();
Expand Down Expand Up @@ -416,6 +433,10 @@ private static (Mock<ISingleClientProxy>, ComponentHub) InitializeComponentHub(
var httpContextFeature = new Mock<IHttpContextFeature>();
httpContextFeature.Setup(x => x.HttpContext).Returns(() => new DefaultHttpContext());
feature.Set(httpContextFeature.Object);
if (userRefreshFeature is not null)
{
feature.Set(userRefreshFeature);
}
mockContext.Setup(x => x.Features).Returns(feature);
mockContext.Setup(x => x.ConnectionId).Returns("123");
mockContext.Setup(x => x.User).Returns(user ?? new ClaimsPrincipal());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,47 @@ public void MapBlazorHub_WiresUp_UnderlyingHub()
// Arrange
var applicationBuilder = CreateAppBuilder();
var called = false;
var authenticationRefreshEnabled = false;

// Act
var app = applicationBuilder
.UseRouting()
.UseEndpoints(endpoints =>
{
endpoints.MapBlazorHub(dispatchOptions => called = true);
endpoints.MapBlazorHub(dispatchOptions =>
{
called = true;
authenticationRefreshEnabled = dispatchOptions.EnableAuthenticationRefresh;
});
}).Build();

// Assert
Assert.True(called);
Assert.True(authenticationRefreshEnabled);
}

[Fact]
public void MapBlazorHub_AllowsAuthenticationRefreshToBeDisabled()
{
var applicationBuilder = CreateAppBuilder();
var authenticationRefreshEnabledBeforeConfiguration = false;
var endpointDisplayNames = new List<string>();

var app = applicationBuilder
.UseRouting()
.UseEndpoints(endpoints =>
{
endpoints.MapBlazorHub(dispatchOptions =>
{
authenticationRefreshEnabledBeforeConfiguration = dispatchOptions.EnableAuthenticationRefresh;
dispatchOptions.EnableAuthenticationRefresh = false;
}).Finally(builder => endpointDisplayNames.Add(builder.DisplayName));
}).Build();

app.Invoke(new DefaultHttpContext());

Assert.True(authenticationRefreshEnabledBeforeConfiguration);
Assert.DoesNotContain("/_blazor/refresh", endpointDisplayNames);
}

[Fact]
Expand Down Expand Up @@ -85,9 +115,10 @@ public void MapBlazorHub_AppliesFinalConventionToEachBuilder()
Assert.True(called);
// Final conventions are applied to each of the builders
// in the Blazor component hub
Assert.Equal(4, buildersAffected.Count);
Assert.Equal(5, buildersAffected.Count);
Assert.Contains("/_blazor/negotiate", buildersAffected);
Assert.Contains("/_blazor", buildersAffected);
Assert.Contains("/_blazor/refresh", buildersAffected);
Assert.Contains("Blazor disconnect", buildersAffected);
Assert.Contains("Blazor initializers", buildersAffected);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher {

const connectionBuilder = new HubConnectionBuilder()
.withUrl('_blazor')
.withHubProtocol(hubProtocol);
.withHubProtocol(hubProtocol)
.withAuthenticationRefresh();

this._options.configureSignalR(connectionBuilder);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import { afterEach, describe, expect, jest, test } from '@jest/globals';
import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr';
import { CircuitManager } from '../../../src/Platform/Circuits/CircuitManager';
import { resolveOptions } from '../../../src/Platform/Circuits/CircuitStartOptions';
import { JSEventRegistry } from '../../../src/Services/JSEventRegistry';

interface InternalCircuitManager {
startConnection(): Promise<HubConnection>;
}

describe('CircuitManager authentication refresh', () => {
afterEach(() => {
jest.restoreAllMocks();
});

test('enables authentication refresh before applying user configuration', async () => {
const configuredOptions: unknown[] = [];
jest.spyOn(HubConnectionBuilder.prototype, 'withAuthenticationRefresh')
.mockImplementation(function (this: HubConnectionBuilder, options = {}) {
configuredOptions.push(options);
return this;
});

const connection = {
on: jest.fn(),
onclose: jest.fn(),
start: () => Promise.resolve(),
} as unknown as HubConnection;
jest.spyOn(HubConnectionBuilder.prototype, 'build').mockReturnValue(connection);

const options = resolveOptions({
configureSignalR: builder => {
builder.withAuthenticationRefresh({ enableAutoRefresh: false });
},
});
const circuit = new CircuitManager(
{} as never,
'',
options,
{ log: () => { /* no-op */ } } as never,
new JSEventRegistry());

await (circuit as unknown as InternalCircuitManager).startConnection();

expect(configuredOptions).toEqual([{}, { enableAutoRefresh: false }]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ namespace Microsoft.AspNetCore.Components.E2ETest;

internal static class BasicTestAppAuthenticationWebDriverExtensions
{
public static void SignInAs(this IWebDriver browser, Uri baseUri, string usernameOrNull, string rolesOrNull, bool useSeparateTab = false)
public static void SignInAs(
this IWebDriver browser,
Uri baseUri,
string usernameOrNull,
string rolesOrNull,
bool useSeparateTab = false,
bool includeNameIdentifier = false)
{
var basePath = baseUri.LocalPath.EndsWith('/') ? baseUri.LocalPath : baseUri.LocalPath + "/";
var authenticationPageUrl = $"{basePath}Authentication";
var baseRelativeUri = usernameOrNull == null
? $"{authenticationPageUrl}?signout=true"
: $"{authenticationPageUrl}?username={usernameOrNull}&roles={rolesOrNull}";
: $"{authenticationPageUrl}?username={usernameOrNull}&roles={rolesOrNull}&nameIdentifier={includeNameIdentifier.ToString().ToLowerInvariant()}";

if (useSeparateTab)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,33 +52,51 @@ void AssertState(string username)
[Fact]
public void UpdatesAuthenticationStateWhenAuthenticationRefreshed()
{
SignInAs("Someone", "IrrelevantRole");
SignInAs("user-a", "TestRole", includeNameIdentifier: true);
var appElement = MountAndNavigateToAuthTest(AuthorizeViewCases, "?captureAuthenticationRefresh");
Browser.Equal("You're not authorized, Someone", () =>
appElement.FindElement(By.CssSelector("#authorize-role .not-authorized")).Text);
Browser.Equal("Welcome, user-a!", () =>
appElement.FindElement(By.CssSelector("#authorize-role .authorized")).Text);

var javascript = (IJavaScriptExecutor)Browser;
var connectionId = Assert.IsType<string>(
javascript.ExecuteScript("return authenticationRefreshConnection.connectionId;"));

SignInAs("Someone", "TestRole", useSeparateTab: true);
var refreshError = javascript.ExecuteAsyncScript("""
const callback = arguments[arguments.length - 1];
authenticationRefreshConnection.refreshAuthentication().then(
() => callback(),
error => callback(String(error)));
""");

Assert.Null(refreshError);
Browser.Equal("Welcome, Someone!", () =>
SignInAs("user-b", "TestRole", useSeparateTab: true, includeNameIdentifier: true);
Assert.Null(RefreshAuthentication());
Browser.Equal("Welcome, user-b!", () =>
appElement.FindElement(By.CssSelector("#authorize-role .authorized")).Text);
Assert.Equal(
connectionId,
Assert.IsType<string>(javascript.ExecuteScript("return authenticationRefreshConnection.connectionId;")));

SignInAs(null, null, useSeparateTab: true);
Assert.Null(RefreshAuthentication());
Browser.Equal("You're not authorized, anonymous", () =>
appElement.FindElement(By.CssSelector("#authorize-role .not-authorized")).Text);
Assert.Equal(
connectionId,
Assert.IsType<string>(javascript.ExecuteScript("return authenticationRefreshConnection.connectionId;")));

object RefreshAuthentication() =>
javascript.ExecuteAsyncScript("""
const callback = arguments[arguments.length - 1];
authenticationRefreshConnection.refreshAuthentication().then(
() => callback(),
error => callback(String(error)));
""");
}

private void SignInAs(string usernName, string roles, bool useSeparateTab = false) =>
Browser.SignInAs(new Uri(_serverFixture.RootUri, "/subdir"), usernName, roles, useSeparateTab);
private void SignInAs(
string userName,
string roles,
bool useSeparateTab = false,
bool includeNameIdentifier = false) =>
Browser.SignInAs(
new Uri(_serverFixture.RootUri, "/subdir"),
userName,
roles,
useSeparateTab,
includeNameIdentifier);

private void PerformReconnection()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
endpoints.MapControllers();
endpoints.MapRazorPages();
endpoints.MapBlazorHub(options => options.EnableAuthenticationRefresh = true)
endpoints.MapBlazorHub()
.AddEndpointFilter(async (context, next) =>
{
if (context.HttpContext.WebSockets.IsWebSocketRequest)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@
new Claim(ClaimTypes.Name, username),
new Claim("test-claim", "Test claim value"),
};
if (Request.Query["nameIdentifier"] == "true")
{
claims.Add(new Claim(ClaimTypes.NameIdentifier, username));
}

var roles = Request.Query["roles"];
if (!string.IsNullOrEmpty(roles))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,23 @@
namespace Microsoft.AspNetCore.Connections.Features;

/// <summary>
/// A feature that allows callbacks to be notified when the user associated with the connection is refreshed,
/// for example, via an authentication refresh.
/// A feature that allows components to validate and observe changes to the user associated with a connection,
/// for example, during an authentication refresh.
/// </summary>
public interface IConnectionUserRefreshFeature
{
/// <summary>
/// Gets or sets the callback invoked before the <see cref="IConnectionUserFeature.User"/> is refreshed.
/// </summary>
/// <remarks>
/// The callback is invoked synchronously while the user update is locked and must return <see langword="true"/>
/// for the update to proceed. It should complete quickly and must not block or reenter the user update.
/// An exception thrown by the callback rejects the update and is not propagated to the caller.
/// Setting this property replaces any previously configured callback. When set to <see langword="null"/>,
/// the feature implementation's default validation policy applies.
/// </remarks>
Func<ClaimsPrincipal, bool>? OnUserRefreshing { get; set; }

/// <summary>
/// Registers a callback to be invoked after the <see cref="IConnectionUserFeature.User"/> has been refreshed.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.InitialT
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.RefreshAuthenticationAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.TimeSpan?>!
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshed(System.Action<System.Security.Claims.ClaimsPrincipal!, object?>! callback, object? state) -> System.IDisposable!
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshing.get -> System.Func<System.Security.Claims.ClaimsPrincipal!, bool>?
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshing.set -> void
Microsoft.AspNetCore.Connections.Features.ITlsHandshakeFeature.Exception.get -> System.Exception?
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.InitialT
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.RefreshAuthenticationAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.TimeSpan?>!
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshed(System.Action<System.Security.Claims.ClaimsPrincipal!, object?>! callback, object? state) -> System.IDisposable!
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshing.get -> System.Func<System.Security.Claims.ClaimsPrincipal!, bool>?
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshing.set -> void
Microsoft.AspNetCore.Connections.Features.ITlsHandshakeFeature.Exception.get -> System.Exception?
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.InitialTokenLifetime.get -> System.TimeSpan?
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.RefreshAuthenticationAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.TimeSpan?>!
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshed(System.Action<System.Security.Claims.ClaimsPrincipal!, object?>! callback, object? state) -> System.IDisposable!
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshed(System.Action<System.Security.Claims.ClaimsPrincipal!, object?>! callback, object? state) -> System.IDisposable!
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshing.get -> System.Func<System.Security.Claims.ClaimsPrincipal!, bool>?
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshing.set -> void
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.InitialTokenLifetime.get -> System.TimeSpan?
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.RefreshAuthenticationAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.TimeSpan?>!
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshed(System.Action<System.Security.Claims.ClaimsPrincipal!, object?>! callback, object? state) -> System.IDisposable!
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshed(System.Action<System.Security.Claims.ClaimsPrincipal!, object?>! callback, object? state) -> System.IDisposable!
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshing.get -> System.Func<System.Security.Claims.ClaimsPrincipal!, bool>?
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshing.set -> void
Loading
Loading