Securing an ASP.NET Core Razor Page App using OpenID Connect Code flow with PKCE

This article shows how to secure an ASP.NET Core Razor Page application using the Open ID Connect code flow with PKCE (Proof Key for Code Exchange). The secure token server is implemented using Duende IdentityServer but any secure token server (STS) can be used which supports PKCE.

Code: https://github.com/damienbod/AspNetCoreHybridFlowWithApi

See WebCodeFlowPkceClient project.

History

  • 2024-08-14 Updated packages, .NET 8
  • 2020-12-11 Updated to .NET 5

An ASP.NET Core .NET 8 Razor Page application without identity was created using the Visual Studio templates. The Microsoft.AspNetCore.Authentication.OpenIdConnect Nuget package was then added to the project.

  • Microsoft.AspNetCore.Authentication.OpenIdConnect

In the start up, the configure services method is used to add the authentication and the authorization. Cookies are used to persist the session, if authorized, and OpenID Connect is used to signin, signout. If a new session is started, the application redirects to Duende IdentityServer and secures both the identity and the application using the OpenID Connect code flow with PKCE (Proof key for code exchange). Both the PKCE and the secret are required.

services.AddAuthentication(options =>
{
	options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
	options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie()
.AddOpenIdConnect(options =>
{
	options.SignInScheme = "Cookies";
	options.Authority = "https://localhost:44352";
	options.RequireHttpsMetadata = true;
	options.ClientId = "codeflowpkceclient";
	options.ClientSecret = "codeflow_pkce_client_secret";
	options.ResponseType = "code";
	options.UsePkce = true;
	options.Scope.Add("profile");
	options.Scope.Add("offline_access");
	options.SaveTokens = true;
	options.GetClaimsFromUserInfoEndpoint = true;
	options.ClaimActions.MapUniqueJsonKey("preferred_username", "preferred_username");
	options.ClaimActions.MapUniqueJsonKey("gender", "gender");
	options.MapInboundClaims = false;
	//options.TokenValidationParameters = new TokenValidationParameters
	//{
	//    NameClaimType = "email",
	//    //RoleClaimType = "Role",   
	//};
});

NOTE: if using a different IDP like Keycloak, this is this only code which needs to be changed.

The configure middleware method adds the middleware so that the authorization is used. Both the UseAuthentication() and UseAuthorization() methods are added, and must be added after the AddRouting() method.

//IdentityModelEventSource.ShowPII = true;
JsonWebTokenHandler.DefaultInboundClaimTypeMap.Clear();

app.UseSerilogRequestLogging();

if (_env!.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
}
else
{
    app.UseExceptionHandler("/Home/Error");
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();

Duende IdentityServer is configured to accept the client configuration from above. Both the PKCE and the secret are required. The configuration must match the client configuration exactly. In a production application, the secrets must be removed from the code and read from a safe configuration like for example Azure Key Vault. The URLs would also be read from app.settings or something like this.

new Client
{
    ClientName = "codeflowpkceclient",
    ClientId = "codeflowpkceclient",
    ClientSecrets = {new Secret("codeflow_pkce_client_secret".Sha256()) },
    AllowedGrantTypes = GrantTypes.Code,
    RequirePkce = true,
    RequireClientSecret = true,
    AllowOfflineAccess = true,
    AlwaysSendClientClaims = true,
    UpdateAccessTokenClaimsOnRefresh = true,
    //AlwaysIncludeUserClaimsInIdToken = true,
    RedirectUris = {
        $"{webCodeFlowPkceClientUrl}/signin-oidc",
        "https://localhost:44345/signin-oidc",
        "https://localhost:44355/signin-oidc",
        "https://localhost:5001/signin-oidc"
    },
    PostLogoutRedirectUris = {
        $"{webCodeFlowPkceClientUrl}/signout-callback-oidc",
        "https://localhost:44345/signout-callback-oidc",
        "https://localhost:44355/signout-callback-oidc",
        "https://localhost:5001/signout-callback-oidc",
    },
    AllowedScopes = new List<string>
    {
        IdentityServerConstants.StandardScopes.OpenId,
        IdentityServerConstants.StandardScopes.Profile,
        IdentityServerConstants.StandardScopes.OfflineAccess,
        "role"
    }
},

The Authorize attribute needs to be added to all pages which are to be secured. You could also require that the whole application is to be secure and opt out for the non-secure pages. If the page is called in a browser, the application will automatically redirect the user, application to authenticate.

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Security.Claims;

namespace WebCodeFlowPkceClient.Pages;

[Authorize]
public class IndexModel : PageModel
{
    private readonly ILogger<IndexModel> _logger;

    public IndexModel(ILogger<IndexModel> logger)
    {
        _logger = logger;
    }

    [BindProperty]
    public IEnumerable<Claim> Claims { get; set; } = Enumerable.Empty<Claim>();

    public void OnGet()
    {
        // var claims = User.Claims.ToList();
        Claims = User.Claims;
    }
}

The application also needs a signout. This is implemented using two new pages, a logout page, and a SignedOut page. If the user clicks the logout link, the application removes the session and redirects to a public page of the application.

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace WebCodeFlowPkceClient.Pages;

[Authorize]
public class LogoutModel : PageModel
{
    public async Task<IActionResult> OnGetAsync()
    {
        await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);

        return Redirect("/SignedOut");
    }
}

Now the Razor page application, identity can signin, signout using OpenID Connect Code Flow with PKCE and also uses a secret to authorize the client.

Links:

https://openid.net/specs/openid-connect-core-1_0.html

https://docs.microsoft.com/en-us/aspnet/core/security/?view=aspnetcore-3.0

https://tools.ietf.org/html/rfc7636

https://docs.microsoft.com/en-us/aspnet/core/razor-pages

8 comments

  1. Thomas Levesque's avatar

    You don’t need a client secret when using PKCE. In fact, that’s the whole point of PKCE and why it’s recommended for public clients that can’t safely store the client secret. But a server web app is a confidential client, so the client secret is safe anyway, and you don’t need PKCE (although it can’t hurt to use it, of course).

    1. damienbod's avatar

      Hi Thomas yes you do need a secret if you want to authenticate the client, this way, only a client hosted by me can be used as the client. This is not a public client.

      Greetings Damien

      1. damienbod's avatar

        And the PKCE is required due to code substitution attacks or you could use the Hybrid flow which also protects against this. Learnt this on twitter…

        Cut and pasted code attack in OAuth 2.0 [RFC6749]

      2. Thomas Levesque's avatar

        Ah, I see. That makes sense, thanks for the explanation!

      3. damienbod's avatar

        Thanks for the feedback

  2. […] Securing an ASP.NET Core Razor Page App using OpenID Connect Code flow with PKCE – Damien Bowden […]

  3. […] Securing an ASP.NET Core Razor Page App using OpenID Connect Code flow with PKCE (Damien Bowden) […]

  4. Deivydas's avatar
    Deivydas · · Reply

    Hi, is IdentityServer4 and its configuration mandatory here?

Leave a comment

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