Search code examples
asp.net-coreauthorizationopeniddicthotchocolateclientcredential

Migrating to Openiddict 4.0.0 breaks HotChocolate GraphQL authorization for client credentials flow if token encryption is enabled


I've an ASP.NET Core project which hosts both an identity server using Openiddict and a resource server using HotChocolate GraphQL package.

Client-credentials flow is enabled in the system. The token is encrypted using RSA algorithm.

Till now, I had Openiddict v3.1.1 and everything used to work flawlessly. Recently, I've migrated to Openiddict v4.0.0. Following this, the authorization has stopped working. If I disable token encyption then authorization works as expected. On enabling token encyption, I saw in debugging, that claims are not being passed at all. I cannot switch off token encyption, as it is a business and security requirement. The Openiddict migration guidelines doesn't mention anything about any change related to encryption keys. I need help to make this work as Openiddict v3.1.1 is no longer supported for bug fixes.

The OpenIddict setup in ASP.NET Core pipeline:

public static void AddOpenIddict(this IServiceCollection services, IConfiguration configuration)
{
        var openIddictOptions = configuration.GetSection("OpenIddict").Get<OpenIddictOptions>();

        var encryptionKeyData = openIddictOptions.EncryptionKey.RSA;
        var signingKeyData = openIddictOptions.SigningKey.RSA;

        var encryptionKey = RSA.Create();
        var signingKey = RSA.Create();

        encryptionKey.ImportFromEncryptedPem(encryptionKeyData.ToCharArray(),
            openIddictOptions.EncryptionKey.Passphrase.ToCharArray());
        signingKey.ImportFromEncryptedPem(signingKeyData.ToCharArray(),
            openIddictOptions.SigningKey.Passphrase.ToCharArray());

        encryptionKey.ImportFromEncryptedPem(encryptionKeyData.ToCharArray(),
            openIddictOptions.EncryptionKey.Passphrase.ToCharArray());
        signingKey.ImportFromEncryptedPem(signingKeyData.ToCharArray(),
            openIddictOptions.SigningKey.Passphrase.ToCharArray());

        var sk = new RsaSecurityKey(signingKey);
        var ek = new RsaSecurityKey(encryptionKey);

        services.AddOpenIddict()
            .AddCore(options =>
            {
                options.UseEntityFrameworkCore()
                    .UseDbContext<AuthDbContext>()
                    .ReplaceDefaultEntities<Guid>();
            })
            .AddServer(options =>
            {
                // https://documentation.openiddict.com/guides/migration/30-to-40.html#update-your-endpoint-uris

                options.SetCryptographyEndpointUris("oauth2/.well-known/jwks");
                options.SetConfigurationEndpointUris("oauth2/.well-known/openid-configuration");
                options.SetTokenEndpointUris("oauth2/connect/token");
                options.AllowClientCredentialsFlow();
                options.SetUserinfoEndpointUris("oauth2/connect/userinfo");
                options.SetIntrospectionEndpointUris("oauth2/connect/introspection");

                options.AddSigningKey(sk);
                options.AddEncryptionKey(ek);

                //options.DisableAccessTokenEncryption(); // If this line is not commented, things work as expected

                options.UseAspNetCore(o =>
                {
                    // NOTE: disabled because by default OpenIddict accepts request from HTTPS endpoints only
                    o.DisableTransportSecurityRequirement();
                    o.EnableTokenEndpointPassthrough();
                });
            })
            .AddValidation(options =>
            {
                options.UseLocalServer();
                options.UseAspNetCore();
            });
    }

Authorization controller token get action:

[HttpPost("~/oauth2/connect/token")]
    [Produces("application/json")]
    public async Task<IActionResult> Exchange()
    {
        var request = HttpContext.GetOpenIddictServerRequest();
        if (request.IsClientCredentialsGrantType())
        {
            // Note: the client credentials are automatically validated by OpenIddict:
            // if client_id or client_secret are invalid, this action won't be invoked.

            var application = await _applicationManager.FindByClientIdAsync(request.ClientId);
            if (application == null)
            {
                throw new InvalidOperationException("The application details cannot be found in the database.");
            }

            // Create a new ClaimsIdentity containing the claims that
            // will be used to create an id_token, a token or a code.
            var identity = new ClaimsIdentity(
                authenticationType: TokenValidationParameters.DefaultAuthenticationType,
                nameType: OpenIddictConstants.Claims.Name,
                roleType: OpenIddictConstants.Claims.Role);

            var clientId = await _applicationManager.GetClientIdAsync(application);
            var organizationId = await _applicationManager.GetDisplayNameAsync(application);

            // https://documentation.openiddict.com/guides/migration/30-to-40.html#remove-calls-to-addclaims-that-specify-a-list-of-destinations

            identity.SetClaim(type: OpenIddictConstants.Claims.Subject, value: organizationId)
                    .SetClaim(type: OpenIddictConstants.Claims.ClientId, value: clientId)
                    .SetClaim(type: "organization_id", value: organizationId);

            identity.SetDestinations(static claim => claim.Type switch
            {
                _ => new[] { OpenIddictConstants.Destinations.AccessToken, OpenIddictConstants.Destinations.IdentityToken }
            });

            return SignIn(new ClaimsPrincipal(identity), OpenIddictServerAspNetCoreDefaults.AuthenticationScheme);
        }

        throw new NotImplementedException("The specified grant type is not implemented.");
    }

