File: PostConfigureDeviceBoundSessionDerivedCookieOptions.cs
Web Access
Project: src\aspnetcore\src\Security\Authentication\DeviceBoundSessions\src\Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj (Microsoft.AspNetCore.Authentication.DeviceBoundSessions)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
#pragma warning disable ASP0031 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
 
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
 
namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
 
/// <summary>
/// Copies cookie settings from the source scheme to the refresh and session cookie schemes
/// so they match HttpOnly, Secure, SameSite, Domain, and lifetime settings.
/// </summary>
internal sealed class PostConfigureDeviceBoundSessionDerivedCookieOptions : IPostConfigureOptions<CookieAuthenticationOptions>
{
    private readonly IOptions<DeviceBoundSessionSourceSchemes> _sourceSchemes;
    private readonly IServiceProvider _services;
 
    public PostConfigureDeviceBoundSessionDerivedCookieOptions(
        IOptions<DeviceBoundSessionSourceSchemes> sourceSchemes,
        IServiceProvider services)
    {
        _sourceSchemes = sourceSchemes;
        _services = services;
    }
 
    public void PostConfigure(string? name, CookieAuthenticationOptions options)
    {
        ArgumentNullException.ThrowIfNull(name);
 
        var schemes = _sourceSchemes.Value;
 
        if (schemes.RefreshSchemes.TryGetValue(name, out var refreshSourceScheme))
        {
            CopyFromSource(refreshSourceScheme, options);
            // Scope the refresh cookie to the DBSC endpoints' directory, applied relative to the request
            // path base so an app mounted under a non-root path base still receives the cookie at its
            // advertised refresh endpoint. Lifetime and sliding behavior are inherited from the source
            // scheme so the refresh cookie ages exactly like the auth cookie it replaces.
            options.Cookie = CreatePathBaseScopedCookie(options.Cookie, ResolveRefreshCookiePath(refreshSourceScheme));
            // Re-run the source scheme's principal validation (e.g. Identity's security-stamp / revocation
            // check) on the long-lived refresh cookie so revocation is enforced at refresh time — which the
            // short-lived session cookie would otherwise bypass.
            ForwardSourceValidatePrincipal(options, refreshSourceScheme);
        }
        else if (schemes.SessionSchemes.TryGetValue(name, out var sessionSourceScheme))
        {
            CopyFromSource(sessionSourceScheme, options);
            // Session cookie uses the source path (default /) but shorter expiry.
            // ExpireTimeSpan is set at sign-in time by the handler via AuthenticationProperties.
            options.SlidingExpiration = false;
        }
    }
 
    private string ResolveRefreshCookiePath(string sourceScheme)
    {
        const string fallback = "/.well-known/dbsc";
 
        // Map source cookie scheme -> DBSC handler scheme so we can read its configured RefreshPath.
        if (!_sourceSchemes.Value.Schemes.TryGetValue(sourceScheme, out var dbscScheme))
        {
            return fallback;
        }
 
        var refreshPath = _services.GetRequiredService<IOptionsMonitor<DeviceBoundSessionOptions>>()
            .Get(dbscScheme).RefreshPath.Value;
        if (string.IsNullOrEmpty(refreshPath))
        {
            return fallback;
        }
 
        // Scope the cookie to the refresh endpoint's directory so it is sent to that endpoint.
        var lastSlash = refreshPath.LastIndexOf('/');
        return lastSlash > 0 ? refreshPath[..lastSlash] : "/";
    }
 
    private void CopyFromSource(string sourceScheme, CookieAuthenticationOptions target)
    {
        var source = _services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>().Get(sourceScheme);
 
        // Copy cookie builder settings (preserving any name/path already set)
        target.Cookie.HttpOnly = source.Cookie.HttpOnly;
        target.Cookie.SecurePolicy = source.Cookie.SecurePolicy;
        target.Cookie.SameSite = source.Cookie.SameSite;
        target.Cookie.Domain = source.Cookie.Domain;
        target.Cookie.IsEssential = source.Cookie.IsEssential;
        target.ExpireTimeSpan = source.ExpireTimeSpan;
        target.SlidingExpiration = source.SlidingExpiration;
    }
 
    // Forwards the source scheme's OnValidatePrincipal to the refresh cookie so revocation checks
    // (e.g. Identity's security stamp) run when the long-lived refresh cookie is used. Resolves the
    // source's events at request time to support both delegate-based Events and EventsType, and invokes
    // only ValidatePrincipal — not the source's sign-in/sign-out handlers.
    private static void ForwardSourceValidatePrincipal(CookieAuthenticationOptions options, string sourceScheme)
    {
        var priorValidatePrincipal = options.Events.OnValidatePrincipal;
        options.Events.OnValidatePrincipal = async context =>
        {
            var services = context.HttpContext.RequestServices;
            var sourceOptions = services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>().Get(sourceScheme);
            var sourceEvents = sourceOptions.EventsType is not null
                ? (CookieAuthenticationEvents)services.GetRequiredService(sourceOptions.EventsType)
                : sourceOptions.Events;
 
            await sourceEvents.ValidatePrincipal(context);
            await priorValidatePrincipal(context);
        };
    }
 
    // Rebuilds the cookie as a path-base-aware builder so the cookie path becomes
    // "{request path base}{additionalPath}" at emit time, instead of a fixed absolute path.
    private static CookieBuilder CreatePathBaseScopedCookie(CookieBuilder source, string additionalPath)
        => new PathBaseScopedCookieBuilder(additionalPath)
        {
            Name = source.Name,
            HttpOnly = source.HttpOnly,
            SecurePolicy = source.SecurePolicy,
            SameSite = source.SameSite,
            Domain = source.Domain,
            IsEssential = source.IsEssential,
            MaxAge = source.MaxAge,
            Expiration = source.Expiration,
            // Path is intentionally left unset so the builder derives (path base + additional path) per request.
        };
 
    private sealed class PathBaseScopedCookieBuilder : RequestPathBaseCookieBuilder
    {
        private readonly string _additionalPath;
 
        public PathBaseScopedCookieBuilder(string additionalPath)
        {
            _additionalPath = additionalPath;
        }
 
        protected override string AdditionalPath => _additionalPath;
    }
}