Skip to content

Commit 62998ac

Browse files
authored
Add a new OnCheckSlidingExpiration event to control renewal #32269 (#33016)
1 parent 3bd0c73 commit 62998ac

File tree

11 files changed

+203
-85
lines changed

11 files changed

+203
-85
lines changed
Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,13 @@
1-
<Project Sdk="Microsoft.NET.Sdk.Web">
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
44
<TargetFrameworks>$(DefaultNetCoreTargetFramework)</TargetFrameworks>
55
<AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
66
</PropertyGroup>
77

88
<ItemGroup>
9+
<Reference Include="Microsoft.AspNetCore" />
910
<Reference Include="Microsoft.AspNetCore.Authentication.Cookies" />
10-
<Reference Include="Microsoft.AspNetCore.Hosting" />
11-
<Reference Include="Microsoft.AspNetCore.DataProtection" />
12-
<Reference Include="Microsoft.AspNetCore.Server.IISIntegration" />
13-
<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />
14-
<Reference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
15-
<Reference Include="Microsoft.Extensions.Logging.Console" />
1611
</ItemGroup>
1712

1813
</Project>
Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,73 @@
1-
using System.IO;
1+
using System;
2+
using System.Linq;
3+
using System.Security.Claims;
24
using System.Threading.Tasks;
3-
using Microsoft.AspNetCore.Hosting;
4-
using Microsoft.Extensions.Hosting;
5-
using Microsoft.Extensions.Logging;
5+
using Microsoft.AspNetCore.Authentication;
6+
using Microsoft.AspNetCore.Authentication.Cookies;
7+
using Microsoft.AspNetCore.Authorization;
8+
using Microsoft.AspNetCore.Builder;
9+
using Microsoft.AspNetCore.Http;
10+
using Microsoft.Extensions.DependencyInjection;
611

7-
namespace CookieSample
8-
{
9-
public static class Program
12+
var builder = WebApplication.CreateBuilder(args);
13+
14+
builder.Services
15+
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
16+
.AddCookie(options =>
1017
{
11-
public static Task Main(string[] args)
18+
options.ExpireTimeSpan = TimeSpan.FromSeconds(20);
19+
options.Events = new CookieAuthenticationEvents()
1220
{
13-
var host = new HostBuilder()
14-
.ConfigureWebHost(webHostBuilder =>
15-
{
16-
webHostBuilder
17-
.UseKestrel()
18-
.UseContentRoot(Directory.GetCurrentDirectory())
19-
.UseIISIntegration()
20-
.UseStartup<Startup>();
21-
})
22-
.ConfigureLogging(factory =>
21+
OnCheckSlidingExpiration = context =>
22+
{
23+
// If 25% expired instead of the default 50%.
24+
context.ShouldRenew = context.ElapsedTime > (context.Options.ExpireTimeSpan / 4);
25+
26+
// Don't renew on API endpoints that use JWT.
27+
var authData = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IAuthorizeData>();
28+
if (authData != null && string.Equals(authData.AuthenticationSchemes, "Bearer", StringComparison.Ordinal))
2329
{
24-
factory.AddConsole();
25-
factory.AddFilter("Console", level => level >= LogLevel.Information);
26-
})
27-
.Build();
30+
context.ShouldRenew = false;
31+
}
32+
33+
return Task.CompletedTask;
34+
}
35+
};
36+
});
37+
38+
var app = builder.Build();
39+
40+
app.UseAuthentication();
41+
42+
app.MapGet("/", async context =>
43+
{
44+
if (!context.User.Identities.Any(identity => identity.IsAuthenticated))
45+
{
46+
var user = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "bob") }, CookieAuthenticationDefaults.AuthenticationScheme));
47+
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, user);
2848

