Securing APIs using ASP.NET Core and OAuth 2.0 DPoP

This article shows how an ASP.NET Core application can access an ASP.NET Core API using OAuth Demonstrating Proof-of-Possession (DPoP). This is a really powerful security enhancement which is relatively easy to support. The access tokens should only be used for what the access tokens are intended for. OAuth DPoP helps force this. This solution was created using Duende IdentityServer and the Duende samples.

The OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer (DPoP) is still a draft standard.

Code: https://github.com/damienbod/DPOP-aspnetcore-idp

Solution setup

Three applications are used in this solution. One application is the OpenID Connect and OAuth server with identity management. This application issues the tokens. The application is implemented using Duende IdentityServer and ASP.NET Core identity. A second application is used as the web client. This application is an ASP.NET Core Razor page application. The application is an OIDC confidential client with PKCE and requests DPoP access tokens. When the Web client sends API requests, it uses the DPoP access token and needs to create the proof token with the required specification claims and valid values for these claims. The third application is the API accepting valid access tokens. The API is implemented using ASP.NET Core with swagger and accepts access tokens using OAuth DPoP validation. The DPoP proof token is required as well as the correct cnf claim value in the access token and all the standard access token validation bits.

How DPoP works

The best way to understand how DPoP works is to read the standard.

https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop

Duende IdentityServer client config

Duende IdentityServer provides very neat APIs for using OIDC and integrating DPoP into your clients. The web client and the API scope requires no extra settings, just the default OIDC confidential code flow with PKCE for the web client and the scope used for the access token. This is very developer friendly.

public static IEnumerable<ApiScope> ApiScopes =>
new ApiScope[]
{
	new ApiScope("scope-dpop")
};

public static IEnumerable<Client> Clients =>
new Client[]
{
	new Client
	{
		ClientId = "web-dpop",
		ClientSecrets = { new Secret("--secret--".Sha256()) },

		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", "scope-dpop" }
	}
};

Web App client

The Web OIDC confidential client creates a key for DPoP and this is sent to the identity server in the form of a proof token created using the key to request an access token, or refresh tokens. The access token is returned with a cnf claim (binding using the dpop_jkt request parameter; Authorization Code Binding to DPoP Key ) which is used in the API DPoP validation. I used an ecdsa 384 certificate to create the key for the DPoP token proofs. The certificate is generated using .NET Core and persisted in pem files. You can also generate this using Azure Key Vault or any other tool. If using this with a public client like a mobile native app, a different key can be generated for each client. For example, on a mobile device, this could be created when installing the app client for the first time and persisted just for this client.

