Managing authentication with Identity Server 4

There may be situations in which you need more control over how Identity Server 4 determines if the user is authenticated.

For example, you may wish to implement a second factor, or deal with elevations in privilege.

One technique would be to customise the session cookie that Identity Server uses for the user session to store additional claims, and have custom logic to validate or reject the ClaimsPrincipal generated from the session cookie.

For example:

services.AddAuthentication("MyScheme")
    .AddCookie("MyScheme", options =>
    {
        options.Cookie.Name = IdentityServerConstants.DefaultCookieAuthenticationScheme;
        options.EventsType = typeof(DefaultAuthenticationEvents);
    });

services.AddScoped<DefaultAuthenticationEvents>();

In your custom cookie events, you can inspect the incoming request and determine if the user is authenticated:

public class DefaultAuthenticationEvents : CookieAuthenticationEvents
{
    private readonly IMyAuthService authService;

    public DefaultAuthenticationEvents(IMyAuthService authService)
    {
        this.authService = authService ?? 
            throw new ArgumentNullException(nameof(authService));
    }

    public override async Task ValidatePrincipal(CookieValidatePrincipalContext context)
    {
        var result = await this.authService.IsAuthenticated(
            context.HttpContext,
            context.Principal);

        if (!result.IsHandled)
        {
            base.ValidatePrincipal(context);
            return;
        }

        if (!result.IsAuthenticated) 
        {
            context.RejectPrincipal();
            return;
        }

        base.ValidatePrincipal(context);
    }
}

Here, the IMyAuthService is a service with Task<AuthenticateResult> IsAuthenticated(HttpContext context, ClaimsPrincipal principal) method.

This determines if the user is authenticated with custom logic. For example, it could inspect claims on the principal against query string parameters on the context.Request.

Typically, you only wish to run your custom authentication logic if the context.Request.Path is /connect/authorize or /connect/authorize/callback, so you can check the request path in your implementation of IMyAuthService. Here, we are returning a custom AuthenticateResult with IsHandled and IsAuthenticated boolean properties.

This technique works, but it has a couple of issues, firstly it makes testing more difficult, as you have to add a valid Identity Server session cookie to your request to test that the authorize endpoint redirects to the expected location (for example the default login page or the redirect URI). The other issue is that the custom logic is only invoked if cookies are used as the mechanism for managing user sessions.

User Session Abstraction

An alternative is to use the IUserSession interface defined by Identity Server. Identity Server will use this to determine if the user is authenticated, and it is agnostic to any mechanism used to manage the user session. So if you switch from cookies, your custom authentication logic will still execute.

The IUserSession has a Task<ClaimsPrincipal> GetUserAsync() method which returns the current principal. If null is returned, then the user is considered unauthenticated.

By default, Identity Server registers a DefaultUserSession implementation which uses the cookie authentication handler to build the ClaimsPrincipal from the session cookie (idsrv by default) on the incoming request.

You can implement and register your own IUserSession to add custom authentication logic to GetUserAsync:

Here, we’re using composition to delegate calls to the existing DefaultUserSession implementation:

public class SecondFactorUserSession : IUserSession
{
    private readonly IMyAuthService authService;

    private readonly IHttpContextAccessor httpContextAccessor;

    private readonly IUserSession userSession;

    public SecondFactorUserSession(
        IMyAuthService authService,
        IHttpContextAccessor httpContextAccessor,
        IAuthenticationSchemeProvider schemes,
        IAuthenticationHandlerProvider handlers,
        IdentityServerOptions options,
        ISystemClock clock,
        ILogger<IUserSession> logger)
    {
        this.authService = authService 
            ?? throw new ArgumentNullException(nameof(authService));
        
        this.httpContextAccessor = httpContextAccessor 
            ?? throw new ArgumentNullException(nameof(httpContextAccessor));
        
        this.userSession = new DefaultUserSession(
            httpContextAccessor,
            schemes,
            handlers,
            options,
            clock,
            logger);
    }

    public async Task CreateSessionIdAsync(ClaimsPrincipal principal, AuthenticationProperties properties)
    {
        // For other members of `IUserSession`, just delegate to `DefaultUserSession`
        await this.userSession.CreateSessionIdAsync(principal, properties);
    }

    public async Task<ClaimsPrincipal> GetUserAsync()
    {
        var principal = await this.userSession.GetUserAsync();

        var result = await this.authService
            .IsAuthenticated(this.httpContextAccessor?.HttpContext, principal);

        if (!result.IsHandled)
        {
            return await this.userSession.GetUserAsync();
        }

        if (!result.IsAuthenticated)
        {
            return null;
        }

        return await this.userSession.GetUserAsync();
    }