29-
return host.RunAsync();
30-
}
49+
context.Response.ContentType = "text/plain";
50+
await context.Response.WriteAsync("Hello First timer");
51+
return;
3152
}
32-
}
53+
54+
context.Response.ContentType = "text/plain";
55+
await context.Response.WriteAsync("Hello old timer");
56+
});
57+
58+
app.MapGet("/ticket", async context =>
59+
{
60+
var ticket = await context.AuthenticateAsync();
61+
if (!ticket.Succeeded)
62+
{
63+
await context.Response.WriteAsync($"Signed Out");
64+
return;
65+
}
66+
67+
foreach (var (key, value) in ticket.Properties.Items)
68+
{
69+
await context.Response.WriteAsync($"{key}: {value}\r\n");
70+
}
71+
});
72+
73+
app.Run();

src/Security/Authentication/Cookies/samples/CookieSample/Properties/launchSettings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"environmentVariables": {
1515
"ASPNETCORE_ENVIRONMENT": "Development"
1616
},
17-
"applicationUrl": "http://localhost:1782/"
17+
"applicationUrl": "http://localhost:5000/"
1818
},
1919
"IIS Express": {
2020
"commandName": "IISExpress",

src/Security/Authentication/Cookies/samples/CookieSample/Startup.cs

Lines changed: 0 additions & 45 deletions
This file was deleted.

src/Security/Authentication/Cookies/samples/CookieSessionSample/Properties/launchSettings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"environmentVariables": {
1515
"ASPNETCORE_ENVIRONMENT": "Development"
1616
},
17-
"applicationUrl": "http://localhost:1776/"
17+
"applicationUrl": "http://localhost:5000/"
1818
},
1919
"IIS Express": {
2020
"commandName": "IISExpress",

src/Security/Authentication/Cookies/samples/CookieSessionSample/Startup.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ public void ConfigureServices(IServiceCollection services)
2020
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
2121
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
2222
}).AddCookie();
23-
23+
24+
services.AddSingleton<ITicketStore, MemoryCacheTicketStore>();
2425
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
25-
.Configure<MemoryCacheTicketStore>((o, ticketStore) => o.SessionStore = ticketStore);
26+
.Configure<ITicketStore>((o, ticketStore) => o.SessionStore = ticketStore);
2627
}
2728

2829
public void Configure(IApplicationBuilder app)

src/Security/Authentication/Cookies/src/CookieAuthenticationEvents.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ public class CookieAuthenticationEvents
1818
/// </summary>
1919
public Func<CookieValidatePrincipalContext, Task> OnValidatePrincipal { get; set; } = context => Task.CompletedTask;
2020

21+
/// <summary>
22+
/// Invoked to check if the cookie should be renewed.
23+
/// </summary>
24+
public Func<CookieSlidingExpirationContext, Task> OnCheckSlidingExpiration { get; set; } = context => Task.CompletedTask;
25+
2126
/// <summary>
2227
/// Invoked on signing in.
2328
/// </summary>

src/Security/Authentication/Cookies/src/CookieAuthenticationHandler.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ private Task<AuthenticateResult> EnsureCookieTicket()
8080
return _readCookieTask;
8181
}
8282

83-
private void CheckForRefresh(AuthenticationTicket ticket)
83+
private async Task CheckForRefreshAsync(AuthenticationTicket ticket)
8484
{
8585
var currentUtc = Clock.UtcNow;
8686
var issuedUtc = ticket.Properties.IssuedUtc;
@@ -91,7 +91,13 @@ private void CheckForRefresh(AuthenticationTicket ticket)
9191
var timeElapsed = currentUtc.Subtract(issuedUtc.Value);
9292
var timeRemaining = expiresUtc.Value.Subtract(currentUtc);
9393

94-
if (timeRemaining < timeElapsed)
94+
var eventContext = new CookieSlidingExpirationContext(Context, Scheme, Options, ticket, timeElapsed, timeRemaining)
95+
{
96+
ShouldRenew = timeRemaining < timeElapsed,
97+
};
98+
await Options.Events.OnCheckSlidingExpiration(eventContext);
99+
100+
if (eventContext.ShouldRenew)
95101
{
96102
RequestRefresh(ticket);
97103
}
@@ -174,8 +180,6 @@ private async Task<AuthenticateResult> ReadCookieTicket()
174180
return AuthenticateResult.Fail("Ticket expired");
175181
}
176182

