Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
  •  
  •  
  •  
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ modules/blogging/app/Volo.BloggingTestApp/Logs/*.*
modules/blogging/app/Volo.BloggingTestApp/wwwroot/files/*.*
modules/docs/app/VoloDocs.Web/Logs/*.*
modules/setting-management/app/Volo.Abp.SettingManagement.DemoApp/Logs/*.*
modules/openiddict/app/OpenIddict.Demo.Server/wwwroot/libs/**
templates/module/app/MyCompanyName.MyProjectName.DemoApp/Logs/*.*
templates/module/aspnet-core/host/MyCompanyName.MyProjectName.Blazor.Server.Host/Logs/logs.txt
templates/mvc/src/MyCompanyName.MyProjectName.Web/Logs/*.*
Expand Down
8 changes: 4 additions & 4 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -117,10 +117,10 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="9.0.0" />
<PackageVersion Include="Microsoft.SourceLink.GitHub" Version="8.0.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.14.0" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.14.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />
<PackageVersion Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.16.0" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.16.0" />
<PackageVersion Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.16.0" />
<PackageVersion Include="Minio" Version="6.0.5" />
<PackageVersion Include="MongoDB.Driver" Version="3.7.0" />
<PackageVersion Include="NEST" Version="7.17.5" />
Expand Down
94 changes: 94 additions & 0 deletions docs/en/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ Here is the list of all available commands before explaining their details:
* **[`install-old-cli`](../cli#install-old-cli)**: Installs old ABP CLI.
* **[`mcp-studio`](../cli#mcp-studio)**: Starts ABP Studio MCP bridge for AI tools (requires ABP Studio running).
* **[`generate-razor-page`](../cli#generate-razor-page)**: Generates a page class that you can use it in the ASP NET Core pipeline to return an HTML page.
* **[`generate-jwks`](../cli#generate-jwks)**: Generates an RSA key pair (JWKS public key + PEM private key) for OpenIddict `private_key_jwt` client authentication.

### help

Expand Down Expand Up @@ -1127,6 +1128,99 @@ app.Use(async (httpContext, next) =>

* ```--version``` or ```-v```: Specifies the version for ABP CLI to be installed.

### generate-jwks

Generates an RSA key pair for use with OpenIddict `private_key_jwt` client authentication.

The command produces two files:

| File | Description |
|---|---|
| `<prefix>.json` | JWKS (JSON Web Key Set) containing the **public key**. Paste this into the **JSON Web Key Set** field of your OpenIddict application in the ABP management UI. |
| `<prefix>-private.pem` | PKCS#8 PEM **private key**. Store this securely in your client application and use it to sign JWT client assertions. |

> **Security notice:** Never commit the private key file to source control. Add it to `.gitignore`. Only the JWKS (public key) needs to be shared with the authorization server.

Usage:

```bash
abp generate-jwks [options]
```

#### Options

* `--output` or `-o`: Output directory. Defaults to the current directory.
* `--key-size` or `-s`: RSA key size in bits. Supported values: `2048` (default), `4096`.
* `--alg`: Signing algorithm. Supported values: `RS256` (default), `RS384`, `RS512`, `PS256`, `PS384`, `PS512`.
* `--kid`: Custom Key ID. Auto-generated if not specified.
* `--file` or `-f`: Output file name prefix. Defaults to `jwks`. Generates `<prefix>.json` and `<prefix>-private.pem`.

#### Examples

```bash
# Generate with defaults (2048-bit RS256, current directory)
abp generate-jwks

# Generate with RS512 and 4096-bit key
abp generate-jwks --alg RS512 --key-size 4096

# Output to a specific directory with a custom file prefix
abp generate-jwks -o ./keys -f myapp
```

#### Workflow

1. Run `abp generate-jwks` to generate the key pair.

2. Open the ABP OpenIddict application management UI, select your **Confidential** application, choose **JWKS (private_key_jwt)** as the authentication method, and paste the contents of `jwks.json` into the **JSON Web Key Set** field.

3. In your client application, load the private key from the PEM file and sign JWT client assertions:

```csharp
// Load private key from PEM file
using var rsa = RSA.Create();
rsa.ImportFromPem(await File.ReadAllTextAsync("jwks-private.pem"));

// The kid must match the "kid" field in the JWKS registered on the server
var signingKey = new RsaSecurityKey(rsa) { KeyId = "<kid-from-jwks.json>" };
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);

var now = DateTime.UtcNow;
var jwtHandler = new JsonWebTokenHandler();
var clientAssertion = jwtHandler.CreateToken(new SecurityTokenDescriptor
{
// OpenIddict requires typ = "client-authentication+jwt"
TokenType = "client-authentication+jwt",
// iss and sub must both equal the client_id
Issuer = "<your-client-id>",
Audience = "<authorization-server-issuer-uri>",
Subject = new ClaimsIdentity(new[]
{
new Claim(JwtRegisteredClaimNames.Sub, "<your-client-id>"),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
}),
IssuedAt = now,
NotBefore = now,
Expires = now.AddMinutes(5),
SigningCredentials = signingCredentials,
});

// Use the assertion in the token request
var tokenResponse = await httpClient.RequestClientCredentialsTokenAsync(
new ClientCredentialsTokenRequest
{
Address = "<token-endpoint>",
ClientId = "<your-client-id>",
ClientCredentialStyle = ClientCredentialStyle.PostBody,
ClientAssertion = new ClientAssertion
{
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
Value = clientAssertion,
},
Scope = "<requested-scopes>",
});
```

## See Also

* [Examples for the new command](./new-command-samples.md)
Expand Down
9 changes: 9 additions & 0 deletions docs/en/package-version-changes.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Package Version Changes

## 10.3.0-rc.1

| Package | Old Version | New Version | PR |
|---------|-------------|-------------|-----|
| Microsoft.IdentityModel.JsonWebTokens | 8.14.0 | 8.16.0 | #25068 |
| Microsoft.IdentityModel.Protocols.OpenIdConnect | 8.14.0 | 8.16.0 | #25068 |
| Microsoft.IdentityModel.Tokens | 8.14.0 | 8.16.0 | #25068 |
| System.IdentityModel.Tokens.Jwt | 8.14.0 | 8.16.0 | #25068 |

## 10.3.0-preview

| Package | Old Version | New Version | PR |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ public override void ConfigureServices(ServiceConfigurationContext context)
options.Commands[RecreateInitialMigrationCommand.Name] = typeof(RecreateInitialMigrationCommand);
options.Commands[GenerateRazorPage.Name] = typeof(GenerateRazorPage);
options.Commands[McpCommand.Name] = typeof(McpCommand);
options.Commands[GenerateJwksCommand.Name] = typeof(GenerateJwksCommand);

options.DisabledModulesToAddToSolution.Add("Volo.Abp.LeptonXTheme.Pro");
options.DisabledModulesToAddToSolution.Add("Volo.Abp.LeptonXTheme.Lite");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Volo.Abp.Cli.Args;
using Volo.Abp.DependencyInjection;

namespace Volo.Abp.Cli.Commands;

public class GenerateJwksCommand : IConsoleCommand, ITransientDependency
{
public const string Name = "generate-jwks";

public ILogger<GenerateJwksCommand> Logger { get; set; }

public GenerateJwksCommand()
{
Logger = NullLogger<GenerateJwksCommand>.Instance;
}

public Task ExecuteAsync(CommandLineArgs commandLineArgs)
{
var outputDir = commandLineArgs.Options.GetOrNull("output", "o")
?? Directory.GetCurrentDirectory();
var keySizeStr = commandLineArgs.Options.GetOrNull("key-size", "s") ?? "2048";
var alg = commandLineArgs.Options.GetOrNull("alg") ?? "RS256";
var kid = commandLineArgs.Options.GetOrNull("kid") ?? Guid.NewGuid().ToString("N");
var filePrefix = commandLineArgs.Options.GetOrNull("file", "f") ?? "jwks";

if (!int.TryParse(keySizeStr, out var keySize) || (keySize != 2048 && keySize != 4096))
{
Logger.LogError("Invalid key size '{0}'. Supported values: 2048, 4096.", keySizeStr);
return Task.CompletedTask;
}

if (!IsValidAlgorithm(alg))
{
Logger.LogError("Invalid algorithm '{0}'. Supported values: RS256, RS384, RS512, PS256, PS384, PS512.", alg);
return Task.CompletedTask;
}

if (!Directory.Exists(outputDir))
{
Directory.CreateDirectory(outputDir);
}

Logger.LogInformation("Generating RSA {0}-bit key pair (algorithm: {1})...", keySize, alg);

using var rsa = RSA.Create();
rsa.KeySize = keySize;

var jwksJson = BuildJwksJson(rsa, alg, kid);
var privateKeyPem = ExportPrivateKeyPem(rsa);

var jwksFilePath = Path.Combine(outputDir, $"{filePrefix}.json");
var privateKeyFilePath = Path.Combine(outputDir, $"{filePrefix}-private.pem");

File.WriteAllText(jwksFilePath, jwksJson, Encoding.UTF8);
File.WriteAllText(privateKeyFilePath, privateKeyPem, Encoding.UTF8);
Comment thread
maliming marked this conversation as resolved.

Logger.LogInformation("");
Logger.LogInformation("Generated files:");
Logger.LogInformation(" JWKS (public key) : {0}", jwksFilePath);
Logger.LogInformation(" Private key (PEM) : {0}", privateKeyFilePath);
Logger.LogInformation("");
Logger.LogInformation("JWKS content (paste this into the ABP OpenIddict application's 'JSON Web Key Set' field):");
Logger.LogInformation("");
Logger.LogInformation("{0}", jwksJson);
Logger.LogInformation("");
Logger.LogInformation("IMPORTANT: Keep the private key file safe. Never share it or commit it to source control.");
Logger.LogInformation(" The JWKS file contains only the public key and is safe to share.");

return Task.CompletedTask;
}

private static string BuildJwksJson(RSA rsa, string alg, string kid)
{
var parameters = rsa.ExportParameters(false);

var n = Base64UrlEncode(parameters.Modulus);
var e = Base64UrlEncode(parameters.Exponent);

using var stream = new System.IO.MemoryStream();
using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });

writer.WriteStartObject();
writer.WriteStartArray("keys");
writer.WriteStartObject();
writer.WriteString("kty", "RSA");
writer.WriteString("use", "sig");
writer.WriteString("kid", kid);
writer.WriteString("alg", alg);
writer.WriteString("n", n);
writer.WriteString("e", e);
writer.WriteEndObject();
writer.WriteEndArray();
writer.WriteEndObject();
writer.Flush();

return Encoding.UTF8.GetString(stream.ToArray());
}
Comment thread
maliming marked this conversation as resolved.

private static string ExportPrivateKeyPem(RSA rsa)
{
#if NET5_0_OR_GREATER
return rsa.ExportPkcs8PrivateKeyPem();
#elif NETSTANDARD2_0
// RSA.ExportPkcs8PrivateKey() was introduced in .NET Standard 2.1.
// The ABP CLI always runs on .NET 5+, so this path is never reached at runtime.
throw new PlatformNotSupportedException("Private key export requires .NET Standard 2.1 or later.");
#else
var privateKeyBytes = rsa.ExportPkcs8PrivateKey();
var base64 = Convert.ToBase64String(privateKeyBytes, Base64FormattingOptions.InsertLineBreaks);
return $"-----BEGIN PRIVATE KEY-----\n{base64}\n-----END PRIVATE KEY-----";
#endif
}

private static string Base64UrlEncode(byte[] input)
{
return Convert.ToBase64String(input)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}

private static bool IsValidAlgorithm(string alg)
{
return alg == "RS256" || alg == "RS384" || alg == "RS512" ||
alg == "PS256" || alg == "PS384" || alg == "PS512";
}

public string GetUsageInfo()
{
var sb = new StringBuilder();

sb.AppendLine("");
sb.AppendLine("Usage:");
sb.AppendLine(" abp generate-jwks [options]");
sb.AppendLine("");
sb.AppendLine("Options:");
sb.AppendLine(" -o|--output <dir> Output directory (default: current directory)");
sb.AppendLine(" -s|--key-size <size> RSA key size: 2048 or 4096 (default: 2048)");
sb.AppendLine(" --alg <alg> Algorithm: RS256, RS384, RS512, PS256, PS384, PS512 (default: RS256)");
sb.AppendLine(" --kid <id> Key ID (kid) - auto-generated if not specified");
sb.AppendLine(" -f|--file <prefix> Output file name prefix (default: jwks)");
sb.AppendLine(" Generates: <prefix>.json (JWKS) and <prefix>-private.pem (private key)");
sb.AppendLine("");
sb.AppendLine("Examples:");
sb.AppendLine(" abp generate-jwks");
sb.AppendLine(" abp generate-jwks --alg RS512 --key-size 4096");
sb.AppendLine(" abp generate-jwks -o ./keys -f myapp");
sb.AppendLine("");
sb.AppendLine("Description:");
sb.AppendLine(" Generates an RSA key pair for use with OpenIddict private_key_jwt client authentication.");
sb.AppendLine(" The JWKS file (public key) should be pasted into the ABP OpenIddict application's");
sb.AppendLine(" 'JSON Web Key Set' field in the management UI.");
sb.AppendLine(" The private key PEM file should be kept secure and used by the client application");
sb.AppendLine(" to sign JWT assertions when authenticating to the token endpoint.");
sb.AppendLine("");
sb.AppendLine("See the documentation for more info: https://abp.io/docs/latest/cli");

return sb.ToString();
}

public static string GetShortDescription()
{
return "Generates an RSA key pair (JWKS + private key) for OpenIddict private_key_jwt authentication.";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,17 @@

<ItemGroup>
<PackageReference Include="Duende.IdentityModel" />
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" />
<ProjectReference Include="..\..\..\..\framework\src\Volo.Abp.AspNetCore.Mvc.Contracts\Volo.Abp.AspNetCore.Mvc.Contracts.csproj" />
</ItemGroup>

<ItemGroup>
<!-- jwks-private.pem is the private key for the AbpConsoleAppWithJwks client, used to sign JWT client assertions.
The corresponding public key (jwks.json) is registered on the server side (OpenIddict.Demo.Server).
Both files originate from the parent app/ directory. -->
<None Include="..\jwks-private.pem" Condition="Exists('..\jwks-private.pem')">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

</Project>
Loading
Loading