Skip to content

Commit f74c6f1

Browse files
[SignalR] Close connection on auth expiration (#32431)
1 parent 7ed667a commit f74c6f1

13 files changed

+662
-9
lines changed

src/Security/Authorization/Policy/src/PolicyEvaluator.cs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,15 @@ public virtual async Task<AuthenticateResult> AuthenticateAsync(AuthorizationPol
6969
}
7070
}
7171

72-
return (context.User?.Identity?.IsAuthenticated ?? false)
73-
? AuthenticateResult.Success(new AuthenticationTicket(context.User, "context.User"))
74-
: AuthenticateResult.NoResult();
72+
// No modifications made to the HttpContext so let's use the existing result if it exists
73+
return context.Features.Get<IAuthenticateResultFeature>()?.AuthenticateResult ?? DefaultAuthenticateResult(context);
74+
75+
static AuthenticateResult DefaultAuthenticateResult(HttpContext context)
76+
{
77+
return (context.User?.Identity?.IsAuthenticated ?? false)
78+
? AuthenticateResult.Success(new AuthenticationTicket(context.User, "context.User"))
79+
: AuthenticateResult.NoResult();
80+
}
7581
}
7682

7783
/// <summary>

src/Security/Authorization/test/AuthorizationMiddlewareTests.cs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -581,16 +581,23 @@ class TestAuthResultFeature : IAuthenticateResultFeature
581581
}
582582

583583
[Fact]
584-
public async Task IAuthenticateResultFeature_UsesExistingFeature()
584+
public async Task IAuthenticateResultFeature_UsesExistingFeature_WithScheme()
585585
{
586586
// Arrange
587-
var policy = new AuthorizationPolicyBuilder().RequireClaim("Permission", "CanViewPage").Build();
587+
var policy = new AuthorizationPolicyBuilder().RequireClaim("Permission", "CanViewPage").AddAuthenticationSchemes("Bearer").Build();
588588
var policyProvider = new Mock<IAuthorizationPolicyProvider>();
589589
policyProvider.Setup(p => p.GetDefaultPolicyAsync()).ReturnsAsync(policy);
590590
var next = new TestRequestDelegate();
591+
var authenticationService = new Mock<IAuthenticationService>();
592+
authenticationService.Setup(s => s.AuthenticateAsync(It.IsAny<HttpContext>(), "Bearer"))
593+
.ReturnsAsync((HttpContext c, string scheme) =>
594+
{
595+
var res = AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(c.User.Identities.FirstOrDefault(i => i.AuthenticationType == scheme)), scheme));
596+
return res;
597+
});
591598

592599
var middleware = CreateMiddleware(next.Invoke, policyProvider.Object);
593-
var context = GetHttpContext(endpoint: CreateEndpoint(new AuthorizeAttribute()));
600+
var context = GetHttpContext(endpoint: CreateEndpoint(new AuthorizeAttribute()), authenticationService: authenticationService.Object);
594601
var testAuthenticateResultFeature = new TestAuthResultFeature();
595602
var authenticateResult = AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(), ""));
596603
testAuthenticateResultFeature.AuthenticateResult = authenticateResult;
@@ -600,14 +607,39 @@ public async Task IAuthenticateResultFeature_UsesExistingFeature()
600607
await middleware.Invoke(context);
601608

602609
// Assert
603-
Assert.True(next.Called);
604610
var authenticateResultFeature = context.Features.Get<IAuthenticateResultFeature>();
605611
Assert.NotNull(authenticateResultFeature);
606612
Assert.NotNull(authenticateResultFeature.AuthenticateResult);
607613
Assert.Same(testAuthenticateResultFeature, authenticateResultFeature);
608614
Assert.NotSame(authenticateResult, authenticateResultFeature.AuthenticateResult);
609615
}
610616

