Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
46 changes: 46 additions & 0 deletions BFF/v4/DPoP/.vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"version": "0.2.0",
"compounds": [
{
"name": "Run All",
"configurations": ["BFF", "API"],
"presentation": {
"hidden": false,
"group": "",
"order": 1
}
}
],
"configurations": [
{
"name": "API",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build-api",
"program": "${workspaceFolder}/DPoP.Api/bin/Debug/net8.0/DPoP.Api.dll",
"args": [],
"cwd": "${workspaceFolder}/DPoP.Api",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"console": "externalTerminal",
},
{
"name": "BFF",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build-bff",
"program": "${workspaceFolder}/DPoP.Bff/bin/Debug/net8.0/DPoP.Bff.dll",
"args": [],
"cwd": "${workspaceFolder}/DPoP.Bff",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"console": "externalTerminal",
}
]
}
43 changes: 43 additions & 0 deletions BFF/v4/DPoP/.vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/DPoP.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "build-api",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}\\DPoP.Api\\DPoP.Api.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
},
{
"label": "build-bff",
"type": "process",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}\\DPoP.Bff\\DPoP.Bff.csproj",

"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"problemMatcher": "$msCompile"
}
]

}
13 changes: 13 additions & 0 deletions BFF/v4/DPoP/DPoP.Api/DPoP.Api.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>true</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="IdentityModel" version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
</ItemGroup>
</Project>
37 changes: 37 additions & 0 deletions BFF/v4/DPoP/DPoP.Api/DPoP/ConfigureJwtBearerOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;

namespace DPoP.Api;

public class ConfigureJwtBearerOptions : IPostConfigureOptions<JwtBearerOptions>
{
private readonly string _configScheme;

public ConfigureJwtBearerOptions(string configScheme)
{
_configScheme = configScheme;
}

public void PostConfigure(string name, JwtBearerOptions options)
{
if (_configScheme == name)
{
if (options.EventsType != null && !typeof(DPoPJwtBearerEvents).IsAssignableFrom(options.EventsType))
{
throw new Exception("EventsType on JwtBearerOptions must derive from DPoPJwtBearerEvents to work with the DPoP support.");
}
if (options.Events != null && !typeof(DPoPJwtBearerEvents).IsAssignableFrom(options.Events.GetType()))
{
throw new Exception("Events on JwtBearerOptions must derive from DPoPJwtBearerEvents to work with the DPoP support.");
}

if (options.Events == null && options.EventsType == null)
{
options.EventsType = typeof(DPoPJwtBearerEvents);
}
}
}
}
81 changes: 81 additions & 0 deletions BFF/v4/DPoP/DPoP.Api/DPoP/DPoPExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.Text.Json;
using IdentityModel;
using Microsoft.AspNetCore.Authentication;
using Microsoft.IdentityModel.Tokens;

namespace DPoP.Api;

/// <summary>
/// Extensions methods for DPoP
/// </summary>
static class DPoPExtensions
{
const string DPoPPrefix = OidcConstants.AuthenticationSchemes.AuthorizationHeaderDPoP + " ";

public static bool IsDPoPAuthorizationScheme(this HttpRequest request)
{
var authz = request.Headers.Authorization.FirstOrDefault();
return authz?.StartsWith(DPoPPrefix, System.StringComparison.Ordinal) == true;
}

public static bool TryGetDPoPAccessToken(this HttpRequest request, out string token)
{
token = null;

var authz = request.Headers.Authorization.FirstOrDefault();
if (authz?.StartsWith(DPoPPrefix, System.StringComparison.Ordinal) == true)
{
token = authz[DPoPPrefix.Length..].Trim();
return true;
}
return false;
}

public static string GetAuthorizationScheme(this HttpRequest request)
{
return request.Headers.Authorization.FirstOrDefault()?.Split(' ', System.StringSplitOptions.RemoveEmptyEntries)[0];
}

public static string GetDPoPProofToken(this HttpRequest request)
{
return request.Headers[OidcConstants.HttpHeaders.DPoP].FirstOrDefault();
}

public static string GetDPoPNonce(this AuthenticationProperties props)
{
if (props.Items.ContainsKey("DPoP-Nonce"))
{
return props.Items["DPoP-Nonce"] as string;
}
return null;
}
public static void SetDPoPNonce(this AuthenticationProperties props, string nonce)
{
props.Items["DPoP-Nonce"] = nonce;
}

/// <summary>
/// Create the value of a thumbprint-based cnf claim
/// </summary>
public static string CreateThumbprintCnf(this JsonWebKey jwk)
{
var jkt = jwk.CreateThumbprint();
var values = new Dictionary<string, string>
{
{ JwtClaimTypes.ConfirmationMethods.JwkThumbprint, jkt }
};
return JsonSerializer.Serialize(values);
}

/// <summary>
/// Create the value of a thumbprint
/// </summary>
public static string CreateThumbprint(this JsonWebKey jwk)
{
var jkt = Base64Url.Encode(jwk.ComputeJwkThumbprint());
return jkt;
}
}
154 changes: 154 additions & 0 deletions BFF/v4/DPoP/DPoP.Api/DPoP/DPoPJwtBearerEvents.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using System.Text;
using IdentityModel;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using static IdentityModel.OidcConstants;

namespace DPoP.Api;

