diff --git a/EXAMPLES.md b/EXAMPLES.md index a57c694b..68f963cf 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -4,6 +4,7 @@ - [Scopes](#scopes) - [Calling an API](#calling-an-api) - [Configuring the refresh leeway](#configuring-the-refresh-leeway) +- [Server-side session storage](#server-side-session-storage) - [Organizations](#organizations) - [Extra parameters](#extra-parameters) - [Roles](#roles) @@ -191,6 +192,90 @@ The above snippet checks whether the SDK is configured to use refresh tokens, if > :information_source: In order for Auth0 to redirect back to the application's login URL, ensure to add the configured redirect URL to the application's `Allowed Logout URLs` in Auth0's dashboard. +## Server-side session storage + +By default, the SDK is **stateless**: the entire authentication session - the user's identity claims together with the ID, access, and refresh tokens - is serialized into the encrypted authentication cookie. This requires no additional infrastructure and lets any instance behind a load balancer serve any request. + +Because everything lives in the cookie, the session is subject to the browser's cookie size limits (around 4 KB per cookie). ASP.NET Core automatically splits a larger payload across multiple cookies, but tokens are large and browsers/proxies also cap the total size of request headers. Sessions that accumulate several tokens can therefore grow large enough to be rejected by the browser, a reverse proxy, or the web server. + +To avoid this, you can move the session **server-side**. The cookie then holds only a small session key, while the full session payload is kept in a store you control. Use `WithSessionStore` to provide an [`ITicketStore`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.cookies.iticketstore) implementation: + +```csharp +services + .AddAuth0WebAppAuthentication(options => + { + options.Domain = Configuration["Auth0:Domain"]; + options.ClientId = Configuration["Auth0:ClientId"]; + options.ClientSecret = Configuration["Auth0:ClientSecret"]; + }) + .WithAccessToken(options => + { + options.Audience = Configuration["Auth0:Audience"]; + options.UseRefreshTokens = true; + }) + .WithSessionStore(); +``` + +Server-side session storage is a built-in ASP.NET Core capability (`CookieAuthenticationOptions.SessionStore`), so this was always possible - but only by post-configuring the cookie options for the exact scheme the SDK uses internally. Getting that scheme name wrong left the store silently unused. `WithSessionStore` simply makes it easier: it attaches the store to the SDK's own cookie scheme for you, so it keeps working even when you set a custom `CookieAuthenticationScheme` - there is no scheme name to wire up by hand. + +Keep the **default cookie-based session** when you want to stay stateless and avoid running extra infrastructure - for most applications it is the simplest and best choice. Server-side storage is an opt-in for the cases above, and it requires a store that is shared across all your instances (for example a distributed cache) when you run more than one. + +### Providing the ITicketStore + +You can pass either a type (resolved from the dependency injection container, so it may depend on other registered services such as `IDistributedCache` and `IDataProtectionProvider`) or an already-constructed instance: + +```csharp +// Resolved from the container - supports constructor injection. +.WithSessionStore(); + +// Or supply an instance directly - you are then responsible for its dependencies. +.WithSessionStore(new RedisTicketStore(cache, dataProtectionProvider)); +``` + +Prefer the type overload when your store depends on registered services (as the `RedisTicketStore` below does); the instance overload is best for stores you can construct by hand. + +A minimal `IDistributedCache`-backed implementation looks like this. Honoring `ticket.Properties.ExpiresUtc` lets the cache expire abandoned sessions automatically. The serialized ticket contains the user's claims and any tokens (access/refresh) carried in `AuthenticationProperties`, so it is encrypted with an [`IDataProtector`](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/consumer-apis/overview) before being written to the cache: + +```csharp +public class RedisTicketStore : ITicketStore +{ + private readonly IDistributedCache _cache; + private readonly IDataProtector _protector; + + public RedisTicketStore(IDistributedCache cache, IDataProtectionProvider provider) + { + _cache = cache; + _protector = provider.CreateProtector("Auth0.AspNetCore.Authentication.RedisTicketStore"); + } + + public async Task StoreAsync(AuthenticationTicket ticket) + { + var key = $"auth-session-{Guid.NewGuid():N}"; + await RenewAsync(key, ticket); + return key; + } + + public async Task RenewAsync(string key, AuthenticationTicket ticket) + { + var options = new DistributedCacheEntryOptions { AbsoluteExpiration = ticket.Properties.ExpiresUtc }; + var payload = _protector.Protect(TicketSerializer.Default.Serialize(ticket)); + await _cache.SetAsync(key, payload, options); + } + + public async Task RetrieveAsync(string key) + { + var bytes = await _cache.GetAsync(key); + return bytes == null ? null : TicketSerializer.Default.Deserialize(_protector.Unprotect(bytes)); + } + + public Task RemoveAsync(string key) => _cache.RemoveAsync(key); +} +``` + +> :warning: **Running multiple instances:** the store must be shared across them (e.g. a distributed cache such as Redis or SQL Server). An in-memory store only works for a single instance and will cause users to appear logged out when their requests are served by a different instance. Likewise, the [Data Protection keys must be persisted and shared](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview) across all instances - otherwise tickets become unreadable after a key rotation, app restart, or when served by a different node. +> +> :warning: **Protecting the ticket:** it holds sensitive data (claims, access and refresh tokens). Encrypting the payload with `IDataProtector` as shown above means an attacker who gains read access to the cache cannot recover those tokens. Treat the cache backend itself as sensitive too: restrict access and enable encryption in transit (e.g. Redis AUTH + TLS) and at rest. + ## Organizations [Organizations](https://auth0.com/docs/organizations) is a set of features that provide better support for developers who build and maintain SaaS and Business-to-Business (B2B) applications. diff --git a/playground/Auth0.AspNetCore.Authentication.Playground/Program.cs b/playground/Auth0.AspNetCore.Authentication.Playground/Program.cs index f39bef81..1b0685bb 100644 --- a/playground/Auth0.AspNetCore.Authentication.Playground/Program.cs +++ b/playground/Auth0.AspNetCore.Authentication.Playground/Program.cs @@ -31,12 +31,15 @@ await context.ChallengeAsync(PlaygroundConstants.AuthenticationScheme, authenticationProperties); } }; - }).WithBackchannelLogout(); + }).WithBackchannelLogout() + // Store the authentication session server-side instead of in the cookie. Only a session + // key is kept in the cookie, which keeps the cookie small regardless of how much the + // session holds. Comment this out to use the default, stateless, cookie-based session. + .WithSessionStore(); // The above configuration works but is not suitable for production as it uses an InMemory cache to store the logout token // Instead, for production use any of the following, additional, configuration. -// Note: For the statefull scenario's, ensure to uncomment `ConfigureStatefullSessions` as well. // ** STATELESS ** @@ -48,8 +51,9 @@ // ** STATEFUL ** -// Ensure to uncomment this when using any of the below configurations -// ConfigureStatefullSessions(services); +// Stateful sessions are enabled by the `.WithSessionStore()` call on +// the authentication builder above. The options below show custom LogoutTokenHandler variants +// that pair with a stateful session. // 2. Configure the SDK to use Stateful session and a custom LogoutTokenHandler to store the tokens. @@ -142,28 +146,3 @@ void ConfigureServicesAuth0StatfullInstantSessionClear(IServiceCollection servic // Configure a custom LogoutTokenHandler, allowing you to clear the stateful session services.AddTransient(); } - -void ConfigureStatefullSessions(IServiceCollection services) -{ - // Configure a custom ITicketStore to store the Identity Information on the server - services.AddTransient(); - // Configure the Cookie Middleware to use the CustomInMemoryTicketStore - services.AddSingleton, ConfigureCookieAuthenticationOptions>(); -} - -public class ConfigureCookieAuthenticationOptions - : IPostConfigureOptions -{ - private readonly ITicketStore _ticketStore; - - public ConfigureCookieAuthenticationOptions(ITicketStore ticketStore) - { - _ticketStore = ticketStore; - } - - public void PostConfigure(string name, - CookieAuthenticationOptions options) - { - options.SessionStore = _ticketStore; - } -} \ No newline at end of file diff --git a/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs b/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs index c90611b6..31852f11 100644 --- a/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs +++ b/src/Auth0.AspNetCore.Authentication/Auth0WebAppAuthenticationBuilder.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Authentication.OpenIdConnect; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -77,7 +78,57 @@ public Auth0WebAppAuthenticationBuilder WithCustomDomains(Action + /// Stores the authentication session server-side using the supplied + /// ITicketStore, + /// keeping only a session key in the cookie. is resolved + /// from the service provider, so it may depend on other registered services + /// (e.g. IDistributedCache). + /// + /// The ITicketStore implementation to use. + /// An instance of + public Auth0WebAppAuthenticationBuilder WithSessionStore() where TStore : class, ITicketStore + { + // Register and resolve the concrete type rather than ITicketStore: this avoids a + // pre-registered ITicketStore silently shadowing TStore, and lets schemes using + // different store types each get their own registration. + _services.TryAddSingleton(); + EnableSessionStore(sp => sp.GetRequiredService()); + return this; + } + + /// + /// Stores the authentication session server-side using the supplied + /// ITicketStore + /// instance, keeping only a session key in the cookie. + /// + /// The ITicketStore instance to use. + /// An instance of + public Auth0WebAppAuthenticationBuilder WithSessionStore(ITicketStore ticketStore) + { + if (ticketStore == null) + { + throw new ArgumentNullException(nameof(ticketStore)); + } + + EnableSessionStore(_ => ticketStore); + return this; + } + + private void EnableSessionStore(Func resolveStore) + { + // Attach the store to the cookie handler on the SDK's own cookie scheme. Resolving + // the scheme here is what frees callers from having to name it correctly themselves + // (a mismatch would otherwise leave the store silently unused). + var cookieScheme = _options.CookieAuthenticationScheme; + + _services + .AddOptions(cookieScheme) + .Configure((cookieOptions, sp) => + cookieOptions.SessionStore = resolveStore(sp)); + } + private void EnableCustomDomains(Action configureOptions) { var customDomainsOptions = new Auth0CustomDomainsOptions(); diff --git a/tests/Auth0.AspNetCore.Authentication.IntegrationTests/SessionStoreTests.cs b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/SessionStoreTests.cs new file mode 100644 index 00000000..8760cbe8 --- /dev/null +++ b/tests/Auth0.AspNetCore.Authentication.IntegrationTests/SessionStoreTests.cs @@ -0,0 +1,126 @@ +using FluentAssertions; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using System.Threading.Tasks; +using Xunit; + +namespace Auth0.AspNetCore.Authentication.IntegrationTests +{ + public class SessionStoreTests + { + private const string CustomCookieScheme = "Custom.Cookies"; + + [Fact] + public void WithSessionStore_Instance_SetsSessionStoreOnDefaultCookieScheme() + { + var store = new FakeTicketStore(); + var provider = BuildProvider(builder => builder.WithSessionStore(store)); + + var cookieOptions = GetCookieOptions(provider, CookieAuthenticationDefaults.AuthenticationScheme); + + cookieOptions.SessionStore.Should().BeSameAs(store); + } + + [Fact] + public void WithSessionStore_TypeParam_SetsSessionStoreResolvedFromContainer() + { + var provider = BuildProvider(builder => builder.WithSessionStore()); + + var cookieOptions = GetCookieOptions(provider, CookieAuthenticationDefaults.AuthenticationScheme); + + cookieOptions.SessionStore.Should().BeOfType(); + } + + [Fact] + public void WithSessionStore_TypeParam_ResolvesDependenciesFromContainer() + { + // A store with a constructor dependency proves the type-param overload goes + // through the container rather than new-ing the store itself. + var provider = BuildProvider( + builder => builder.WithSessionStore(), + services => services.AddSingleton(new StoreDependency("injected"))); + + var cookieOptions = GetCookieOptions(provider, CookieAuthenticationDefaults.AuthenticationScheme); + + cookieOptions.SessionStore.Should().BeOfType(); + ((DependentTicketStore)cookieOptions.SessionStore!).Dependency.Value.Should().Be("injected"); + } + + [Fact] + public void WithSessionStore_TargetsConfiguredCookieScheme_NotJustTheDefault() + { + // The whole point of the wrapper: it attaches to the SDK's resolved cookie scheme, + // so a custom CookieAuthenticationScheme still gets the store without the caller + // having to name it. + var store = new FakeTicketStore(); + var provider = BuildProvider( + builder => builder.WithSessionStore(store), + configureAuth0: options => options.CookieAuthenticationScheme = CustomCookieScheme); + + GetCookieOptions(provider, CustomCookieScheme).SessionStore.Should().BeSameAs(store); + } + + [Fact] + public void WithSessionStore_WhenNotCalled_LeavesSessionStoreNull() + { + var provider = BuildProvider(_ => { }); + + GetCookieOptions(provider, CookieAuthenticationDefaults.AuthenticationScheme) + .SessionStore.Should().BeNull(); + } + + private static ServiceProvider BuildProvider( + System.Action configureBuilder, + System.Action? configureServices = null, + System.Action? configureAuth0 = null) + { + var services = new ServiceCollection(); + services.AddLogging(); + configureServices?.Invoke(services); + + var builder = services.AddAuth0WebAppAuthentication(options => + { + options.Domain = "test.auth0.com"; + options.ClientId = "client-id"; + options.ClientSecret = "client-secret"; + configureAuth0?.Invoke(options); + }); + + configureBuilder(builder); + + return services.BuildServiceProvider(); + } + + private static CookieAuthenticationOptions GetCookieOptions(ServiceProvider provider, string scheme) + { + return provider.GetRequiredService>().Get(scheme); + } + + private class FakeTicketStore : ITicketStore + { + public Task StoreAsync(AuthenticationTicket ticket) => Task.FromResult("key"); + public Task RenewAsync(string key, AuthenticationTicket ticket) => Task.CompletedTask; + public Task RetrieveAsync(string key) => Task.FromResult(null); + public Task RemoveAsync(string key) => Task.CompletedTask; + } + + private class StoreDependency + { + public StoreDependency(string value) => Value = value; + public string Value { get; } + } + + private class DependentTicketStore : ITicketStore + { + public DependentTicketStore(StoreDependency dependency) => Dependency = dependency; + public StoreDependency Dependency { get; } + + public Task StoreAsync(AuthenticationTicket ticket) => Task.FromResult("key"); + public Task RenewAsync(string key, AuthenticationTicket ticket) => Task.CompletedTask; + public Task RetrieveAsync(string key) => Task.FromResult(null); + public Task RemoveAsync(string key) => Task.CompletedTask; + } + } +}