From de7b784235e698344c028e9a3bcb0e8d0ec4878d Mon Sep 17 00:00:00 2001 From: Javier Calvarro Nelson <6995051+javiercn@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:52:37 +0200 Subject: [PATCH 1/5] Use SignalR authentication refresh for Blazor Identity --- .../src/Platform/Circuits/CircuitManager.ts | 66 ++++++++++++++++- ...ircuitManagerAuthenticationRefresh.test.ts | 71 ++++++++++++++++++- .../ServerExecutionTests/ServerAuthTest.cs | 23 ++++++ .../Pages/_ServerHost.cshtml | 9 ++- .../.template.config/template.json | 6 -- ...RevalidatingAuthenticationStateProvider.cs | 47 ------------ .../BlazorWebCSharp.1/Program.Main.cs | 6 -- .../BlazorWebCSharp.1/Program.cs | 6 -- .../Templates.Tests/template-baselines.json | 6 -- 9 files changed, 164 insertions(+), 76 deletions(-) delete mode 100644 src/ProjectTemplates/Web.ProjectTemplates/content/BlazorWeb-CSharp/BlazorWebCSharp.1/Components/Account/IdentityRevalidatingAuthenticationStateProvider.cs diff --git a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts index 5b7f432e326e..17da1250c06a 100644 --- a/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts +++ b/src/Components/Web.JS/src/Platform/Circuits/CircuitManager.ts @@ -20,6 +20,8 @@ import { showErrorNotification } from '../../BootErrors'; import { attachWebRendererInterop, detachWebRendererInterop, isRendererAttached } from '../../Rendering/WebRendererInteropMethods'; import { sendJSDataStream } from './CircuitStreamingInterop'; +const authenticationRefreshIntervalInMilliseconds = 30 * 60 * 1000; + export class CircuitManager implements DotNet.DotNetCallDispatcher { private readonly _componentManager: RootComponentManager; @@ -38,6 +40,10 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { private _connection?: HubConnection; + private _authenticationRefreshTimer?: ReturnType; + + private _authenticationRefreshConnection?: HubConnection; + private _interopMethodsForReconnection?: DotNet.DotNetObject; private _circuitId?: string; @@ -140,7 +146,10 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { const connectionBuilder = new HubConnectionBuilder() .withUrl('_blazor') .withHubProtocol(hubProtocol) - .withAuthenticationRefresh(); + .withAuthenticationRefresh({ + onAuthenticationRefreshed: context => this.scheduleAuthenticationRefresh(context.connection), + onAuthenticationRefreshFailed: context => this.scheduleAuthenticationRefresh(context.connection), + }); this._options.configureSignalR(connectionBuilder); @@ -207,6 +216,7 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { }); connection.on('JS.EndLocationChanging', Blazor._internal.navigationManager.endLocationChanging); connection.onclose(error => { + this.clearAuthenticationRefresh(connection); this._interopMethodsForReconnection = detachWebRendererInterop(WebRendererId.Server); this.handleConnectionDown(); @@ -231,6 +241,7 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { try { await connection.start(); this.handleConnectionUp(); + this.scheduleAuthenticationRefresh(connection, true); } catch (ex: any) { this.unhandledError(ex as Error); @@ -263,6 +274,57 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { return connection; } + private scheduleAuthenticationRefresh(connection: HubConnection, replaceExistingConnection = false): void { + if (!replaceExistingConnection && + this._authenticationRefreshConnection && + this._authenticationRefreshConnection !== connection) { + return; + } + + this.clearAuthenticationRefresh(); + + if (this._disposed || connection.state !== HubConnectionState.Connected) { + return; + } + + this._authenticationRefreshConnection = connection; + this._authenticationRefreshTimer = setTimeout(() => { + this._authenticationRefreshTimer = undefined; + void this.refreshAuthentication(connection); + }, authenticationRefreshIntervalInMilliseconds); + } + + private async refreshAuthentication(connection: HubConnection): Promise { + if (this._authenticationRefreshConnection !== connection || + connection.state !== HubConnectionState.Connected) { + return; + } + + try { + await connection.refreshAuthentication(); + } catch (error) { + this._logger.log(LogLevel.Debug, `Failed to refresh authentication: ${error}`); + } finally { + if (this._authenticationRefreshConnection === connection && + this._authenticationRefreshTimer === undefined) { + this.scheduleAuthenticationRefresh(connection); + } + } + } + + private clearAuthenticationRefresh(connection?: HubConnection): void { + if (connection && this._authenticationRefreshConnection !== connection) { + return; + } + + if (this._authenticationRefreshTimer !== undefined) { + clearTimeout(this._authenticationRefreshTimer); + this._authenticationRefreshTimer = undefined; + } + + this._authenticationRefreshConnection = undefined; + } + public async disconnect(): Promise { if (!this._circuitId) { throw new Error('Circuit host not initialized.'); @@ -635,6 +697,8 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher { } private async disposeCore(): Promise { + this.clearAuthenticationRefresh(); + if (!this._startPromise) { // The circuit hasn't started, so there isn't anything to dispose. this._disposed = true; diff --git a/src/Components/Web.JS/test/Platform/Circuits/CircuitManagerAuthenticationRefresh.test.ts b/src/Components/Web.JS/test/Platform/Circuits/CircuitManagerAuthenticationRefresh.test.ts index 54a3dbea21e4..799e2934a40e 100644 --- a/src/Components/Web.JS/test/Platform/Circuits/CircuitManagerAuthenticationRefresh.test.ts +++ b/src/Components/Web.JS/test/Platform/Circuits/CircuitManagerAuthenticationRefresh.test.ts @@ -2,7 +2,8 @@ // 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 { HubConnection, HubConnectionBuilder, HubConnectionState } from '@microsoft/signalr'; +import type { IAuthenticationRefreshOptions } from '@microsoft/signalr'; import { CircuitManager } from '../../../src/Platform/Circuits/CircuitManager'; import { resolveOptions } from '../../../src/Platform/Circuits/CircuitStartOptions'; import { JSEventRegistry } from '../../../src/Services/JSEventRegistry'; @@ -14,10 +15,12 @@ interface InternalCircuitManager { describe('CircuitManager authentication refresh', () => { afterEach(() => { jest.restoreAllMocks(); + jest.useRealTimers(); }); test('enables authentication refresh before applying user configuration', async () => { - const configuredOptions: unknown[] = []; + jest.useFakeTimers(); + const configuredOptions: IAuthenticationRefreshOptions[] = []; jest.spyOn(HubConnectionBuilder.prototype, 'withAuthenticationRefresh') .mockImplementation(function (this: HubConnectionBuilder, options = {}) { configuredOptions.push(options); @@ -28,6 +31,7 @@ describe('CircuitManager authentication refresh', () => { on: jest.fn(), onclose: jest.fn(), start: () => Promise.resolve(), + state: HubConnectionState.Connected, } as unknown as HubConnection; jest.spyOn(HubConnectionBuilder.prototype, 'build').mockReturnValue(connection); @@ -45,6 +49,67 @@ describe('CircuitManager authentication refresh', () => { await (circuit as unknown as InternalCircuitManager).startConnection(); - expect(configuredOptions).toEqual([{}, { enableAutoRefresh: false }]); + expect(configuredOptions).toHaveLength(2); + expect(configuredOptions[0].onAuthenticationRefreshed).toEqual(expect.any(Function)); + expect(configuredOptions[0].onAuthenticationRefreshFailed).toEqual(expect.any(Function)); + expect(configuredOptions[1]).toEqual({ enableAutoRefresh: false }); + + await circuit.dispose(); + }); + + test('refreshes authentication every 30 minutes', async () => { + jest.useFakeTimers(); + let authenticationRefreshOptions: IAuthenticationRefreshOptions | undefined; + jest.spyOn(HubConnectionBuilder.prototype, 'withAuthenticationRefresh') + .mockImplementation(function (this: HubConnectionBuilder, options = {}) { + authenticationRefreshOptions = options; + return this; + }); + + const refreshAuthentication = jest.fn(() => Promise.resolve(undefined)); + const connection = { + on: jest.fn(), + onclose: jest.fn(), + start: () => Promise.resolve(), + state: HubConnectionState.Connected, + refreshAuthentication, + } as unknown as HubConnection; + jest.spyOn(HubConnectionBuilder.prototype, 'build').mockReturnValue(connection); + + const circuit = new CircuitManager( + {} as never, + '', + resolveOptions(), + { log: () => { /* no-op */ } } as never, + new JSEventRegistry()); + + await (circuit as unknown as InternalCircuitManager).startConnection(); + + jest.advanceTimersByTime(10 * 60 * 1000); + await authenticationRefreshOptions!.onAuthenticationRefreshFailed!({ connection } as never); + jest.advanceTimersByTime(20 * 60 * 1000); + expect(refreshAuthentication).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(10 * 60 * 1000); + await Promise.resolve(); + await Promise.resolve(); + + expect(refreshAuthentication).toHaveBeenCalledTimes(1); + expect(jest.getTimerCount()).toBe(1); + + jest.advanceTimersByTime(10 * 60 * 1000); + await authenticationRefreshOptions!.onAuthenticationRefreshed!({ connection } as never); + jest.advanceTimersByTime(20 * 60 * 1000); + expect(refreshAuthentication).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(10 * 60 * 1000); + await Promise.resolve(); + await Promise.resolve(); + + expect(refreshAuthentication).toHaveBeenCalledTimes(2); + expect(jest.getTimerCount()).toBe(1); + + await circuit.dispose(); + expect(jest.getTimerCount()).toBe(0); }); }); diff --git a/src/Components/test/E2ETest/ServerExecutionTests/ServerAuthTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/ServerAuthTest.cs index 5b9eff853310..5e1fdbbbc433 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/ServerAuthTest.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/ServerAuthTest.cs @@ -86,6 +86,29 @@ object RefreshAuthentication() => """); } + [Fact] + public void UpdatesAuthenticationStateWhenAuthenticationRefreshesAutomatically() + { + SignInAs("user-a", "TestRole", includeNameIdentifier: true); + var appElement = MountAndNavigateToAuthTest( + AuthorizeViewCases, + "?captureAuthenticationRefresh&accelerateAuthenticationRefresh"); + Browser.Equal("Welcome, user-a!", () => + appElement.FindElement(By.CssSelector("#authorize-role .authorized")).Text); + + var javascript = (IJavaScriptExecutor)Browser; + var connectionId = Assert.IsType( + javascript.ExecuteScript("return authenticationRefreshConnection.connectionId;")); + + SignInAs(null, null, useSeparateTab: true); + + Browser.Equal("You're not authorized, anonymous", () => + appElement.FindElement(By.CssSelector("#authorize-role .not-authorized")).Text); + Assert.Equal( + connectionId, + Assert.IsType(javascript.ExecuteScript("return authenticationRefreshConnection.connectionId;"))); + } + private void SignInAs( string userName, string roles, diff --git a/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml b/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml index 6de9575c0f98..8447f1251b4f 100644 --- a/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml +++ b/src/Components/test/testassets/Components.TestServer/Pages/_ServerHost.cshtml @@ -53,9 +53,16 @@