177-
CheckForRefresh(ticket);
178-
179183
// Finally we have a valid ticket
180184
return AuthenticateResult.Success(ticket);
181185
}
@@ -189,6 +193,10 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
189193
return result;
190194
}
191195

196+
// We check this before the ValidatePrincipal event because we want to make sure we capture a clean clone
197+
// without picking up any per-request modifications to the principal.
198+
await CheckForRefreshAsync(result.Ticket);
199+
192200
Debug.Assert(result.Ticket != null);
193201
var context = new CookieValidatePrincipalContext(Context, Scheme, Options, result.Ticket);
194202
await Events.ValidatePrincipal(context);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using Microsoft.AspNetCore.Http;
6+
7+
namespace Microsoft.AspNetCore.Authentication.Cookies
8+
{
9+
/// <summary>
10+
/// Context object passed to the CookieAuthenticationEvents OnCheckSlidingExpiration method.
11+
/// </summary>
12+
public class CookieSlidingExpirationContext : PrincipalContext<CookieAuthenticationOptions>
13+
{
14+
/// <summary>
15+
/// Creates a new instance of the context object.
16+
/// </summary>
17+
/// <param name="context"></param>
18+
/// <param name="scheme"></param>
19+
/// <param name="ticket">Contains the initial values for identity and extra data</param>
20+
/// <param name="elapsedTime"></param>
21+
/// <param name="remainingTime"></param>
22+
/// <param name="options"></param>
23+
public CookieSlidingExpirationContext(HttpContext context, AuthenticationScheme scheme, CookieAuthenticationOptions options,
24+
AuthenticationTicket ticket, TimeSpan elapsedTime, TimeSpan remainingTime)
25+
: base(context, scheme, options, ticket?.Properties)
26+
{
27+
if (ticket == null)
28+
{
29+
throw new ArgumentNullException(nameof(ticket));
30+
}
31+
32+
Principal = ticket.Principal;
33+
ElapsedTime = elapsedTime;
34+
RemainingTime = remainingTime;
35+
}
36+
37+
/// <summary>
38+
/// The amount of time that has elapsed since the cookie was issued or renewed.
39+
/// </summary>
40+
public TimeSpan ElapsedTime { get; }
41+
42+
/// <summary>
43+
/// The amount of time left until the cookie expires.
44+
/// </summary>
45+
public TimeSpan RemainingTime { get; }
46+
47+
/// <summary>
48+
/// If true, the cookie will be renewed. The initial value will be true if the elapsed time
49+
/// is greater than the remaining time (e.g. more than 50% expired).
50+
/// </summary>
51+
public bool ShouldRenew { get; set; }
52+
}
53+
}