public class DPoPJwtBearerEvents : JwtBearerEvents
{
private readonly IOptionsMonitor<DPoPOptions> _optionsMonitor;
private readonly DPoPProofValidator _validator;

public DPoPJwtBearerEvents(IOptionsMonitor<DPoPOptions> optionsMonitor, DPoPProofValidator validator)
{
_optionsMonitor = optionsMonitor;
_validator = validator;
}

public override Task MessageReceived(MessageReceivedContext context)
{
var dpopOptions = _optionsMonitor.Get(context.Scheme.Name);

if (context.HttpContext.Request.TryGetDPoPAccessToken(out var token))
{
context.Token = token;
}
else if (dpopOptions.Mode == DPoPMode.DPoPOnly)
{
// this rejects the attempt for this handler,
// since we don't want to attempt Bearer given the Mode
context.NoResult();
}

return Task.CompletedTask;
}

public override async Task TokenValidated(TokenValidatedContext context)
{
var dpopOptions = _optionsMonitor.Get(context.Scheme.Name);

if (context.HttpContext.Request.TryGetDPoPAccessToken(out var at))
{
var proofToken = context.HttpContext.Request.GetDPoPProofToken();
var result = await _validator.ValidateAsync(new DPoPProofValidatonContext
{
Scheme = context.Scheme.Name,
ProofToken = proofToken,
AccessToken = at,
Method = context.HttpContext.Request.Method,
Url = context.HttpContext.Request.Scheme + "://" + context.HttpContext.Request.Host + context.HttpContext.Request.PathBase + context.HttpContext.Request.Path
});

if (result.IsError)
{
// fails the result
context.Fail(result.ErrorDescription ?? result.Error);

// we need to stash these values away so they are available later when the Challenge method is called later
context.HttpContext.Items["DPoP-Error"] = result.Error;
if (!string.IsNullOrWhiteSpace(result.ErrorDescription))
{
context.HttpContext.Items["DPoP-ErrorDescription"] = result.ErrorDescription;
}
if (!string.IsNullOrWhiteSpace(result.ServerIssuedNonce))
{
context.HttpContext.Items["DPoP-Nonce"] = result.ServerIssuedNonce;
}
}
}
else if (dpopOptions.Mode == DPoPMode.DPoPAndBearer)
{
// if the scheme used was not DPoP, then it was Bearer
// and if a access token was presented with a cnf, then the
// client should have sent it as DPoP, so we fail the request
if (context.Principal.HasClaim(x => x.Type == JwtClaimTypes.Confirmation))
{
context.HttpContext.Items["Bearer-ErrorDescription"] = "Must use DPoP when using an access token with a 'cnf' claim";
context.Fail("Must use DPoP when using an access token with a 'cnf' claim");
}
}
}

public override Task Challenge(JwtBearerChallengeContext context)
{
var dpopOptions = _optionsMonitor.Get(context.Scheme.Name);

if (dpopOptions.Mode == DPoPMode.DPoPOnly)
{
// if we are using DPoP only, then we don't need/want the default
// JwtBearerHandler to add its WWW-Authenticate response header
// so we have to set the status code ourselves
context.Response.StatusCode = 401;
context.HandleResponse();
}
else if (context.HttpContext.Items.ContainsKey("Bearer-ErrorDescription"))
{
var description = context.HttpContext.Items["Bearer-ErrorDescription"] as string;
context.ErrorDescription = description;
}

if (context.HttpContext.Request.IsDPoPAuthorizationScheme())
{
// if we are challening due to dpop, then don't allow bearer www-auth to emit an error
context.Error = null;
}

// now we always want to add our WWW-Authenticate for DPoP
// For example:
// WWW-Authenticate: DPoP error="invalid_dpop_proof", error_description="Invalid 'iat' value."
var sb = new StringBuilder();
sb.Append(OidcConstants.AuthenticationSchemes.AuthorizationHeaderDPoP);

if (context.HttpContext.Items.ContainsKey("DPoP-Error"))
{
var error = context.HttpContext.Items["DPoP-Error"] as string;
sb.Append(" error=\"");
sb.Append(error);
sb.Append('\"');

if (context.HttpContext.Items.ContainsKey("DPoP-ErrorDescription"))
{
var description = context.HttpContext.Items["DPoP-ErrorDescription"] as string;

sb.Append(", error_description=\"");
sb.Append(description);
sb.Append('\"');
}
}

context.Response.Headers.Append(HeaderNames.WWWAuthenticate, sb.ToString());


if (context.HttpContext.Items.ContainsKey("DPoP-Nonce"))
{
var nonce = context.HttpContext.Items["DPoP-Nonce"] as string;
context.Response.Headers[HttpHeaders.DPoPNonce] = nonce;
}
else
{
var nonce = context.Properties.GetDPoPNonce();
if (nonce != null)
{
context.Response.Headers[HttpHeaders.DPoPNonce] = nonce;
}
}

return Task.CompletedTask;
}
}
16 changes: 16 additions & 0 deletions BFF/v4/DPoP/DPoP.Api/DPoP/DPoPMode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

namespace DPoP.Api;

public enum DPoPMode
{
/// <summary>
/// Only DPoP tokens will be accepted
/// </summary>
DPoPOnly,
/// <summary>
/// Both DPoP and Bearer tokens will be accepted
/// </summary>
DPoPAndBearer
}
Loading
Loading