Skip to content

Commit 0c6d0f4

Browse files
Add SignalR Auth Refresh support to server and .NET client (#67111) (#67400)
Server-side: - Add /refresh HTTP endpoint mapped alongside /negotiate - Add tokenLifetimeSeconds to negotiate response (NegotiationResponse + NegotiateProtocol) - Server re-authenticates on /refresh and updates connection's User and auth expiration - Compute TTL from AuthenticationProperties.ExpiresUtc - New virtual Hub.OnAuthRefreshedAsync() lifecycle hook .NET Client-side: - Add IAuthRefreshFeature interface in Connections.Abstractions - HttpConnection implements IAuthRefreshFeature, POSTs to /refresh endpoint - HubConnection.RefreshAuthAsync() discovers IAuthRefreshFeature via Features collection - Auto-refresh timer schedules at: now + TTL - RefreshBeforeExpiration (default 5 min) (cherry picked from commit 8835dfc)
1 parent 26881cc commit 0c6d0f4

50 files changed

Lines changed: 5341 additions & 105 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
8+
namespace Microsoft.AspNetCore.Connections.Features;
9+
10+
/// <summary>
11+
/// Feature for refreshing authentication on a connection.
12+
/// When present, indicates the connection supports authentication token refresh.
13+
/// </summary>
14+
public interface IAuthenticationRefreshFeature
15+
{
16+
/// <summary>
17+
/// Gets the initial token lifetime as provided by the server during negotiation.
18+
/// Null if the server did not provide a token lifetime.
19+
/// </summary>
20+
TimeSpan? InitialTokenLifetime { get; }
21+
22+
/// <summary>
23+
/// Sends a refresh request to the server with new authentication credentials.
24+
/// Returns the updated token lifetime, or null if not provided.
25+
/// </summary>
26+
Task<TimeSpan?> RefreshAuthenticationAsync(CancellationToken cancellationToken = default);
27+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Security.Claims;
6+
7+
namespace Microsoft.AspNetCore.Connections.Features;
8+
9+
/// <summary>
10+
/// A feature that allows callbacks to be notified when the user associated with the connection is refreshed,
11+
/// for example, via an authentication refresh.
12+
/// </summary>
13+
public interface IConnectionUserRefreshFeature
14+
{
15+
/// <summary>
16+
/// Registers a callback to be invoked after the <see cref="IConnectionUserFeature.User"/> has been refreshed.
17+
/// </summary>
18+
/// <param name="callback">The callback to invoke with the refreshed principal and associated <paramref name="state"/>.</param>
19+
/// <param name="state">The state to pass to <paramref name="callback"/>.</param>
20+
/// <returns>An <see cref="IDisposable"/> that can be disposed to unregister the callback.</returns>
21+
/// <remarks>
22+
/// Callbacks should be quick and avoid blocking; the callback is invoked on the thread that performed the update.
23+
/// Exceptions thrown from callbacks are not propagated to the caller of the update.
24+
/// The previous principal is intentionally not exposed because its underlying resources
25+
/// (for example a <c>WindowsIdentity</c>'s <c>SafeHandle</c>) may be disposed when the
26+
/// authentication-refresh request completes, making later access unsafe.
27+
/// </remarks>
28+
IDisposable OnUserRefreshed(Action<ClaimsPrincipal, object?> callback, object? state);
29+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature
3+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.InitialTokenLifetime.get -> System.TimeSpan?
4+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.RefreshAuthenticationAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.TimeSpan?>!
5+
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature
6+
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshed(System.Action<System.Security.Claims.ClaimsPrincipal!, object?>! callback, object? state) -> System.IDisposable!
27
Microsoft.AspNetCore.Connections.Features.ITlsHandshakeFeature.Exception.get -> System.Exception?
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,7 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature
3+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.InitialTokenLifetime.get -> System.TimeSpan?
4+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.RefreshAuthenticationAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.TimeSpan?>!
5+
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature
6+
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshed(System.Action<System.Security.Claims.ClaimsPrincipal!, object?>! callback, object? state) -> System.IDisposable!
27
Microsoft.AspNetCore.Connections.Features.ITlsHandshakeFeature.Exception.get -> System.Exception?
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature
3+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.InitialTokenLifetime.get -> System.TimeSpan?
4+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.RefreshAuthenticationAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.TimeSpan?>!
5+
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature
6+
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshed(System.Action<System.Security.Claims.ClaimsPrincipal!, object?>! callback, object? state) -> System.IDisposable!
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature
3+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.InitialTokenLifetime.get -> System.TimeSpan?
4+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.RefreshAuthenticationAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.TimeSpan?>!
5+
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature
6+
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshed(System.Action<System.Security.Claims.ClaimsPrincipal!, object?>! callback, object? state) -> System.IDisposable!
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature
3+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.InitialTokenLifetime.get -> System.TimeSpan?
4+
Microsoft.AspNetCore.Connections.Features.IAuthenticationRefreshFeature.RefreshAuthenticationAsync(System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<System.TimeSpan?>!
5+
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature
6+
Microsoft.AspNetCore.Connections.Features.IConnectionUserRefreshFeature.OnUserRefreshed(System.Action<System.Security.Claims.ClaimsPrincipal!, object?>! callback, object? state) -> System.IDisposable!
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Threading;
6+
using System.Threading.Tasks;
7+
8+
namespace Microsoft.AspNetCore.SignalR.Client;
9+
10+
/// <summary>
11+
/// Configures automatic authentication token refresh for a <see cref="HubConnection"/>.
12+
/// </summary>
13+
public sealed class AuthenticationRefreshOptions
14+
{
15+
/// <summary>
16+
/// Enables automatic token refresh before the server-reported token expiration.
17+
/// Default is <c>true</c>.
18+
/// </summary>
19+
/// <remarks>
20+
/// When enabled, the client schedules a refresh based on the <c>tokenLifetimeSeconds</c>
21+
/// reported by the server in the negotiate (and subsequent refresh) responses. If the server
22+
/// does not report a token lifetime, no automatic refresh is scheduled regardless of this setting;
23+
/// the application may still call <see cref="HubConnection.RefreshAuthenticationAsync"/> manually.
24+
/// </remarks>
25+
public bool EnableAutoRefresh { get; set; } = true;
26+
27+
/// <summary>
28+
/// How far before the server-reported token expiration the client should refresh.
29+
/// The client schedules refresh at: <c>now + tokenLifetimeSeconds - RefreshBeforeExpiration</c>.
30+
/// Default is 5 minutes.
31+
/// </summary>
32+
public TimeSpan RefreshBeforeExpiration
33+
{
34+
get;
35+
set
36+
{
37+
if (value < TimeSpan.Zero)
38+
{
39+
throw new ArgumentOutOfRangeException(nameof(value), value, "The value must be zero or greater.");
40+
}
41+
42+
field = value;
43+
}
44+
} = TimeSpan.FromMinutes(5);
45+
46+
/// <summary>
47+
/// Optional callback invoked after a successful authentication refresh.
48+
/// </summary>
49+
public Func<AuthenticationRefreshedContext, Task>? OnAuthenticationRefreshed { get; set; }
50+
51+
/// <summary>
52+
/// Optional callback invoked when an authentication refresh attempt fails.
53+
/// </summary>
54+
public Func<AuthenticationRefreshFailedContext, Task>? OnAuthenticationRefreshFailed { get; set; }
55+
}
56+
57+
/// <summary>
58+
/// Context passed to <see cref="AuthenticationRefreshOptions.OnAuthenticationRefreshed"/> after a successful refresh.
59+
/// </summary>
60+
public sealed class AuthenticationRefreshedContext
61+
{
62+
internal AuthenticationRefreshedContext(HubConnection hubConnection, TimeSpan? newTokenLifetime)
63+
{
64+
HubConnection = hubConnection;
65+
NewTokenLifetime = newTokenLifetime;
66+
RefreshedAt = DateTimeOffset.UtcNow;
67+
}
68+
69+
/// <summary>
70+
/// Gets the <see cref="HubConnection"/> whose authentication was refreshed.
71+
/// </summary>
72+
public HubConnection HubConnection { get; }
73+
74+
/// <summary>
75+
/// Gets the new token lifetime reported by the server, or <c>null</c> if not provided.
76+
/// </summary>
77+
public TimeSpan? NewTokenLifetime { get; }
78+
79+
/// <summary>
80+
/// Gets the time at which the refresh completed.
81+
/// </summary>
82+
public DateTimeOffset RefreshedAt { get; }
83+
}
84+
85+
/// <summary>
86+
/// Context passed to <see cref="AuthenticationRefreshOptions.OnAuthenticationRefreshFailed"/> when a refresh attempt fails.
87+
/// </summary>
88+
public sealed class AuthenticationRefreshFailedContext
89+
{
90+
internal AuthenticationRefreshFailedContext(HubConnection hubConnection, Exception exception)
91+
{
92+
HubConnection = hubConnection;
93+
Exception = exception;
94+
}
95+
96+
/// <summary>
97+
/// Gets the <see cref="HubConnection"/> on which the refresh attempt failed.
98+
/// </summary>
99+
public HubConnection HubConnection { get; }
100+
101+
/// <summary>
102+
/// Gets the exception that caused the refresh attempt to fail.
103+
/// </summary>
104+
public Exception Exception { get; }
105+
}

src/SignalR/clients/csharp/Client.Core/src/HubConnection.Log.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,5 +340,17 @@ public static void ErrorHandshakeTimedOut(ILogger logger, TimeSpan handshakeTime
340340

341341
[LoggerMessage(94, LogLevel.Error, "Failed to bind argument received in stream '{StreamId}'.", EventName = "StreamBindingFailure")]
342342
public static partial void StreamBindingFailure(ILogger logger, string? streamId, Exception exception);
343+
344+
[LoggerMessage(95, LogLevel.Debug, "Starting authentication token refresh.", EventName = "AuthenticationRefreshStarting")]
345+
public static partial void AuthenticationRefreshStarting(ILogger logger);
346+
347+
[LoggerMessage(96, LogLevel.Debug, "Authentication token refresh completed. New TTL: {TokenLifetime}.", EventName = "AuthenticationRefreshCompleted")]
348+
public static partial void AuthenticationRefreshCompleted(ILogger logger, TimeSpan? tokenLifetime);
349+
350+
[LoggerMessage(97, LogLevel.Error, "Authentication token refresh failed.", EventName = "AuthenticationRefreshFailed")]
351+
public static partial void AuthenticationRefreshFailed(ILogger logger, Exception exception);
352+
353+
[LoggerMessage(98, LogLevel.Error, "Authentication refresh user callback threw an exception.", EventName = "AuthenticationRefreshCallbackFailed")]
354+
public static partial void AuthenticationRefreshCallbackFailed(ILogger logger, Exception exception);
343355
}
344356
}

0 commit comments

Comments
 (0)