Skip to content
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
85 changes: 85 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<RedisTicketStore>();
```

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<RedisTicketStore>();

// 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<string> 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<AuthenticationTicket?> 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CustomInMemoryTicketStore>();


// 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 **
Expand All @@ -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<CustomInMemoryTicketStore>()` 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.
Expand Down Expand Up @@ -142,28 +146,3 @@ void ConfigureServicesAuth0StatfullInstantSessionClear(IServiceCollection servic
// Configure a custom LogoutTokenHandler, allowing you to clear the stateful session
services.AddTransient<ILogoutTokenHandler, CustomClearSessionLogoutTokenHandler>();
}

void ConfigureStatefullSessions(IServiceCollection services)
{
// Configure a custom ITicketStore to store the Identity Information on the server
services.AddTransient<ITicketStore, CustomInMemoryTicketStore>();
// Configure the Cookie Middleware to use the CustomInMemoryTicketStore
services.AddSingleton<IPostConfigureOptions<CookieAuthenticationOptions>, ConfigureCookieAuthenticationOptions>();
}

public class ConfigureCookieAuthenticationOptions
: IPostConfigureOptions<CookieAuthenticationOptions>
{
private readonly ITicketStore _ticketStore;

public ConfigureCookieAuthenticationOptions(ITicketStore ticketStore)
{
_ticketStore = ticketStore;
}

public void PostConfigure(string name,
CookieAuthenticationOptions options)
{
options.SessionStore = _ticketStore;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -77,7 +78,57 @@ public Auth0WebAppAuthenticationBuilder WithCustomDomains(Action<Auth0CustomDoma
EnableCustomDomains(configureOptions);
return this;
}


/// <summary>
/// Stores the authentication session server-side using the supplied
/// <see href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.cookies.iticketstore">ITicketStore</see>,
/// keeping only a session key in the cookie. <typeparamref name="TStore"/> is resolved
/// from the service provider, so it may depend on other registered services
/// (e.g. <see href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.caching.distributed.idistributedcache">IDistributedCache</see>).
/// </summary>
/// <typeparam name="TStore">The <c>ITicketStore</c> implementation to use.</typeparam>
/// <returns>An instance of <see cref="Auth0WebAppAuthenticationBuilder"/></returns>
public Auth0WebAppAuthenticationBuilder WithSessionStore<TStore>() 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<TStore>();
EnableSessionStore(sp => sp.GetRequiredService<TStore>());
return this;
}

/// <summary>
/// Stores the authentication session server-side using the supplied
/// <see href="https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.authentication.cookies.iticketstore">ITicketStore</see>
/// instance, keeping only a session key in the cookie.
/// </summary>
/// <param name="ticketStore">The <c>ITicketStore</c> instance to use.</param>
/// <returns>An instance of <see cref="Auth0WebAppAuthenticationBuilder"/></returns>
public Auth0WebAppAuthenticationBuilder WithSessionStore(ITicketStore ticketStore)
{
if (ticketStore == null)
{
throw new ArgumentNullException(nameof(ticketStore));
}

EnableSessionStore(_ => ticketStore);
return this;
}

private void EnableSessionStore(Func<IServiceProvider, ITicketStore> 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<CookieAuthenticationOptions>(cookieScheme)
.Configure<IServiceProvider>((cookieOptions, sp) =>
cookieOptions.SessionStore = resolveStore(sp));
}

private void EnableCustomDomains(Action<Auth0CustomDomainsOptions> configureOptions)
{
var customDomainsOptions = new Auth0CustomDomainsOptions();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<FakeTicketStore>());

var cookieOptions = GetCookieOptions(provider, CookieAuthenticationDefaults.AuthenticationScheme);

cookieOptions.SessionStore.Should().BeOfType<FakeTicketStore>();
}

[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<DependentTicketStore>(),
services => services.AddSingleton(new StoreDependency("injected")));

var cookieOptions = GetCookieOptions(provider, CookieAuthenticationDefaults.AuthenticationScheme);

cookieOptions.SessionStore.Should().BeOfType<DependentTicketStore>();
((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<Auth0WebAppAuthenticationBuilder> configureBuilder,
System.Action<IServiceCollection>? configureServices = null,
System.Action<Auth0WebAppOptions>? 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<IOptionsMonitor<CookieAuthenticationOptions>>().Get(scheme);
}

private class FakeTicketStore : ITicketStore
{
public Task<string> StoreAsync(AuthenticationTicket ticket) => Task.FromResult("key");
public Task RenewAsync(string key, AuthenticationTicket ticket) => Task.CompletedTask;
public Task<AuthenticationTicket?> RetrieveAsync(string key) => Task.FromResult<AuthenticationTicket?>(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<string> StoreAsync(AuthenticationTicket ticket) => Task.FromResult("key");
public Task RenewAsync(string key, AuthenticationTicket ticket) => Task.CompletedTask;
public Task<AuthenticationTicket?> RetrieveAsync(string key) => Task.FromResult<AuthenticationTicket?>(null);
public Task RemoveAsync(string key) => Task.CompletedTask;
}
}
}
Loading