Improve ASP.NET Core authentication using OAuth PAR and OpenID Connect

This article shows how an ASP.NET Core application can be authenticated using OpenID Connect and OAuth 2.0 Pushed Authorization Requests (PAR) RFC 9126. The OpenID Connect server is implemented using Duende IdentityServer. The Razor Page ASP.NET Core application authenticates using an OpenID Connect confidential client with PKCE and using the OAuth PAR extension.

Code: https://github.com/damienbod/oidc-par-aspnetcore-duende

Note: The code in this example was created using the Duende example found here: https://github.com/DuendeSoftware/IdentityServer

By using Pushed Authorization Requests (PAR), the authentication flow security is improved. In ASP.NET Core using PAR, the application is authenticated on the trusted backchannel before sending any authentication request. The parameters are no longer sent in the URL reducing the risk by not sharing the parameters or prevent parameter pollution with redirect_uri injection. No parameters are shared in the front channel. The OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR) RFC 9101 can also be used together with this to further improve the authentication security.

Overview

The OAuth PAR extension adds an extra step to the two step OpenID Connect client authentication code flow. OAuth Pushed Authorization Requests (PAR) extended flow has three steps:

  1. Client sends a HTTP request in the back channel with the authorization parameters and the client is authenticated first. The body of the request has the OpenID Connect code flow parameters. The server responds with the request_uri.
  2. The client uses the request_uri from the first step and authenticates. The server uses the flow parameters from the first request. As code flow with PKCE is used, the code is returned in the front channel.
  3. The client completes the authentication using the code flow in the back channel, standard OpenID Connect code flow with PKCE.

Duende IdentityServer setup

I used Duende IdentityServer to implement the standard. Any OpenID Connect server which supports the OAuth PAR standard can be used. It is very simple to support this using Duende IdentityServer. The RequirePushedAuthorization is set to true and PAR is active for this client. The rest of the client configuration is a standard OIDC confidential client using code flow with PKCE.

new Client[]
{
  new Client
  {
	ClientId = "web-par",
	ClientSecrets = 
	{ 
		new Secret("--your-secret--".Sha256()) 
	},

	RequirePushedAuthorization = true,

	AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,

	RedirectUris = 
	{ 
		"https://localhost:5007/signin-oidc" 
	},
	FrontChannelLogoutUri = 
		"https://localhost:5007/signout-oidc",
	PostLogoutRedirectUris = 
	{ 
		"https://localhost:5007/signout-callback-oidc" 
	},

	AllowOfflineAccess = true,
	AllowedScopes = { "openid", "profile" }
  }
};

ASP.NET Core OpenID Connect client

The ASP.NET Core client requests extra changes. An extra back channel PAR request is sent in the OpenID Connect events. The OIDC events needs to be changed compared to the standard core OIDC setup. I used the Duende.AccessTokenManagement.OpenIdConnect nuget package to implement this and updated the OIDC events using the ParOidcEvents class from the Duende examples. The setup uses the PAR events in the AddOpenIdConnect configuration which requires a HttpClient and the IDiscoveryCache interface from Duende.

services.AddTransient<ParOidcEvents>();

// Duende.AccessTokenManagement.OpenIdConnect nuget package
services.AddSingleton<IDiscoveryCache>(_ => 
    new DiscoveryCache(configuration["OidcDuende:Authority"]!));

services.AddHttpClient();

services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
    options.ExpireTimeSpan = TimeSpan.FromHours(8);
    options.SlidingExpiration = false;
    options.Events.OnSigningOut = async e =>
    {
        // automatically revoke refresh token at signout time
        await e.HttpContext.RevokeRefreshTokenAsync();
    };
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    options.Authority = configuration["OidcDuende:Authority"];
    options.ClientId = configuration["OidcDuende:ClientId"];
    options.ClientSecret = configuration["OidcDuende:ClientSecret"];
    options.ResponseType = "code";
    options.ResponseMode = "query";
    options.UsePkce = true;

    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("offline_access");
    options.GetClaimsFromUserInfoEndpoint = true;
    options.SaveTokens = true;
    options.MapInboundClaims = false;

    // needed to add PAR support
    options.EventsType = typeof(ParOidcEvents);

    options.TokenValidationParameters = new TokenValidationParameters
    {
        NameClaimType = "name",
        RoleClaimType = "role"
    };
});

// Duende.AccessTokenManagement.OpenIdConnect nuget package
// add automatic token management
services.AddOpenIdConnectAccessTokenManagement();

The ParOidcEvents class is used to implement the events required by the OAuth PAR standard. This can be used with any token server which supports the standard.

/// <summary>
/// original code src:
/// https://github.com/DuendeSoftware/IdentityServer
/// </summary>
public class ParOidcEvents(HttpClient httpClient, IDiscoveryCache discoveryCache, ILogger<ParOidcEvents> logger, IConfiguration configuration) : OpenIdConnectEvents
{
    private readonly HttpClient _httpClient = httpClient;
    private readonly IDiscoveryCache _discoveryCache = discoveryCache;
    private readonly ILogger<ParOidcEvents> _logger = logger;
    private readonly IConfiguration _configuration = configuration;

    public override async Task RedirectToIdentityProvider(RedirectContext context)
    {
        var clientId = context.ProtocolMessage.ClientId;

        // Construct the state parameter and add it to the protocol message
        // so that we include it in the pushed authorization request
        SetStateParameterForParRequest(context);

        // Make the actual pushed authorization request
        var parResponse = await PushAuthorizationParameters(context, clientId);

        // Now replace the parameters that would normally be sent to the
        // authorize endpoint with just the client id and PAR request uri.
        SetAuthorizeParameters(context, clientId, parResponse);

        // Mark the request as handled, because we don't want the normal
        // behavior that attaches state to the outgoing request (we already
        // did that in the PAR request). 
        context.HandleResponse();

        // Finally redirect to the authorize endpoint
        await RedirectToAuthorizeEndpoint(context, context.ProtocolMessage);
    }