src/Security/Authentication/Cookies/src/PublicAPI.Unshipped.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
#nullable enable
22
*REMOVED*Microsoft.AspNetCore.Authentication.Cookies.ITicketStore.RetrieveAsync(string! key) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Authentication.AuthenticationTicket!>!
3+
Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationEvents.OnCheckSlidingExpiration.get -> System.Func<Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext!, System.Threading.Tasks.Task!>!
4+
Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationEvents.OnCheckSlidingExpiration.set -> void
5+
Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext
6+
Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext.CookieSlidingExpirationContext(Microsoft.AspNetCore.Http.HttpContext! context, Microsoft.AspNetCore.Authentication.AuthenticationScheme! scheme, Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationOptions! options, Microsoft.AspNetCore.Authentication.AuthenticationTicket! ticket, System.TimeSpan elapsedTime, System.TimeSpan remainingTime) -> void
7+
Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext.ElapsedTime.get -> System.TimeSpan
8+
Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext.RemainingTime.get -> System.TimeSpan
9+
Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext.ShouldRenew.get -> bool
10+
Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext.ShouldRenew.set -> void
311
Microsoft.AspNetCore.Authentication.Cookies.ITicketStore.RemoveAsync(string! key, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
412
Microsoft.AspNetCore.Authentication.Cookies.ITicketStore.RenewAsync(string! key, Microsoft.AspNetCore.Authentication.AuthenticationTicket! ticket, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
513
Microsoft.AspNetCore.Authentication.Cookies.ITicketStore.RetrieveAsync(string! key) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Authentication.AuthenticationTicket?>!

src/Security/Authentication/test/CookieTests.cs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -935,6 +935,58 @@ public async Task CookieIsRenewedWithSlidingExpirationWithoutTransformations()
935935
Assert.Equal("Alice", FindClaimValue(transaction5, ClaimTypes.Name));
936936
}
937937

938+
[Fact]
939+
public async Task CookieIsRenewedWithSlidingExpirationEvent()
940+
{
941+
using var host = await CreateHost(o =>
942+
{
943+
o.ExpireTimeSpan = TimeSpan.FromMinutes(10);
944+
o.SlidingExpiration = true;
945+
o.Events = new CookieAuthenticationEvents()
946+
{
947+
OnCheckSlidingExpiration = context =>
948+
{
949+
var expectRenew = string.Equals("1", context.Request.Query["expectrenew"]);
950+
var renew = string.Equals("1", context.Request.Query["renew"]);
951+
Assert.Equal(expectRenew, context.ShouldRenew);
952+
context.ShouldRenew = renew;
953+
return Task.CompletedTask;
954+
}
955+
};
956+
},
957+
SignInAsAlice);
958+
959+
using var server = host.GetTestServer();
960+
var transaction1 = await SendAsync(server, "http://example.com/testpath");
961+
962+
var transaction2 = await SendAsync(server, "http://example.com/me/Cookies?expectrenew=0&renew=0", transaction1.CookieNameValue);
963+
Assert.Null(transaction2.SetCookie);
964+
Assert.Equal("Alice", FindClaimValue(transaction2, ClaimTypes.Name));
965+
966+
_clock.Add(TimeSpan.FromMinutes(4));
967+
968+
var transaction3 = await SendAsync(server, "http://example.com/me/Cookies?expectrenew=0&renew=0", transaction1.CookieNameValue);
969+
Assert.Null(transaction3.SetCookie);
970+
Assert.Equal("Alice", FindClaimValue(transaction3, ClaimTypes.Name));
971+
972+
_clock.Add(TimeSpan.FromMinutes(4));
973+
974+
// A renewal is now expected, but we've suppressed it
975+
var transaction4 = await SendAsync(server, "http://example.com/me/Cookies?expectrenew=1&renew=0", transaction1.CookieNameValue);
976+
Assert.Null(transaction4.SetCookie);
977+
Assert.Equal("Alice", FindClaimValue(transaction4, ClaimTypes.Name));
978+
979+
// Allow the default renewal to happen
980+
var transaction5 = await SendAsync(server, "http://example.com/me/Cookies?expectrenew=1&renew=1", transaction1.CookieNameValue);
981+
Assert.NotNull(transaction5.SetCookie);
982+
Assert.Equal("Alice", FindClaimValue(transaction5, ClaimTypes.Name));
983+
984+
// Force a renewal on an un-expired new cookie
985+
var transaction6 = await SendAsync(server, "http://example.com/me/Cookies?expectrenew=0&renew=1", transaction5.CookieNameValue);
986+
Assert.NotNull(transaction5.SetCookie);
987+
Assert.Equal("Alice", FindClaimValue(transaction6, ClaimTypes.Name));
988+
}
989+
938990
[Fact]
939991
public async Task CookieUsesPathBaseByDefault()
940992
{

0 commit comments

Comments
 (0)