Skip to content

Commit

Permalink
Merge pull request #1620 from DuendeSoftware/releases/7.0.x
Browse files Browse the repository at this point in the history
Merge 7.0.8 forward
  • Loading branch information
josephdecock authored Oct 31, 2024
2 parents 4fa28b8 + f28cac9 commit 0850cda
Show file tree
Hide file tree
Showing 5 changed files with 311 additions and 17 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
Url = Context.Request.Scheme + "://" + Context.Request.Host + Context.Request.PathBase + Context.Request.Path,
ValidateAccessToken = true,
AccessToken = token,
AccessTokenClaims = tokenResult.Claims,
ExpirationValidationMode = client.DPoPValidationMode,
ClientClockSkew = client.DPoPClockSkew,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

using Duende.IdentityServer.Models;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;

namespace Duende.IdentityServer.Validation;

Expand Down Expand Up @@ -47,7 +50,14 @@ public class DPoPProofValidatonContext
public bool ValidateAccessToken { get; set; }

/// <summary>
/// The access token to validate if ValidateAccessToken is set
/// The access token to validate if <see cref="ValidateAccessToken"/> is set
/// </summary>
public string? AccessToken { get; set; }

/// <summary>
/// The claims associated with the access token, used if <see cref="ValidateAccessToken"/> is set.
/// This is included separately from the <see cref="AccessToken"/> because getting the claims
/// might be an expensive operation (especially if the token is a reference token).
/// </summary>
public IEnumerable<Claim> AccessTokenClaims { get; set; } = Enumerable.Empty<Claim>();
}
57 changes: 54 additions & 3 deletions src/IdentityServer/Validation/Default/DefaultDPoPProofValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
using IdentityModel;
using System.Linq;
using Duende.IdentityServer.Services;
using static Duende.IdentityServer.IdentityServerConstants;
using Duende.IdentityServer.Models;
using Microsoft.AspNetCore.DataProtection;
using System.Security.Cryptography;
Expand Down Expand Up @@ -126,10 +125,10 @@ public async Task<DPoPProofValidatonResult> ValidateAsync(DPoPProofValidatonCont
protected virtual Task ValidateHeaderAsync(DPoPProofValidatonContext context, DPoPProofValidatonResult result)
{
JsonWebToken token;
var handler = new JsonWebTokenHandler();

try
{
var handler = new JsonWebTokenHandler();
token = handler.ReadJsonWebToken(context.ProofToken);
}
catch (Exception ex)
Expand Down Expand Up @@ -185,8 +184,60 @@ protected virtual Task ValidateHeaderAsync(DPoPProofValidatonContext context, DP

result.JsonWebKey = jwkJson;
result.JsonWebKeyThumbprint = jwk.CreateThumbprint();
result.Confirmation = jwk.CreateThumbprintCnf();

if (context.ValidateAccessToken)
{
var cnf = context.AccessTokenClaims.FirstOrDefault(c => c.Type == JwtClaimTypes.Confirmation);
if (cnf is not { Value.Length: > 0 })
{
result.IsError = true;
result.ErrorDescription = "Missing 'cnf' value.";
return Task.CompletedTask;
}
try
{
var cnfJson = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(cnf.Value);
if (cnfJson == null)
{
Logger.LogDebug("Null cnf value in DPoP access token.");
result.IsError = true;
result.ErrorDescription = "Missing 'cnf' value.";
return Task.CompletedTask;
}
else if (cnfJson.TryGetValue(JwtClaimTypes.ConfirmationMethods.JwkThumbprint, out var jktJson))
{
var accessTokenJkt = jktJson.ToString();
if (accessTokenJkt == result.JsonWebKeyThumbprint)
{
result.Confirmation = cnf.Value;
}
else
{
Logger.LogDebug("jkt in DPoP access token does not match proof token key thumbprint.");
}
}
else
{
Logger.LogDebug("jkt member missing from cnf claim in DPoP access token.");
}
}
catch (JsonException e)
{
Logger.LogDebug("Failed to parse DPoP cnf claim: {JsonExceptionMessage}", e.Message);
}

if (result.Confirmation == null)
{
result.IsError = true;
result.ErrorDescription = "Invalid 'cnf' value.";
}
}
else
{
// The ValidateAccessToken == false case occurs when we are generating tokens. The confirmation value here
// ultimately is put into the generated token's cnf claim.
result.Confirmation = jwk.CreateThumbprintCnf();
}
return Task.CompletedTask;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Claims;
Expand Down Expand Up @@ -41,11 +43,16 @@ public class LocalApiAuthenticationTests
public ClaimsPrincipal ApiPrincipal { get; set; }

static LocalApiAuthenticationTests()
{
_jwk = GenerateJwk();
}

private static string GenerateJwk()
{
var rsaKey = new RsaSecurityKey(RSA.Create(2048));
var jsonWebKey = JsonWebKeyConverter.ConvertFromRSASecurityKey(rsaKey);
jsonWebKey.Alg = "PS256";
_jwk = JsonSerializer.Serialize(jsonWebKey);
return JsonSerializer.Serialize(jsonWebKey);
}

public LocalApiAuthenticationTests()
Expand All @@ -57,6 +64,14 @@ public LocalApiAuthenticationTests()
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedScopes = new List<string> { "api1", "api2" },
},
new Client
{
ClientId = "introspection",
AllowedGrantTypes = GrantTypes.ClientCredentials,
ClientSecrets = { new Secret("secret".Sha256()) },
AllowedScopes = new List<string> { "api1", "api2" },
AccessTokenType = AccessTokenType.Reference
}
});