617+
[Fact]
618+
public async Task IAuthenticateResultFeature_UsesExistingFeatureAndResult_WithoutScheme()
619+
{
620+
// Arrange
621+
var policy = new AuthorizationPolicyBuilder().RequireClaim("Permission", "CanViewPage").Build();
622+
var policyProvider = new Mock<IAuthorizationPolicyProvider>();
623+
policyProvider.Setup(p => p.GetDefaultPolicyAsync()).ReturnsAsync(policy);
624+
var next = new TestRequestDelegate();
625+
var middleware = CreateMiddleware(next.Invoke, policyProvider.Object);
626+
var context = GetHttpContext(endpoint: CreateEndpoint(new AuthorizeAttribute()));
627+
var testAuthenticateResultFeature = new TestAuthResultFeature();
628+
var authenticateResult = AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(), ""));
629+
testAuthenticateResultFeature.AuthenticateResult = authenticateResult;
630+
context.Features.Set<IAuthenticateResultFeature>(testAuthenticateResultFeature);
631+
632+
// Act
633+
await middleware.Invoke(context);
634+
635+
// Assert
636+
var authenticateResultFeature = context.Features.Get<IAuthenticateResultFeature>();
637+
Assert.NotNull(authenticateResultFeature);
638+
Assert.NotNull(authenticateResultFeature.AuthenticateResult);
639+
Assert.Same(testAuthenticateResultFeature, authenticateResultFeature);
640+
Assert.Same(authenticateResult, authenticateResultFeature.AuthenticateResult);
641+
}
642+
611643
private AuthorizationMiddleware CreateMiddleware(RequestDelegate requestDelegate = null, IAuthorizationPolicyProvider policyProvider = null)
612644
{
613645
requestDelegate = requestDelegate ?? ((context) => Task.CompletedTask);

src/SignalR/common/Http.Connections/src/HttpConnectionDispatcherOptions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Collections.Generic;
66
using System.IO.Pipelines;
77
using System.Threading;
8+
using Microsoft.AspNetCore.Authentication;
89
using Microsoft.AspNetCore.Authorization;
910

1011
namespace Microsoft.AspNetCore.Http.Connections
@@ -94,6 +95,15 @@ public TimeSpan TransportSendTimeout
9495
}
9596
}
9697

98+
/// <summary>
99+
/// Authenticated connections whose token sets the <see cref="AuthenticationProperties.ExpiresUtc"/> value will be closed
100+
/// and allowed to reconnect when the token expires.
101+
/// </summary>
102+
/// <remarks>
103+
/// Closed connections will miss messages sent while closed.
104+
/// </remarks>
105+
public bool CloseOnAuthenticationExpiration { get; set; }
106+
97107
internal long TransportSendTimeoutTicks { get; private set; }
98108
internal bool TransportSendTimeoutEnabled => _transportSendTimeout != Timeout.InfiniteTimeSpan;
99109

