Skip to content

Add a new OnCheckSlidingExpiration event to control renewal #33016

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 27, 2021
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
@@ -1,18 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFrameworks>$(DefaultNetCoreTargetFramework)</TargetFrameworks>
<AspNetCoreHostingModel>OutOfProcess</AspNetCoreHostingModel>
</PropertyGroup>

<ItemGroup>
<Reference Include="Microsoft.AspNetCore" />
<Reference Include="Microsoft.AspNetCore.Authentication.Cookies" />
<Reference Include="Microsoft.AspNetCore.Hosting" />
<Reference Include="Microsoft.AspNetCore.DataProtection" />
<Reference Include="Microsoft.AspNetCore.Server.IISIntegration" />
<Reference Include="Microsoft.AspNetCore.Server.Kestrel" />
<Reference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<Reference Include="Microsoft.Extensions.Logging.Console" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -1,32 +1,73 @@
using System.IO;
using System;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace CookieSample
{
public static class Program
var builder = WebApplication.CreateBuilder(args);

builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
public static Task Main(string[] args)
options.ExpireTimeSpan = TimeSpan.FromSeconds(20);
options.Events = new CookieAuthenticationEvents()
{
var host = new HostBuilder()
.ConfigureWebHost(webHostBuilder =>
{
webHostBuilder
.UseKestrel()
.UseContentRoot(Directory.GetCurrentDirectory())
.UseIISIntegration()
.UseStartup<Startup>();
})
.ConfigureLogging(factory =>
OnCheckSlidingExpiration = context =>
{
// If 25% expired instead of the default 50%.
context.ShouldRenew = context.ElapsedTime > (context.Options.ExpireTimeSpan / 4);

// Don't renew on API endpoints that use JWT.
var authData = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<IAuthorizeData>();
if (authData != null && string.Equals(authData.AuthenticationSchemes, "Bearer", StringComparison.Ordinal))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could consider moving this to auth samples instead of manual and adding a test for this scenario (always nicer to have coverage)

https://github.com/dotnet/aspnetcore/blob/main/src/Security/test/AuthSamples.FunctionalTests/CookiesTests.cs

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mainly wanted to show @brockallen that there were other patterns than matching request paths.

{
factory.AddConsole();
factory.AddFilter("Console", level => level >= LogLevel.Information);
})
.Build();
context.ShouldRenew = false;
}

return Task.CompletedTask;
}
};
});

var app = builder.Build();

app.UseAuthentication();

app.MapGet("/", async context =>
{
if (!context.User.Identities.Any(identity => identity.IsAuthenticated))
{
var user = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, "bob") }, CookieAuthenticationDefaults.AuthenticationScheme));
await context.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, user);

return host.RunAsync();
}
context.Response.ContentType = "text/plain";
await context.Response.WriteAsync("Hello First timer");
return;
}
}

context.Response.ContentType = "text/plain";
await context.Response.WriteAsync("Hello old timer");
});

app.MapGet("/ticket", async context =>
{
var ticket = await context.AuthenticateAsync();
if (!ticket.Succeeded)
{
await context.Response.WriteAsync($"Signed Out");
return;
}

foreach (var (key, value) in ticket.Properties.Items)
{
await context.Response.WriteAsync($"{key}: {value}\r\n");
}
});

app.Run();
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:1782/"
"applicationUrl": "http://localhost:5000/"
},
"IIS Express": {
"commandName": "IISExpress",
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "http://localhost:1776/"
"applicationUrl": "http://localhost:5000/"
},
"IIS Express": {
"commandName": "IISExpress",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ public void ConfigureServices(IServiceCollection services)
options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme;
}).AddCookie();


services.AddSingleton<ITicketStore, MemoryCacheTicketStore>();
services.AddOptions<CookieAuthenticationOptions>(CookieAuthenticationDefaults.AuthenticationScheme)
.Configure<MemoryCacheTicketStore>((o, ticketStore) => o.SessionStore = ticketStore);
.Configure<ITicketStore>((o, ticketStore) => o.SessionStore = ticketStore);
}