Expand Down Expand Up @@ -128,12 +143,12 @@ void Init(LocalApiTokenMode mode = LocalApiTokenMode.DPoPAndBearer)
_pipeline.Initialize();
}

async Task<string> GetAccessTokenAsync(bool dpop = false)
async Task<string> GetAccessTokenAsync(bool dpop = false, bool reference = false)
{
var req = new ClientCredentialsTokenRequest
{
Address = "https://server/connect/token",
ClientId = "client",
ClientId = reference ? "introspection" : "client",
ClientSecret = "secret",
Scope = "api1",
};
Expand All @@ -151,9 +166,9 @@ async Task<string> GetAccessTokenAsync(bool dpop = false)

return result.AccessToken;
}
string CreateProofToken(string method, string url, string accessToken = null, string nonce = null)
string CreateProofToken(string method, string url, string accessToken = null, string nonce = null, string jwkString = null)
{
var jsonWebKey = new Microsoft.IdentityModel.Tokens.JsonWebKey(_jwk);
var jsonWebKey = new Microsoft.IdentityModel.Tokens.JsonWebKey(jwkString ?? _jwk);

// jwk: representing the public key chosen by the client, in JSON Web Key (JWK) [RFC7517] format,
// as defined in Section 4.1.3 of [RFC7515]. MUST NOT contain a private key.
Expand Down Expand Up @@ -267,6 +282,81 @@ public async Task dpop_token_should_validate()
ApiPrincipal.Identity.IsAuthenticated.Should().BeTrue();
}

[Fact]
[Trait("Category", Category)]
public async Task dpop_token_should_not_validate_if_cnf_from_jwt_access_token_does_not_match_proof_token()
{
var req = new HttpRequestMessage(HttpMethod.Get, "https://server/api");
var at = await GetAccessTokenAsync(true);
req.Headers.Authorization = new AuthenticationHeaderValue("DPoP", at);

// Use a new key to make the proof token that we present when we make the API request.
// This doesn't prove that we have possession of the key that the access token is bound to,
// so it should fail.
var newKey = GenerateJwk();
var newJwk = new Microsoft.IdentityModel.Tokens.JsonWebKey(newKey);
var newJkt = Base64Url.Encode(newJwk.ComputeJwkThumbprint());
var proofToken = CreateProofToken("GET", "https://server/api", at, jwkString: newKey);
req.Headers.Add("DPoP", proofToken);

// Double check that the thumbprint in the access token's cnf claim doesn't match
// the thumbprint of the new key we just used.
var handler = new JwtSecurityTokenHandler();
var parsedAt = handler.ReadJwtToken(at);
var parsedProof = handler.ReadJwtToken(proofToken);
var cnf = parsedAt.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Confirmation);
var json = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(cnf.Value);
if (json.TryGetValue(JwtClaimTypes.ConfirmationMethods.JwkThumbprint, out var jktJson))
{
var accessTokenJkt = jktJson.ToString();
accessTokenJkt.Should().NotBeEquivalentTo(newJkt);
}

var response = await _pipeline.BackChannelClient.SendAsync(req);

response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
[Trait("Category", Category)]
public async Task dpop_token_should_not_validate_if_cnf_from_introspection_does_not_match_proof_token()
{
var req = new HttpRequestMessage(HttpMethod.Get, "https://server/api");
var at = await GetAccessTokenAsync(dpop: true, reference: true);
req.Headers.Authorization = new AuthenticationHeaderValue("DPoP", at);

// Use a new key to make the proof token that we present when we make the API request.
// This doesn't prove that we have possession of the key that the access token is bound to,
// so it should fail.
var newKey = GenerateJwk();
var newJwk = new Microsoft.IdentityModel.Tokens.JsonWebKey(newKey);
var newJkt = Base64Url.Encode(newJwk.ComputeJwkThumbprint());
var proofToken = CreateProofToken("GET", "https://server/api", at, jwkString: newKey);
req.Headers.Add("DPoP", proofToken);

var introspectionRequest = new TokenIntrospectionRequest
{
Address = "https://server/connect/introspect",
ClientId = "introspection",
ClientSecret = "secret",
Token = at
};
var introspectionResponse = await _pipeline.BackChannelClient.IntrospectTokenAsync(introspectionRequest);
introspectionResponse.IsError.Should().BeFalse();

var cnf = introspectionResponse.Claims.FirstOrDefault(c => c.Type == JwtClaimTypes.Confirmation);
var json = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(cnf.Value);
if (json.TryGetValue(JwtClaimTypes.ConfirmationMethods.JwkThumbprint, out var jktJson))
{
var accessTokenJkt = jktJson.ToString();
accessTokenJkt.Should().NotBeEquivalentTo(newJkt);
}

var response = await _pipeline.BackChannelClient.SendAsync(req);

response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
[Trait("Category", Category)]
public async Task dpop_nonce_required_should_require_nonce()
Expand Down
Loading

0 comments on commit 0850cda

Please sign in to comment.