Skip to content

#56 Fixes backchannel and JwtTokens with System.Text.Json#57

Open
enkelmedia wants to merge 1 commit into
alexhiggins732:masterfrom
Obviuse:56-backchannel-issue-system-text-json
Open

#56 Fixes backchannel and JwtTokens with System.Text.Json#57
enkelmedia wants to merge 1 commit into
alexhiggins732:masterfrom
Obviuse:56-backchannel-issue-system-text-json

Conversation

@enkelmedia

Copy link
Copy Markdown

This PR fixes the issue with JSON formatting in the BackChannel logout payload described here: #56

IdentityServer has historically recommended that implement BachChannel logout parses the events property like this:

var events = JObject.Parse(eventsJson);
var logoutEvent = events.TryGetValue("http://schemas.openid.net/event/backchannel-logout");
if (logoutEvent == null)
{
    // 2.6 Loout Token Validation
    throw new Exception("Invalid logout token");
}

This would require a valid JSON payload for the logout token, like so

{
  "nbf": 1753779716,
  "exp": 1753780016,
  "iss": "https://server",
  "sub": "bob",
  "aud": "client3",
  "iat": 1753779716,
  "jti": "EF49D8E05DBB899C61A97FF99813D80A",
  "sid": "D54002B8CE423E793F09BD5188D68E83",
   "events": {
      "http://schemas.openid.net/event/backchannel-logout": {}
    }
}

However, at some point when Microsoft switched from using Newtonsoft.Json to System.Text.Json the seriliazation started to fail, producing JSON payloads like this:

{
  "nbf": 1753779716,
  "exp": 1753780016,
  "iss": "https://server",
  "sub": "bob",
  "aud": "client3",
  "iat": 1753779716,
  "jti": "EF49D8E05DBB899C61A97FF99813D80A",
  "sid": "D54002B8CE423E793F09BD5188D68E83",
  "events": [
    [
      []
    ]
  ]
}

This payload is invalid, and the value(s) for the event property get discarded.

This PR fixes this by:
Updating TokenExtensions.CreateJwtPayload to use types from System.Text.Json. This way the JwtSecurityTokenHandler in DefaultTokenCreationService can serialize to a valid JSON payload.

I've also:

  • Updated failing unit tests in ClientCredentialsClient.cs that was validating a invalid payload (false positive). Details: cnf property should be a object not an array according to the spec.
  • Add more unit test to test Jwt Payload Creation

@enkelmedia

Copy link
Copy Markdown
Author

If someone wants to run a patched version until this has been included in a release, here is how.

PathedTokenExtensions.cs:

using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using IdentityModel;
using IdentityServer8;
using IdentityServer8.Configuration;
using IdentityServer8.Extensions;
using IdentityServer8.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;

namespace Lorem;