Resource controller (where Authorization is not working):


public class Query
{
    private readonly IMapper _mapper;

    public Query(IMapper mapper)
    {
        _mapper = mapper;
    }

[HotChocolate.AspNetCore.Authorization.Authorize]
    public async Task<Organization> GetMe(ClaimsPrincipal claimsPrincipal,
        [Service] IDbContextFactory<DbContext> dbContextFactory,
        CancellationToken ct)
    {
        var organizationId = Ulid.Parse(claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier));

        ... // further code removed for brevity
    }
  }
}

GraphQL setup in ASP.NET Core pipeline:

public static void AddGraphQL(this IServiceCollection services, IWebHostEnvironment webHostEnvironment)
{
        services.AddGraphQLServer()
            .AddAuthorization()
            .AddQueryType<Query>()
}

Packages with versions:

  1. OpenIddict (4.0.0)
  2. OpenIddict.AspNetCore (4.0.0)
  3. OpenIddict.EntityFrameworkCore (4.0.0)
  4. HotChocolate.AspNetCore (12.13.2)
  5. HotChocolate.AspNetCore.Authorization (12.13.2)
  6. HotChocolate.Diagnostics (12.13.2)

EDIT: After enabling logging, here is the exception message:

Microsoft.IdentityModel.Tokens.SecurityTokenDecryptionFailedException: IDX10603: Decryption failed. Keys tried: 'Microsoft.IdentityModel.Tokens.SymmetricSecurityKey, KeyId: '', InternalId: 'nu9-WIVbfvvJafm3g4th-uHPFDf8eoyJf-M1ByrotW8'.\r\n'.\nExceptions caught:\n 'System.ArgumentNullException: IDX10000: The parameter 'ciphertext' cannot be a 'null' or an empty object. (Parameter 'ciphertext')\r\n at Microsoft.IdentityModel.Tokens.AuthenticatedEncryptionProvider.Decrypt(Byte[] ciphertext, Byte[] authenticatedData, Byte[] iv, Byte[] authenticationTag)\r\n at Microsoft.IdentityModel.JsonWebTokens.JwtTokenUtilities.DecryptToken(CryptoProviderFactory cryptoProviderFactory, SecurityKey key, String encAlg, Byte[] ciphertext, Byte[] headerAscii, Byte[] initializationVector, Byte[] authenticationTag)\r\n at Microsoft.IdentityModel.JsonWebTokens.JwtTokenUtilities.DecryptJwtToken(SecurityToken securityToken, TokenValidationParameters validationParameters, JwtTokenDecryptionParameters decryptionParameters

And the token validation parameter registration in ASP.NET core pipeline is:

services.AddAuthentication()
        .AddJwtBearer(options =>
        {
            var openIddictOptions = Configuration.GetSection("OpenIddict").Get<OpenIddictOptions>();
            var encryptionKeyData = openIddictOptions.EncryptionKey.RSA;
            var signingKeyData = openIddictOptions.SigningKey.RSA;

            var encryptionKey = RSA.Create();
            var signingKey = RSA.Create();

            encryptionKey.ImportFromEncryptedPem(encryptionKeyData.ToCharArray(),
                openIddictOptions.EncryptionKey.Passphrase.ToCharArray());
            signingKey.ImportFromEncryptedPem(signingKeyData.ToCharArray(),
                openIddictOptions.SigningKey.Passphrase.ToCharArray());

            encryptionKey.ImportFromEncryptedPem(encryptionKeyData.ToCharArray(),
                openIddictOptions.EncryptionKey.Passphrase.ToCharArray());
            signingKey.ImportFromEncryptedPem(signingKeyData.ToCharArray(),
                openIddictOptions.SigningKey.Passphrase.ToCharArray());

            var sk = new RsaSecurityKey(signingKey);
            var ek = new RsaSecurityKey(encryptionKey);

            options.IncludeErrorDetails = true;
            options.RequireHttpsMetadata = false;

            options.TokenValidationParameters = new TokenValidationParameters
            {
                IssuerSigningKey = sk,
                TokenDecryptionKey = ek,
                RequireExpirationTime = false,
                ValidateLifetime = true,
                ValidateAudience = false,
                ValidateIssuer = false
            };
        });

Solution

  • This issue is resolved now. The resolution for us was to manually upgrade System.IdentityModel.Tokens.Jwt package to version >= 6.31.0.

    As explained here, there was an issue in the aforementioned package.