    ...

Here, composition is used to delegate any IUserSession calls to the DefaultUserSession implementation. The only custom logic we require is our authentication logic within GetUserAsync. The existing IMyAuthService is used to determine if the current user is authenticated.

To override the DefaultUserSession implementation, we register our new SecondFactorUserSession, for example if using Autofac:

builder.RegisterType<MyAuthService>().As<IMyAuthService>();
builder.RegisterType<SecondFactorUserSession>().As<IUserSession>();

Note that the current version of Autofac has an issue with component registrations having greater priority than decorator registrations, so if we attempted to register a decorator to augment the functionality of the DefaultUserSession, then our decorator would not be resolved, the DefaultUserSession would take precedence. There is work being done on Autofac to enhance the decorator support. Hence, SecondFactorUserSession is not implemented as a decorator.

Testing

In order to test our custom authentication logic under different circumstances, we need to be able to stub out the current principal to be a known user.

This can be achieved with a TestUserSession implementation of IUserSession which is only registered within the tests.

This implementation can delegate all calls to the SecondFactorUserSession, but use a custom IAuthenticationHandlerProvider. This type is used by the DefaultUserSession to authenticate the user every time a ClaimsPrincipal is required.

By default, this will use the cookie authentication handler to build the ClaimsPrincipal from the incoming cookie, but we can implement a known user version which always returns our expected user:

public class TestUserSession : IUserSession
{
    private readonly IUserSession userSession;

    public TestUserSession(
        IMyAuthService authService,
        IHttpContextAccessor httpContextAccessor,
        IAuthenticationSchemeProvider schemes,
        IdentityServerOptions options,
        ISystemClock clock,
        ILogger<IUserSession> logger,
        Guid userId)
    {
        if (userId == Guid.Empty)
        {
            throw new ArgumentNullException(nameof(userId));
        }

        var handlers = new KnownUserAuthenticationHandlerProvider(userId);
        this.userSession = new SecondFactorUserSession(
            secondFactorService,
            httpContextAccessor,
            schemes,
            handlers,
            options,
            clock,
            logger);
    }
    
    public async Task CreateSessionIdAsync(ClaimsPrincipal principal, AuthenticationProperties properties)
    {
        // For all methods of `IUserSession`, we delegate to the `SecondFactorUserSession`
        await this.userSession.CreateSessionIdAsync(principal, properties);
    }

    ...

The KnownUserAuthenticationHandlerProvider takes the user identifier we wish to return for the current user. You could pass any other information that you wish to add to the ClaimsPrincipal here as well, for example additional claims.

The implementation of the handler provider always returns the same KnownUserHandler for every scheme requested:

public class KnownUserAuthenticationHandlerProvider : IAuthenticationHandlerProvider
{
    private readonly Guid userId;

    public KnownUserAuthenticationHandlerProvider(Guid userId)
    {
        if (userId == Guid.Empty)
        {
            throw new ArgumentNullException(nameof(userId));
        }

        this.userId = userId;    
    }
    
    public Task<IAuthenticationHandler> GetHandlerAsync(HttpContext context, string authenticationScheme)
    {
        IAuthenticationHandler handler = new KnownUserAuthenticationHandler(this.userId);
        return Task.FromResult(handler);
    }
}

The handler implements the AuthenticateAsync method which returns an AuthenticateResult containing a ticket built from our known user. The IdentityServerUser type provided by Identity Server is used to help build the known ClaimsPrincipal:

public class KnownUserAuthenticationHandler : IAuthenticationHandler
{
    private readonly Guid userId;

    public KnownUserAuthenticationHandler(Guid userId)
    {
        if (userId == Guid.Empty)
        {
            throw new ArgumentNullException(nameof(userId));
        }

        this.userId = userId;
    }

    public Task InitializeAsync(AuthenticationScheme scheme, HttpContext context)
    {
        return Task.CompletedTask;
    }

    public Task<AuthenticateResult> AuthenticateAsync()
    {
        var user = new IdentityServerUser(this.userId.ToString())
        {
            AuthenticationTime = DateTime.UtcNow,
            AuthenticationMethods = new List<string> { OidcConstants.AuthenticationMethods.Password },
            AdditionalClaims = new List<Claim>
            {
                ... // add additional claims
            },
        };

        var ticket = new AuthenticationTicket(
            user.CreatePrincipal(), properties: null, authenticationScheme: "Cookie");

        return Task.FromResult(AuthenticateResult.Success(ticket));
    }
    
    public Task ChallengeAsync(AuthenticationProperties properties)
    {
        return Task.CompletedTask;
    }

    public Task ForbidAsync(AuthenticationProperties properties)
    {
        return Task.CompletedTask;
    }
}

Registering the TestUserSession for tests only means that whenever the custom SecondFactorUserSession delegates calls to the DefaultUserSession, the known user authentication handler will be used every time a ClaimsPrincipal is required.

In production, the default registration of IAuthenticationHandlerProvider will be used, which will build the ClaimsPrincipal from the cookie authentication handler.