src/SignalR/common/Http.Connections/src/Internal/HttpConnectionContext.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,8 @@ internal class HttpConnectionContext : ConnectionContext,
3030
IHttpContextFeature,
3131
IHttpTransportFeature,
3232
IConnectionInherentKeepAliveFeature,
33-
IConnectionLifetimeFeature
33+
IConnectionLifetimeFeature,
34+
IConnectionLifetimeNotificationFeature
3435
{
3536
private readonly HttpConnectionDispatcherOptions _options;
3637

@@ -43,6 +44,7 @@ internal class HttpConnectionContext : ConnectionContext,
4344
private IDuplexPipe _application;
4445
private IDictionary<object, object?>? _items;
4546
private readonly CancellationTokenSource _connectionClosedTokenSource;
47+
private readonly CancellationTokenSource _connectionCloseRequested;
4648

4749
private CancellationTokenSource? _sendCts;
4850
private bool _activeSend;
@@ -87,9 +89,14 @@ public HttpConnectionContext(string connectionId, string connectionToken, ILogge
8789
Features.Set<IHttpTransportFeature>(this);
8890
Features.Set<IConnectionInherentKeepAliveFeature>(this);
8991
Features.Set<IConnectionLifetimeFeature>(this);
92+
Features.Set<IConnectionLifetimeNotificationFeature>(this);
9093

9194
_connectionClosedTokenSource = new CancellationTokenSource();
9295
ConnectionClosed = _connectionClosedTokenSource.Token;
96+
97+
_connectionCloseRequested = new CancellationTokenSource();
98+
ConnectionClosedRequested = _connectionCloseRequested.Token;
99+
AuthenticationExpiration = DateTimeOffset.MaxValue;
93100
}
94101

95102
public CancellationTokenSource? Cancellation { get; set; }
@@ -104,6 +111,10 @@ public HttpConnectionContext(string connectionId, string connectionToken, ILogge
104111
// Used for LongPolling because we need to create a scope that spans the lifetime of multiple requests on the cloned HttpContext
105112
internal AsyncServiceScope? ServiceScope { get; set; }
106113

114+
internal DateTimeOffset AuthenticationExpiration { get; set; }
115+
116+
internal bool IsAuthenticationExpirationEnabled => _options.CloseOnAuthenticationExpiration;
117+
107118
public Task? TransportTask { get; set; }
108119

109120
public Task PreviousPollTask { get; set; } = Task.CompletedTask;
@@ -176,6 +187,8 @@ public IDuplexPipe Application
176187

177188
public override CancellationToken ConnectionClosed { get; set; }
178189

190+
public CancellationToken ConnectionClosedRequested { get; set; }
191+
179192
public override void Abort()
180193
{
181194
ThreadPool.UnsafeQueueUserWorkItem(cts => ((CancellationTokenSource)cts!).Cancel(), _connectionClosedTokenSource);
@@ -601,6 +614,11 @@ internal void StopSendCancellation()
601614
}
602615
}
603616

617+
public void RequestClose()
618+
{
619+
ThreadPool.UnsafeQueueUserWorkItem(static cts => ((CancellationTokenSource)cts!).Cancel(), _connectionCloseRequested);
620+
}
621+
604622
private static class Log
605623
{
606624
private static readonly Action<ILogger, string, Exception?> _disposingConnection =

src/SignalR/common/Http.Connections/src/Internal/HttpConnectionDispatcher.cs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
using System.Security.Principal;
1111
using System.Threading;
1212
using System.Threading.Tasks;
13+
using Microsoft.AspNetCore.Authentication;
14+
using Microsoft.AspNetCore.Authorization;
1315
using Microsoft.AspNetCore.Connections;
1416
using Microsoft.AspNetCore.Http.Connections.Internal.Transports;
1517
using Microsoft.AspNetCore.Http.Features;
@@ -566,13 +568,26 @@ private async Task<bool> EnsureConnectionStateAsync(HttpConnectionContext connec
566568
// Setup the connection state from the http context
567569
connection.User = connection.HttpContext?.User;
568570

571+
UpdateExpiration(connection, context);
572+
569573
// Set the Connection ID on the logging scope so that logs from now on will have the
570574
// Connection ID metadata set.
571575
logScope.ConnectionId = connection.ConnectionId;
572576

573577
return true;
574578
}
575579

580+
private static void UpdateExpiration(HttpConnectionContext connection, HttpContext context)
581+
{
582+
var authenticateResultFeature = context.Features.Get<IAuthenticateResultFeature>();
583+
584+
if (authenticateResultFeature is not null)
585+
{
586+
connection.AuthenticationExpiration =
587+
authenticateResultFeature.AuthenticateResult?.Properties?.ExpiresUtc ?? DateTimeOffset.MaxValue;
588+
}
589+
}
590+
576591
private static void CloneUser(HttpContext newContext, HttpContext oldContext)
577592
{
578593
// If the identity is a WindowsIdentity we need to clone the User.

src/SignalR/common/Http.Connections/src/Internal/HttpConnectionManager.Log.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ private static class Log
3838
private static readonly Action<ILogger, Exception?> _heartbeatEnded =
3939
LoggerMessage.Define(LogLevel.Trace, new EventId(10, "HeartBeatEnded"), "Ending connection heartbeat.");
4040

41+
private static readonly Action<ILogger, string, Exception?> _authenticationExpired =
42+
LoggerMessage.Define<string>(LogLevel.Debug, new EventId(11, "AuthenticationExpired"), "Connection {TransportConnectionId} closing because the authentication token has expired.");
43+
4144
public static void CreatedNewConnection(ILogger logger, string connectionId)
4245
{
4346
_createdNewConnection(logger, connectionId, null);
@@ -77,6 +80,11 @@ public static void HeartBeatEnded(ILogger logger)
7780
{
7881
_heartbeatEnded(logger, null);
7982
}
83+
84+
public static void AuthenticationExpired(ILogger logger, string connectionId)
85+
{
86+
_authenticationExpired(logger, connectionId, null);
87+
}
8088
}
8189
}
8290
}

src/SignalR/common/Http.Connections/src/Internal/HttpConnectionManager.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,13 @@ public void Scan()
167167

168168
// Tick the heartbeat, if the connection is still active
169169
connection.TickHeartbeat();
170+
171+
if (connection.IsAuthenticationExpirationEnabled && connection.AuthenticationExpiration < utcNow &&
172+
!connection.ConnectionClosedRequested.IsCancellationRequested)
173+
{
174+
Log.AuthenticationExpired(_logger, connection.ConnectionId);
175+
connection.RequestClose();
176+
}
170177
}
171178
}
172179
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#nullable enable
22
*REMOVED*Microsoft.AspNetCore.Http.Connections.Features.IHttpContextFeature.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext!
33
Microsoft.AspNetCore.Http.Connections.Features.IHttpContextFeature.HttpContext.get -> Microsoft.AspNetCore.Http.HttpContext?
4+
Microsoft.AspNetCore.Http.Connections.HttpConnectionDispatcherOptions.CloseOnAuthenticationExpiration.get -> bool
5+
Microsoft.AspNetCore.Http.Connections.HttpConnectionDispatcherOptions.CloseOnAuthenticationExpiration.set -> void
46
Microsoft.AspNetCore.Http.Connections.HttpConnectionDispatcherOptions.TransportSendTimeout.get -> System.TimeSpan
57
Microsoft.AspNetCore.Http.Connections.HttpConnectionDispatcherOptions.TransportSendTimeout.set -> void

0 commit comments

Comments
 (0)