public void Configure(IApplicationBuilder app)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ public class CookieAuthenticationEvents
/// </summary>
public Func<CookieValidatePrincipalContext, Task> OnValidatePrincipal { get; set; } = context => Task.CompletedTask;

/// <summary>
/// Invoked to check if the cookie should be renewed.
/// </summary>
public Func<CookieSlidingExpirationContext, Task> OnCheckSlidingExpiration { get; set; } = context => Task.CompletedTask;

/// <summary>
/// Invoked on signing in.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ private Task<AuthenticateResult> EnsureCookieTicket()
return _readCookieTask;
}

private void CheckForRefresh(AuthenticationTicket ticket)
private async Task CheckForRefreshAsync(AuthenticationTicket ticket)
{
var currentUtc = Clock.UtcNow;
var issuedUtc = ticket.Properties.IssuedUtc;
Expand All @@ -91,7 +91,13 @@ private void CheckForRefresh(AuthenticationTicket ticket)
var timeElapsed = currentUtc.Subtract(issuedUtc.Value);
var timeRemaining = expiresUtc.Value.Subtract(currentUtc);

if (timeRemaining < timeElapsed)
var eventContext = new CookieSlidingExpirationContext(Context, Scheme, Options, ticket, timeElapsed, timeRemaining)
{
ShouldRenew = timeRemaining < timeElapsed,
};
await Options.Events.OnCheckSlidingExpiration(eventContext);

if (eventContext.ShouldRenew)
{
RequestRefresh(ticket);
}
Expand Down Expand Up @@ -174,8 +180,6 @@ private async Task<AuthenticateResult> ReadCookieTicket()
return AuthenticateResult.Fail("Ticket expired");
}

CheckForRefresh(ticket);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved this to clarify that it's only relevant to Authenticate scenarios. SignIn and SignOut also call ReadCookieTicket, but only to populate the _sessionKey.


// Finally we have a valid ticket
return AuthenticateResult.Success(ticket);
}
Expand All @@ -189,6 +193,10 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
return result;
}

// We check this before the ValidatePrincipal event because we want to make sure we capture a clean clone
// without picking up any per-request modifications to the principal.
await CheckForRefreshAsync(result.Ticket);

Debug.Assert(result.Ticket != null);
var context = new CookieValidatePrincipalContext(Context, Scheme, Options, result.Ticket);
await Events.ValidatePrincipal(context);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using Microsoft.AspNetCore.Http;