services.AddAuthentication(options =>
{
	options.DefaultScheme = "cookie";
	options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie", options =>
{
	options.ExpireTimeSpan = TimeSpan.FromHours(8);
	options.SlidingExpiration = false;
	options.Events.OnSigningOut = async e =>
	{
		await e.HttpContext.RevokeRefreshTokenAsync();
	};
})
.AddOpenIdConnect("oidc", options =>
{
	options.Authority = "https://localhost:5001";
	options.ClientId = "web-dpop";
	options.ClientSecret = "--secret--";
	options.ResponseType = "code";
	options.ResponseMode = "query";
	options.UsePkce = true;

	options.Scope.Clear();
	options.Scope.Add("openid");
	options.Scope.Add("profile");
	options.Scope.Add("scope-dpop");
	options.Scope.Add("offline_access");
	options.GetClaimsFromUserInfoEndpoint = true;
	options.SaveTokens = true;

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

var privatePem = File.ReadAllText(Path.Combine(_env.ContentRootPath,
	"ecdsa384-private.pem"));
var publicPem = File.ReadAllText(Path.Combine(_env.ContentRootPath,
	"ecdsa384-public.pem"));
var ecdsaCertificate = X509Certificate2.CreateFromPem(publicPem, privatePem);
var ecdsaCertificateKey = new ECDsaSecurityKey(ecdsaCertificate.GetECDsaPrivateKey());

services.AddOpenIdConnectAccessTokenManagement(options =>
{
	var jwk = JsonWebKeyConverter.ConvertFromSecurityKey(ecdsaCertificateKey);
	jwk.Alg = "ES384";
	options.DPoPJsonWebKey = JsonSerializer.Serialize(jwk);
});

services.AddUserAccessTokenHttpClient(
	"dpop-api-client", configureClient: client =>
{
	client.BaseAddress = new Uri("https://localhost:5005");
});

services.AddRazorPages();

API implementation

Implementing the API requires a bit more logic. The Duende samples provide a code implementation of the specification. The access token is validated and the ConfigureDPoPTokensForScheme adds all the required OAuth DPoP validation requirements. The Duende sample is included in the links.

services.AddAuthentication("dpoptokenscheme")
	.AddJwtBearer("dpoptokenscheme", options =>
	{
		options.Authority = stsServer;
		options.TokenValidationParameters
			.ValidateAudience = false;
		options.MapInboundClaims = false;

		options.TokenValidationParameters
			.ValidTypes = new[] { "at+jwt" };
	});

services.ConfigureDPoPTokensForScheme("dpoptokenscheme");

builder.Services.AddAuthorization(options =>
	options.AddPolicy("protectedScope", policy =>
	{
		policy.RequireClaim("scope", "scope-dpop");
	})
);

Testing

When all three applications are started, the UI application can authenticate and gets an access token back with a cnf claim. This is sent to the API along with a DPoP proof token and the API can validate the access token using OAuth DPoP as per specification.

The web client redirects to the IDP to authentication. The dpop_jkt parameter in the redirect URL or body if using different standards contains the value which is used for the cnf claim. This is the client’s proof-of-possession key. The is used in combination with the PKCE.

https://localhost:5001/connect/authorize?
client_id=web-dpop
&redirect_uri=https%3A%2F%2Flocalhost%3A5007%2Fsignin-oidc
&response_type=code
&scope=openid%20profile%20scope-dpop%20offline_access
&code_challenge=fDrpFF8OTmalCNi6KeM-L3CX-Pa8Hozsnw6vf-Q9_Tk
&code_challenge_method=S256
&nonce=...
&dpop_jkt=loM5ro2mqcEyBS46Z8CN1bP_wlYn2XPaGmTXQUzQc94
&state=...

The HTTP header Authorization contains the DPoP value with the access token:

DPoP access-token-with-cnf-claim

The access token can be decoded:

{
  "iss": "https://localhost:5001",
  "nbf": 1691659611,
  "iat": 1691659611,
  "exp": 1691663211,
  "aud": "https://localhost:5001/resources",
  "cnf": {
    "jkt": "loM5ro2mqcEyBS46Z8CN1bP_wlYn2XPaGmTXQUzQc94"
  },
  "scope": [
    "openid",
    "profile",
    "scope-dpop",
    "offline_access"
  ],
  "amr": [
    "pwd"
  ],
  "client_id": "web-dpop",
  "sub": "1cdddaaf-c671-4726-841b-d9b4abdede3d",
  "auth_time": 1691659610,
  "idp": "local",
  "sid": "7602A8B025FF27CFE5ED34C62DC10B8E",
  "jti": "5EC3ECF725B4BA3F608323C380FCD6F6"
}

The HTTP header DPoP contains the proof token used for the DPoP validation and the access token request.

proof token:

{
  "alg": "ES384",
  "typ": "dpop+jwt",
  "jwk": {
    "kty": "EC",
    "x": "c_Ua8nenm8XjoXvxcFvonuNeJgYg3YBAvhY2zuBI5IYl1mOhMFHWtacGoLfzA11W",
    "y": "zXJSqLxYgyqq3jPdBeuqgcvuW9d4JwVL_fgsqwT8wvr05uihuU5FsX3DY-LtGF7E",
    "crv": "P-384"
  }
}
{
  "jti": "xaWoIEMRqtRbrta13AVceLUVJxW1zqbl2RcQhmuG0nA",
  "htm": "GET",
  "htu": "https://localhost:5005/api/values",
  "iat": 1691659934,
  "ath": "5p3pmE3nvOURS-mZoEsPZkWb49vFNp7cuFrXs1mITxM"
}

The different claims are used for validation as per specification. Checking DPoP proof tokens:

https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop#section-4.3

When the three applications are started, everything works as expected. Using DPoP increases the level of security for this type of flow.

Links

https://datatracker.ietf.org/doc/html/draft-ietf-oauth-dpop

https://github.com/DuendeSoftware

https://github.com/DuendeSoftware/Samples/tree/main/IdentityServer/v6/DPoP

https://github.com/DuendeSoftware/IdentityServer.Templates

https://docs.duendesoftware.com/identityserver/v6/tokens/pop/dpop/

https://developer.okta.com/docs/guides/dpop/main/#build-the-request

https://darutk.medium.com/illustrated-dpop-oauth-access-token-security-enhancement-801680d761ff

https://learn.microsoft.com/en-us/entra/msal/dotnet/advanced/proof-of-possession-tokens

One comment

  1. […] Securing APIs using ASP.NET Core and OAuth 2.0 DPoP – Damien Bowden […]

Leave a comment

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