Skip to content

Commit 4d86584

Browse files
committed
Add OpenIddict private_key_jwt JWKS article
1 parent 3786ddf commit 4d86584

3 files changed

Lines changed: 185 additions & 0 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# Passwordless Client Authentication in ABP: Using JWKS with OpenIddict
2+
3+
If you've built a confidential client with ABP's OpenIddict module, you know the drill: create an application in the management UI, set a `client_id`, generate a `client_secret`, and paste that secret into your client's `appsettings.json` or environment variables. It works. It's familiar. And for a lot of projects, it's perfectly fine.
4+
5+
But `client_secret` is a **shared secret** — and shared secrets carry an uncomfortable truth: the same value exists in two places at once. The authorization server stores a hash of it in the database, and your client stores the raw value in configuration. That means two potential leak points. Worse, the secret has no inherent identity. Anyone who obtains the string can impersonate your client and the server has no way to tell the difference.
6+
7+
For many teams, this tradeoff is acceptable. But certain scenarios make it hard to ignore:
8+
9+
- **Microservice-to-microservice calls**: A backend mesh of a dozen services, each with its own `client_secret` scattered across deployment configs and CI/CD pipelines. Rotating them across environments without missing one becomes a coordination problem.
10+
- **Multi-tenant SaaS platforms**: Every tenant's client application deserves truly isolated credentials. With shared secrets, the database holds hashed copies for all tenants — a breach of that table is a breach of everyone's credentials.
11+
- **Financial-grade API (FAPI) compliance**: Standards like [FAPI 2.0](https://openid.net/specs/fapi-2_0-security-profile.html) explicitly require asymmetric client authentication. `client_secret` doesn't make the cut.
12+
- **Zero-trust architectures**: In a zero-trust model, identity must be cryptographically provable, not based on a string that can be copied and pasted.
13+
14+
The underlying problem is that a shared secret is just a password. It can be stolen, replicated, and used without leaving a trace. The fix has existed in cryptography for decades: **asymmetric keys**.
15+
16+
With asymmetric key authentication, the client generates a key pair. The public key is registered with the authorization server. The private key never leaves the client. Each time the client needs a token, it signs a short-lived JWT — called a _client assertion_ — with the private key. The server verifies the signature using the registered public key. There is no secret on the server side that could be used to forge a request, because the private key is never transmitted or stored remotely.
17+
18+
This is exactly what the **`private_key_jwt`** client authentication method, defined in [OpenID Connect Core](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication), provides. ABP Pro's OpenIddict module now supports it end-to-end: you register a **JSON Web Key Set (JWKS)** containing your public key through the application management UI, and your client authenticates using the corresponding private key.
19+
20+
> This feature is available starting from **ABP Framework 10.3**.
21+
22+
## How It Works
23+
24+
The flow is straightforward:
25+
26+
1. The client holds an RSA key pair — **private key** (kept locally) and **public key** (registered on the authorization server as a JWKS).
27+
2. On each token request, the client uses the private key to sign a JWT with a short expiry and a unique `jti` claim.
28+
3. The authorization server verifies the signature against the registered public key and issues a token if it checks out.
29+
30+
The private key never leaves the client. Even if someone obtains the authorization server's database, there's nothing there that can be used to generate a valid client assertion.
31+
32+
## Generating a Key Pair
33+
34+
ABP CLI includes a `generate-jwks` command that creates an RSA key pair in the right formats:
35+
36+
```bash
37+
abp generate-jwks
38+
```
39+
40+
This produces two files in the current directory:
41+
42+
- `jwks.json` — the public key in JWKS format, to be uploaded to the server
43+
- `jwks-private.pem` — the private key in PKCS#8 PEM format, to be kept on the client
44+
45+
You can customize the output directory, key size, and signing algorithm:
46+
47+
```bash
48+
abp generate-jwks --alg RS512 --key-size 4096 -o ./keys -f myapp
49+
```
50+
51+
> Supported algorithms: `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, `PS512`. The default is `RS256` with a 2048-bit key.
52+
53+
The command also prints the contents of `jwks.json` to the console so you can copy it directly.
54+
55+
## Registering the JWKS in the Management UI
56+
57+
Open **OpenIddict → Applications** in the ABP back-office and create or edit a confidential application (Client Type: `Confidential`).
58+
59+
In the **Client authentication method** section, you'll find the new **JSON Web Key Set** field.
60+
61+
![](./create-edit-ui.png)
62+
63+
Paste the contents of `jwks.json` into the **JSON Web Key Set** field:
64+
65+
```json
66+
{
67+
"keys": [
68+
{
69+
"kty": "RSA",
70+
"use": "sig",
71+
"kid": "6444...",
72+
"alg": "RS256",
73+
"n": "tx...",
74+
"e": "AQAB"
75+
}
76+
]
77+
}
78+
```
79+
80+
Save the application. It's now configured for `private_key_jwt` authentication. You can set either `client_secret` or a JWKS, or both — ABP enforces that a confidential application always has at least one credential.
81+
82+
## Requesting a Token with the Private Key
83+
84+
On the client side, each token request requires building a _client assertion_ JWT signed with the private key. Here's a complete `client_credentials` example:
85+
86+
```csharp
87+
// Discover the authorization server endpoints (including the issuer URI).
88+
var client = new HttpClient();
89+
var configuration = await client.GetDiscoveryDocumentAsync("https://your-auth-server/");
90+
91+
// Load the private key generated by `abp generate-jwks`.
92+
using var rsaKey = RSA.Create();
93+
rsaKey.ImportFromPem(await File.ReadAllTextAsync("jwks-private.pem"));
94+
95+
// Read the kid from jwks.json so it stays in sync with the server-registered public key.
96+
string? signingKid = null;
97+
if (File.Exists("jwks.json"))
98+
{
99+
using var jwksDoc = JsonDocument.Parse(await File.ReadAllTextAsync("jwks.json"));
100+
if (jwksDoc.RootElement.TryGetProperty("keys", out var keysElem) &&
101+
keysElem.GetArrayLength() > 0 &&
102+
keysElem[0].TryGetProperty("kid", out var kidElem))
103+
{
104+
signingKid = kidElem.GetString();
105+
}
106+
}
107+
108+
var signingKey = new RsaSecurityKey(rsaKey) { KeyId = signingKid };
109+
var signingCredentials = new SigningCredentials(signingKey, SecurityAlgorithms.RsaSha256);
110+
111+
// Build the client assertion JWT.
112+
var now = DateTime.UtcNow;
113+
var jwtHandler = new JsonWebTokenHandler();
114+
var clientAssertionToken = jwtHandler.CreateToken(new SecurityTokenDescriptor
115+
{
116+
// OpenIddict requires typ = "client-authentication+jwt" for client assertion JWTs.
117+
TokenType = "client-authentication+jwt",
118+
Issuer = "MyClientId",
119+
// aud must equal the authorization server's issuer URI from the discovery document,
120+
// not the token endpoint URL.
121+
Audience = configuration.Issuer,
122+
Subject = new ClaimsIdentity(new[]
123+
{
124+
new Claim(JwtRegisteredClaimNames.Sub, "MyClientId"),
125+
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
126+
}),
127+
IssuedAt = now,
128+
NotBefore = now,
129+
Expires = now.AddMinutes(5),
130+
SigningCredentials = signingCredentials,
131+
});
132+
133+
// Request a token using the client_credentials flow.
134+
var tokenResponse = await client.RequestClientCredentialsTokenAsync(
135+
new ClientCredentialsTokenRequest
136+
{
137+
Address = configuration.TokenEndpoint,
138+
ClientId = "MyClientId",
139+
ClientCredentialStyle = ClientCredentialStyle.PostBody,
140+
ClientAssertion = new ClientAssertion
141+
{
142+
Type = OidcConstants.ClientAssertionTypes.JwtBearer,
143+
Value = clientAssertionToken,
144+
},
145+
Scope = "MyAPI",
146+
});
147+
```
148+
149+
A few things worth paying attention to:
150+
151+
- **`TokenType`** must be `"client-authentication+jwt"`. OpenIddict rejects client assertion JWTs that don't carry this header.
152+
- **`Audience`** must match the authorization server's issuer URI exactly — use `configuration.Issuer` from the discovery document, not the token endpoint URL.
153+
- **`Jti`** must be unique per request to prevent replay attacks.
154+
- Keep **`Expires`** short (five minutes or less). A client assertion is a one-time proof of identity, not a long-lived credential.
155+
156+
This example uses [Duende.IdentityModel](https://github.com/DuendeSoftware/IdentityModel) for the token request helpers and [Microsoft.IdentityModel.JsonWebTokens](https://www.nuget.org/packages/Microsoft.IdentityModel.JsonWebTokens) for JWT creation.
157+
158+
## Key Rotation Without Downtime
159+
160+
One of the practical advantages of JWKS is that it can hold multiple public keys simultaneously. This makes **zero-downtime key rotation** straightforward:
161+
162+
1. Run `abp generate-jwks` to produce a new key pair.
163+
2. Append the new public key to the `keys` array in your existing `jwks.json` and update the JWKS in the management UI.
164+
3. Switch the client to sign assertions with the new private key.
165+
4. Once the transition is complete, remove the old public key from the JWKS.
166+
167+
During the transition window, both the old and new public keys are registered on the server, so any in-flight requests signed with either key will still validate correctly.
168+
169+
## Summary
170+
171+
To use `private_key_jwt` authentication in an ABP Pro application:
172+
173+
1. Run `abp generate-jwks` to generate an RSA key pair.
174+
2. Paste the `jwks.json` contents into the **JSON Web Key Set** field in the OpenIddict application management UI.
175+
3. On the client side, sign a short-lived _client assertion_ JWT with the private key — making sure to set the correct `typ`, `aud` (from the discovery document), and a unique `jti` — then use it to request a token.
176+
177+
ABP handles public key storage and validation automatically. OpenIddict handles the signature verification on the token endpoint. As a developer, you only need to keep the private key file secure — there's no shared secret to synchronize between client and server.
178+
179+
## References
180+
181+
- [OpenID Connect Core — Client Authentication](https://openid.net/specs/openid-connect-core-1_0.html#ClientAuthentication)
182+
- [RFC 7523 — JWT Profile for Client Authentication](https://datatracker.ietf.org/doc/html/rfc7523)
183+
- [ABP OpenIddict Module Documentation](https://abp.io/docs/latest/modules/openiddict)
184+
- [ABP CLI Documentation](https://abp.io/docs/latest/cli)
185+
- [OpenIddict Documentation](https://documentation.openiddict.com/)
1.14 MB
Loading
383 KB
Loading

0 commit comments

Comments
 (0)