namespace Microsoft.AspNetCore.Authentication.Cookies
{
/// <summary>
/// Context object passed to the CookieAuthenticationEvents OnCheckSlidingExpiration method.
/// </summary>
public class CookieSlidingExpirationContext : PrincipalContext<CookieAuthenticationOptions>
{
/// <summary>
/// Creates a new instance of the context object.
/// </summary>
/// <param name="context"></param>
/// <param name="scheme"></param>
/// <param name="ticket">Contains the initial values for identity and extra data</param>
/// <param name="elapsedTime"></param>
/// <param name="remainingTime"></param>
/// <param name="options"></param>
public CookieSlidingExpirationContext(HttpContext context, AuthenticationScheme scheme, CookieAuthenticationOptions options,
AuthenticationTicket ticket, TimeSpan elapsedTime, TimeSpan remainingTime)
: base(context, scheme, options, ticket?.Properties)
{
if (ticket == null)
{
throw new ArgumentNullException(nameof(ticket));
}

Principal = ticket.Principal;
ElapsedTime = elapsedTime;
RemainingTime = remainingTime;
}

/// <summary>
/// The amount of time that has elapsed since the cookie was issued or renewed.
/// </summary>
public TimeSpan ElapsedTime { get; }

/// <summary>
/// The amount of time left until the cookie expires.
/// </summary>
public TimeSpan RemainingTime { get; }

/// <summary>
/// If true, the cookie will be renewed. The initial value will be true if the elapsed time
/// is greater than the remaining time (e.g. more than 50% expired).
/// </summary>
public bool ShouldRenew { get; set; }
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
#nullable enable
*REMOVED*Microsoft.AspNetCore.Authentication.Cookies.ITicketStore.RetrieveAsync(string! key) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Authentication.AuthenticationTicket!>!
Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationEvents.OnCheckSlidingExpiration.get -> System.Func<Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext!, System.Threading.Tasks.Task!>!
Microsoft.AspNetCore.Authentication.Cookies.CookieAuthenticationEvents.OnCheckSlidingExpiration.set -> void
Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext
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
Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext.ElapsedTime.get -> System.TimeSpan
Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext.RemainingTime.get -> System.TimeSpan
Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext.ShouldRenew.get -> bool
Microsoft.AspNetCore.Authentication.Cookies.CookieSlidingExpirationContext.ShouldRenew.set -> void
Microsoft.AspNetCore.Authentication.Cookies.ITicketStore.RemoveAsync(string! key, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Authentication.Cookies.ITicketStore.RenewAsync(string! key, Microsoft.AspNetCore.Authentication.AuthenticationTicket! ticket, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Authentication.Cookies.ITicketStore.RetrieveAsync(string! key) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Authentication.AuthenticationTicket?>!
Expand Down
52 changes: 52 additions & 0 deletions src/Security/Authentication/test/CookieTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -935,6 +935,58 @@ public async Task CookieIsRenewedWithSlidingExpirationWithoutTransformations()
Assert.Equal("Alice", FindClaimValue(transaction5, ClaimTypes.Name));
}

[Fact]
public async Task CookieIsRenewedWithSlidingExpirationEvent()
{
using var host = await CreateHost(o =>
{
o.ExpireTimeSpan = TimeSpan.FromMinutes(10);
o.SlidingExpiration = true;
o.Events = new CookieAuthenticationEvents()
{
OnCheckSlidingExpiration = context =>
{
var expectRenew = string.Equals("1", context.Request.Query["expectrenew"]);
var renew = string.Equals("1", context.Request.Query["renew"]);
Assert.Equal(expectRenew, context.ShouldRenew);
context.ShouldRenew = renew;
return Task.CompletedTask;
}
};
},
SignInAsAlice);

using var server = host.GetTestServer();
var transaction1 = await SendAsync(server, "http://example.com/testpath");

var transaction2 = await SendAsync(server, "http://example.com/me/Cookies?expectrenew=0&renew=0", transaction1.CookieNameValue);
Assert.Null(transaction2.SetCookie);
Assert.Equal("Alice", FindClaimValue(transaction2, ClaimTypes.Name));

_clock.Add(TimeSpan.FromMinutes(4));

var transaction3 = await SendAsync(server, "http://example.com/me/Cookies?expectrenew=0&renew=0", transaction1.CookieNameValue);
Assert.Null(transaction3.SetCookie);
Assert.Equal("Alice", FindClaimValue(transaction3, ClaimTypes.Name));

_clock.Add(TimeSpan.FromMinutes(4));

// A renewal is now expected, but we've suppressed it
var transaction4 = await SendAsync(server, "http://example.com/me/Cookies?expectrenew=1&renew=0", transaction1.CookieNameValue);
Assert.Null(transaction4.SetCookie);
Assert.Equal("Alice", FindClaimValue(transaction4, ClaimTypes.Name));

// Allow the default renewal to happen
var transaction5 = await SendAsync(server, "http://example.com/me/Cookies?expectrenew=1&renew=1", transaction1.CookieNameValue);
Assert.NotNull(transaction5.SetCookie);
Assert.Equal("Alice", FindClaimValue(transaction5, ClaimTypes.Name));

// Force a renewal on an un-expired new cookie
var transaction6 = await SendAsync(server, "http://example.com/me/Cookies?expectrenew=0&renew=1", transaction5.CookieNameValue);
Assert.NotNull(transaction5.SetCookie);
Assert.Equal("Alice", FindClaimValue(transaction6, ClaimTypes.Name));
}

[Fact]
public async Task CookieUsesPathBaseByDefault()
{
Expand Down