    private const string HeaderValueEpocDate = "Thu, 01 Jan 1970 00:00:00 GMT";

    private async Task RedirectToAuthorizeEndpoint(RedirectContext context, OpenIdConnectMessage message)
    {
        // This code is copied from the ASP.NET handler. We want most of its
        // default behavior related to redirecting to the identity provider,
        // except we already pushed the state parameter, so that is left out
        // here. See https://github.com/dotnet/aspnetcore/blob/c85baf8db0c72ae8e68643029d514b2e737c9fae/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectHandler.cs#L364
        if (string.IsNullOrEmpty(message.IssuerAddress))
        {
            throw new InvalidOperationException(
                "Cannot redirect to the authorization endpoint, the configuration may be missing or invalid.");
        }

        if (context.Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.RedirectGet)
        {
            var redirectUri = message.CreateAuthenticationRequestUrl();
            if (!Uri.IsWellFormedUriString(redirectUri, UriKind.Absolute))
            {
                _logger.LogWarning("The redirect URI is not well-formed. The URI is: '{AuthenticationRequestUrl}'.", redirectUri);
            }

            context.Response.Redirect(redirectUri);
            return;
        }
        else if (context.Options.AuthenticationMethod == OpenIdConnectRedirectBehavior.FormPost)
        {
            var content = message.BuildFormPost();
            var buffer = Encoding.UTF8.GetBytes(content);

            context.Response.ContentLength = buffer.Length;
            context.Response.ContentType = "text/html;charset=UTF-8";

            // Emit Cache-Control=no-cache to prevent client caching.
            context.Response.Headers.CacheControl = "no-cache, no-store";
            context.Response.Headers.Pragma = "no-cache";
            context.Response.Headers.Expires = HeaderValueEpocDate;

            await context.Response.Body.WriteAsync(buffer);

            return;
        }

        throw new NotImplementedException($"An unsupported authentication method has been configured: {context.Options.AuthenticationMethod}");
    }

    private async Task<ParResponse> PushAuthorizationParameters(RedirectContext context, string clientId)
    {
        // Send our PAR request
        var requestBody = new FormUrlEncodedContent(context.ProtocolMessage.Parameters);
        
        var secret = _configuration["OidcDuende:ClientSecret"] ?? 
            throw new Exception("secret missing");

        _httpClient.SetBasicAuthentication(clientId, secret);

        var disco = await _discoveryCache.GetAsync();
        if (disco.IsError)
        {
            throw new Exception(disco.Error);
        }

        var parEndpoint = disco.TryGetValue("pushed_authorization_request_endpoint").GetString();
        var response = await _httpClient.PostAsync(parEndpoint, requestBody);
        
        if (!response.IsSuccessStatusCode)
        {
            throw new Exception("PAR failure");
        }

        return await response.Content.ReadFromJsonAsync<ParResponse>();

    }

    private static void SetAuthorizeParameters(RedirectContext context, string clientId, ParResponse parResponse)
    {
        // Remove all the parameters from the protocol message, and replace with what we got from the PAR response
        context.ProtocolMessage.Parameters.Clear();
        // Then, set client id and request uri as parameters
        context.ProtocolMessage.ClientId = clientId;
        context.ProtocolMessage.RequestUri = parResponse.RequestUri;
    }

    private static OpenIdConnectMessage SetStateParameterForParRequest(RedirectContext context)
    {
        // Construct State, we also need that (this chunk copied from the OIDC handler)
        var message = context.ProtocolMessage;
        // When redeeming a code for an AccessToken, this value is needed
        context.Properties.Items.Add(OpenIdConnectDefaults.RedirectUriForCodePropertiesKey, message.RedirectUri);
        message.State = context.Options.StateDataFormat.Protect(context.Properties);
        return message;
    }

    public override Task TokenResponseReceived(TokenResponseReceivedContext context)
    {
        return base.TokenResponseReceived(context);
    }

    private class ParResponse
    {
        [JsonPropertyName("expires_in")]
        public int ExpiresIn { get; set; }

        [JsonPropertyName("request_uri")]
        public string RequestUri { get; set; } = string.Empty;
    }
}

Notes

It is simple to use PAR and the it adds an improved authentication security with one extra request in the authentication flow. This should be used if possible. The standard can be used together with the OAuth JAR standard and even extended with the OAuth RAR.

Links

https://github.com/DuendeSoftware/IdentityServer

OAuth 2.0 Pushed Authorization Requests (PAR) RFC 9126

OAuth 2.0 Authorization Framework: JWT-Secured Authorization Request (JAR) RFC 9101

OAuth 2.0 Rich Authorization Requests (RAR) RFC 9396

4 comments

  1. Hi Damien,

    this looks very much like our sample that we published the other day. You make it look like your own original work.

    Am I missing the source attribution somewhere?

    thanks
    Dominick

    1. Hi Dominick, I have linked the src (your repo) in the readme, in the blog here and in the class which I created from the example saying I created this from your example.

      Greetings Damien

      1. Also added another explicit reference to your example at the top of the blog. I hope this is the correct attribution you require. Greetings Damien

  2. […] Improve ASP.NET Core authentication using OAuth PAR and OpenID Connect – Damien Bowden […]

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.