public static class PathedTokenExtensions
{
    /// <summary>
    /// Creates the default JWT payload.
    /// </summary>
    /// <param name="token">The token.</param>
    /// <param name="clock">The clock.</param>
    /// <param name="options">The options</param>
    /// <param name="logger">The logger.</param>
    /// <returns></returns>
    /// <exception cref="Exception">
    /// </exception>
    public static JwtPayload PatchedCreateJwtPayload(this Token token, ISystemClock clock, IdentityServerOptions options, ILogger logger)
    {
        var payload = new JwtPayload(
            token.Issuer,
            null,
            null,
            clock.UtcNow.UtcDateTime,
            clock.UtcNow.UtcDateTime.AddSeconds(token.Lifetime));

        foreach (var aud in token.Audiences)
        {
            payload.AddClaim(new Claim(JwtClaimTypes.Audience, aud));
        }

        var amrClaims = token.Claims.Where(x => x.Type == JwtClaimTypes.AuthenticationMethod).ToArray();
        var scopeClaims = token.Claims.Where(x => x.Type == JwtClaimTypes.Scope).ToArray();
        var jsonClaims = token.Claims.Where(x => x.ValueType == IdentityServerConstants.ClaimValueTypes.Json).ToList();

        // add confirmation claim if present (it's JSON valued)
        if (!string.IsNullOrEmpty(token.Confirmation))
        {
            jsonClaims.Add(new Claim(JwtClaimTypes.Confirmation, token.Confirmation, IdentityServerConstants.ClaimValueTypes.Json));
        }

        var normalClaims = token.Claims
            .Except(amrClaims)
            .Except(jsonClaims)
            .Except(scopeClaims);

        payload.AddClaims(normalClaims);

        // scope claims
        if (!scopeClaims.EnumerableIsNullOrEmpty())
        {
            var scopeValues = scopeClaims.Select(x => x.Value).ToArray();

            if (options.EmitScopesAsSpaceDelimitedStringInJwt)
            {
                payload.Add(JwtClaimTypes.Scope, string.Join(" ", scopeValues));
            }
            else
            {
                payload.Add(JwtClaimTypes.Scope, scopeValues);
            }
        }

        // amr claims
        if (!amrClaims.EnumerableIsNullOrEmpty())
        {
            var amrValues = amrClaims.Select(x => x.Value).Distinct().ToArray();
            payload.Add(JwtClaimTypes.AuthenticationMethod, amrValues);
        }

        // deal with json types
        // calling ToArray() to trigger JSON parsing once and so later 
        // collection identity comparisons work for the anonymous type
        try
        {
            var jsonTokens = jsonClaims.Select(x => new { x.Type, JsonValue = JsonDocument.Parse(x.Value).RootElement.Clone() }).ToArray();

            var jsonObjects = jsonTokens.Where(x => x.JsonValue.ValueKind == JsonValueKind.Object).ToArray();
            var jsonObjectGroups = jsonObjects.GroupBy(x => x.Type).ToArray();

            foreach (var group in jsonObjectGroups)
            {
                if (payload.ContainsKey(group.Key))
                {
                    throw new Exception($"Can't add two claims where one is a JSON object and the other is not a JSON object ({group.Key})");
                }

                if (group.Skip(1).Any())
                {
                    // add as array
                    payload.Add(group.Key, group.Select(x => x.JsonValue).ToArray());
                }
                else
                {
                    // add just one
                    payload.Add(group.Key, group.First().JsonValue);
                }
            }

            var jsonArrays = jsonTokens.Where(x => x.JsonValue.ValueKind == JsonValueKind.Array).ToArray();
            var jsonArrayGroups = jsonArrays.GroupBy(x => x.Type).ToArray();
            foreach (var group in jsonArrayGroups)
            {
                if (payload.ContainsKey(group.Key))
                {
                    throw new Exception(
                        $"Can't add two claims where one is a JSON array and the other is not a JSON array ({group.Key})");
                }

                var newArr = new List<JsonElement>();
                foreach (var arrays in group)
                {
                    newArr.AddRange(arrays.JsonValue.EnumerateArray());
                }

                // add just one array for the group/key/claim type
                payload.Add(group.Key, newArr.ToArray());
            }

            var unsupportedJsonTokens = jsonTokens.Except(jsonObjects).Except(jsonArrays).ToArray();
            var unsupportedJsonClaimTypes = unsupportedJsonTokens.Select(x => x.Type).Distinct().ToArray();
            if (unsupportedJsonClaimTypes.Any())
            {
                throw new Exception(
                    $"Unsupported JSON type for claim types: {unsupportedJsonClaimTypes.Aggregate((x, y) => x + ", " + y)}");
            }

            return payload;
        }
        catch (Exception ex)
        {
            logger.LogCritical(ex, "Error creating a JSON valued claim");
            throw;
        }
    }
}

PatchedDefaultTokenCreationService.cs:

using System.IdentityModel.Tokens.Jwt;
using System.Threading.Tasks;
using IdentityServer8.Configuration;
using IdentityServer8.Models;
using IdentityServer8.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;

namespace Lorem;

public class PatchedDefaultTokenCreationService : DefaultTokenCreationService
{
    public PatchedDefaultTokenCreationService(
        ISystemClock clock,
        IKeyMaterialService keys,
        IdentityServerOptions options,
        ILogger<DefaultTokenCreationService> logger)
        : base(clock, keys, options, logger)
    {

    }

    protected override Task<JwtPayload> CreatePayloadAsync(Token token)
    {
        var payload = token.PatchedCreateJwtPayload(Clock, Options, Logger);
        return Task.FromResult(payload);
    }
}

During registration make sure to put this line before .AddIdentityServer():

// This adds a patched ITokenCreatingService to get around this issue:
// https://github.com/alexhiggins732/IdentityServer8/issues/56
services.AddTransient<ITokenCreationService, PatchedDefaultTokenCreationService>();

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants